...
 
Commits (7)
......@@ -36,6 +36,16 @@ along with Postorius. If not, see <http://www.gnu.org/licenses/>.
test against, without having to record tapes to be replayed.
* Corrected display message in 'recieve_list_copy' option in global mailman
preferences of mailman settings. (Closes #351)
* Allow setting a MailingList's Preferred Language. (Closes #303)
* Allow a empty templates as a workaround for missing settings to skip
email decoration. (Closes #331)
* Expose ``digest_volume_frequency``, ``digest_send_periodict`` and
``digests_enabled`` settings for MailingLists.
* Add a badge with count of held messages and pending subscription requests
for moderator approval. (Closes #308)
* Add support to add, view and remove domain owners.
* Allow setting the visibility options for MailingList's member list.
1.2.4
=====
......
......@@ -95,3 +95,11 @@ class DomainEditForm(DomainForm):
separte from the DomainForm, so that the mail_host can't be changed.
"""
mail_host = None
class DomainOwnerForm(forms.Form):
"""Form to add a owner for a domain."""
email = forms.EmailField(
label=_("Owner's Email"),
required=True,
)
......@@ -27,6 +27,7 @@ from django.utils.translation import ugettext_lazy as _
from django_mailman3.lib.mailman import get_mailman_client
from postorius.forms.fields import ListOfStringsField
from postorius.languages import LANGUAGES
ACTION_CHOICES = (
......@@ -37,6 +38,20 @@ ACTION_CHOICES = (
("defer", _("Default processing")),
)
DIGEST_FREQUENCY_CHOICES = (
("daily", _("Daily")),
("weekly", _("Weekly")),
("quarterly", _("Quarterly")),
("monthly", _("Monthly")),
("yearly", _("Yearly"))
)
ROSTER_VISIBILITY_CHOICES = (
("moderators", _("Only mailinglist moderators")),
("members", _("Only mailinglist members")),
("public", _("Anyone")),
)
EMPTY_STRING = ''
......@@ -346,6 +361,27 @@ class DigestSettingsForm(ListSettingsForm):
"""
List digest settings.
"""
digests_enabled = forms.ChoiceField(
choices=((True, _('Yes')), (False, _('No'))),
widget=forms.RadioSelect,
required=False,
label=_('Enable Digests'),
help_text=_('Should Mailman enable digests for this MailingList?'),
)
digest_send_periodic = forms.ChoiceField(
choices=((True, _('Yes')), (False, _('No'))),
widget=forms.RadioSelect,
required=False,
label=_('Send Digest Periodically'),
help_text=_('Should Mailman send out digests periodically?'),
)
digest_volume_frequency = forms.ChoiceField(
choices=DIGEST_FREQUENCY_CHOICES,
widget=forms.RadioSelect,
required=False,
label=_('Digest Frequency'),
help_text=_('At what frequency should Mailman send out digests?'),
)
digest_size_threshold = forms.DecimalField(
label=_('Digest size threshold'),
help_text=_('How big in Kb should a digest be before '
......@@ -649,6 +685,19 @@ class ListIdentityForm(ListSettingsForm):
strip=False,
required=False,
)
preferred_language = forms.ChoiceField(
label=_('Preferred Language'),
required=False,
widget=forms.Select(),
choices=LANGUAGES,
)
member_roster_visibility = forms.ChoiceField(
label=_('Members List Visibility'),
required=False,
widget=forms.Select(),
choices=ROSTER_VISIBILITY_CHOICES,
help_text=_('Who is allowed to see members list for this MailingList?')
)
def clean_subject_prefix(self):
"""
......
# -*- coding: utf-8 -*-
# Copyright (C) 2019 by the Free Software Foundation, Inc.
#
# This file is part of Postorius.
#
# Postorius is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Postorius is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Postorius. If not, see <http://www.gnu.org/licenses/>.
#
LANGUAGES = (
("ar", "Arabic"),
("ast", "Asturian"),
("ca", "Catalan"),
("cs", "Czech"),
("da", "Danish"),
("de", "German"),
("el", "Greek"),
("es", "Spanish"),
("et", "Estonian"),
("eu", "Euskara"),
("fi", "Finnish"),
("fr", "French"),
("gl", "Galician"),
("he", "Hebrew"),
("hr", "Croatian"),
("hu", "Hungarian"),
("ia", "Interlingua"),
("it", "Italian"),
("ja", "Japanese"),
("ko", "Korean"),
("lt", "Lithuanian"),
("nl", "Dutch"),
("no", "Norwegian"),
("pl", "Polish"),
("pt", "Protuguese"),
("pt_BR", "Protuguese (Brazil)"),
("ro", "Romanian"),
("ru", "Russian"),
("sk", "Slovak"),
("sl", "Slovenian"),
("sr", "Serbian"),
("sv", "Swedish"),
("tr", "Turkish"),
("uk", "Ukrainian"),
("vi", "Vietnamese"),
("zh_CN", "Chinese"),
("zh_TW", "Chinese (Taiwan)"),
("en", "English (USA)"),
)
# Generated by Django 2.2 on 2019-05-08 16:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('postorius', '0008_auto_20190310_0717'),
]
operations = [
migrations.AlterField(
model_name='emailtemplate',
name='data',
field=models.TextField(blank=True,
help_text="Note: Do not add any secret content in templates as they are publicly accessible.\nYou can use these variables in the templates. \n$hyperkitty_url: Permalink to archived message in Hyperkitty\n$listname: Name of the Mailing List e.g. ant@example.com \n$list_id: The List-ID header e.g. ant.example.com \n$display_name: Display name of the mailing list e.g. Ant \n$short_listname: Local part of the listname e.g. ant \n$domain: The domain part of the listname e.g. example.com \n$info: The mailing list's longer descriptive text \n$request_email: The email address for -request address \n$owner_email: The email address for -owner address \n$site_email: The email address to reach the owners of the site \n$language: The two letter language code for list's preferred language e.g. fr, en, de \n"), # noqa: E501
),
]
......@@ -247,7 +247,8 @@ class EmailTemplate(models.Model):
'$owner_email: The email address for -owner address \n'
'$site_email: The email address to reach the owners of the site \n'
'$language: The two letter language code for list\'s preferred language e.g. fr, en, de \n' # noqa: E501
)
),
blank=True,
)
language = models.CharField(
max_length=5, choices=LANGUAGES,
......
......@@ -3,6 +3,11 @@ html {
position: relative;
min-height: 100%;
}
@media (min-width:1200px){
.container{width:1370px}
}
body {
margin-bottom: 90px;
}
......
{% extends "postorius/base.html" %}
{% load i18n %}
{% load membership_helpers %}
{% block head_title %}
{% trans 'Domains' %} - {{ block.super }}
{% endblock %}
......@@ -22,6 +22,7 @@
<th>{% trans 'Description' %}</th>
<th>{% trans 'Alias Domain' %}</th>
<th>{% trans 'Web Host' %}</th>
<th>{% trans 'Owners' %}</th>
<th>{% trans 'Action' %}</th>
</tr>
</thead>
......@@ -32,6 +33,19 @@
<td>{% if domain.description %}{{ domain.description }}{% endif %}</td>
<td>{% if domain.alias_domain %}{{ domain.alias_domain }}{% endif %}</td>
<td>{{ domain.site.name }} ({{ domain.site.domain }})</td>
<td>
<ul>
{% for owner in domain.owners %}
<li>
{{ owner|owner_repr }}
<form action="" method="post" class="form-inline">
{% csrf_token %}
<a href="{% url 'remove_domain_owner' domain=domain.mail_host user_id=owner.user_id %}">({% trans 'remove' %})</a>
</form>
</li>
{% endfor %}
<li><a href="{% url 'domain_owners' domain.mail_host %}">{% trans 'Add' %}</a></li>
</ul></td>
<td>
<a href="{% url 'domain_template_list' domain.mail_host %}" class="btn btn-xs btn-primary">{% trans 'Templates' %}</a>
<a href="{% url 'domain_edit' domain.mail_host %}" class="btn btn-xs btn-primary">{% trans 'Edit' %}</a>
......
{% extends "postorius/base.html" %}
{% load i18n %}
{% load bootstrap_tags %}
{% block head_title %}
{% trans 'Add domain owner ' %} - {{ block.super }}
{% endblock %}
{% block content %}
<div class="page-header">
<h2>{% trans 'Add a new owner to' %} {{ domain }}</h2>
</div>
<form action="{% url 'domain_owners' domain.mail_host %}" method="post" class="form-horizontal">
{% bootstrap_form_horizontal form 2 8 'Add Owner' %}
</form>
{% endblock content %}
......@@ -13,7 +13,11 @@
<ul class="nav nav-tabs margin-bottom" role="tablist">
{% for section in section_names %}
<li role="tab" {% if section.0 == visible_section %}class="active"{% endif %}><a href="{% url 'list_settings' list_id=list.list_id visible_section=section.0 %}">{{ section.1 }}</a></li>
<li role="tab" {% if section.0 == visible_section %}class="active"{% endif %}>
<a href="{% url 'list_settings' list_id=list.list_id visible_section=section.0 %}">
{{ section.1 }}
</a>
</li>
{% endfor %}
</ul>
......
......@@ -10,7 +10,9 @@
{% if user.is_superuser or user.is_list_owner or user.is_list_moderator %}
<li role="tab" class="{% nav_active_class current 'list_subscription_requests' %} dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true"
aria-expanded="false">{% trans 'Subscription requests' %}<span class="caret"></span>
aria-expanded="false">
{% trans 'Subscription requests' %}<span class="badge">{{ list|pending_subscriptions }}</span>
<span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li><a href="{% url 'list_subscription_requests' list.list_id %}">
......@@ -23,7 +25,7 @@
</li>
<li role="tab" class="{% nav_active_class current 'list_held_messages' %}">
<a href="{% url 'list_held_messages' list.list_id %}">
{% trans 'Held messages' %}
{% trans 'Held messages' %}<span class="badge"> {{ list|held_count }}</span>
</a>
</li>
{% endif %}
......
......@@ -60,3 +60,10 @@ def user_is_list_moderator(user, mlist):
the user is one of the list moderators, False otherwise.
"""
return user_is_in_list_roster(user, get_list(mlist), 'moderators')
@register.filter
def owner_repr(owner):
name = owner.display_name or ''
address = owner.addresses[0].original_email
return '{} {}'.format(name, address)
......@@ -43,3 +43,15 @@ def nav_active_class(context, current, view_name):
if current == view_name:
return 'active'
return ''
@register.filter
def held_count(mlist):
return mlist.get_held_page().total_size
@register.filter
def pending_subscriptions(mlist):
return len(list(r
for r in mlist.requests
if r['token_owner'] == 'moderator'))
......@@ -32,6 +32,7 @@ class DomainIndexPageTest(ViewTestCase):
def setUp(self):
super(DomainIndexPageTest, self).setUp()
self.domain = self.mm_client.create_domain('example.com')
self.domain.add_owner('person@domain.com')
try:
self.foo_list = self.domain.create_list('foo')
except HTTPError:
......@@ -51,25 +52,37 @@ class DomainIndexPageTest(ViewTestCase):
self.foo_list.add_owner('owner@example.com')
self.foo_list.add_moderator('moderator@example.com')
def test_domain_index_not_accessible_to_public(self):
response = self.client.get(reverse('domain_index'))
def _test_not_accesible_to_public(self, url):
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
def test_domain_index_not_accessible_to_unpriveleged_user(self):
def _test_not_accessible_to_unpriveleged_use(self, url):
self.client.login(username='testuser', password='testpass')
response = self.client.get(reverse('domain_index'))
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_domain_index_not_accessible_to_moderators(self):
def _test_not_accessible_to_moderators(self, url):
self.client.login(username='testmoderator', password='testpass')
response = self.client.get(reverse('domain_index'))
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_domain_index_not_accessible_to_owners(self):
def _test_not_accessible_to_owner(self, url):
self.client.login(username='testowner', password='testpass')
response = self.client.get(reverse('domain_index'))
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_domain_index_not_accessible_to_public(self):
self._test_not_accesible_to_public(reverse('domain_index'))
def test_domain_index_not_accessible_to_unpriveleged_user(self):
self._test_not_accessible_to_unpriveleged_use(reverse('domain_index'))
def test_domain_index_not_accessible_to_moderators(self):
self._test_not_accessible_to_moderators(reverse('domain_index'))
def test_domain_index_not_accessible_to_owners(self):
self._test_not_accessible_to_owner(reverse('domain_index'))
def test_contains_domains_and_site(self):
# The list index page should contain the lists
self.client.login(username='testsu', password='testpass')
......@@ -79,3 +92,37 @@ class DomainIndexPageTest(ViewTestCase):
self.assertContains(response, 'example.com')
self.assertTrue(
MailDomain.objects.filter(mail_domain='example.com').exists())
# Test there are owners are listed.
self.assertContains(response, 'person@domain.com')
def test_domain_add_owner_not_acceesible_to_anyone_but_superuser(self):
url = reverse('domain_owners', args=(self.domain.mail_host,))
self._test_not_accesible_to_public(url)
self._test_not_accessible_to_unpriveleged_use(url)
self._test_not_accessible_to_moderators(url)
self._test_not_accessible_to_owner(url)
self.client.login(username='testsu', password='testpass')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, b"Add a new owner to example.com")
response = self.client.post(
url,
dict(email='person@example.com'))
self.assertEqual(response.status_code, 302)
self.assertIn(
'person@example.com',
[owner.addresses[0].email for owner in self.domain.owners])
def test_domain_delete_owner_not_acceesible_to_anyone_but_superuser(self):
self.domain.add_owner('one@example.com')
self.domain.add_owner('two@example.com')
url = reverse('remove_domain_owner',
args=(self.domain.mail_host,
'person@domain.com'))
self.client.login(username='testsu', password='testpass')
response = self.client.post(url)
self.assertEqual(response.status_code, 302)
self.assertTrue(len(self.domain.owners), 2)
self.assertEqual(sorted(owner.addresses[0].email
for owner in self.domain.owners),
['one@example.com', 'two@example.com'])
......@@ -155,6 +155,8 @@ class ListSettingsTest(ViewTestCase):
'subject_prefix': '',
'description': '',
'advertised': 'True',
'preferred_language': 'en',
'member_roster_visibility': 'public',
})
self.assertRedirects(response, url)
self.assertHasSuccessMessage(response)
......
# -*- coding: utf-8 -*-
# Copyright (C) 2019 by the Free Software Foundation, Inc.
#
# This file is part of Postorius.
#
# Postorius is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Postorius is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Postorius. If not, see <http://www.gnu.org/licenses/>.
#
import time
from postorius.templatetags.nav_helpers import (
held_count, pending_subscriptions)
from postorius.tests.utils import ViewTestCase
class TestNavigationHelpers(ViewTestCase):
def setUp(self):
super().setUp()
# Create a domain.
self.domain = self.mm_client.create_domain('example.com')
self.mlist = self.domain.create_list('test_list')
def tearDown(self):
self.domain.delete()
def test_subscription_request_count(self):
# Initially, the count of held messages is 0.
self.assertEqual(pending_subscriptions(self.mlist), 0)
self.mlist.settings['subscription_policy'] = 'moderate'
self.mlist.settings.save()
self.mlist.subscribe('needsmoderation@example.com',
pre_verified=True)
self.assertEqual(pending_subscriptions(self.mlist), 1)
# Make sure the ones pending user approval don't show up.
self.mlist.settings['subscription_policy'] = 'confirm'
self.mlist.settings.save()
self.mlist.subscribe('needsconfirmation@example.com',
pre_verified=True)
self.assertEqual(pending_subscriptions(self.mlist), 1)
def test_held_message_count(self):
# Initially, the count of held messages is 0.
self.assertEqual(held_count(self.mlist), 0)
# Now, let's inject some message.
msg = """\
From: nonmember@example.com
To: test_list@example.com
Subject: What??
Message-ID: <moderated_01>
Hello.
"""
inq = self.mm_client.queues['in']
inq.inject('test_list.example.com', msg)
# Wait for the message to be processed.
while True:
if len(inq.files) == 0:
break
time.sleep(0.1)
# Wait for message to show up in held queue.
while True:
all_held = self.mlist.held
if len(all_held) > 0:
break
time.sleep(0.1)
self.assertEqual(held_count(self.mlist), 1)
......@@ -439,6 +439,30 @@ class TestTemplateAPIView(ViewTestCase):
pass
super().tearDown()
def test_get_empty_templates_via_API(self):
# Test that we can get a domain level template from API.
data = dict(name='list:admin:action:post',
context='domain',
identifier='example.com',
data='')
# First, let's create a template.
EmailTemplate.objects.create(**data)
# Now, let's try to GET this template.
url = reverse(
'rest_template',
kwargs=dict(
name=data['name'],
context=data['context'],
identifier=data['identifier']
)
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b'')
self.assertRegex(response["Content-Type"],
'charset=' + settings.DEFAULT_CHARSET)
self.assertNotRegex(response["Content-Type"], 'charset=$')
def test_get_one_domain_template_via_API(self):
# Test that we can get a domain level template from API.
data = dict(name='list:admin:action:post',
......
......@@ -20,7 +20,8 @@
from django.contrib.sites.models import Site
from django.test import TestCase
from postorius.forms import DomainEditForm, DomainForm
from postorius.forms.domain_forms import (
DomainEditForm, DomainForm, DomainOwnerForm)
class TestDomainEditForm(TestCase):
......@@ -32,6 +33,15 @@ class TestDomainEditForm(TestCase):
self.assertFalse('mail_host' in form.fields)
class TestDomainOwnerAddForm(TestCase):
def test_form_sanity(self):
form = DomainOwnerForm(dict(email='some'))
self.assertFalse(form.is_valid())
self.assertIn('email', form.errors)
form = DomainOwnerForm(dict(email='some@example.com'))
self.assertTrue(form.is_valid())
class TestDomainForm(TestCase):
def test_form_contains_mail_host(self):
form = DomainForm()
......
......@@ -235,6 +235,8 @@ class TestListIdentityForm(TestCase):
'info': 'This is a larger description of this mailing list.',
'display_name': 'Most Desirable Mailing List',
'subject_prefix': ' [Most Desirable] ',
'preferred_language': 'en',
'member_roster_visibility': 'public',
}, mlist=None)
self.assertFalse(form.is_valid())
self.assertTrue('advertised' in form.errors)
......@@ -423,7 +425,14 @@ class TestDigestSettingsForm(TestCase):
self.assertTrue(form.is_valid())
def test_all_fields(self):
pass
formdata = dict(
digests_enabled='True',
digests_send_periodic='True',
digests_volume_frequency='daily',
digest_size_threshold='10',
)
form = DigestSettingsForm(formdata, mlist=None)
self.assertTrue(form.is_valid())
class TestMessageAcceptanceForm(TestCase):
......
......@@ -108,6 +108,11 @@ urlpatterns = [
name='domain_edit'),
url(r'^domains/(?P<domain>[^/]+)/delete$', domain_views.domain_delete,
name='domain_delete'),
url(r'^domains/(?P<domain>[^/]+)/owners$', domain_views.domain_owners,
name='domain_owners'),
url(r'^domains/(?P<domain>[^/]+)/owners/(?P<user_id>.+)/remove$',
domain_views.remove_owners,
name='remove_domain_owner'),
# Ideally, these paths should be accessible by domain_owners, however,
# we don't have good ways to check that, so for now, this views are
# protected by superuser privileges.
......
......@@ -16,6 +16,7 @@
# You should have received a copy of the GNU General Public License along with
# Postorius. If not, see <http://www.gnu.org/licenses/>.
import logging
from django.contrib import messages
from django.contrib.auth.decorators import login_required
......@@ -24,14 +25,20 @@ from django.http import Http404
from django.shortcuts import redirect, render
from django.utils.six.moves.urllib.error import HTTPError
from django.utils.translation import gettext as _
from django.views.decorators.http import require_POST
from django_mailman3.lib.mailman import get_mailman_client
from django_mailman3.models import MailDomain
from postorius.auth.decorators import superuser_required
from postorius.forms import DomainEditForm, DomainForm
from postorius.forms.domain_forms import (
DomainEditForm, DomainForm, DomainOwnerForm)
from postorius.models import Domain, Mailman404Error
log = logging.getLogger(__name__)
@login_required
@superuser_required
def domain_index(request):
......@@ -137,3 +144,54 @@ def domain_delete(request, domain):
return render(request, 'postorius/domain/confirm_delete.html',
{'domain': domain_obj,
'lists': domain_lists_page})
@login_required
@superuser_required
def domain_owners(request, domain):
domain_obj = Domain.objects.get(mail_host=domain)
if request.method == 'POST':
form = DomainOwnerForm(request.POST)
if form.is_valid():
email = form.cleaned_data['email']
domain_obj.add_owner(email)
messages.success(request,
_('Added {} as an owner for {}'
).format(email, domain_obj.mail_host))
return redirect("domain_index")
else:
form = DomainOwnerForm()
return render(request, 'postorius/domain/owners.html',
{'domain': domain_obj,
'form': form})
@require_POST
@login_required
@superuser_required
def remove_owners(request, domain, user_id):
domain_obj = Domain.objects.get(mail_host=domain)
# Since there is no way to remove one single owner, we do the only possible
# thing, remove all owners and add the rest back.
client = get_mailman_client()
try:
remove_email = client.get_user(user_id).addresses[0].email
all_owners_emails = [owner.addresses[0].email
for owner in domain_obj.owners]
except (KeyError, ValueError) as e:
# We get KeyError if the user has no address due to [0].
log.error('Unable to delete owner: %s', str(e))
raise Http404(str(e))
if remove_email in all_owners_emails:
all_owners_emails.remove(remove_email)
else:
messages.error(_('{} is not an owner for {}').format(
remove_email, domain_obj.mail_host))
return redirect("domain_index")
domain_obj.remove_all_owners()
for owner in all_owners_emails:
domain_obj.add_owner(owner)
messages.success(request,
_('Removed {} as an owner for {}'
).format(remove_email, domain_obj.mail_host))
return redirect("domain_index")
......@@ -7,9 +7,9 @@ changedir = {toxinidir}/example_project
deps =
mock
beautifulsoup4
mailman
mailman
pytest
pytest-django
pytest-django
head: git+https://gitlab.com/mailman/mailmanclient.git
head: git+https://gitlab.com/mailman/django-mailman3.git
dev: -e../mailmanclient
......@@ -18,10 +18,10 @@ deps =
django111: Django>=1.11,<1.12
django20: Django>=2.0,<2.1
django21: Django>=2.1,<2.2
django22: Django>=2.2,<2.3
django22: Django>=2.2,<2.3
django-latest: https://github.com/django/django/archive/master.tar.gz
commands =
pytest {posargs:../src}
pytest {posargs:../src}
setenv =
LC_ALL = C.UTF-8
LANG = C.UTF-8
......