diff --git a/.travis.yml b/.travis.yml index 6f735f22a..c2636b754 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,7 @@ before_install: install: - python setup.py -q develop - pip install -q psycopg2 + - pip install -q -r test-requirements.txt before_script: - psql -c 'create database modoboa_test;' -U postgres diff --git a/modoboa/admin/api.py b/modoboa/admin/api.py new file mode 100644 index 000000000..2235ba2f1 --- /dev/null +++ b/modoboa/admin/api.py @@ -0,0 +1,22 @@ +"""Admin API.""" + +from rest_framework import viewsets +from rest_framework.permissions import DjangoModelPermissions + +from . import models +from . import serializers + + +class DomainViewSet(viewsets.ModelViewSet): + """ViewSet for Domain.""" + + permission_classes = [DjangoModelPermissions, ] + serializer_class = serializers.DomainSerializer + + def get_queryset(self): + """Filter queryset based on current user.""" + return models.Domain.objects.get_for_admin(self.request.user) + + def perform_destroy(self, instance): + """Add custom args to delete call.""" + instance.delete(self.request.user) diff --git a/modoboa/admin/forms/domain.py b/modoboa/admin/forms/domain.py index a8fc13311..7d4806168 100644 --- a/modoboa/admin/forms/domain.py +++ b/modoboa/admin/forms/domain.py @@ -22,7 +22,6 @@ from ..models import ( DOMAIN_TYPES = [ ("domain", _("Domain")), ] -DOMAIN_TYPES += events.raiseQueryEvent("ExtraDomainTypes") class DomainFormGeneral(forms.ModelForm, DynamicForm): @@ -30,7 +29,7 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): """A form to create/edit a domain.""" type = forms.ChoiceField( - label=ugettext_lazy("Type"), choices=DOMAIN_TYPES + label=ugettext_lazy("Type"), ) quota = forms.IntegerField( label=ugettext_lazy("Quota"), @@ -59,6 +58,9 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): self.oldname = kwargs["instance"].name super(DomainFormGeneral, self).__init__(*args, **kwargs) + self.fields["type"].choices = ( + DOMAIN_TYPES + events.raiseQueryEvent("ExtraDomainTypes")) + self.field_widths = { "quota": 3 } @@ -142,7 +144,7 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): q.delete() def save(self, user, commit=True, domalias_post_create=False): - """Custom save method + """Custom save method. Updating a domain may have consequences on other objects (domain alias, mailbox, quota). The most tricky part concerns @@ -150,46 +152,34 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): """ d = super(DomainFormGeneral, self).save(commit=False) - if commit: - old_mail_homes = None - if self.oldname is not None and d.name != self.oldname: - d.name = self.oldname - old_mail_homes = \ - dict((mb.id, mb.mail_home) for mb in d.mailbox_set.all()) - d.name = self.cleaned_data['name'] - d.save() - Mailbox.objects.filter(domain=d, use_domain_quota=True) \ - .update(quota=d.quota) - aliases = [] - for k, v in self.cleaned_data.iteritems(): - if not k.startswith("aliases"): - continue - if v in ["", None]: - continue - aliases.append(v) - for dalias in d.domainalias_set.all(): - if dalias.name not in aliases: - dalias.delete() + if not commit: + return d + d.save() + aliases = [] + for k, v in self.cleaned_data.iteritems(): + if not k.startswith("aliases"): + continue + if v in ["", None]: + continue + aliases.append(v) + for dalias in d.domainalias_set.all(): + if dalias.name not in aliases: + dalias.delete() + else: + aliases.remove(dalias.name) + if aliases: + events.raiseEvent( + "CanCreate", user, "domain_aliases", len(aliases)) + for alias in aliases: + try: + d.domainalias_set.get(name=alias) + except DomainAlias.DoesNotExist: + pass else: - aliases.remove(dalias.name) - if aliases: - events.raiseEvent( - "CanCreate", user, "domain_aliases", len(aliases)) - for alias in aliases: - try: - d.domainalias_set.get(name=alias) - except DomainAlias.DoesNotExist: - pass - else: - continue - al = DomainAlias(name=alias, target=d, enabled=d.enabled) - al.save(creator=user) if domalias_post_create else al.save() - - if old_mail_homes is not None: - self.update_mailbox_quotas(d) - for mb in d.mailbox_set.all(): - mb.rename_dir(old_mail_homes[mb.id]) - + continue + options = {"creator": user} if domalias_post_create else {} + DomainAlias(name=alias, target=d, enabled=d.enabled).save( + **options) return d diff --git a/modoboa/admin/handlers.py b/modoboa/admin/handlers.py index a767b0666..04e3b2175 100644 --- a/modoboa/admin/handlers.py +++ b/modoboa/admin/handlers.py @@ -6,6 +6,29 @@ from django.dispatch import receiver from . import models +@receiver(signals.post_save, sender=models.Domain) +def update_domain_mailboxes(sender, instance, **kwargs): + """Update associated mailboxes.""" + if kwargs.get("created"): + return + instance.mailbox_set.filter(use_domain_quota=True).update( + quota=instance.quota) + if instance.old_mail_homes is None: + return + qset = ( + models.Quota.objects.filter( + username__contains="@{}".format(instance.oldname)) + ) + for q in qset: + username = q.username.replace( + "@{}".format(instance.oldname), "@{}".format(instance.name)) + models.Quota.objects.create( + username=username, bytes=q.bytes, messages=q.messages) + q.delete() + for mb in instance.mailbox_set.all(): + mb.rename_dir(instance.old_mail_homes[mb.pk]) + + @receiver(signals.post_save, sender=models.DomainAlias) def create_alias_for_domainalias(sender, instance, **kwargs): """Create a dedicated alias for domain alias.""" diff --git a/modoboa/admin/models/domain.py b/modoboa/admin/models/domain.py index bbe6928f4..65267a6a9 100644 --- a/modoboa/admin/models/domain.py +++ b/modoboa/admin/models/domain.py @@ -29,7 +29,6 @@ class DomainManager(Manager): @python_2_unicode_compatible class Domain(AdminObject): - """Mail domain.""" name = models.CharField(ugettext_lazy('name'), max_length=100, unique=True, @@ -53,6 +52,12 @@ class Domain(AdminObject): ordering = ["name"] app_label = "admin" + def __init__(self, *args, **kwargs): + """Save name for further use.""" + super(Domain, self).__init__(*args, **kwargs) + self.old_mail_homes = None + self.oldname = self.name + @property def domainalias_count(self): return self.domainalias_set.count() @@ -124,9 +129,16 @@ class Domain(AdminObject): for al in self.alias_set.all(): ungrant_access_to_object(al, account) + def save(self, *args, **kwargs): + """Store current data if domain is renamed.""" + if self.oldname != self.name: + self.old_mail_homes = ( + dict((mb.id, mb.mail_home) for mb in self.mailbox_set.all()) + ) + super(Domain, self).save(*args, **kwargs) + def delete(self, fromuser, keepdir=False): - """Custom delete method. - """ + """Custom delete method.""" from modoboa.lib.permissions import ungrant_access_to_objects from .mailbox import Quota diff --git a/modoboa/admin/serializers.py b/modoboa/admin/serializers.py new file mode 100644 index 000000000..ca1a0ea4f --- /dev/null +++ b/modoboa/admin/serializers.py @@ -0,0 +1,14 @@ +"""Admin serializers.""" + +from rest_framework import serializers + +from . import models + + +class DomainSerializer(serializers.ModelSerializer): + + """Base Domain serializer.""" + + class Meta: + model = models.Domain + fields = ("pk", "name", "quota", "enabled", "type", ) diff --git a/modoboa/admin/tests/__init__.py b/modoboa/admin/tests/__init__.py index bdc06b647..b950e511d 100644 --- a/modoboa/admin/tests/__init__.py +++ b/modoboa/admin/tests/__init__.py @@ -2,6 +2,7 @@ from .account import AccountTestCase, PermissionsTestCase from .alias import AliasTestCase +from .api import APITestCase from .domain import DomainTestCase from .domain_alias import DomainAliasTestCase from .export import ExportTestCase @@ -13,6 +14,7 @@ from .user import ForwardTestCase __all__ = [ 'AccountTestCase', 'AliasTestCase', + 'APITestCase', 'DomainTestCase', 'DomainAliasTestCase', 'ExportTestCase', diff --git a/modoboa/admin/tests/api.py b/modoboa/admin/tests/api.py new file mode 100644 index 000000000..d6aa4c9b4 --- /dev/null +++ b/modoboa/admin/tests/api.py @@ -0,0 +1,79 @@ +"""Admin API related tests.""" + +from django.core.urlresolvers import reverse + +from rest_framework.authtoken.models import Token + +from modoboa.core import models as core_models +from modoboa.lib.tests import ModoAPITestCase + +from .. import factories +from .. import models + + +class APITestCase(ModoAPITestCase): + + """Check API.""" + + @classmethod + def setUpTestData(cls): + """Create test data.""" + super(APITestCase, cls).setUpTestData() + factories.populate_database() + cls.da_token = Token.objects.create( + user=core_models.User.objects.get(username="admin@test.com")) + + def test_get_domains(self): + """Retrieve a list of domains.""" + url = reverse("external_api:domain-list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 2) + url = reverse( + "external_api:domain-detail", args=[response.data[0]["pk"]]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["name"], "test2.com") + + def test_create_domain(self): + """Check domain creation.""" + url = reverse("external_api:domain-list") + response = self.client.post(url, {"name": "test3.com", "quota": 10}) + self.assertEqual(response.status_code, 201) + self.assertTrue( + models.Domain.objects.filter(name="test3.com").exists()) + response = self.client.post(url, {}) + self.assertEqual(response.status_code, 400) + self.assertIn("name", response.data) + self.assertIn("quota", response.data) + + self.client.credentials( + HTTP_AUTHORIZATION='Token ' + self.da_token.key) + response = self.client.post(url, {"name": "test4.com", "quota": 10}) + self.assertEqual(response.status_code, 403) + + def test_update_domain(self): + """Check domain update.""" + domain = models.Domain.objects.get(name="test.com") + models.Mailbox.objects.filter( + domain__name="test.com", address="user").update( + use_domain_quota=True) + url = reverse("external_api:domain-detail", args=[domain.pk]) + response = self.client.put(url, {"name": "test.com", "quota": 1000}) + self.assertEqual(response.status_code, 200) + domain.refresh_from_db() + self.assertEqual(domain.quota, 1000) + mb = models.Mailbox.objects.get( + domain__name="test.com", address="user") + self.assertEqual(mb.quota, 1000) + + response = self.client.put(url, {"name": "test42.com", "quota": 1000}) + self.assertEqual(response.status_code, 200) + + def test_delete_domain(self): + """Try to delete a domain.""" + domain = models.Domain.objects.get(name="test.com") + url = reverse("external_api:domain-detail", args=[domain.pk]) + response = self.client.delete(url) + self.assertEqual(response.status_code, 204) + self.assertFalse(models.Domain.objects.filter(pk=domain.pk).exists()) diff --git a/modoboa/admin/urls_api.py b/modoboa/admin/urls_api.py new file mode 100644 index 000000000..b3e1c33fd --- /dev/null +++ b/modoboa/admin/urls_api.py @@ -0,0 +1,11 @@ +"""Admin API urls.""" + +from rest_framework import routers + +from . import api + + +router = routers.SimpleRouter() +router.register(r"domains", api.DomainViewSet, base_name="domain") + +urlpatterns = router.urls diff --git a/modoboa/urls_api.py b/modoboa/urls_api.py index 5baa6af29..0027c4bd4 100644 --- a/modoboa/urls_api.py +++ b/modoboa/urls_api.py @@ -7,4 +7,5 @@ urlpatterns = patterns( "", ("", include("modoboa.core.urls_api")), + ("", include("modoboa.admin.urls_api")), ) diff --git a/requirements.txt b/requirements.txt index 941583e64..f265c3af9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ django-reversion==1.9.3 dj-database-url pycrypto argparse -factory-boy>=2.4 passlib django-versionfield2>=0.3.3 requests diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..e37c21246 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,3 @@ +factory-boy>=2.4 +testfixtures==4.7.0 +