mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-18 05:51:26 +02:00
Merge pull request #3537 from modoboa/cleanup/remove_old_interface
Remove legacy interface
This commit is contained in:
@@ -123,8 +123,6 @@ async function updateGeneralSettings() {
|
||||
.updateAccount(form.value)
|
||||
.then(() => {
|
||||
busStore.displayNotification({ msg: $gettext('Profile updated') })
|
||||
console.log(authStore.accountLanguage)
|
||||
console.log(available)
|
||||
if (!(authStore.accountLanguage in available)) {
|
||||
languageError.value = $gettext('Language does no exist')
|
||||
}
|
||||
|
||||
@@ -38,8 +38,16 @@ import rules from '@/plugins/rules.js'
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const props = defineProps({ modelValue: { type: Object, default: null } })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const domain = computed(() => props.modelValue)
|
||||
const domain = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value) {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const vFormRef = ref()
|
||||
|
||||
@@ -50,14 +58,14 @@ function cleanTransport(value) {
|
||||
}
|
||||
const domainTypes = [
|
||||
{
|
||||
label: 'Domain',
|
||||
label: $gettext('Domain'),
|
||||
icon: 'mdi-earth',
|
||||
value: $gettext('domain'),
|
||||
value: 'domain',
|
||||
},
|
||||
{
|
||||
label: 'Relay domain',
|
||||
label: $gettext('Relay domain'),
|
||||
icon: 'mdi-earth',
|
||||
value: $gettext('relaydomain'),
|
||||
value: 'relaydomain',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -85,6 +85,10 @@ class DomainSerializer(serializers.ModelSerializer):
|
||||
|
||||
def validate_name(self, value):
|
||||
"""Check name constraints."""
|
||||
if models.DomainAlias.objects.filter(name=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
_("domain alias with this name already exists")
|
||||
)
|
||||
domains_must_have_authorized_mx = param_tools.get_global_parameter(
|
||||
"domains_must_have_authorized_mx"
|
||||
)
|
||||
@@ -288,45 +292,12 @@ class AccountExistsSerializer(serializers.Serializer):
|
||||
exists = serializers.BooleanField()
|
||||
|
||||
|
||||
class AccountPasswordSerializer(serializers.ModelSerializer):
|
||||
"""A serializer used to change a user password."""
|
||||
|
||||
new_password = serializers.CharField()
|
||||
|
||||
class Meta:
|
||||
model = core_models.User
|
||||
fields = (
|
||||
"password",
|
||||
"new_password",
|
||||
)
|
||||
|
||||
def validate_password(self, value):
|
||||
"""Check password."""
|
||||
if not self.instance.check_password(value):
|
||||
raise serializers.ValidationError("Password not correct")
|
||||
return value
|
||||
|
||||
def validate_new_password(self, value):
|
||||
"""Check new password."""
|
||||
try:
|
||||
password_validation.validate_password(value, self.instance)
|
||||
except ValidationError as exc:
|
||||
raise serializers.ValidationError(exc.messages[0]) from None
|
||||
return value
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Set new password."""
|
||||
instance.set_password(validated_data["new_password"])
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class WritableAccountSerializer(AccountSerializer):
|
||||
"""Serializer to create account."""
|
||||
|
||||
random_password = serializers.BooleanField(default=False)
|
||||
random_password = serializers.BooleanField(default=False, write_only=True)
|
||||
role = serializers.ChoiceField(choices=core_constants.ROLES)
|
||||
password = serializers.CharField(required=False)
|
||||
password = serializers.CharField(required=False, write_only=True)
|
||||
|
||||
class Meta(AccountSerializer.Meta):
|
||||
fields = AccountSerializer.Meta.fields + ("password", "random_password")
|
||||
@@ -337,10 +308,6 @@ class WritableAccountSerializer(AccountSerializer):
|
||||
request = self.context.get("request")
|
||||
if not request:
|
||||
return
|
||||
user = self.context["request"].user
|
||||
self.fields["role"] = serializers.ChoiceField(
|
||||
choices=permissions.get_account_roles(user)
|
||||
)
|
||||
self.fields["domains"] = serializers.ListField(
|
||||
child=serializers.CharField(), allow_empty=False, required=False
|
||||
)
|
||||
@@ -349,6 +316,13 @@ class WritableAccountSerializer(AccountSerializer):
|
||||
"""Lower case username."""
|
||||
return value.lower()
|
||||
|
||||
def validate_role(self, value):
|
||||
user = self.context["request"].user
|
||||
allowed_values = permissions.get_account_roles(user, self.instance)
|
||||
if value not in [role[0] for role in allowed_values]:
|
||||
raise serializers.ValidationError("Invalid choice")
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
"""Check constraints."""
|
||||
master_user = data.get("master_user", False)
|
||||
@@ -439,7 +413,11 @@ class WritableAccountSerializer(AccountSerializer):
|
||||
mb = admin_models.Mailbox(
|
||||
user=account, address=self.address, domain=self.domain, **data
|
||||
)
|
||||
mb.set_quota(quota, creator.has_perm("admin.add_domain"))
|
||||
override_rules = (
|
||||
creator.has_perm("admin.add_domain")
|
||||
and not creator.userobjectlimit_set.get(name="quota").max_value
|
||||
)
|
||||
mb.set_quota(quota, override_rules)
|
||||
default_msg_limit = param_tools.get_global_parameter(
|
||||
"default_mailbox_message_limit"
|
||||
)
|
||||
@@ -527,7 +505,7 @@ class AliasSerializer(serializers.ModelSerializer):
|
||||
value, self.context["request"].user, instance=self.instance
|
||||
)
|
||||
except ValidationError as err:
|
||||
raise serializers.ValidationError(err) from None
|
||||
raise serializers.ValidationError(err.message) from None
|
||||
except AliasExists:
|
||||
raise serializers.ValidationError(_("This alias already exists")) from None
|
||||
return value.lower()
|
||||
|
||||
@@ -72,6 +72,24 @@ class DomainAPITestCase(ModoAPITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_create_utf8(self):
|
||||
"""Test the creation of a domain with non-ASCII characters."""
|
||||
url = reverse("v1:domain-list")
|
||||
values = {
|
||||
"name": "pouét.com",
|
||||
"quota": 1000,
|
||||
"default_mailbox_quota": 100,
|
||||
"type": "domain",
|
||||
}
|
||||
response = self.client.post(url, values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
dom = models.Domain.objects.get(name="pouét.com")
|
||||
self.assertEqual(dom.name, "pouét.com")
|
||||
self.assertEqual(dom.quota, 1000)
|
||||
self.assertEqual(dom.default_mailbox_quota, 100)
|
||||
self.assertTrue(dom.enabled)
|
||||
self.assertFalse(dom.admins)
|
||||
|
||||
@mock.patch.object(dns.resolver.Resolver, "resolve")
|
||||
@mock.patch("socket.getaddrinfo")
|
||||
def test_create_domain_with_mx_check(self, mock_getaddrinfo, mock_query):
|
||||
|
||||
@@ -14,6 +14,7 @@ from rest_framework.response import Response
|
||||
|
||||
from modoboa.core import models as core_models
|
||||
from modoboa.core import sms_backends
|
||||
from modoboa.core.api.v1 import serializers as core_serializers
|
||||
from modoboa.lib import renderers as lib_renderers
|
||||
from modoboa.lib import viewsets as lib_viewsets
|
||||
from modoboa.lib.throttle import GetThrottleViewsetMixin, PasswordResetRequestThrottle
|
||||
@@ -112,7 +113,7 @@ class AccountViewSet(
|
||||
action_dict = {
|
||||
"list": serializers.AccountSerializer,
|
||||
"retrieve": serializers.AccountSerializer,
|
||||
"password": serializers.AccountPasswordSerializer,
|
||||
"password": core_serializers.AccountPasswordSerializer,
|
||||
"reset_password": serializers.ResetPasswordSerializer,
|
||||
}
|
||||
return action_dict.get(self.action, serializers.WritableAccountSerializer)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Admin API v2 serializers."""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings
|
||||
@@ -21,10 +22,12 @@ from modoboa.lib import email_utils
|
||||
from modoboa.lib import exceptions as lib_exceptions
|
||||
from modoboa.lib import fields as lib_fields
|
||||
from modoboa.lib import validators, web_utils
|
||||
from modoboa.lib.permissions import get_object_owner
|
||||
from modoboa.lib.sysutils import exec_cmd
|
||||
from modoboa.parameters import tools as param_tools
|
||||
from modoboa.limits import constants as limits_constants
|
||||
from modoboa.limits import models as limits_models
|
||||
from modoboa.limits.lib import allocate_resources_from_user
|
||||
from modoboa.transport import models as transport_models
|
||||
from modoboa.transport.api.v2 import serializers as transport_serializers
|
||||
|
||||
@@ -69,6 +72,15 @@ class DomainSerializer(v1_serializers.DomainSerializer):
|
||||
many=True, source="domainobjectlimit_set", required=False
|
||||
)
|
||||
|
||||
def validate_enable_dkim(self, value):
|
||||
"""Check prerequisites."""
|
||||
if not value:
|
||||
return value
|
||||
storage_dir = param_tools.get_global_parameter("dkim_keys_storage_dir")
|
||||
if not storage_dir:
|
||||
raise ValidationError(_("DKIM keys storage directory not configured"))
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
result = super().validate(data)
|
||||
dtype = data.get("type", "domain")
|
||||
@@ -76,6 +88,21 @@ class DomainSerializer(v1_serializers.DomainSerializer):
|
||||
raise serializers.ValidationError(
|
||||
{"transport": _("This field is required")}
|
||||
)
|
||||
elif dtype == "domain" and data.get("transport"):
|
||||
raise serializers.ValidationError(
|
||||
{"transport": _("This field is valid when type is relaydomain")}
|
||||
)
|
||||
user = self.context["request"].user
|
||||
if user.role == "Resellers":
|
||||
limit = user.userobjectlimit_set.get(name="quota")
|
||||
if limit.max_value != 0:
|
||||
quota = data["quota"]
|
||||
msg = _("You can't define an unlimited quota.")
|
||||
if quota == 0:
|
||||
raise serializers.ValidationError({"quota": msg})
|
||||
default_mailbox_quota = data["default_mailbox_quota"]
|
||||
if default_mailbox_quota == 0:
|
||||
raise serializers.ValidationError({"default_mailbox_quota": msg})
|
||||
return result
|
||||
|
||||
def create(self, validated_data):
|
||||
@@ -89,10 +116,14 @@ class DomainSerializer(v1_serializers.DomainSerializer):
|
||||
transport.save()
|
||||
domain.transport = transport
|
||||
domain.save()
|
||||
|
||||
if not domain_admin:
|
||||
return domain
|
||||
|
||||
# 0. Check user limit
|
||||
user = self.context["request"].user
|
||||
core_signals.can_create_object.send(
|
||||
self.__class__, context=user, object_type="domain_admins"
|
||||
)
|
||||
# 1. Create a domain administrator
|
||||
username = "{}@{}".format(domain_admin["username"], domain.name)
|
||||
try:
|
||||
@@ -101,7 +132,6 @@ class DomainSerializer(v1_serializers.DomainSerializer):
|
||||
pass
|
||||
else:
|
||||
raise lib_exceptions.Conflict(_("User '%s' already exists") % username)
|
||||
user = self.context["request"].user
|
||||
core_signals.can_create_object.send(
|
||||
self.__class__, context=user, klass=models.Mailbox
|
||||
)
|
||||
@@ -150,7 +180,7 @@ class DomainSerializer(v1_serializers.DomainSerializer):
|
||||
"""Update domain and create/update transport."""
|
||||
transport_def = validated_data.pop("transport", None)
|
||||
resources = validated_data.pop("domainobjectlimit_set", None)
|
||||
super().update(instance, validated_data)
|
||||
instance = super().update(instance, validated_data)
|
||||
if transport_def and instance.type == "relaydomain":
|
||||
transport = getattr(instance, "transport", None)
|
||||
created = False
|
||||
@@ -185,7 +215,7 @@ class AdminGlobalParametersSerializer(serializers.Serializer):
|
||||
|
||||
# Domain settings
|
||||
enable_mx_checks = serializers.BooleanField(default=True)
|
||||
valid_mxs = serializers.CharField(allow_blank=True)
|
||||
valid_mxs = serializers.CharField(default="", allow_blank=True)
|
||||
domains_must_have_authorized_mx = serializers.BooleanField(default=False)
|
||||
enable_ipv6_mx_checks = serializers.BooleanField(default=True)
|
||||
enable_spf_checks = serializers.BooleanField(default=True)
|
||||
@@ -233,6 +263,8 @@ class AdminGlobalParametersSerializer(serializers.Serializer):
|
||||
raise serializers.ValidationError(
|
||||
_("openssl not found, please make sure it is installed.")
|
||||
)
|
||||
if not os.path.exists(value):
|
||||
raise serializers.ValidationError(_("Directory not found."))
|
||||
return value
|
||||
|
||||
def validate_valid_mxs(self, value):
|
||||
@@ -618,10 +650,13 @@ class WritableAccountSerializer(v1_serializers.WritableAccountSerializer):
|
||||
instance.save()
|
||||
resources = validated_data.get("resources")
|
||||
if resources:
|
||||
owner = get_object_owner(instance)
|
||||
for resource in resources:
|
||||
instance.userobjectlimit_set.filter(name=resource["name"]).update(
|
||||
max_value=resource["max_value"]
|
||||
)
|
||||
limit = instance.userobjectlimit_set.get(name=resource["name"])
|
||||
if not owner.is_superuser:
|
||||
allocate_resources_from_user(limit, owner, resource["max_value"])
|
||||
limit.max_value = resource["max_value"]
|
||||
limit.save()
|
||||
self.set_permissions(instance, domains)
|
||||
return instance
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.utils.encoding import force_str
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from modoboa.admin import factories, models, constants
|
||||
from modoboa.core import models as core_models
|
||||
from modoboa.core import factories as core_factories, models as core_models
|
||||
from modoboa.lib.tests import ModoAPITestCase
|
||||
|
||||
|
||||
@@ -98,6 +98,18 @@ class DomainViewSetTestCase(ModoAPITestCase):
|
||||
resp = self.client.post(url)
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
|
||||
def test_delete_auto_account_removal(self):
|
||||
self.set_global_parameter("auto_account_removal", True)
|
||||
dom = models.Domain.objects.get(name="test2.com")
|
||||
url = reverse("v2:domain-delete", args=[dom.pk])
|
||||
response = self.client.post(url, {}, format="json")
|
||||
self.assertEqual(response.status_code, 204)
|
||||
with self.assertRaises(models.Domain.DoesNotExist):
|
||||
dom.refresh_from_db()
|
||||
self.assertFalse(
|
||||
core_models.User.objects.filter(username="admin@test2.com").exists()
|
||||
)
|
||||
|
||||
def test_administrators(self):
|
||||
domain = models.Domain.objects.get(name="test.com")
|
||||
url = reverse("v2:domain-administrators", args=[domain.pk])
|
||||
@@ -169,8 +181,9 @@ class AccountViewSetTestCase(ModoAPITestCase):
|
||||
|
||||
def test_create(self):
|
||||
url = reverse("v2:account-list")
|
||||
username = "toto@test.com"
|
||||
data = {
|
||||
"username": "toto@test.com",
|
||||
"username": username,
|
||||
"role": "SimpleUsers",
|
||||
"mailbox": {"use_domain_quota": True},
|
||||
"password": "Toto12345",
|
||||
@@ -180,6 +193,31 @@ class AccountViewSetTestCase(ModoAPITestCase):
|
||||
resp = self.client.post(url, data, format="json")
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
self.assertTrue(models.Alias.objects.filter(address="alias3@test.com").exists())
|
||||
# Check if self alias has been created
|
||||
self.assertTrue(
|
||||
models.AliasRecipient.objects.select_related("alias")
|
||||
.filter(
|
||||
alias__address=username,
|
||||
address=username,
|
||||
alias__internal=True,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
|
||||
def test_create_with_existing_alias(self):
|
||||
alias = models.Alias.objects.get(address="alias@test.com")
|
||||
alias.set_recipients(["user@external.com"])
|
||||
values = {
|
||||
"username": "tester@test.com",
|
||||
"role": "SimpleUsers",
|
||||
"mailbox": {"use_domain_quota": True},
|
||||
"is_active": True,
|
||||
"password": "Toto1234",
|
||||
"aliases": ["alias@test.com"],
|
||||
}
|
||||
response = self.client.post(reverse("v2:account-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(alias.aliasrecipient_set.count() == 2)
|
||||
|
||||
def test_create_admin(self):
|
||||
url = reverse("v2:account-list")
|
||||
@@ -241,12 +279,44 @@ class AccountViewSetTestCase(ModoAPITestCase):
|
||||
self.assertIn("password", resp.json())
|
||||
|
||||
def test_delete(self):
|
||||
account = core_models.User.objects.get(username="user@test.com")
|
||||
username = "user@test.com"
|
||||
account = core_models.User.objects.get(username=username)
|
||||
url = reverse("v2:account-delete", args=[account.pk])
|
||||
resp = self.client.post(url)
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
with self.assertRaises(core_models.User.DoesNotExist):
|
||||
account.refresh_from_db()
|
||||
# Check if self alias has been deleted
|
||||
self.assertFalse(
|
||||
models.AliasRecipient.objects.select_related("alias")
|
||||
.filter(
|
||||
alias__address=username,
|
||||
address=username,
|
||||
alias__internal=True,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
|
||||
def test_delete_default_superadmin(self):
|
||||
"""Delete default superadmin."""
|
||||
sadmin2 = core_factories.UserFactory(username="admin2")
|
||||
sadmin2.role = "SuperAdmins"
|
||||
sadmin = core_models.User.objects.get(username="admin")
|
||||
self.authenticate_user(sadmin2)
|
||||
response = self.client.post(reverse("v2:account-delete", args=[sadmin.pk]), {})
|
||||
self.assertEqual(response.status_code, 204)
|
||||
values = {
|
||||
"username": "user@test.com",
|
||||
"role": "DomainAdmins",
|
||||
"is_active": True,
|
||||
"language": "en",
|
||||
"mailbox": {"use_domain_quota": True},
|
||||
}
|
||||
account = core_models.User.objects.get(username="user@test.com")
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[account.pk]), values, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_update(self):
|
||||
account = core_models.User.objects.get(username="user@test.com")
|
||||
@@ -261,6 +331,40 @@ class AccountViewSetTestCase(ModoAPITestCase):
|
||||
resp = self.client.patch(url, data, format="json")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_user_updates_password(self):
|
||||
account = core_models.User.objects.get(username="user@test.com")
|
||||
self.authenticate_user(account)
|
||||
response = self.client.post(
|
||||
reverse("v2:account-set-me-password"),
|
||||
{"password": "toto", "new_password": "Toto1234"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_rename(self):
|
||||
account = core_models.User.objects.get(username="user@test.com")
|
||||
url = reverse("v2:account-detail", args=[account.pk])
|
||||
new_username = "user_updated@test.com"
|
||||
data = {
|
||||
"username": new_username,
|
||||
"role": "SimpleUsers",
|
||||
"password": "Toto12345",
|
||||
"mailbox": {"quota": 10},
|
||||
"aliases": ["aliasupdate1@test.com"],
|
||||
}
|
||||
response = self.client.put(url, data, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Check if self alias has been updated
|
||||
self.assertTrue(
|
||||
models.AliasRecipient.objects.select_related("alias")
|
||||
.filter(
|
||||
alias__address=new_username,
|
||||
address=new_username,
|
||||
alias__internal=True,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
|
||||
def test_mailbox_options_update(self):
|
||||
account = core_models.User.objects.get(username="user@test.com")
|
||||
url = reverse("v2:account-detail", args=[account.pk])
|
||||
@@ -408,6 +512,49 @@ class AccountViewSetTestCase(ModoAPITestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(len(resp.json()["resources"]), 2)
|
||||
|
||||
def _set_quota(self, email, value, expected_status=200):
|
||||
account = core_models.User.objects.get(username=email)
|
||||
values = {
|
||||
"username": email,
|
||||
"role": "SimpleUsers",
|
||||
"mailbox": {"use_domain_quota": False, "quota": value},
|
||||
"is_active": True,
|
||||
"language": "en",
|
||||
}
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[account.id]), values, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
|
||||
def test_set_nul_quota_as_superadmin(self):
|
||||
self._set_quota("user@test.com", 0)
|
||||
|
||||
def test_set_nul_quota_as_domainadmin(self):
|
||||
"""Check cases where a domain admin set unlimited quota."""
|
||||
dadmin = core_models.User.objects.get(username="admin@test.com")
|
||||
self.authenticate_user(dadmin)
|
||||
# Fails because domain has a quota
|
||||
self._set_quota("user@test.com", 0, 400)
|
||||
|
||||
dadmin = core_models.User.objects.get(username="admin@test2.com")
|
||||
self.authenticate_user(dadmin)
|
||||
# Ok because domain has no quota
|
||||
self._set_quota("user@test2.com", 0)
|
||||
|
||||
def test_domain_quota(self):
|
||||
"""Check domain quota."""
|
||||
dom = models.Domain.objects.get(name="test.com")
|
||||
dom.quota = 100
|
||||
dom.save(update_fields=["quota"])
|
||||
# 2 x 10MB
|
||||
self.assertEqual(dom.allocated_quota, 20)
|
||||
self._set_quota("user@test.com", 80)
|
||||
del dom.allocated_quota
|
||||
# 10 + 80 < 100 => ok
|
||||
self.assertEqual(dom.allocated_quota, 90)
|
||||
# 30 + 80 > 100 => failure
|
||||
self._set_quota("admin@test.com", 30, 400)
|
||||
|
||||
|
||||
class IdentityViewSetTestCase(ModoAPITestCase):
|
||||
@classmethod
|
||||
@@ -525,6 +672,20 @@ class AliasViewSetTestCase(ModoAPITestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn("address", resp.json())
|
||||
|
||||
def test_create_on_existing_account(self):
|
||||
values = {
|
||||
"address": "user@test.com",
|
||||
"recipients": ["ext@doman.test"],
|
||||
"enabled": True,
|
||||
}
|
||||
response = self.client.post(reverse("v2:alias-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
# Check that internal alias has not been updated
|
||||
alias = models.Alias.objects.get(address="user@test.com", internal=True)
|
||||
self.assertTrue(alias.aliasrecipient_set.count() == 1)
|
||||
alias = models.Alias.objects.get(address="user@test.com", internal=False)
|
||||
self.assertTrue(alias.aliasrecipient_set.count() == 1)
|
||||
|
||||
|
||||
class UserAccountViewSetTestCase(ModoAPITestCase):
|
||||
@classmethod
|
||||
|
||||
+36
-274
@@ -1,282 +1,14 @@
|
||||
"""Modoboa admin settings."""
|
||||
|
||||
import collections
|
||||
import ipaddress
|
||||
import copy
|
||||
import os
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.translation import gettext as _, gettext_lazy
|
||||
from django.forms.fields import GenericIPAddressField
|
||||
from django.utils.translation import gettext_lazy
|
||||
|
||||
from modoboa.lib.form_utils import SeparatorField, YesNoField
|
||||
from modoboa.lib.sysutils import exec_cmd
|
||||
from modoboa.parameters import forms as param_forms
|
||||
|
||||
from . import constants
|
||||
|
||||
|
||||
class AdminParametersForm(param_forms.AdminParametersForm):
|
||||
app = "admin"
|
||||
|
||||
dom_sep = SeparatorField(label=gettext_lazy("Domains"))
|
||||
|
||||
enable_mx_checks = YesNoField(
|
||||
label=gettext_lazy("Enable MX checks"),
|
||||
initial=True,
|
||||
help_text=gettext_lazy("Check that every domain has a valid MX record"),
|
||||
)
|
||||
|
||||
valid_mxs = forms.CharField(
|
||||
label=gettext_lazy("Valid MXs"),
|
||||
initial="",
|
||||
help_text=gettext_lazy(
|
||||
"A list of IP or network address every MX record should match."
|
||||
" A warning will be sent if a record does not respect it."
|
||||
),
|
||||
widget=forms.Textarea,
|
||||
required=False,
|
||||
)
|
||||
|
||||
domains_must_have_authorized_mx = YesNoField(
|
||||
label=gettext_lazy("New domains must use authorized MX(s)"),
|
||||
initial=False,
|
||||
help_text=gettext_lazy(
|
||||
"Prevent the creation of a new domain if its MX record does "
|
||||
"not use one of the defined addresses."
|
||||
),
|
||||
)
|
||||
|
||||
enable_ipv6_mx_checks = YesNoField(
|
||||
label=gettext_lazy("Enable IPV6 checks"),
|
||||
initial=True,
|
||||
help_text=gettext_lazy("Check if MX setup for ipv6 is valid"),
|
||||
)
|
||||
|
||||
enable_spf_checks = YesNoField(
|
||||
label=gettext_lazy("Enable SPF checks"),
|
||||
initial=True,
|
||||
help_text=gettext_lazy("Check if every domain has a valid SPF record"),
|
||||
)
|
||||
|
||||
enable_dkim_checks = YesNoField(
|
||||
label=gettext_lazy("Enable DKIM checks"),
|
||||
initial=True,
|
||||
help_text=gettext_lazy(
|
||||
"Check if every domain with DKIM signin enabled has a valid DNS " "record"
|
||||
),
|
||||
)
|
||||
|
||||
enable_dmarc_checks = YesNoField(
|
||||
label=gettext_lazy("Enable DMARC checks"),
|
||||
initial=True,
|
||||
help_text=gettext_lazy("Check if every domain has a valid DMARC record"),
|
||||
)
|
||||
|
||||
enable_autoconfig_checks = YesNoField(
|
||||
label=gettext_lazy("Enable autoconfig checks"),
|
||||
initial=True,
|
||||
help_text=gettext_lazy(
|
||||
"Check if every domain has a valid records for autoconfiguration"
|
||||
),
|
||||
)
|
||||
|
||||
enable_dnsbl_checks = YesNoField(
|
||||
label=gettext_lazy("Enable DNSBL checks"),
|
||||
initial=True,
|
||||
help_text=gettext_lazy("Check every domain against major DNSBL providers"),
|
||||
)
|
||||
|
||||
custom_dns_server = GenericIPAddressField(
|
||||
label=gettext_lazy("Custom DNS server"),
|
||||
required=False,
|
||||
initial="",
|
||||
help_text=gettext_lazy(
|
||||
"Use a custom DNS server instead of local server configuration"
|
||||
),
|
||||
)
|
||||
|
||||
dkim_keys_storage_dir = forms.CharField(
|
||||
label=gettext_lazy("DKIM keys storage directory"),
|
||||
initial="",
|
||||
help_text=gettext_lazy(
|
||||
"Absolute path of the directory where DKIM private keys will "
|
||||
"be stored. Make sure this directory belongs to root user "
|
||||
"and is not readable by the outside world."
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
|
||||
dkim_default_key_length = forms.ChoiceField(
|
||||
label=gettext_lazy("Default DKIM key length"),
|
||||
initial=2048,
|
||||
choices=constants.DKIM_KEY_LENGTHS,
|
||||
help_text=gettext_lazy("Default length in bits for newly generated DKIM keys."),
|
||||
)
|
||||
|
||||
default_domain_quota = forms.IntegerField(
|
||||
label=gettext_lazy("Default domain quota"),
|
||||
initial=0,
|
||||
help_text=gettext_lazy(
|
||||
"Default quota (in MB) applied to freshly created domains with no "
|
||||
"value specified. A value of 0 means no quota."
|
||||
),
|
||||
)
|
||||
|
||||
default_domain_message_limit = forms.IntegerField(
|
||||
label=gettext_lazy("Default domain sending limit"),
|
||||
required=False,
|
||||
help_text=gettext_lazy(
|
||||
"Number of messages freshly created domains will be "
|
||||
"allowed to send per day. Leave empty for no limit."
|
||||
),
|
||||
)
|
||||
|
||||
mbsep = SeparatorField(label=gettext_lazy("Mailboxes"))
|
||||
|
||||
handle_mailboxes = YesNoField(
|
||||
label=gettext_lazy("Handle mailboxes on filesystem"),
|
||||
initial=False,
|
||||
help_text=gettext_lazy(
|
||||
"Rename or remove mailboxes on the filesystem when they get"
|
||||
" renamed or removed within Modoboa"
|
||||
),
|
||||
)
|
||||
|
||||
default_mailbox_quota = forms.IntegerField(
|
||||
label=gettext_lazy("Default mailbox quota"),
|
||||
initial=0,
|
||||
help_text=gettext_lazy(
|
||||
"Default mailbox quota (in MB) applied to freshly created "
|
||||
"mailboxes with no value specified. A value of 0 means no quota."
|
||||
),
|
||||
)
|
||||
|
||||
default_mailbox_message_limit = forms.IntegerField(
|
||||
label=gettext_lazy("Default mailbox sending limit"),
|
||||
required=False,
|
||||
help_text=gettext_lazy(
|
||||
"Number of messages freshly created mailboxes will be "
|
||||
"allowed to send per day. Leave empty for no limit."
|
||||
),
|
||||
)
|
||||
|
||||
auto_account_removal = YesNoField(
|
||||
label=gettext_lazy("Automatic account removal"),
|
||||
initial=False,
|
||||
help_text=gettext_lazy(
|
||||
"When a mailbox is removed, also remove the associated account"
|
||||
),
|
||||
)
|
||||
|
||||
auto_create_domain_and_mailbox = YesNoField(
|
||||
label=gettext_lazy("Automatic domain/mailbox creation"),
|
||||
initial=True,
|
||||
help_text=gettext_lazy(
|
||||
"Create a domain and a mailbox when an account is automatically " "created."
|
||||
),
|
||||
)
|
||||
|
||||
create_alias_on_mbox_rename = YesNoField(
|
||||
label=gettext_lazy("Create an alias when a mailbox is renamed"),
|
||||
initial=False,
|
||||
help_text=gettext_lazy(
|
||||
"Create an alias using the old address when a mailbox is renamed."
|
||||
),
|
||||
)
|
||||
|
||||
# Visibility rules
|
||||
visibility_rules = {
|
||||
"valid_mxs": "enable_mx_checks=True",
|
||||
"domains_must_have_authorized_mx": "enable_mx_checks=True",
|
||||
"enable_ipv6_mx_checks": "enable_mx_checks=True",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.field_widths = {"default_domain_quota": 2, "default_mailbox_quota": 2}
|
||||
hide_fields = False
|
||||
dpath = None
|
||||
code, output = exec_cmd("which dovecot")
|
||||
if not code:
|
||||
dpath = force_str(output).strip()
|
||||
else:
|
||||
known_paths = getattr(
|
||||
settings,
|
||||
"DOVECOT_LOOKUP_PATH",
|
||||
("/usr/sbin/dovecot", "/usr/local/sbin/dovecot"),
|
||||
)
|
||||
for fpath in known_paths:
|
||||
if os.path.isfile(fpath) and os.access(fpath, os.X_OK):
|
||||
dpath = fpath
|
||||
if dpath:
|
||||
try:
|
||||
code, version = exec_cmd(f"{dpath} --version")
|
||||
except OSError:
|
||||
hide_fields = True
|
||||
else:
|
||||
version = force_str(version)
|
||||
if code or not version.strip().startswith("2"):
|
||||
hide_fields = True
|
||||
else:
|
||||
hide_fields = True
|
||||
if hide_fields:
|
||||
del self.fields["handle_mailboxes"]
|
||||
|
||||
def clean_default_domain_quota(self):
|
||||
"""Ensure quota is a positive integer."""
|
||||
if self.cleaned_data["default_domain_quota"] < 0:
|
||||
raise forms.ValidationError(gettext_lazy("Must be a positive integer"))
|
||||
return self.cleaned_data["default_domain_quota"]
|
||||
|
||||
def clean_default_mailbox_quota(self):
|
||||
"""Ensure quota is a positive integer."""
|
||||
if self.cleaned_data["default_mailbox_quota"] < 0:
|
||||
raise forms.ValidationError(gettext_lazy("Must be a positive integer"))
|
||||
return self.cleaned_data["default_mailbox_quota"]
|
||||
|
||||
def clean_dkim_keys_storage_dir(self):
|
||||
"""Check that directory exists."""
|
||||
storage_dir = self.cleaned_data.get("dkim_keys_storage_dir", "")
|
||||
if storage_dir:
|
||||
if not os.path.isdir(storage_dir):
|
||||
raise forms.ValidationError(gettext_lazy("Directory not found."))
|
||||
code, output = exec_cmd("which openssl")
|
||||
if code:
|
||||
raise forms.ValidationError(
|
||||
gettext_lazy("openssl not found, please make sure it is installed.")
|
||||
)
|
||||
return storage_dir
|
||||
|
||||
def clean_valid_mxs(self):
|
||||
"""Make sure it only contains IP addresses."""
|
||||
value = self.cleaned_data.get("valid_mxs")
|
||||
if not value:
|
||||
return value
|
||||
try:
|
||||
[ipaddress.ip_network(v.strip()) for v in value.split() if v.strip()]
|
||||
except ValueError:
|
||||
raise forms.ValidationError(
|
||||
gettext_lazy("This field only allows valid IP addresses (or networks)")
|
||||
) from None
|
||||
return value
|
||||
|
||||
def clean(self):
|
||||
"""Check MX options."""
|
||||
cleaned_data = super().clean()
|
||||
condition = (
|
||||
cleaned_data.get("enable_mx_checks")
|
||||
and cleaned_data.get("domains_must_have_authorized_mx")
|
||||
and not cleaned_data.get("valid_mxs")
|
||||
)
|
||||
if condition:
|
||||
self.add_error(
|
||||
"valid_mxs", _("Define at least one authorized network / address")
|
||||
)
|
||||
if not cleaned_data.get("enable_mx_checks"):
|
||||
cleaned_data["enable_ipv6_mx_checks"] = False
|
||||
return cleaned_data
|
||||
|
||||
|
||||
GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict(
|
||||
@@ -511,18 +243,48 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict(
|
||||
)
|
||||
|
||||
|
||||
def init_structure() -> dict:
|
||||
structure = copy.deepcopy(GLOBAL_PARAMETERS_STRUCT)
|
||||
hide_fields = False
|
||||
dpath = None
|
||||
code, output = exec_cmd("which dovecot")
|
||||
if not code:
|
||||
dpath = force_str(output).strip()
|
||||
else:
|
||||
known_paths = getattr(
|
||||
settings,
|
||||
"DOVECOT_LOOKUP_PATH",
|
||||
("/usr/sbin/dovecot", "/usr/local/sbin/dovecot"),
|
||||
)
|
||||
for fpath in known_paths:
|
||||
if os.path.isfile(fpath) and os.access(fpath, os.X_OK):
|
||||
dpath = fpath
|
||||
if dpath:
|
||||
try:
|
||||
code, version = exec_cmd(f"{dpath} --version")
|
||||
except OSError:
|
||||
hide_fields = True
|
||||
else:
|
||||
version = force_str(version)
|
||||
if code or not version.strip().startswith("2"):
|
||||
hide_fields = True
|
||||
else:
|
||||
hide_fields = True
|
||||
if hide_fields:
|
||||
del structure["mailboxes"]["params"]["handle_mailboxes"]
|
||||
return structure
|
||||
|
||||
|
||||
def load_admin_settings():
|
||||
"""Load admin settings."""
|
||||
from modoboa.parameters import tools as param_tools
|
||||
from .api.v2 import serializers
|
||||
|
||||
param_tools.registry.add(
|
||||
"global", AdminParametersForm, gettext_lazy("Administration")
|
||||
)
|
||||
structure = init_structure()
|
||||
param_tools.registry.add2(
|
||||
"global",
|
||||
"admin",
|
||||
gettext_lazy("Administration"),
|
||||
GLOBAL_PARAMETERS_STRUCT,
|
||||
structure,
|
||||
serializers.AdminGlobalParametersSerializer,
|
||||
)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
from .account import (
|
||||
AccountForm,
|
||||
AccountFormGeneral,
|
||||
AccountFormMail,
|
||||
AccountPermissionsForm,
|
||||
AccountWizard,
|
||||
)
|
||||
from .alias import AliasForm
|
||||
from .domain import DomainForm, DomainFormGeneral, DomainFormOptions, DomainWizard
|
||||
from .export import ExportDataForm
|
||||
from .forward import ForwardForm
|
||||
from .import_ import ImportDataForm, ImportIdentitiesForm
|
||||
|
||||
__all__ = [
|
||||
"DomainFormGeneral",
|
||||
"DomainFormOptions",
|
||||
"DomainForm",
|
||||
"AccountFormGeneral",
|
||||
"AccountFormMail",
|
||||
"AccountPermissionsForm",
|
||||
"AccountForm",
|
||||
"AliasForm",
|
||||
"ImportDataForm",
|
||||
"ImportIdentitiesForm",
|
||||
"ExportDataForm",
|
||||
"ForwardForm",
|
||||
"DomainWizard",
|
||||
"AccountWizard",
|
||||
]
|
||||
@@ -1,679 +0,0 @@
|
||||
"""Forms related to accounts management."""
|
||||
|
||||
from collections import OrderedDict
|
||||
from functools import reduce
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import password_validation
|
||||
from django.http import QueryDict
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _, gettext_lazy
|
||||
|
||||
from modoboa.core import signals as core_signals
|
||||
from modoboa.core.models import User
|
||||
from modoboa.lib import exceptions as lib_exceptions, fields as lib_fields
|
||||
from modoboa.lib.email_utils import split_mailbox
|
||||
from modoboa.lib.form_utils import DynamicForm, TabForms, WizardForm, WizardStep
|
||||
from modoboa.lib.permissions import get_account_roles
|
||||
from modoboa.lib.validators import validate_utf8_email
|
||||
from modoboa.lib.web_utils import render_to_json_response, size2integer
|
||||
from modoboa.parameters import tools as param_tools
|
||||
from .. import lib, models, signals
|
||||
|
||||
|
||||
class AccountFormGeneral(forms.ModelForm):
|
||||
"""General account form."""
|
||||
|
||||
username = forms.CharField(
|
||||
label=gettext_lazy("Username"),
|
||||
help_text=gettext_lazy(
|
||||
"The user's name. Must be a valid e-mail address for simple users "
|
||||
"or administrators with a mailbox."
|
||||
),
|
||||
)
|
||||
role = forms.ChoiceField(
|
||||
label=gettext_lazy("Role"),
|
||||
choices=[("", gettext_lazy("Choose"))],
|
||||
help_text=gettext_lazy("What level of permission this user will have"),
|
||||
)
|
||||
random_password = forms.BooleanField(
|
||||
label=gettext_lazy("Random password"),
|
||||
help_text=gettext_lazy(
|
||||
"Generate a random password. If you're updating this account and "
|
||||
"check this box, a new password will be generated."
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
password1 = forms.CharField(
|
||||
label=gettext_lazy("Password"),
|
||||
widget=forms.widgets.PasswordInput,
|
||||
required=False,
|
||||
)
|
||||
|
||||
password2 = forms.CharField(
|
||||
label=gettext_lazy("Confirmation"),
|
||||
widget=forms.widgets.PasswordInput,
|
||||
help_text=gettext_lazy("Enter the same password as above, for verification."),
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
"username",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"role",
|
||||
"is_active",
|
||||
"master_user",
|
||||
)
|
||||
labels = {"is_active": gettext_lazy("Enabled")}
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields = OrderedDict(
|
||||
(key, self.fields[key])
|
||||
for key in [
|
||||
"role",
|
||||
"username",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"random_password",
|
||||
"password1",
|
||||
"password2",
|
||||
"master_user",
|
||||
"is_active",
|
||||
]
|
||||
)
|
||||
self.user = user
|
||||
condition = (
|
||||
user.role == "DomainAdmins"
|
||||
or user.role == "Resellers"
|
||||
and self.instance == user
|
||||
)
|
||||
if condition:
|
||||
self.fields["role"] = forms.CharField(
|
||||
label="",
|
||||
widget=forms.HiddenInput(attrs={"class": "form-control"}),
|
||||
required=False,
|
||||
)
|
||||
else:
|
||||
self.fields["role"].choices += (
|
||||
get_account_roles(user, self.instance)
|
||||
if self.instance.pk
|
||||
else get_account_roles(user)
|
||||
)
|
||||
|
||||
if not user.is_superuser:
|
||||
del self.fields["master_user"]
|
||||
|
||||
if not self.instance.pk:
|
||||
return
|
||||
|
||||
domain_disabled = (
|
||||
hasattr(self.instance, "mailbox")
|
||||
and not self.instance.mailbox.domain.enabled
|
||||
)
|
||||
if domain_disabled:
|
||||
self.fields["is_active"].widget.attrs["disabled"] = "disabled"
|
||||
if args and domain_disabled:
|
||||
del self.fields["is_active"]
|
||||
self.fields["role"].initial = self.instance.role
|
||||
condition = (
|
||||
not self.instance.is_local
|
||||
and param_tools.get_global_parameter("ldap_auth_method", app="core")
|
||||
== "directbind"
|
||||
)
|
||||
if condition:
|
||||
del self.fields["random_password"]
|
||||
del self.fields["password1"]
|
||||
del self.fields["password2"]
|
||||
|
||||
def domain_is_disabled(self):
|
||||
"""Little shortcut to get the domain's state.
|
||||
|
||||
We need this information inside a template and the form is the
|
||||
only object available...
|
||||
|
||||
"""
|
||||
if not hasattr(self.instance, "mailbox"):
|
||||
return False
|
||||
return not self.instance.mailbox.domain.enabled
|
||||
|
||||
def clean_role(self):
|
||||
if self.user.role == "DomainAdmins":
|
||||
if self.instance == self.user:
|
||||
return "DomainAdmins"
|
||||
return "SimpleUsers"
|
||||
elif self.user.role == "Resellers" and self.instance == self.user:
|
||||
return "Resellers"
|
||||
return self.cleaned_data["role"]
|
||||
|
||||
def clean_username(self):
|
||||
"""username must be a valid email address for simple users."""
|
||||
username = self.cleaned_data["username"].lower()
|
||||
if "role" not in self.cleaned_data:
|
||||
return username
|
||||
if self.cleaned_data["role"] != "SimpleUsers" and "@" not in username:
|
||||
return username
|
||||
username = username.lower()
|
||||
validate_utf8_email(username)
|
||||
return username
|
||||
|
||||
def clean(self):
|
||||
"""Check master user mode."""
|
||||
super().clean()
|
||||
if self.errors:
|
||||
return self.cleaned_data
|
||||
condition = (
|
||||
self.cleaned_data.get("master_user")
|
||||
and self.cleaned_data["role"] != "SuperAdmins"
|
||||
)
|
||||
if condition:
|
||||
self.add_error(
|
||||
"master_user", _("Only super administrators are allowed for this mode")
|
||||
)
|
||||
random_password = self.cleaned_data.get("random_password")
|
||||
if random_password:
|
||||
self.cleaned_data["password2"] = lib.make_password()
|
||||
elif "random_password" in self.fields and not random_password:
|
||||
password1 = self.cleaned_data.get("password1", "")
|
||||
password2 = self.cleaned_data.get("password2", "")
|
||||
empty_password = password1 == "" and password2 == ""
|
||||
if not self.instance.pk or not empty_password:
|
||||
if not password1:
|
||||
self.add_error("password1", _("This field is required."))
|
||||
if not password2:
|
||||
self.add_error("password2", _("This field is required."))
|
||||
if self.errors:
|
||||
return self.cleaned_data
|
||||
if password1 != password2:
|
||||
self.add_error(
|
||||
"password2", _("The two password fields didn't match.")
|
||||
)
|
||||
return self.cleaned_data
|
||||
try:
|
||||
password_validation.validate_password(password2, self.instance)
|
||||
except forms.ValidationError as ve:
|
||||
self.add_error("password2", ve.messages)
|
||||
return self.cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
account = super().save(commit=False)
|
||||
if self.user == account and not self.cleaned_data["is_active"]:
|
||||
raise lib_exceptions.PermDeniedException(
|
||||
_("You can't disable your own account")
|
||||
)
|
||||
if not account.pk:
|
||||
account.language = settings.LANGUAGE_CODE
|
||||
if commit:
|
||||
if self.cleaned_data.get("password2", "") != "":
|
||||
account.set_password(self.cleaned_data["password2"])
|
||||
account.save()
|
||||
account.role = self.cleaned_data["role"]
|
||||
if hasattr(account, "mailbox"):
|
||||
# Update forward status according to account status
|
||||
models.Alias.objects.filter(address=account.email).update(
|
||||
enabled=account.is_active
|
||||
)
|
||||
return account
|
||||
|
||||
|
||||
class AccountProfileForm(forms.ModelForm):
|
||||
"""Form to edit account profile."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("secondary_email", "phone_number", "language")
|
||||
|
||||
|
||||
class AccountFormMail(forms.Form, DynamicForm):
|
||||
"""Form to handle mail part."""
|
||||
|
||||
email = lib_fields.UTF8EmailField(label=gettext_lazy("E-mail"), required=False)
|
||||
create_alias_with_old_address = forms.BooleanField(
|
||||
label=gettext_lazy("Create an alias using the old address"),
|
||||
required=False,
|
||||
initial=False,
|
||||
)
|
||||
quota = forms.CharField(
|
||||
label=gettext_lazy("Quota"),
|
||||
required=False,
|
||||
help_text=_(
|
||||
"Quota for this mailbox, can be expressed in KB, MB (default) or "
|
||||
"GB. Define a custom value or "
|
||||
"use domain's default one. Leave empty to define an "
|
||||
"unlimited value (not allowed for domain "
|
||||
"administrators)."
|
||||
),
|
||||
widget=forms.widgets.TextInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
quota_act = forms.BooleanField(required=False)
|
||||
message_limit = forms.IntegerField(
|
||||
label=gettext_lazy("Message sending limit"),
|
||||
required=False,
|
||||
min_value=0,
|
||||
help_text=gettext_lazy("Number of messages this mailbox can send per day"),
|
||||
)
|
||||
is_send_only = forms.BooleanField(
|
||||
required=False,
|
||||
help_text=gettext_lazy(
|
||||
"Block IMAP connection and incoming mails for this mailbox"
|
||||
),
|
||||
)
|
||||
aliases = lib_fields.UTF8AndEmptyUserEmailField(
|
||||
label=gettext_lazy("Alias(es)"),
|
||||
required=False,
|
||||
help_text=gettext_lazy(
|
||||
"Alias(es) of this mailbox. Indicate only one address per input, "
|
||||
"press ENTER to add a new input. To create a catchall alias, just "
|
||||
"enter the domain name (@domain.tld)."
|
||||
),
|
||||
)
|
||||
senderaddress = lib_fields.UTF8AndEmptyUserEmailField(
|
||||
label=gettext_lazy("Sender addresses"),
|
||||
required=False,
|
||||
help_text=gettext_lazy(
|
||||
"Additional sender address(es) for this account. The user will be "
|
||||
"allowed to send emails using this address, even if it "
|
||||
"does not exist locally. Indicate one address per input. Press "
|
||||
"ENTER to add a new input."
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.mb = kwargs.pop("instance", None)
|
||||
self.user = user
|
||||
super().__init__(*args, **kwargs)
|
||||
self.field_widths = {"quota": 3}
|
||||
params = dict(param_tools.get_global_parameters("admin"))
|
||||
if self.mb is not None:
|
||||
self.fields["email"].required = True
|
||||
qset = self.mb.aliasrecipient_set.filter(alias__internal=False)
|
||||
for cpt, ralias in enumerate(qset):
|
||||
name = f"aliases_{cpt + 1}"
|
||||
self._create_field(
|
||||
lib_fields.UTF8AndEmptyUserEmailField, name, ralias.alias.address
|
||||
)
|
||||
for cpt, saddress in enumerate(self.mb.senderaddress_set.all()):
|
||||
name = f"senderaddress_{cpt + 1}"
|
||||
self._create_field(
|
||||
lib_fields.UTF8AndEmptyUserEmailField, name, saddress.address
|
||||
)
|
||||
self.fields["email"].initial = self.mb.full_address
|
||||
self.fields["quota_act"].initial = self.mb.use_domain_quota
|
||||
if not self.mb.use_domain_quota and self.mb.quota:
|
||||
self.fields["quota"].initial = self.mb.quota
|
||||
if self.mb.message_limit:
|
||||
self.fields["message_limit"].initial = self.mb.message_limit
|
||||
self.fields["is_send_only"].initial = self.mb.is_send_only
|
||||
self.fields["create_alias_with_old_address"].initial = params[
|
||||
"create_alias_on_mbox_rename"
|
||||
]
|
||||
else:
|
||||
del self.fields["create_alias_with_old_address"]
|
||||
self.fields["quota_act"].initial = True
|
||||
if params["default_mailbox_message_limit"] is not None:
|
||||
self.fields["message_limit"].initial = params[
|
||||
"default_mailbox_message_limit"
|
||||
]
|
||||
|
||||
if len(args) and isinstance(args[0], QueryDict):
|
||||
self._load_from_qdict(
|
||||
args[0], "aliases", lib_fields.UTF8AndEmptyUserEmailField
|
||||
)
|
||||
self._load_from_qdict(
|
||||
args[0], "senderaddress", lib_fields.UTF8AndEmptyUserEmailField
|
||||
)
|
||||
|
||||
def clean_email(self):
|
||||
"""Ensure lower case emails"""
|
||||
email = self.cleaned_data["email"].lower()
|
||||
self.locpart, domname = split_mailbox(email)
|
||||
if not domname:
|
||||
return email
|
||||
try:
|
||||
self.domain = models.Domain.objects.get(name=domname)
|
||||
except models.Domain.DoesNotExist:
|
||||
raise forms.ValidationError(_("Domain does not exist")) from None
|
||||
if not self.mb:
|
||||
try:
|
||||
core_signals.can_create_object.send(
|
||||
sender=self.__class__, context=self.domain, object_type="mailboxes"
|
||||
)
|
||||
except lib_exceptions.ModoboaException as inst:
|
||||
raise forms.ValidationError(inst) from None
|
||||
return email
|
||||
|
||||
def clean_quota(self):
|
||||
"""Convert quota to Bytes."""
|
||||
return size2integer(self.cleaned_data["quota"], output_unit="MB")
|
||||
|
||||
def clean(self):
|
||||
"""Custom fields validation.
|
||||
|
||||
Check if quota is >= 0 only when the domain value is not used.
|
||||
"""
|
||||
cleaned_data = super().clean()
|
||||
use_default_domain_quota = cleaned_data["quota_act"]
|
||||
condition = (
|
||||
not use_default_domain_quota
|
||||
and cleaned_data["quota"] is not None
|
||||
and cleaned_data["quota"] < 0
|
||||
)
|
||||
if condition:
|
||||
self.add_error("quota", _("Must be a positive integer"))
|
||||
self.aliases = []
|
||||
self.sender_addresses = []
|
||||
for name, value in list(cleaned_data.items()):
|
||||
if value == "":
|
||||
continue
|
||||
if name.startswith("aliases"):
|
||||
local_part, domname = split_mailbox(value)
|
||||
domain = models.Domain.objects.filter(name=domname).first()
|
||||
if not domain:
|
||||
self.add_error(name, _("Local domain does not exist"))
|
||||
continue
|
||||
if not self.user.can_access(domain):
|
||||
self.add_error(name, _("You don't have access to this domain"))
|
||||
continue
|
||||
self.aliases.append(value.lower())
|
||||
elif name.startswith("senderaddress"):
|
||||
local_part, domname = split_mailbox(value)
|
||||
domain = models.Domain.objects.filter(name=domname).first()
|
||||
if domain and not self.user.can_access(domain):
|
||||
self.add_error(name, _("You don't have access to this domain"))
|
||||
continue
|
||||
self.sender_addresses.append(value.lower())
|
||||
return cleaned_data
|
||||
|
||||
def create_mailbox(self, user, account):
|
||||
"""Create a mailbox associated to :kw:`account`."""
|
||||
if not user.can_access(self.domain):
|
||||
raise lib_exceptions.PermDeniedException
|
||||
core_signals.can_create_object.send(
|
||||
self.__class__, context=user, klass=models.Mailbox
|
||||
)
|
||||
self.mb = models.Mailbox(
|
||||
address=self.locpart,
|
||||
domain=self.domain,
|
||||
user=account,
|
||||
use_domain_quota=self.cleaned_data["quota_act"],
|
||||
message_limit=self.cleaned_data.get("message_limit"),
|
||||
is_send_only=self.cleaned_data.get("is_send_only", False),
|
||||
)
|
||||
override_rules = (
|
||||
user.is_superuser
|
||||
or user.has_perm("admin.add_domain")
|
||||
and not user.userobjectlimit_set.get(name="quota").max_value
|
||||
)
|
||||
self.mb.set_quota(self.cleaned_data["quota"], override_rules)
|
||||
self.mb.save(creator=user)
|
||||
|
||||
def _update_aliases(self, user, account):
|
||||
"""Update mailbox aliases."""
|
||||
# 1. We check if existing aliases were removed...
|
||||
qset = self.mb.aliasrecipient_set.select_related("alias").filter(
|
||||
alias__internal=False
|
||||
)
|
||||
for ralias in qset:
|
||||
if ralias.alias.address not in self.aliases:
|
||||
alias = ralias.alias
|
||||
ralias.delete()
|
||||
if alias.recipients_count > 0:
|
||||
continue
|
||||
alias.delete()
|
||||
else:
|
||||
self.aliases.remove(ralias.alias.address)
|
||||
# 2. Nothing left to do, return
|
||||
if not self.aliases:
|
||||
return
|
||||
# 3. Check limits at all levels
|
||||
core_signals.can_create_object.send(
|
||||
self.__class__, context=user, klass=models.Alias, count=len(self.aliases)
|
||||
)
|
||||
core_signals.can_create_object.send(
|
||||
self.__class__,
|
||||
context=self.mb.domain,
|
||||
object_type="mailbox_aliases",
|
||||
count=len(self.aliases),
|
||||
)
|
||||
# 4. Create / update aliases
|
||||
for alias in self.aliases:
|
||||
if (
|
||||
self.mb.aliasrecipient_set.select_related("alias")
|
||||
.filter(alias__address=alias, alias__internal=False)
|
||||
.exists()
|
||||
):
|
||||
continue
|
||||
local_part, domname = split_mailbox(alias)
|
||||
al, _ = models.Alias.objects.get_or_create(
|
||||
address=alias,
|
||||
domain=models.Domain.objects.get(name=domname),
|
||||
internal=False,
|
||||
defaults={"enabled": account.is_active},
|
||||
)
|
||||
al.add_recipients([self.mb.full_address])
|
||||
al.post_create(user)
|
||||
|
||||
def _update_sender_addresses(self):
|
||||
"""Update mailbox sender addresses."""
|
||||
for saddress in self.mb.senderaddress_set.all():
|
||||
if saddress.address not in self.sender_addresses:
|
||||
saddress.delete()
|
||||
else:
|
||||
self.sender_addresses.remove(saddress.address)
|
||||
if not len(self.sender_addresses):
|
||||
return
|
||||
to_create = []
|
||||
for saddress in self.sender_addresses:
|
||||
to_create.append(models.SenderAddress(address=saddress, mailbox=self.mb))
|
||||
models.SenderAddress.objects.bulk_create(to_create)
|
||||
|
||||
def save(self, user, account):
|
||||
"""Save or update account mailbox."""
|
||||
if self.cleaned_data["email"] == "":
|
||||
return None
|
||||
|
||||
if self.cleaned_data["quota_act"]:
|
||||
self.cleaned_data["quota"] = None
|
||||
|
||||
if not hasattr(self, "mb") or self.mb is None:
|
||||
self.create_mailbox(user, account)
|
||||
else:
|
||||
self.cleaned_data["use_domain_quota"] = self.cleaned_data["quota_act"]
|
||||
if self.cleaned_data.get("create_alias_with_old_address", False):
|
||||
self.aliases.append(self.mb.full_address)
|
||||
self.mb.update_from_dict(user, self.cleaned_data)
|
||||
|
||||
account.email = self.cleaned_data["email"]
|
||||
account.save()
|
||||
|
||||
self._update_aliases(user, account)
|
||||
self._update_sender_addresses()
|
||||
|
||||
return self.mb
|
||||
|
||||
|
||||
class AccountPermissionsForm(forms.Form, DynamicForm):
|
||||
"""A form to assign domain(s) permission."""
|
||||
|
||||
domains = lib_fields.DomainNameField(
|
||||
label=gettext_lazy("Domain(s)"),
|
||||
required=False,
|
||||
help_text=gettext_lazy("Domain(s) that user administrates"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if "instance" in kwargs:
|
||||
self.account = kwargs["instance"]
|
||||
del kwargs["instance"]
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not hasattr(self, "account") or self.account is None:
|
||||
return
|
||||
qset = models.Domain.objects.get_for_admin(self.account)
|
||||
for pos, dom in enumerate(qset):
|
||||
name = f"domains_{pos + 1}"
|
||||
self._create_field(lib_fields.DomainNameField, name, dom.name)
|
||||
if len(args) and isinstance(args[0], QueryDict):
|
||||
self._load_from_qdict(args[0], "domains", lib_fields.DomainNameField)
|
||||
|
||||
def save(self):
|
||||
if self.account.role == "SimpleUsers":
|
||||
return
|
||||
current_domains = [
|
||||
dom.name for dom in models.Domain.objects.get_for_admin(self.account)
|
||||
]
|
||||
for name, value in self.cleaned_data.items():
|
||||
if not name.startswith("domains"):
|
||||
continue
|
||||
if value in ["", None]:
|
||||
continue
|
||||
if value not in current_domains:
|
||||
domain = models.Domain.objects.get(name=value)
|
||||
domain.add_admin(self.account)
|
||||
|
||||
for domain in models.Domain.objects.get_for_admin(self.account):
|
||||
if domain.name not in self.cleaned_data.values():
|
||||
domain.remove_admin(self.account)
|
||||
|
||||
|
||||
class AccountForm(TabForms):
|
||||
"""Account edition form."""
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
self.user = request.user
|
||||
self.forms = [
|
||||
{
|
||||
"id": "general",
|
||||
"title": _("General"),
|
||||
"formtpl": "admin/account_general_form.html",
|
||||
"cls": AccountFormGeneral,
|
||||
"new_args": [self.user],
|
||||
"mandatory": True,
|
||||
},
|
||||
{
|
||||
"id": "profile",
|
||||
"title": _("Profile"),
|
||||
"formtpl": "admin/account_profile_form.html",
|
||||
"cls": AccountProfileForm,
|
||||
},
|
||||
{
|
||||
"id": "mail",
|
||||
"title": _("Mail"),
|
||||
"formtpl": "admin/mailform.html",
|
||||
"cls": AccountFormMail,
|
||||
"new_args": [self.user],
|
||||
},
|
||||
{
|
||||
"id": "perms",
|
||||
"title": _("Permissions"),
|
||||
"formtpl": "admin/permsform.html",
|
||||
"cls": AccountPermissionsForm,
|
||||
},
|
||||
]
|
||||
cbargs = {"user": self.user}
|
||||
if "instances" in kwargs:
|
||||
cbargs["account"] = kwargs["instances"]["general"]
|
||||
results = signals.extra_account_forms.send(sender=self.__class__, **cbargs)
|
||||
self.forms += reduce(lambda a, b: a + b, [result[1] for result in results])
|
||||
super().__init__(request, *args, **kwargs)
|
||||
|
||||
def extra_context(self, context):
|
||||
account = self.instances["general"]
|
||||
context.update(
|
||||
{
|
||||
"title": account.username,
|
||||
"formid": "accountform",
|
||||
"action": reverse("admin:account_change", args=[account.id]),
|
||||
}
|
||||
)
|
||||
|
||||
def check_perms(self, account):
|
||||
"""Check if perms form must displayed or not."""
|
||||
return (
|
||||
self.user.is_superuser
|
||||
and not account.is_superuser
|
||||
and account.has_perm("core.add_user")
|
||||
)
|
||||
|
||||
def _before_is_valid(self, form):
|
||||
if form["id"] == "general":
|
||||
return True
|
||||
|
||||
if hasattr(self, "check_{}".format(form["id"])):
|
||||
if not getattr(self, "check_{}".format(form["id"]))(self.account):
|
||||
return False
|
||||
return True
|
||||
|
||||
results = signals.check_extra_account_form.send(
|
||||
sender=self.__class__, account=self.account, form=form
|
||||
)
|
||||
results = [result[1] for result in results]
|
||||
if False in results:
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_valid(self):
|
||||
"""Two steps validation."""
|
||||
self.instances["general"].oldgroup = self.instances["general"].role
|
||||
if super().is_valid(mandatory_only=True):
|
||||
self.account = self.forms[0]["instance"].save()
|
||||
return super().is_valid(optional_only=True)
|
||||
return False
|
||||
|
||||
def save(self):
|
||||
"""Custom save method
|
||||
|
||||
As forms interact with each other, it is simpler to make
|
||||
custom code to save them.
|
||||
"""
|
||||
self.forms[1]["instance"].save()
|
||||
self.forms[2]["instance"].save(self.user, self.account)
|
||||
if len(self.forms) <= 3:
|
||||
return
|
||||
for f in self.forms[3:]:
|
||||
f["instance"].save()
|
||||
|
||||
def done(self):
|
||||
return render_to_json_response(_("Account updated"))
|
||||
|
||||
|
||||
class AccountWizard(WizardForm):
|
||||
"""Account creation wizard."""
|
||||
|
||||
def __init__(self, request):
|
||||
super().__init__(request)
|
||||
self.add_step(
|
||||
WizardStep(
|
||||
"general", AccountFormGeneral, _("General"), new_args=[request.user]
|
||||
)
|
||||
)
|
||||
self.add_step(
|
||||
WizardStep(
|
||||
"mail",
|
||||
AccountFormMail,
|
||||
_("Mail"),
|
||||
"admin/mailform.html",
|
||||
new_args=[request.user],
|
||||
)
|
||||
)
|
||||
|
||||
def extra_context(self, context):
|
||||
context.update(
|
||||
{
|
||||
"title": _("New account"),
|
||||
"action": reverse("admin:account_add"),
|
||||
"formid": "newaccount_form",
|
||||
}
|
||||
)
|
||||
|
||||
def done(self):
|
||||
account = self.first_step.form.save()
|
||||
account.post_create(self.request.user)
|
||||
mailform = self.steps[1].form
|
||||
mailform.save(self.request.user, account)
|
||||
return render_to_json_response(_("Account created"))
|
||||
@@ -1,138 +0,0 @@
|
||||
"""Forms related to aliases management."""
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.http import QueryDict
|
||||
from django.utils.translation import gettext as _, gettext_lazy
|
||||
|
||||
from modoboa.core import signals as core_signals
|
||||
from modoboa.lib import exceptions as lib_exceptions, fields as lib_fields
|
||||
from modoboa.lib.email_utils import split_mailbox
|
||||
from modoboa.lib.form_utils import DynamicForm
|
||||
from ..models import Alias, Domain
|
||||
|
||||
|
||||
class AliasForm(forms.ModelForm, DynamicForm):
|
||||
"""A form to create/modify an alias."""
|
||||
|
||||
random_address = forms.BooleanField(
|
||||
label=gettext_lazy("Random address"), required=False
|
||||
)
|
||||
address = lib_fields.UTF8AndEmptyUserEmailField(
|
||||
label=gettext_lazy("Email address"),
|
||||
help_text=gettext_lazy(
|
||||
"The alias address. To create a catchall alias, just enter the "
|
||||
"domain name (@domain.tld)."
|
||||
),
|
||||
widget=forms.TextInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
recipients = lib_fields.UTF8AndEmptyUserEmailField(
|
||||
label=gettext_lazy("Recipients"),
|
||||
required=False,
|
||||
help_text=gettext_lazy(
|
||||
"Addresses this alias will point to. Indicate only one address "
|
||||
"per input, press ENTER to add a new input."
|
||||
),
|
||||
widget=forms.TextInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Alias
|
||||
fields = ("address", "domain", "enabled", "expire_at", "description")
|
||||
labels = {"domain": gettext_lazy("Domain")}
|
||||
widgets = {
|
||||
"domain": forms.widgets.Select(
|
||||
attrs={"class": "selectpicker", "data-live-search": "true"}
|
||||
)
|
||||
}
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.user = user
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields = OrderedDict(
|
||||
(key, self.fields[key])
|
||||
for key in [
|
||||
"random_address",
|
||||
"address",
|
||||
"domain",
|
||||
"recipients",
|
||||
"enabled",
|
||||
"expire_at",
|
||||
"description",
|
||||
]
|
||||
)
|
||||
if self.instance.pk:
|
||||
del self.fields["random_address"]
|
||||
del self.fields["domain"]
|
||||
else:
|
||||
self.fields["address"].required = False
|
||||
self.fields["domain"].required = False
|
||||
self.fields["domain"].queryset = Domain.objects.get_for_admin(user)
|
||||
if len(args) and isinstance(args[0], QueryDict):
|
||||
if "instance" in kwargs:
|
||||
if not kwargs["instance"].domain.enabled:
|
||||
del self.fields["enabled"]
|
||||
self._load_from_qdict(args[0], "recipients", forms.EmailField)
|
||||
elif "instance" in kwargs:
|
||||
alias = kwargs["instance"]
|
||||
if not alias.domain.enabled:
|
||||
self.fields["enabled"].widget.attrs["disabled"] = "disabled"
|
||||
cpt = 1
|
||||
for rcpt in alias.aliasrecipient_set.filter(alias__internal=False):
|
||||
name = f"recipients_{cpt}"
|
||||
self._create_field(forms.EmailField, name, rcpt.address, 2)
|
||||
cpt += 1
|
||||
|
||||
def clean_address(self):
|
||||
"""Check if address points to a local domain."""
|
||||
if not self.cleaned_data["address"]:
|
||||
return self.cleaned_data["address"]
|
||||
localpart, domname = split_mailbox(self.cleaned_data["address"])
|
||||
try:
|
||||
domain = Domain.objects.get(name=domname)
|
||||
except Domain.DoesNotExist:
|
||||
raise forms.ValidationError(_("Domain does not exist")) from None
|
||||
if not self.user.can_access(domain):
|
||||
raise forms.ValidationError(_("You don't have access to this domain"))
|
||||
if not self.instance.pk:
|
||||
try:
|
||||
core_signals.can_create_object.send(
|
||||
sender=self.__class__, context=domain, object_type="mailbox_aliases"
|
||||
)
|
||||
except lib_exceptions.ModoboaException as inst:
|
||||
raise forms.ValidationError(inst) from None
|
||||
return self.cleaned_data["address"].lower()
|
||||
|
||||
def clean(self):
|
||||
"""Check it there is at least one recipient."""
|
||||
super().clean()
|
||||
random_address = self.cleaned_data.get("random_address")
|
||||
if not random_address:
|
||||
if not self.cleaned_data.get("address"):
|
||||
self.add_error("address", _("This field is required"))
|
||||
elif not self.cleaned_data.get("domain"):
|
||||
self.add_error("domain", _("This field is required"))
|
||||
for field, value in self.cleaned_data.items():
|
||||
if field.startswith("recipients") and value:
|
||||
return self.cleaned_data
|
||||
self.add_error("recipients", _("No recipient defined"))
|
||||
return self.cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
"""Custom save method."""
|
||||
alias = super().save(commit=False)
|
||||
if self.cleaned_data.get("random_address"):
|
||||
alias.address = f"{Alias.generate_random_address()}@{alias.domain}"
|
||||
else:
|
||||
local_part, domname = split_mailbox(self.cleaned_data["address"])
|
||||
alias.domain = Domain.objects.get(name=domname)
|
||||
if commit:
|
||||
alias.save()
|
||||
address_list = [
|
||||
value
|
||||
for field, value in self.cleaned_data.items()
|
||||
if field.startswith("recipients") and value
|
||||
]
|
||||
alias.set_recipients(address_list)
|
||||
return alias
|
||||
@@ -1,481 +0,0 @@
|
||||
"""Forms related to domains management."""
|
||||
|
||||
from functools import reduce
|
||||
|
||||
from django import forms
|
||||
from django.http import QueryDict
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.translation import gettext as _, gettext_lazy
|
||||
|
||||
from modoboa.core import signals as core_signals
|
||||
from modoboa.core.models import User
|
||||
from modoboa.lib.exceptions import Conflict
|
||||
from modoboa.lib.fields import DomainNameField
|
||||
from modoboa.lib.form_utils import (
|
||||
DynamicForm,
|
||||
TabForms,
|
||||
WizardForm,
|
||||
WizardStep,
|
||||
YesNoField,
|
||||
)
|
||||
from modoboa.lib.web_utils import render_to_json_response, size2integer
|
||||
from modoboa.parameters import tools as param_tools
|
||||
from .. import constants, lib, signals
|
||||
from ..models import Alias, Domain, DomainAlias, Mailbox
|
||||
|
||||
|
||||
class DomainFormGeneral(forms.ModelForm, DynamicForm):
|
||||
"""A form to create/edit a domain."""
|
||||
|
||||
type = forms.ChoiceField( # NOQA:A003
|
||||
label=gettext_lazy("Type"),
|
||||
)
|
||||
aliases = DomainNameField(
|
||||
label=gettext_lazy("Alias(es)"),
|
||||
required=False,
|
||||
help_text=gettext_lazy(
|
||||
"Alias(es) of this domain. Indicate only one name per input, "
|
||||
"press ENTER to add a new input."
|
||||
),
|
||||
)
|
||||
dkim_key_selector = forms.CharField(
|
||||
label=gettext_lazy("Key selector"), initial="modoboa", required=False
|
||||
)
|
||||
quota = forms.CharField(
|
||||
label=gettext_lazy("Quota"),
|
||||
initial=0,
|
||||
help_text=gettext_lazy(
|
||||
"Quota shared between mailboxes. Can be expressed in KB, "
|
||||
"MB (default) or GB. A value of 0 means no quota."
|
||||
),
|
||||
)
|
||||
default_mailbox_quota = forms.CharField(
|
||||
label=gettext_lazy("Default mailbox quota"),
|
||||
initial=0,
|
||||
help_text=gettext_lazy(
|
||||
"Default quota applied to mailboxes. Can be expressed in KB, MB "
|
||||
"(default) or GB. A value of 0 means no quota."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Domain
|
||||
fields = (
|
||||
"name",
|
||||
"type",
|
||||
"quota",
|
||||
"default_mailbox_quota",
|
||||
"message_limit",
|
||||
"aliases",
|
||||
"enabled",
|
||||
"enable_dns_checks",
|
||||
"enable_dkim",
|
||||
"dkim_key_selector",
|
||||
"dkim_key_length",
|
||||
)
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.oldname = None
|
||||
if "instance" in kwargs:
|
||||
self.old_dkim_key_length = kwargs["instance"].dkim_key_length
|
||||
self.oldname = kwargs["instance"].name
|
||||
super().__init__(*args, **kwargs)
|
||||
params = dict(param_tools.get_global_parameters("admin"))
|
||||
self.fields["quota"].initial = params["default_domain_quota"]
|
||||
if params["default_domain_message_limit"] is not None:
|
||||
self.fields["message_limit"].initial = params[
|
||||
"default_domain_message_limit"
|
||||
]
|
||||
self.fields["default_mailbox_quota"].initial = params["default_mailbox_quota"]
|
||||
self.fields["type"].choices = constants.DOMAIN_TYPES
|
||||
self.field_widths = {"quota": 3, "default_mailbox_quota": 3}
|
||||
self.user = user
|
||||
|
||||
if len(args) and isinstance(args[0], QueryDict):
|
||||
self._load_from_qdict(args[0], "aliases", DomainNameField)
|
||||
elif "instance" in kwargs:
|
||||
d = kwargs["instance"]
|
||||
for pos, dalias in enumerate(d.domainalias_set.all()):
|
||||
name = f"aliases_{pos + 1}"
|
||||
self._create_field(forms.CharField, name, dalias.name, 3)
|
||||
|
||||
def clean_name(self):
|
||||
"""Check unicity and more."""
|
||||
name = self.cleaned_data["name"].lower()
|
||||
label = lib.check_if_domain_exists(name, [(DomainAlias, _("domain alias"))])
|
||||
if label is not None:
|
||||
raise forms.ValidationError(
|
||||
_("A %s with this name already exists") % force_str(label)
|
||||
)
|
||||
|
||||
domains_must_have_authorized_mx = param_tools.get_global_parameter(
|
||||
"domains_must_have_authorized_mx"
|
||||
)
|
||||
if domains_must_have_authorized_mx and not self.user.is_superuser:
|
||||
if not lib.domain_has_authorized_mx(name):
|
||||
raise forms.ValidationError(
|
||||
_("No authorized MX record found for this domain")
|
||||
)
|
||||
|
||||
return name
|
||||
|
||||
def clean_enable_dkim(self):
|
||||
"""Check prerequisites."""
|
||||
enabled = self.cleaned_data.get("enable_dkim")
|
||||
if not enabled:
|
||||
return enabled
|
||||
storage_dir = param_tools.get_global_parameter("dkim_keys_storage_dir")
|
||||
if not storage_dir:
|
||||
raise forms.ValidationError(_("DKIM keys storage directory not configured"))
|
||||
return enabled
|
||||
|
||||
def clean_quota(self):
|
||||
"""Return proper quota value."""
|
||||
return size2integer(self.cleaned_data["quota"], output_unit="MB")
|
||||
|
||||
def clean_default_mailbox_quota(self):
|
||||
"""Return proper quota value."""
|
||||
return size2integer(
|
||||
self.cleaned_data["default_mailbox_quota"], output_unit="MB"
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
"""Custom fields validation.
|
||||
|
||||
We want to prevent duplicate names between domains and domain
|
||||
aliases.
|
||||
|
||||
The validation way is not very smart...
|
||||
"""
|
||||
cleaned_data = super().clean()
|
||||
if self._errors:
|
||||
return cleaned_data
|
||||
condition = (
|
||||
self.cleaned_data["quota"] != 0
|
||||
and self.cleaned_data["default_mailbox_quota"] > self.cleaned_data["quota"]
|
||||
)
|
||||
if condition:
|
||||
self.add_error(
|
||||
"default_mailbox_quota", _("Cannot be greater than domain quota")
|
||||
)
|
||||
elif self.user.role == "Resellers":
|
||||
limit = self.user.userobjectlimit_set.get(name="quota")
|
||||
if limit.max_value != 0:
|
||||
quota = self.cleaned_data["quota"]
|
||||
msg = _("You can't define an unlimited quota.")
|
||||
if quota == 0:
|
||||
self.add_error("quota", msg)
|
||||
default_mailbox_quota = self.cleaned_data["default_mailbox_quota"]
|
||||
if default_mailbox_quota == 0:
|
||||
self.add_error("default_mailbox_quota", msg)
|
||||
if self.cleaned_data["enable_dkim"]:
|
||||
if not self.cleaned_data.get("dkim_key_selector"):
|
||||
self.add_error("dkim_key_selector", _("This field is required."))
|
||||
self.aliases = []
|
||||
copied_data = cleaned_data.copy()
|
||||
for k in copied_data.keys():
|
||||
if not k.startswith("aliases"):
|
||||
continue
|
||||
if cleaned_data[k] == "":
|
||||
del cleaned_data[k]
|
||||
continue
|
||||
if cleaned_data[k] == self.cleaned_data["name"]:
|
||||
self._errors[k] = self.error_class(
|
||||
[_("A %s with this name already exists") % _("domain")]
|
||||
)
|
||||
del cleaned_data[k]
|
||||
continue
|
||||
label = lib.check_if_domain_exists(cleaned_data[k], [(Domain, _("domain"))])
|
||||
if label is not None:
|
||||
self.add_error(
|
||||
k, _("A %s with this name already exists") % force_str(label)
|
||||
)
|
||||
else:
|
||||
self.aliases.append(cleaned_data[k])
|
||||
return cleaned_data
|
||||
|
||||
def save(self, user, commit=True, domalias_post_create=False):
|
||||
"""Custom save method.
|
||||
|
||||
Updating a domain may have consequences on other objects
|
||||
(domain alias, mailbox, quota). The most tricky part concerns
|
||||
quotas update.
|
||||
|
||||
"""
|
||||
d = super().save(commit=False)
|
||||
core_signals.can_create_object.send(
|
||||
sender=self.__class__, context=user, klass=Domain, instance=d
|
||||
)
|
||||
if not commit:
|
||||
return d
|
||||
d.save()
|
||||
for dalias in d.domainalias_set.all():
|
||||
if dalias.name not in self.aliases:
|
||||
dalias.delete()
|
||||
else:
|
||||
self.aliases.remove(dalias.name)
|
||||
if self.aliases:
|
||||
core_signals.can_create_object.send(
|
||||
self.__class__, context=user, klass=DomainAlias, count=len(self.aliases)
|
||||
)
|
||||
core_signals.can_create_object.send(
|
||||
self.__class__,
|
||||
context=d,
|
||||
object_type="domain_aliases",
|
||||
count=len(self.aliases),
|
||||
)
|
||||
for alias in self.aliases:
|
||||
if d.domainalias_set.filter(name=alias).exists():
|
||||
continue
|
||||
options = {"creator": user} if domalias_post_create else {}
|
||||
DomainAlias(name=alias, target=d, enabled=d.enabled).save(**options)
|
||||
if (
|
||||
self.cleaned_data.get("enable_dkim") is False
|
||||
and d.dkim_private_key_path != ""
|
||||
):
|
||||
d.dkim_private_key_path = ""
|
||||
d.save()
|
||||
return d
|
||||
|
||||
|
||||
class DomainFormOptions(forms.Form):
|
||||
"""A form containing options for domain creation."""
|
||||
|
||||
create_dom_admin = YesNoField(
|
||||
label=gettext_lazy("Create a domain administrator"),
|
||||
initial=False,
|
||||
help_text=gettext_lazy("Automatically create an administrator for this domain"),
|
||||
)
|
||||
|
||||
dom_admin_username = forms.CharField(
|
||||
label=gettext_lazy("Name"),
|
||||
initial="admin",
|
||||
help_text=gettext_lazy(
|
||||
"The administrator's name. Don't include the domain's name here, "
|
||||
"it will be automatically appended."
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
|
||||
random_password = YesNoField(
|
||||
label=gettext_lazy("Random password"),
|
||||
initial=False,
|
||||
help_text=gettext_lazy("Generate a random password for the administrator."),
|
||||
required=False,
|
||||
)
|
||||
|
||||
with_mailbox = YesNoField(
|
||||
label=gettext_lazy("With a mailbox"),
|
||||
initial=True,
|
||||
help_text=gettext_lazy("Create a mailbox for the administrator."),
|
||||
required=False,
|
||||
)
|
||||
|
||||
create_aliases = YesNoField(
|
||||
label=gettext_lazy("Create aliases"),
|
||||
initial=True,
|
||||
help_text=gettext_lazy("Automatically create standard aliases for this domain"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
results = core_signals.user_can_set_role.send(
|
||||
sender=self.__class__, user=user, role="DomainAdmins"
|
||||
)
|
||||
if False in [result[1] for result in results]:
|
||||
self.fields = {}
|
||||
return
|
||||
|
||||
def clean_dom_admin_username(self):
|
||||
"""Ensure admin username is an email address."""
|
||||
if "@" in self.cleaned_data["dom_admin_username"]:
|
||||
raise forms.ValidationError(_("Invalid format"))
|
||||
return self.cleaned_data["dom_admin_username"]
|
||||
|
||||
def clean(self):
|
||||
"""Check required values."""
|
||||
cleaned_data = super().clean()
|
||||
if cleaned_data.get("create_dom_admin"):
|
||||
if not cleaned_data.get("dom_admin_username"):
|
||||
self.add_error("dom_admin_username", _("This field is required."))
|
||||
if "create_aliases" not in cleaned_data:
|
||||
self.add_error("create_aliases", _("This field is required."))
|
||||
return cleaned_data
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.fields:
|
||||
return
|
||||
if not self.cleaned_data["create_dom_admin"]:
|
||||
return
|
||||
user = kwargs.pop("user")
|
||||
domain = kwargs.pop("domain")
|
||||
username = f"{self.cleaned_data['dom_admin_username']}@{domain.name}"
|
||||
try:
|
||||
da = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
raise Conflict(_("User '%s' already exists") % username)
|
||||
core_signals.can_create_object.send(self.__class__, context=user, klass=Mailbox)
|
||||
da = User(username=username, email=username, is_active=True)
|
||||
if self.cleaned_data["random_password"]:
|
||||
password = lib.make_password()
|
||||
else:
|
||||
password = param_tools.get_global_parameter("default_password", app="core")
|
||||
da.set_password(password)
|
||||
da.save()
|
||||
da.role = "DomainAdmins"
|
||||
da.post_create(user)
|
||||
|
||||
if self.cleaned_data["with_mailbox"]:
|
||||
dom_admin_username = self.cleaned_data["dom_admin_username"]
|
||||
mb = Mailbox(
|
||||
address=dom_admin_username,
|
||||
domain=domain,
|
||||
user=da,
|
||||
use_domain_quota=True,
|
||||
)
|
||||
mb.set_quota(override_rules=user.has_perm("admin.change_domain"))
|
||||
mb.save(creator=user)
|
||||
|
||||
condition = (
|
||||
domain.type == "domain"
|
||||
and self.cleaned_data["create_aliases"]
|
||||
and dom_admin_username != "postmaster"
|
||||
)
|
||||
if condition:
|
||||
core_signals.can_create_object.send(
|
||||
self.__class__, context=user, klass=Alias
|
||||
)
|
||||
address = f"postmaster@{domain.name}"
|
||||
alias = Alias.objects.create(
|
||||
address=address, domain=domain, enabled=True
|
||||
)
|
||||
alias.set_recipients([mb.full_address])
|
||||
alias.post_create(user)
|
||||
|
||||
domain.add_admin(da)
|
||||
|
||||
|
||||
class DomainForm(TabForms):
|
||||
"""Domain edition form."""
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
self.user = request.user
|
||||
self.forms = []
|
||||
self.domain = None
|
||||
if self.user.has_perm("admin.change_domain"):
|
||||
self.forms.append(
|
||||
{
|
||||
"id": "general",
|
||||
"title": _("General"),
|
||||
"formtpl": "admin/domain_general_form.html",
|
||||
"cls": DomainFormGeneral,
|
||||
"mandatory": True,
|
||||
"new_args": [request.user],
|
||||
}
|
||||
)
|
||||
|
||||
cbargs = {"user": self.user}
|
||||
if "instances" in kwargs:
|
||||
self.domain = kwargs["instances"]["general"]
|
||||
cbargs["domain"] = self.domain
|
||||
results = signals.extra_domain_forms.send(sender=self.__class__, **cbargs)
|
||||
self.forms += reduce(lambda a, b: a + b, [result[1] for result in results])
|
||||
if not self.forms:
|
||||
self.active_id = "admins"
|
||||
super().__init__(request, *args, **kwargs)
|
||||
|
||||
def extra_context(self, context):
|
||||
"""Add information to template context."""
|
||||
context.update(
|
||||
{
|
||||
"title": self.domain.name,
|
||||
"action": reverse("admin:domain_change", args=[self.domain.pk]),
|
||||
"formid": "domform",
|
||||
"domain": self.domain,
|
||||
}
|
||||
)
|
||||
|
||||
def is_valid(self):
|
||||
"""Custom validation.
|
||||
|
||||
We just save the current name before it is potentially
|
||||
modified.
|
||||
|
||||
"""
|
||||
if "general" in self.instances:
|
||||
instance = self.instances["general"]
|
||||
instance.oldname = instance.name
|
||||
return super().is_valid()
|
||||
|
||||
def save(self):
|
||||
"""Custom save method.
|
||||
|
||||
As forms interact with each other, it is easier to make custom
|
||||
code to save them.
|
||||
"""
|
||||
if not self.forms:
|
||||
return
|
||||
first_form = self.forms[0]["instance"]
|
||||
options = {}
|
||||
if isinstance(first_form, DomainFormGeneral):
|
||||
domain = first_form.save(self.request.user, domalias_post_create=True)
|
||||
options.update({"domain": domain})
|
||||
else:
|
||||
first_form.save(self.request.user)
|
||||
for f in self.forms[1:]:
|
||||
f["instance"].save(self.request.user, **options)
|
||||
|
||||
def done(self):
|
||||
return render_to_json_response(_("Domain modified"))
|
||||
|
||||
|
||||
class DomainWizard(WizardForm):
|
||||
"""Domain creation wizard."""
|
||||
|
||||
def __init__(self, request):
|
||||
super().__init__(request)
|
||||
self.add_step(
|
||||
WizardStep(
|
||||
"general",
|
||||
DomainFormGeneral,
|
||||
_("General"),
|
||||
"admin/domain_general_form.html",
|
||||
[request.user],
|
||||
)
|
||||
)
|
||||
results = signals.extra_domain_wizard_steps.send(sender=self.__class__)
|
||||
for result in results:
|
||||
for step in result[1]:
|
||||
self.add_step(step)
|
||||
self.add_step(
|
||||
WizardStep(
|
||||
"options",
|
||||
DomainFormOptions,
|
||||
_("Options"),
|
||||
"admin/domain_options_form.html",
|
||||
[self.request.user],
|
||||
)
|
||||
)
|
||||
|
||||
def extra_context(self, context):
|
||||
context.update(
|
||||
{
|
||||
"title": _("New domain"),
|
||||
"action": reverse("admin:domain_add"),
|
||||
"formid": "domform",
|
||||
}
|
||||
)
|
||||
|
||||
def done(self):
|
||||
genform = self.first_step.form
|
||||
domain = genform.save(self.request.user)
|
||||
domain.post_create(self.request.user)
|
||||
for step in self.steps[1:]:
|
||||
if not step.check_access(self):
|
||||
continue
|
||||
step.form.save(user=self.request.user, domain=domain)
|
||||
return render_to_json_response(_("Domain created"))
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Export related forms."""
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy
|
||||
|
||||
|
||||
class ExportDataForm(forms.Form):
|
||||
sepchar = forms.CharField(
|
||||
label=gettext_lazy("Separator"),
|
||||
max_length=1,
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["sepchar"].widget.attrs = {"class": "col-md-1 form-control"}
|
||||
|
||||
def clean_sepchar(self):
|
||||
if self.cleaned_data["sepchar"] == "":
|
||||
return ";"
|
||||
return self.cleaned_data["sepchar"]
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Forms related to forwards management."""
|
||||
|
||||
from django import forms
|
||||
from django.core import validators
|
||||
from django.utils.translation import gettext_lazy
|
||||
|
||||
|
||||
class ForwardForm(forms.Form):
|
||||
"""Forward definition form."""
|
||||
|
||||
dest = forms.CharField(
|
||||
label=gettext_lazy("Recipient(s)"),
|
||||
widget=forms.Textarea(attrs={"class": "form-control"}),
|
||||
required=False,
|
||||
help_text=gettext_lazy("Indicate one or more recipients separated by a ','"),
|
||||
)
|
||||
keepcopies = forms.BooleanField(
|
||||
label=gettext_lazy("Keep local copies"),
|
||||
required=False,
|
||||
help_text=gettext_lazy(
|
||||
"Forward messages and store copies into your local mailbox"
|
||||
),
|
||||
)
|
||||
|
||||
def clean_dest(self):
|
||||
"""Check recipients validity."""
|
||||
rawdata = self.cleaned_data.get("dest", "").strip()
|
||||
recipients = []
|
||||
if not rawdata:
|
||||
return recipients
|
||||
for rcpt in rawdata.split(","):
|
||||
validators.validate_email(rcpt)
|
||||
recipients += [rcpt]
|
||||
return recipients
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Forms related to import operations."""
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy
|
||||
|
||||
|
||||
class ImportDataForm(forms.Form):
|
||||
"""Base form to import objects."""
|
||||
|
||||
sourcefile = forms.FileField(label=gettext_lazy("Select a file"))
|
||||
sepchar = forms.CharField(
|
||||
label=gettext_lazy("Separator"),
|
||||
max_length=1,
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
continue_if_exists = forms.BooleanField(
|
||||
label=gettext_lazy("Continue on error"),
|
||||
required=False,
|
||||
help_text=gettext_lazy("Don't treat duplicated objects as error"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["sepchar"].widget.attrs = {"class": "col-md-1 form-control"}
|
||||
|
||||
def clean_sepchar(self):
|
||||
if self.cleaned_data["sepchar"] == "":
|
||||
return ";"
|
||||
return self.cleaned_data["sepchar"]
|
||||
|
||||
|
||||
class ImportIdentitiesForm(ImportDataForm):
|
||||
"""A form to import identities."""
|
||||
|
||||
crypt_password = forms.BooleanField(
|
||||
label=gettext_lazy("Crypt passwords"),
|
||||
required=False,
|
||||
help_text=gettext_lazy(
|
||||
"Check this option if passwords contained in your file " "are not crypted"
|
||||
),
|
||||
)
|
||||
+11
-72
@@ -4,14 +4,12 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management import call_command
|
||||
from django.db.models import signals
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
import django_rq
|
||||
|
||||
from modoboa.core import models as core_models, signals as core_signals
|
||||
from modoboa.lib import exceptions, permissions, signals as lib_signals
|
||||
from modoboa.lib.cryptutils import encrypt
|
||||
from modoboa.lib.email_utils import split_mailbox
|
||||
from modoboa.parameters import tools as param_tools
|
||||
from . import lib, models, postfix_maps, signals as admin_signals
|
||||
@@ -271,57 +269,6 @@ def import_account_mailbox(sender, user, account, row, **kwargs):
|
||||
dom.add_admin(account)
|
||||
|
||||
|
||||
@receiver(core_signals.extra_admin_menu_entries)
|
||||
def admin_menu(sender, location, user, **kwargs):
|
||||
"""Add extra menu entries for admins."""
|
||||
if location != "top_menu":
|
||||
return []
|
||||
entries = []
|
||||
if user.has_perm("admin.view_domain"):
|
||||
entries += [
|
||||
{
|
||||
"name": "domains",
|
||||
"url": reverse("admin:domain_list"),
|
||||
"label": _("Domains"),
|
||||
}
|
||||
]
|
||||
conditions = (user.has_perm("core.add_user"), user.has_perm("admin.add_alias"))
|
||||
if any(conditions):
|
||||
entries += [
|
||||
{
|
||||
"name": "identities",
|
||||
"url": reverse("admin:identity_list"),
|
||||
"label": _("Identities"),
|
||||
},
|
||||
]
|
||||
return entries
|
||||
|
||||
|
||||
@receiver(core_signals.extra_user_menu_entries)
|
||||
def user_menu(sender, location, user, **kwargs):
|
||||
"""Add extra menu entries for users."""
|
||||
if location != "uprefs_menu":
|
||||
return []
|
||||
if not hasattr(user, "mailbox"):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"name": "forward",
|
||||
"class": "ajaxnav",
|
||||
"url": "forward/",
|
||||
"label": _("Forward"),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@receiver(core_signals.user_login)
|
||||
def user_logged_in(sender, user, password, **kwargs):
|
||||
"""Store user password in session."""
|
||||
request = lib_signals.get_request()
|
||||
if hasattr(user, "mailbox"):
|
||||
request.session["password"] = encrypt(password)
|
||||
|
||||
|
||||
@receiver(core_signals.account_role_changed)
|
||||
def grant_access_to_all_objects(sender, account, role, **kwargs):
|
||||
"""Grant all permissions if new role is SuperAdmin."""
|
||||
@@ -340,25 +287,6 @@ def grant_access_to_all_objects(sender, account, role, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
@receiver(core_signals.extra_admin_dashboard_widgets)
|
||||
def add_widgets_to_admin_dashboard(sender, user, **kwargs):
|
||||
"""Add admin widgets to dashboard."""
|
||||
if user.is_superuser:
|
||||
context = {
|
||||
"domains_counter": models.Domain.objects.count(),
|
||||
"domain_aliases_counter": models.DomainAlias.objects.count(),
|
||||
"identities_counter": (
|
||||
core_models.User.objects.count()
|
||||
+ models.Alias.objects.filter(internal=False).count()
|
||||
),
|
||||
}
|
||||
template = "admin/_global_statistics_widget.html"
|
||||
else:
|
||||
context = {"domains": models.Domain.objects.get_for_admin(user)}
|
||||
template = "admin/_per_domain_statistics_widget.html"
|
||||
return [{"column": "left", "template": template, "context": context}]
|
||||
|
||||
|
||||
@receiver(admin_signals.import_object)
|
||||
def get_import_func(sender, objtype, **kwargs):
|
||||
"""Return function used to import objtype."""
|
||||
@@ -375,3 +303,14 @@ def get_import_func(sender, objtype, **kwargs):
|
||||
elif objtype == "dlist":
|
||||
return lib.import_dlist
|
||||
return None
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=core_models.User)
|
||||
def disable_aliases_on_account_disabled(sender, instance, created, **kwargs):
|
||||
"""Make sure aliases of a disabled account are also disabled."""
|
||||
if created or not hasattr(instance, "mailbox"):
|
||||
return
|
||||
for alr in instance.mailbox.aliasrecipient_set.all().select_related("alias"):
|
||||
if alr.alias.recipients_count == 1:
|
||||
alr.alias.enabled = False
|
||||
alr.alias.save()
|
||||
|
||||
@@ -6,7 +6,6 @@ import ipaddress
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from functools import wraps
|
||||
from itertools import chain
|
||||
|
||||
import dns.resolver
|
||||
@@ -32,25 +31,6 @@ from . import signals
|
||||
from .models import Alias, Domain, DomainAlias
|
||||
|
||||
|
||||
def needs_mailbox():
|
||||
"""Check if the current user owns at least one mailbox
|
||||
|
||||
Some applications (the webmail for example) need a mailbox to
|
||||
work.
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped_f(request, *args, **kwargs):
|
||||
if hasattr(request.user, "mailbox"):
|
||||
return f(request, *args, **kwargs)
|
||||
raise PermDeniedException(_("A mailbox is required"))
|
||||
|
||||
return wrapped_f
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_identities(user, searchquery=None, idtfilter=None, grpfilter=None):
|
||||
"""Return all the identities owned by a user.
|
||||
|
||||
|
||||
@@ -108,24 +108,6 @@ class Domain(mixins.MessageLimitMixin, AdminObject):
|
||||
"""Number of alarms currently opened for this domain."""
|
||||
return self.alarms.opened().count()
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
label = ""
|
||||
for dt in constants.DOMAIN_TYPES:
|
||||
if self.type == dt[0]:
|
||||
label = dt[1]
|
||||
result = [{"name": self.type, "label": label, "type": "dom"}]
|
||||
if self.transport:
|
||||
result.append(
|
||||
{
|
||||
"name": self.transport.service,
|
||||
"label": self.transport.service,
|
||||
"type": "srv",
|
||||
"color": "info",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@property
|
||||
def admins(self):
|
||||
"""Return the domain administrators of this domain.
|
||||
@@ -194,16 +176,6 @@ class Domain(mixins.MessageLimitMixin, AdminObject):
|
||||
return "ok"
|
||||
return "critical"
|
||||
|
||||
@cached_property
|
||||
def dnsbl_status_color(self):
|
||||
"""Shortcut to DNSBL results."""
|
||||
if not self.dnsblresult_set.exists():
|
||||
return "warning"
|
||||
elif self.dnsblresult_set.blacklisted().exists():
|
||||
return "danger"
|
||||
else:
|
||||
return "success"
|
||||
|
||||
@property
|
||||
def spf_record(self):
|
||||
"""Return SPF record."""
|
||||
@@ -268,24 +240,6 @@ class Domain(mixins.MessageLimitMixin, AdminObject):
|
||||
"""Return the key used to store messages count."""
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def bind_format_dkim_public_key(self):
|
||||
"""Return DKIM public key using Bind format."""
|
||||
record = f"v=DKIM1;k=rsa;p={self.dkim_public_key}"
|
||||
# TXT records longer than 255 characters need split into chunks
|
||||
# split record into 74 character chunks (+2 for indent, +2 for quotes(")
|
||||
# == 78 characters) to make more readable in text editors.
|
||||
split_record = []
|
||||
while record:
|
||||
if len(record) > 74:
|
||||
split_record.append(f' "{record[:74]}"')
|
||||
record = record[74:]
|
||||
else:
|
||||
split_record.append(f' "{record}"')
|
||||
break
|
||||
record = "\n".join(split_record)
|
||||
return f"{self.dkim_key_selector}._domainkey.{self.name}. IN TXT (\n{record})"
|
||||
|
||||
def add_admin(self, account):
|
||||
"""Add a new administrator to this domain.
|
||||
|
||||
@@ -293,6 +247,7 @@ class Domain(mixins.MessageLimitMixin, AdminObject):
|
||||
"""
|
||||
from modoboa.lib.permissions import grant_access_to_object
|
||||
|
||||
# Check if domain's limit for domain admins is not reached yet
|
||||
core_signals.can_create_object.send(
|
||||
sender=self.__class__, context=self, object_type="domain_admins"
|
||||
)
|
||||
|
||||
@@ -317,7 +317,7 @@ class Mailbox(mixins.MessageLimitMixin, AdminObject):
|
||||
or user.has_perm("admin.add_domain")
|
||||
and not user.userobjectlimit_set.get(name="quota").max_value
|
||||
)
|
||||
self.set_quota(values["quota"], override_rules)
|
||||
self.set_quota(values.get("quota"), override_rules)
|
||||
if "message_limit" in values:
|
||||
self.message_limit = values["message_limit"]
|
||||
if "is_send_only" in values:
|
||||
|
||||
@@ -13,7 +13,8 @@ class MXRecordQuerySet(models.QuerySet):
|
||||
|
||||
def has_valids(self):
|
||||
"""Return managed results."""
|
||||
if param_tools.get_global_parameter("valid_mxs").strip():
|
||||
valid_mxs = param_tools.get_global_parameter("valid_mxs")
|
||||
if valid_mxs and valid_mxs.strip():
|
||||
return self.filter(managed=True).exists()
|
||||
return self.exists()
|
||||
|
||||
|
||||
@@ -2,21 +2,8 @@
|
||||
|
||||
import django.dispatch
|
||||
|
||||
check_extra_account_form = django.dispatch.Signal() # Provides account, form
|
||||
extra_account_dashboard_widgets = django.dispatch.Signal() # Provides user, account
|
||||
extra_account_forms = django.dispatch.Signal() # Provides user, account
|
||||
extra_admin_content = django.dispatch.Signal() # Provides user, location, currentpage
|
||||
extra_domain_actions = django.dispatch.Signal() # Provides user, domain
|
||||
extra_domain_dashboard_widgets = django.dispatch.Signal() # Provides user, domain
|
||||
extra_domain_filters = django.dispatch.Signal()
|
||||
extra_domain_forms = django.dispatch.Signal() # Provides user, domain
|
||||
extra_domain_menu_entries = django.dispatch.Signal() # Provides user
|
||||
extra_domain_qset_filters = django.dispatch.Signal() # Provides domfilter, extrafilters
|
||||
# extra_domain_types = django.dispatch.Signal()
|
||||
extra_domain_wizard_steps = django.dispatch.Signal()
|
||||
get_account_form_instances = django.dispatch.Signal() # Provides user, account
|
||||
get_domain_form_instances = django.dispatch.Signal() # Provides user, domain
|
||||
# get_domain_tags = django.dispatch.Signal(providing_args=["domain"])
|
||||
import_object = django.dispatch.Signal() # Provides objtype
|
||||
use_external_recipients = django.dispatch.Signal() # Provides recipients
|
||||
extra_account_identities_actions = django.dispatch.Signal() # Provides account
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#searchform {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
span.title {
|
||||
margin-left: 40px;
|
||||
margin-bottom: 10px;
|
||||
float: left;
|
||||
font-weight: bold;
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
table th#actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
table td[name="actions"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
table .progress {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
table tbody tr:hover {
|
||||
background-color: #F9F9F9;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 85%;
|
||||
-webkit-border-radius:14px;-moz-border-radius:14px;border-radius:14px;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.dkimbutton {
|
||||
border-radius: 10px;
|
||||
}
|
||||
.dkimbutton:hover {
|
||||
background-color: rgba(131, 131, 250, 0.283);
|
||||
transition: all 0.8s
|
||||
}
|
||||
.dkimbutton:active {
|
||||
background-color: rgba(60, 255, 60, 0.136);
|
||||
transition: all 0.2s
|
||||
}
|
||||
.dkim {
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
|
||||
#settings_content .form-horizontal .control-label {
|
||||
margin-left: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#settings_content > .form-horizontal .controls {
|
||||
margin-left: 240px;
|
||||
}
|
||||
@@ -1,724 +0,0 @@
|
||||
/**
|
||||
* Creates an instance of Admin.
|
||||
*
|
||||
* @constructor
|
||||
* @param {Object} options - instance options
|
||||
* @classdesc This class contains utility methods used by the admin
|
||||
* application.
|
||||
*/
|
||||
|
||||
/* global $ gettext Listing */
|
||||
|
||||
var Admin = function(options) {
|
||||
Listing.call(this, options);
|
||||
};
|
||||
|
||||
Admin.prototype = {
|
||||
defaults: {
|
||||
deflocation: "list/",
|
||||
squery: null
|
||||
},
|
||||
|
||||
initialize: function(options) {
|
||||
this.options = $.extend({}, this.defaults, options);
|
||||
this.options.defcallback = $.proxy(this.list_cb, this);
|
||||
Listing.prototype.initialize.call(this, this.options);
|
||||
this.listen();
|
||||
},
|
||||
|
||||
/**
|
||||
* Callback used when the initial content of the listing is
|
||||
* received.
|
||||
*
|
||||
* @this Admin
|
||||
* @param {Object} data - response of the ajax call (JSON)
|
||||
*/
|
||||
list_cb: function(data) {
|
||||
if (data.rows) {
|
||||
$("#objects_table tbody").html(data.rows);
|
||||
} else {
|
||||
$("#objects_table tbody").html("");
|
||||
}
|
||||
this.update_listing(data);
|
||||
if (data.length === 0) {
|
||||
this.get_load_page_args();
|
||||
this.end_of_list_reached();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Children must override this method.
|
||||
*/
|
||||
listen: function() {
|
||||
},
|
||||
|
||||
/**
|
||||
* Load a new page using an AJAX request.
|
||||
*
|
||||
* @this Admin
|
||||
* @param {Object} e - event object
|
||||
*/
|
||||
page_loader: function(e) {
|
||||
var $link = get_target(e);
|
||||
e.preventDefault();
|
||||
if ($link.hasClass("navigation")) {
|
||||
$(".sidebar li.active").removeClass("active");
|
||||
$link.parent().addClass("active");
|
||||
}
|
||||
this.navobj.baseurl($link.attr("href")).update();
|
||||
},
|
||||
|
||||
importform_cb: function() {
|
||||
$(".submit").one('click', function(e) {
|
||||
e.preventDefault();
|
||||
if ($("#id_sourcefile").val() === "") {
|
||||
return;
|
||||
}
|
||||
$("#import_status").css("display", "block");
|
||||
$("#import_result").html("").removeClass("alert alert-danger");
|
||||
$("#importform").submit();
|
||||
});
|
||||
},
|
||||
|
||||
importdone: function(status, msg) {
|
||||
$("#import_status").css("display", "none");
|
||||
if (status === "ok") {
|
||||
$("#modalbox").modal('hide');
|
||||
this.reload_listing(msg);
|
||||
} else {
|
||||
$("#import_result").addClass("alert alert-danger");
|
||||
$("#import_result").html(msg);
|
||||
this.importform_cb();
|
||||
}
|
||||
},
|
||||
|
||||
exportform_cb: function() {
|
||||
$(".submit").one('click', function(e) {
|
||||
e.preventDefault();
|
||||
$("#exportform").submit();
|
||||
$("#modalbox").modal('hide');
|
||||
});
|
||||
},
|
||||
|
||||
reload_listing: function(data) {
|
||||
this.navobj.update(true);
|
||||
if (data) {
|
||||
$("body").notify("success", data, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Admin.prototype = $.extend({}, Listing.prototype, Admin.prototype);
|
||||
|
||||
/**
|
||||
* Creates an instance of Domains.
|
||||
*
|
||||
* @constructor
|
||||
* @param {Object} options - instance options
|
||||
* @classdesc This class extends the Admin one by adding methods
|
||||
* specific to domains management.
|
||||
*/
|
||||
var Domains = function(options) {
|
||||
Admin.call(this, options);
|
||||
};
|
||||
|
||||
Domains.prototype = {
|
||||
initialize: function(options) {
|
||||
Admin.prototype.initialize.call(this, options);
|
||||
this.options.navigation_params.push("domfilter", "srvfilter");
|
||||
this.options.eor_message = gettext("No more domain to show");
|
||||
this.register_tag_handler("dom");
|
||||
this.register_tag_handler("srv", this.srv_tag_handler);
|
||||
},
|
||||
|
||||
/**
|
||||
* Custom callbacks declaration.
|
||||
*/
|
||||
listen: function() {
|
||||
Admin.prototype.listen.call(this);
|
||||
$(document).on("click", "a.ajaxnav", $.proxy(this.page_loader, this));
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize the domain links embedded within a page.
|
||||
*
|
||||
* @param {Object} data - options
|
||||
*/
|
||||
init_domain_links: function(data) {
|
||||
var deloptions = (data.handle_mailboxes) ?
|
||||
{keepdir: gettext("Do not delete domain directory")} : {};
|
||||
var warnmsg = (data.auto_account_removal && data.auto_account_removal === "yes")
|
||||
? gettext("This operation will remove ALL data associated to this domain.")
|
||||
: gettext("This operation will remove all data associated to this domain, excepting accounts.");
|
||||
|
||||
$("a[name=deldomain]").confirm({
|
||||
question: function() { return htmlEncode(this.$element.attr('title')); },
|
||||
method: "POST",
|
||||
warning: warnmsg,
|
||||
checkboxes: deloptions,
|
||||
success_cb: $.proxy(this.reload_listing, this)
|
||||
});
|
||||
$(document).trigger('domain_listing_refresh');
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a type to let the server know what kind of object we expect.
|
||||
*
|
||||
* @this Listing
|
||||
*/
|
||||
get_load_page_args: function() {
|
||||
var args = Admin.prototype.get_load_page_args.call(this);
|
||||
|
||||
if (this.navobj.getbaseurl() === "list") {
|
||||
args.objtype = "domain";
|
||||
this.options.eor_message = gettext("No more domain to show");
|
||||
} else if (this.navobj.getbaseurl() === "quotas") {
|
||||
args.objtype = "quota";
|
||||
this.options.eor_message = gettext("No more quota to show");
|
||||
} else {
|
||||
args.objtype = "log";
|
||||
this.options.eor_message = gettext("No more log to show");
|
||||
}
|
||||
return args;
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigation callback: default.
|
||||
*
|
||||
* @param {Object} data - response of the ajax call (JSON)
|
||||
*/
|
||||
list_cb: function(data) {
|
||||
if ($(".sidebar li.active").length === 0) {
|
||||
var menu = this.navobj.getbaseurl();
|
||||
if (menu === "list") {
|
||||
menu = "domains";
|
||||
}
|
||||
$("a[name={0}]".format(menu)).parent().addClass("active");
|
||||
}
|
||||
$("#objects_table thead tr").html(data.headers);
|
||||
Admin.prototype.list_cb.call(this, data);
|
||||
this.init_domain_links(data);
|
||||
},
|
||||
|
||||
srv_tag_handler: function(tag, $link) {
|
||||
if (this.navobj.getparam(tag + "filter") === undefined && $link.hasClass(tag)) {
|
||||
var text = $link.attr("name");
|
||||
this.navobj
|
||||
.setparam("domfilter", "relaydomain")
|
||||
.setparam(tag + "filter", text)
|
||||
.update();
|
||||
if ($("a[name=dom]").length === 0) {
|
||||
$("#taglist").append(this.make_tag("relaydomain", "dom"));
|
||||
}
|
||||
$("#taglist").append(this.make_tag(text, tag));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* A new page has been received, inject it.
|
||||
*
|
||||
* @param {Object} data - page content
|
||||
* @param {string} direction -
|
||||
*/
|
||||
add_new_page: function(data, direction) {
|
||||
Admin.prototype.add_new_page.call(this, data, direction);
|
||||
this.init_domain_links(data);
|
||||
},
|
||||
|
||||
change_inputs_state: function(value) {
|
||||
$("#id_dom_admin_username").attr("disabled", value);
|
||||
$("input[name=with_mailbox]").attr("disabled", value);
|
||||
$("input[name=create_aliases]").attr("disabled", value);
|
||||
},
|
||||
|
||||
create_dom_admin_changed: function(e) {
|
||||
var $target = $(e.target);
|
||||
this.change_inputs_state(($target.val() === "True") ? false : true);
|
||||
},
|
||||
|
||||
withMailboxChanged: function(e) {
|
||||
var $target = $(e.target);
|
||||
$("input[name=create_aliases]").attr(
|
||||
"disabled", $target.val() === "False");
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide or show password inputs depending on value.
|
||||
*
|
||||
* @param {bool} value - visibility flag
|
||||
*/
|
||||
toggleDKIMFields: function (value) {
|
||||
if (value) {
|
||||
$('#id_dkim_key_selector').parents(".form-group").show();
|
||||
$('#id_dkim_key_length').parents(".form-group").show();
|
||||
} else {
|
||||
$('#id_dkim_key_selector').parents(".form-group").hide();
|
||||
$('#id_dkim_key_length').parents(".form-group").hide();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize the main form contained in the domain edition modal.
|
||||
*
|
||||
* @this Domains
|
||||
*/
|
||||
generalform_init: function() {
|
||||
$('input:text:visible:first').focus();
|
||||
$("#original_aliases").dynamic_input({
|
||||
input_added: function($row) {
|
||||
$row.find("label").html("");
|
||||
},
|
||||
input_removed: function($input) {
|
||||
$input.parents(".form-group").remove();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
$('#id_enable_dkim').click($.proxy(function (e) {
|
||||
var $this = $(e.target);
|
||||
this.toggleDKIMFields($this.prop('checked'));
|
||||
}, this));
|
||||
this.toggleDKIMFields($('#id_enable_dkim').prop('checked'));
|
||||
},
|
||||
|
||||
optionsform_init: function() {
|
||||
$("input[name=create_dom_admin]").click(
|
||||
$.proxy(this.create_dom_admin_changed, this));
|
||||
$("input[name=with_mailbox]").click(
|
||||
$.proxy(this.withMailboxChanged, this));
|
||||
this.change_inputs_state(
|
||||
$("input[name=create_dom_admin]:checked").val() === "True" ? false : true
|
||||
);
|
||||
this.optionsform_prefill();
|
||||
},
|
||||
|
||||
optionsform_prefill: function() {
|
||||
var $span = $("#id_dom_admin_username").next("span");
|
||||
$span.html("@" + htmlEncode($("#id_name").val()));
|
||||
},
|
||||
|
||||
domadminsform_init: function() {
|
||||
$("a[name=removeperm]").click(function(e) {
|
||||
var $tr = $(this).parents('tr');
|
||||
simple_ajax_request.apply(this, [e, {
|
||||
ok_cb: function(resp) {
|
||||
$tr.remove();
|
||||
if (!$("#domadmins").find("tr").length) {
|
||||
$("#admins").html('<div class="alert alert-info">'
|
||||
+ gettext("No domain administrator defined") + "</div>");
|
||||
}
|
||||
}
|
||||
}]);
|
||||
});
|
||||
},
|
||||
|
||||
newdomain_cb: function() {
|
||||
this.generalform_init();
|
||||
this.optionsform_init();
|
||||
$("#wizard").cwizard({
|
||||
formid: "domform",
|
||||
transition_callbacks: {
|
||||
"options": this.optionsform_prefill
|
||||
},
|
||||
error_callbacks: {
|
||||
"options": $.proxy(this.optionsform_init, this)
|
||||
},
|
||||
success_callback: $.proxy(this.reload_listing, this)
|
||||
});
|
||||
$(document).trigger('domwizard_init');
|
||||
},
|
||||
|
||||
domainform_cb: function() {
|
||||
this.generalform_init();
|
||||
this.domadminsform_init();
|
||||
$(".submit").one('click', $.proxy(function(e) {
|
||||
simple_ajax_form_post(e, {
|
||||
formid: "domform",
|
||||
error_cb: $.proxy(this.domainform_cb, this),
|
||||
reload_on_success: false,
|
||||
success_cb: $.proxy(this.reload_listing, this)
|
||||
});
|
||||
}, this));
|
||||
$(document).trigger('domform_init');
|
||||
}
|
||||
};
|
||||
|
||||
Domains.prototype = $.extend({}, Admin.prototype, Domains.prototype);
|
||||
|
||||
/**
|
||||
* Creates an instance of Identities.
|
||||
*
|
||||
* @constructor
|
||||
* @param {Object} options - instance options
|
||||
* @classdesc This class extends the Admin one by adding methods
|
||||
* specific to identities management.
|
||||
*/
|
||||
var Identities = function(options) {
|
||||
Admin.call(this, options);
|
||||
};
|
||||
|
||||
Identities.prototype = {
|
||||
initialize: function(options) {
|
||||
Admin.prototype.initialize.call(this, options);
|
||||
this.options.navigation_params.push("idtfilter", "grpfilter");
|
||||
this.options.eor_message = gettext("No more identity to show");
|
||||
this.domain_list = [];
|
||||
this.register_tag_handler("idt");
|
||||
this.register_tag_handler("grp", this.grp_tag_handler);
|
||||
},
|
||||
|
||||
listen: function() {
|
||||
Admin.prototype.listen.call(this);
|
||||
$(document).on("click", "a.ajaxnav", $.proxy(this.page_loader, this));
|
||||
$(document).on("click", "a.ajaxcall", $.proxy(this.send_call, this));
|
||||
},
|
||||
|
||||
/**
|
||||
* Send an ajax call.
|
||||
*/
|
||||
send_call: function(evt) {
|
||||
var $link = get_target(evt, "a");
|
||||
var $this = this;
|
||||
var method = $link.attr("data-call_method");
|
||||
|
||||
if (method === undefined) {
|
||||
method = "GET";
|
||||
}
|
||||
evt.preventDefault();
|
||||
$.ajax({
|
||||
url: $link.attr("href"),
|
||||
type: method,
|
||||
dataType: "json"
|
||||
}).done(function(data) {
|
||||
$this.reload_listing(data.respmsg);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize the links embedded within a page.
|
||||
*
|
||||
* @param {Object} data - options
|
||||
*/
|
||||
init_identity_links: function(data) {
|
||||
var deloptions = {};
|
||||
|
||||
if (data.handle_mailboxes) {
|
||||
deloptions = {keepdir: gettext("Do not delete mailbox directory")};
|
||||
}
|
||||
|
||||
$("a[name=delaccount]").confirm({
|
||||
question: function() { return htmlEncode(this.$element.attr('title')); },
|
||||
method: "POST",
|
||||
checkboxes: deloptions,
|
||||
success_cb: $.proxy(this.reload_listing, this)
|
||||
});
|
||||
$("a[name=delalias]").confirm({
|
||||
question: function() { return this.$element.attr('title'); },
|
||||
method: "DELETE",
|
||||
success_cb: $.proxy(this.reload_listing, this)
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a type to let the server know what kind of object we expect.
|
||||
*
|
||||
* @this Listing
|
||||
*/
|
||||
get_load_page_args: function() {
|
||||
var args = Admin.prototype.get_load_page_args.call(this);
|
||||
|
||||
if (this.navobj.getbaseurl() === "list") {
|
||||
args.objtype = "identity";
|
||||
this.options.eor_message = gettext("No more identity to show");
|
||||
} else {
|
||||
args.objtype = "quota";
|
||||
this.options.eor_message = gettext("No more quota to show");
|
||||
}
|
||||
return args;
|
||||
},
|
||||
|
||||
/**
|
||||
* A new page has been received, inject it.
|
||||
*
|
||||
* @param {Object} data - page content
|
||||
* @param {string} direction -
|
||||
*/
|
||||
add_new_page: function(data, direction) {
|
||||
Admin.prototype.add_new_page.call(this, data, direction);
|
||||
this.init_identity_links(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigation callback: default.
|
||||
*
|
||||
* @param {Object} data - response of the ajax call (JSON)
|
||||
*/
|
||||
list_cb: function(data) {
|
||||
if ($(".sidebar li.active").length === 0) {
|
||||
var menu = this.navobj.getbaseurl();
|
||||
if (menu === "list") {
|
||||
menu = "identities";
|
||||
}
|
||||
$("a[name={0}]".format(menu)).parent().addClass("active");
|
||||
}
|
||||
$("#objects_table thead tr").html(data.headers);
|
||||
Admin.prototype.list_cb.call(this, data);
|
||||
this.init_identity_links(data);
|
||||
},
|
||||
|
||||
grp_tag_handler: function(tag, $link) {
|
||||
if (this.navobj.getparam(tag + "filter") === undefined &&
|
||||
$link.hasClass(tag)) {
|
||||
var text = $link.attr("name");
|
||||
this.navobj
|
||||
.setparam("idtfilter", "account")
|
||||
.setparam(tag + "filter", text)
|
||||
.update();
|
||||
if ($("a[name=idt]").length === 0) {
|
||||
$("#taglist").append(this.make_tag("account", "idt"));
|
||||
}
|
||||
$("#taglist").append(this.make_tag(text, tag));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a list of domain from the server.
|
||||
*
|
||||
* @this Identities
|
||||
* @return {Array} a list of domain names
|
||||
*/
|
||||
get_domain_list: function() {
|
||||
if (!this.domain_list.length) {
|
||||
$.ajax({
|
||||
url: this.options.domain_list_url,
|
||||
dataType: "json",
|
||||
async: false
|
||||
}).done($.proxy(function(data) {
|
||||
this.domain_list = data;
|
||||
}, this));
|
||||
}
|
||||
return this.domain_list;
|
||||
},
|
||||
|
||||
simpleuser_mode: function() {
|
||||
var $username = $('#id_username');
|
||||
var $email = $('#id_email');
|
||||
|
||||
$username.change(function (evt) {
|
||||
$email.val($(this).val());
|
||||
$email.change();
|
||||
});
|
||||
$username.autocompleter({
|
||||
from_character: "@",
|
||||
choices: $.proxy(this.get_domain_list, this)
|
||||
});
|
||||
$("#id_email").addClass("disabled")
|
||||
.attr("readonly", "")
|
||||
.autocompleter("unbind");
|
||||
this.toggle_master_user();
|
||||
},
|
||||
|
||||
normal_mode: function(group) {
|
||||
$("#id_email").removeClass("disabled")
|
||||
.attr("readonly", null)
|
||||
.autocompleter("listen");
|
||||
this.toggle_master_user(group);
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide or show the master user checkbox according to desired role.
|
||||
*
|
||||
* @param {string} group - The desired role (or group)
|
||||
*/
|
||||
toggle_master_user: function(group) {
|
||||
var $target = $("#id_master_user").parents(".form-group");
|
||||
if (group !== "SuperAdmins") {
|
||||
$target.hide();
|
||||
} else {
|
||||
$target.show();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide or show password inputs depending on value.
|
||||
*
|
||||
* @param {bool} value - visibility flag
|
||||
*/
|
||||
togglePasswordInputs: function(value) {
|
||||
if (value) {
|
||||
$('#id_password1').parents(".form-group").show();
|
||||
$('#id_password2').parents(".form-group").show();
|
||||
} else {
|
||||
$('#id_password1').parents(".form-group").hide();
|
||||
$('#id_password2').parents(".form-group").hide();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Callback to watch for username change.
|
||||
*
|
||||
*/
|
||||
emailChanged: function(event) {
|
||||
var $target = get_target(event);
|
||||
var $username = $('#id_username');
|
||||
|
||||
if ($target.val() !== this.originalEmail) {
|
||||
$('#id_create_alias_with_old_address').parents(".form-group").show();
|
||||
}
|
||||
if ($username.val() !== $target.val()) {
|
||||
$username.val($target.val());
|
||||
}
|
||||
},
|
||||
|
||||
generalform_init: function(notrigger) {
|
||||
var $role = $('#id_role');
|
||||
|
||||
$role.change($.proxy(function(e) {
|
||||
var $this = $(e.target);
|
||||
var value = $this.val();
|
||||
var email = $('#id_email').val();
|
||||
|
||||
if (value === 'SimpleUsers' || email !== '') {
|
||||
this.simpleuser_mode();
|
||||
} else {
|
||||
this.normal_mode(value);
|
||||
}
|
||||
}, this));
|
||||
$('#id_random_password').click($.proxy(function (e) {
|
||||
var $this = $(e.target);
|
||||
this.togglePasswordInputs(!$this.prop('checked'));
|
||||
}, this));
|
||||
this.toggle_master_user($role.val());
|
||||
$('#id_create_alias_with_old_address').parents('.form-group').hide();
|
||||
if (notrigger !== undefined && notrigger) {
|
||||
return;
|
||||
}
|
||||
$role.trigger("change");
|
||||
},
|
||||
|
||||
mailform_init: function() {
|
||||
var $email = $('#id_email');
|
||||
|
||||
this.originalEmail = $email.val();
|
||||
$email.change($.proxy(this.emailChanged, this));
|
||||
$("#id_aliases").autocompleter({
|
||||
from_character: "@",
|
||||
choices: $.proxy(this.get_domain_list, this)
|
||||
});
|
||||
$("#original_aliases").dynamic_input({
|
||||
input_added: function($row) {
|
||||
$row.find("label").html("");
|
||||
},
|
||||
input_removed: function($input) {
|
||||
$input.parents(".form-group").remove();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
$("#original_senderaddress").dynamic_input({
|
||||
input_added: function($row) {
|
||||
$row.find("label").html("");
|
||||
},
|
||||
input_removed: function($input) {
|
||||
$input.parents(".form-group").remove();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
$email.autocompleter({
|
||||
from_character: "@",
|
||||
choices: $.proxy(this.get_domain_list, this)
|
||||
});
|
||||
if ($("#id_role").length) {
|
||||
$("#id_role").trigger("change");
|
||||
} else {
|
||||
this.simpleuser_mode();
|
||||
}
|
||||
$("#id_domains").autocompleter({
|
||||
choices: $.proxy(this.get_domain_list, this)
|
||||
});
|
||||
$("#original_domains").dynamic_input({
|
||||
input_added: function($row) {
|
||||
$row.find("label").html("");
|
||||
},
|
||||
input_removed: function($input) {
|
||||
$input.parents(".form-group").remove();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
activate_widget.call($("#id_quota_act"));
|
||||
},
|
||||
|
||||
accountform_init: function() {
|
||||
this.generalform_init(true);
|
||||
this.mailform_init();
|
||||
},
|
||||
|
||||
mailform_prefill: function() {
|
||||
var $role = $("#id_role");
|
||||
if (!$role.length || $role.val() === "" || $role.val() === "SimpleUsers") {
|
||||
$("#id_email").val($("#id_username").val());
|
||||
}
|
||||
},
|
||||
|
||||
newaccount_cb: function() {
|
||||
this.accountform_init();
|
||||
$("#wizard").cwizard({
|
||||
formid: "newaccount_form",
|
||||
transition_callbacks: {
|
||||
mail: this.mailform_prefill
|
||||
},
|
||||
success_callback: $.proxy(this.reload_listing, this)
|
||||
});
|
||||
},
|
||||
|
||||
editaccount_cb: function() {
|
||||
this.accountform_init();
|
||||
$('.submit').on('click', $.proxy(function(e) {
|
||||
simple_ajax_form_post(e, {
|
||||
formid: "accountform",
|
||||
reload_on_success: false,
|
||||
success_cb: $.proxy(this.reload_listing, this)
|
||||
});
|
||||
}, this));
|
||||
},
|
||||
|
||||
aliasform_cb: function() {
|
||||
$("#id_random_address").click(function(evt) {
|
||||
if ($(this).prop("checked")) {
|
||||
$("#id_address").parents(".form-group").hide();
|
||||
$("#id_domain").parents(".form-group").show();
|
||||
} else {
|
||||
$("#id_address").parents(".form-group").show();
|
||||
$("#id_domain").parents(".form-group").hide();
|
||||
}
|
||||
});
|
||||
$("#id_address").autocompleter({
|
||||
from_character: "@",
|
||||
choices: $.proxy(this.get_domain_list, this)
|
||||
});
|
||||
$("#original_recipients").dynamic_input({
|
||||
input_added: function($row) {
|
||||
$row.find("label").html("");
|
||||
},
|
||||
input_removed: function($input) {
|
||||
$input.parents(".form-group").remove();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
$("#id_expire_at").datetimepicker({
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
});
|
||||
$(".submit").on('click', $.proxy(function(e) {
|
||||
simple_ajax_form_post(e, {
|
||||
formid: "aliasform",
|
||||
reload_on_success: false,
|
||||
success_cb: $.proxy(this.reload_listing, this)
|
||||
});
|
||||
}, this));
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
Identities.prototype = $.extend({}, Admin.prototype, Identities.prototype);
|
||||
@@ -1,23 +0,0 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">×</span><span class="sr-only">{% trans "Close" %}</span></button>
|
||||
<h3 class="modal-title">{% blocktrans trimmed with domain=domain.name %}DKIM public key for {{ domain }}{% endblocktrans %}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info" style="overflow-wrap: break-word;">
|
||||
<strong>{% trans "Raw format. Click to copy" %}</strong><br>
|
||||
<button onclick="copy(`copy_raw_dkim_key`)" class="dkimbutton" id="copy_raw_dkim_key"><pre class="dkim">{{ domain.dkim_public_key }}</pre></button>
|
||||
</div>
|
||||
<div class="alert alert-info" style="overflow-wrap: break-word;">
|
||||
<strong>{% trans "Bind/named format. Click to copy" %}</strong><br>
|
||||
<button onclick="copy(`copy_dns_dkim_key`)" class="dkimbutton" id="copy_dns_dkim_key"><pre class="dkim">{{ domain.bind_format_dkim_public_key }}</pre></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a class="btn btn-default" data-dismiss="modal">{% trans "Close" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,49 +0,0 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% if not domain.enable_dns_checks or domain.uses_a_reserved_tld %}
|
||||
<span class="label label-mini label-default">{% trans "Disabled" context "checks" %}</span>
|
||||
{% elif domain.awaiting_checks %}
|
||||
<span class="label label-mini label-info">{% trans "Awaiting checks" %}</span>
|
||||
{% else %}
|
||||
{% if enable_mx_checks %}
|
||||
<span class="label label-mini label-{% if not domain.mxrecord_set.has_valids %}danger{% else %}success{% endif %}"><a href="{% url 'admin:mx_domain_detail' domain.pk %}" data-toggle="ajaxmodal">MX</a></span>
|
||||
{% endif %}
|
||||
{% if enable_dnsbl_checks %}
|
||||
<span class="label label-mini label-{{ domain.dnsbl_status_color }}"><a href="{% url 'admin:dnsbl_domain_detail' domain.pk %}" data-toggle="ajaxmodal">DNSBL</a></span>
|
||||
{% endif %}
|
||||
{% if enable_spf_checks %}
|
||||
{% if not domain.spf_record %}
|
||||
<span class="label label-mini label-danger" title="{% trans 'No record found' %}">SPF</span>
|
||||
{% else %}
|
||||
<span class="label label-mini label-{% if not domain.spf_record.is_valid %}warning{% else %}success{% endif %}">
|
||||
<a href="{% url 'dnstools:dns_record_detail' domain.spf_record.pk %}" data-toggle="ajaxmodal">SPF</a></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if enable_dkim_checks and domain.enable_dkim %}
|
||||
{% if not domain.dkim_public_key %}
|
||||
<span class="label label-mini label-default" title="{% trans 'Waiting for key to be generated' %}">DKIM</span>
|
||||
{% elif not domain.dkim_record %}
|
||||
<span class="label label-mini label-danger" title="{% trans 'No record found' %}">DKIM</span>
|
||||
{% else %}
|
||||
<span class="label label-mini label-{% if not domain.dkim_record %}danger{% elif not domain.dkim_record.is_valid %}warning{% else %}success{% endif %}">
|
||||
<a href="{% url 'dnstools:dns_record_detail' domain.dkim_record.pk %}" data-toggle="ajaxmodal">DKIM</a></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if enable_dmarc_checks %}
|
||||
{% if not domain.dmarc_record %}
|
||||
<span class="label label-mini label-danger" title="{% trans 'No record found' %}">DMARC</span>
|
||||
{% else %}
|
||||
<span class="label label-mini label-{% if not domain.dmarc_record.is_valid %}warning{% else %}success{% endif %}">
|
||||
<a href="{% url 'dnstools:dns_record_detail' domain.dmarc_record.pk %}" data-toggle="ajaxmodal">DMARC</a></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if enable_autoconfig_checks %}
|
||||
{% if not domain.autoconfig_record and not domain.autodiscover_record %}
|
||||
<span class="label label-mini label-danger" title="{% trans 'No record found' %}">autoconfig</span>
|
||||
{% elif not domain.autoconfig_record or not domain.autodiscover_record %}
|
||||
<span class="label label-mini label-warning"><a href="{% url 'dnstools:autoconfig_records_status' domain.pk %}" data-toggle="ajaxmodal">autoconfig</a></span>
|
||||
{% else %}
|
||||
<span class="label label-mini label-success"><a href="{% url 'dnstools:autoconfig_records_status' domain.pk %}" data-toggle="ajaxmodal">autoconfig</a></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -1,20 +0,0 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="panel panel-primary">
|
||||
<div class="panel-heading"><h3 class="panel-title">{% trans "Global statistics" %}</h3></div>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>{% trans "Type" %}</th>
|
||||
<th>{% trans "Quantity" %}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans "Domains" %}</td><td>{{ domains_counter }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans "Domain aliases" %}</td><td>{{ domain_aliases_counter }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans "Identities" %}</td><td>{{ identities_counter }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,17 +0,0 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="panel panel-primary">
|
||||
<div class="panel-heading"><h3 class="panel-title">{% trans "Per-domain statistics" %}</h3></div>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>{% trans "Domain" %}</th>
|
||||
<th>{% trans "Identities" %}</th>
|
||||
</tr>
|
||||
{% for domain in domains %}
|
||||
<tr>
|
||||
<td><a href="{% url 'admin:domain_detail' domain.pk %}">{{ domain.name }}</a></td>
|
||||
<td>{{ domain.identities_count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,109 +0,0 @@
|
||||
{% extends "admin/header.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load admin_tags %}
|
||||
{% load lib_tags %}
|
||||
|
||||
{% block leftcol %}
|
||||
<ul class="nav nav-sidebar">
|
||||
{% identities_menu user "identities" False %}
|
||||
</ul>
|
||||
|
||||
{% get_extra_admin_content user "leftcol" selection %}
|
||||
{% endblock %}
|
||||
|
||||
{% block apparea %}
|
||||
<h2>{{ object }}{% if not object.is_active %} <small>{% trans "(disabled)" %}</small>{% endif %}</h2>
|
||||
<hr>
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<div class="panel panel-info">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Summary" %}</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>{% trans "Full name" %}</th><td>{{ object.fullname }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Role" %}</th><td>{{ object.get_role_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Created" %}</th><td>{{ object.date_joined|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Last login" %}</th><td>{{ object.last_login|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Secondary email" %}</th><td>{{ object.secondary_email|default:_("Not defined") }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Phone number" %}</th><td>{{ object.phone_number|default:_("Not defined") }}</td>
|
||||
</tr>
|
||||
{% if object.mailbox %}
|
||||
<tr>
|
||||
<th>{% trans "Quota" %}</th>
|
||||
<td>{% include "common/progressbar.html" with id='quotabar' value=object.mailbox.get_quota_in_percent %}</td>
|
||||
</tr>
|
||||
{% if object.mailbox.message_limit is not None %}
|
||||
<tr>
|
||||
<th>{% trans "Message sending limit" %}</a></th>
|
||||
<td>{% include "common/progressbar.html" with id='messagelimitbar' value=object.mailbox.sent_messages_in_percent %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if domains %}
|
||||
<div class="panel panel-info">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Administered domains" %}</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
{% for domain in domains %}
|
||||
<tr><td>{{ domain }}</td></tr>
|
||||
{% empty %}
|
||||
<tr><td>{% trans "No domain defined." %}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for template in templates.left %}
|
||||
{% include template %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
{% if object.mailbox %}
|
||||
<div class="panel panel-info">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Aliases" %}</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
{% for alias in object.mailbox.alias_addresses %}
|
||||
<tr><td>{{ alias }}</td></tr>
|
||||
{% empty %}
|
||||
<tr><td>{% trans "No alias defined" %}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-info">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Additional sender addresses" %}</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
{% for saddress in object.mailbox.senderaddress_set.all %}
|
||||
<tr><td>{{ saddress }}</td></tr>
|
||||
{% empty %}
|
||||
<tr><td>{% trans "No sender address defined" %}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for template in templates.right %}
|
||||
{% include template %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,15 +0,0 @@
|
||||
{% load form_tags %}
|
||||
|
||||
{% render_field form.role %}
|
||||
{% render_field form.username %}
|
||||
{% render_field form.first_name %}
|
||||
{% render_field form.last_name %}
|
||||
{% render_field form.random_password %}
|
||||
{% render_field form.password1 %}
|
||||
{% render_field form.password2 %}
|
||||
{% if form.master_user %}{% render_field form.master_user %}{% endif %}
|
||||
{% if form.domain_is_disabled %}
|
||||
{% include "common/generic_field.html" with field=form.is_active label_width="col-sm-4" inline_message=_("Domain is disabled") %}
|
||||
{% else %}
|
||||
{% render_field form.is_active %}
|
||||
{% endif %}
|
||||
@@ -1,3 +0,0 @@
|
||||
{% load form_tags %}
|
||||
|
||||
{% render_form form %}
|
||||
@@ -1,45 +0,0 @@
|
||||
{% extends "admin/header.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load admin_tags %}
|
||||
{% load lib_tags %}
|
||||
|
||||
{% block leftcol %}
|
||||
<ul class="nav nav-sidebar">
|
||||
{% identities_menu user "identities" False %}
|
||||
</ul>
|
||||
|
||||
{% get_extra_admin_content user "leftcol" selection %}
|
||||
{% endblock %}
|
||||
|
||||
{% block apparea %}
|
||||
<h2>{% trans "Alias" %} {{ object }}{% if not object.enabled %} <small>{% trans "(disabled)" %}</small>{% endif %}</h2>
|
||||
<hr>
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<div class="panel panel-info">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Summary" %}</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "Created" %}</dt><dd>{{ alias.creation|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
||||
<dt>{% trans "Modified" %}</dt><dd>{{ alias.last_modification|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
||||
{% if alias.expire_at %}
|
||||
<dt>{% trans "Expire at" %}</dt><dd>{{ alias.expire_at|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
||||
{% endif %}
|
||||
{% if alias.description %}
|
||||
<dt>{% trans "Description" %}</dt><dd>{{ alias.description }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
<table class="table">
|
||||
<tr><th>{% trans "Recipients" %}</th></tr>
|
||||
{% for recipient in object.recipients %}
|
||||
<tr><td>{{ recipient }}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,17 +0,0 @@
|
||||
{% extends "common/generic_modal_form.html" %}
|
||||
|
||||
{% load form_tags %}
|
||||
|
||||
{% block formbody %}
|
||||
{% if form.random_address %}{% render_field form.random_address %}{% endif %}
|
||||
{% render_field form.address %}
|
||||
{% if form.domain %}{% render_field form.domain hidden=True %}{% endif %}
|
||||
{% render_fields_group form "recipients" %}
|
||||
{% if not form.instance.pk or form.instance.domain.enabled %}
|
||||
{% render_field form.enabled %}
|
||||
{% else %}
|
||||
{% include "common/generic_field.html" with field=form.enabled label_width="col-sm-4" inline_message=_("Domain is disabled") extra_classes="info" %}
|
||||
{% endif %}
|
||||
{% render_field form.expire_at %}
|
||||
{% render_field form.description %}
|
||||
{% endblock %}
|
||||
@@ -1,35 +0,0 @@
|
||||
{% extends "common/generic_modal.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block modalbody %}
|
||||
{% if domain.dnsbl_status_color == "danger" %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Provider" %}</th>
|
||||
<th>{% trans "Address" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for result in domain.dnsblresult_set.blacklisted %}
|
||||
<tr>
|
||||
<td>{{ result.provider }}</td>
|
||||
<td>{{ result.mx }}</td>
|
||||
<td>
|
||||
{% if result.status %}<span class="label label-danger">{% trans "listed" %}</span>{% else %}<span class="label label-success"></span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
{% if domain.dnsbl_status_color == "success" %}
|
||||
<div class="alert alert-success">
|
||||
{% trans "Everything is fine, this domain is not listed." %}</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "No information available for this domain." %}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,46 +0,0 @@
|
||||
{% extends "admin/header.html" %}
|
||||
|
||||
{% load admin_tags i18n lib_tags static %}
|
||||
|
||||
{% block extra_css %}
|
||||
{{ block.super }}
|
||||
{% extra_static_content "domains" "css" user %}
|
||||
{% endblock %}
|
||||
|
||||
{% block leftcol %}
|
||||
<ul class="nav nav-sidebar">
|
||||
{% domains_menu "domains" user False %}
|
||||
</ul>
|
||||
|
||||
{% get_extra_admin_content user "leftcol" selection %}
|
||||
{% endblock %}
|
||||
|
||||
{% block apparea %}
|
||||
<h2>{% trans "Opened alarms for" %} {{ object }}</h2>
|
||||
<hr>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "Message" %}</th>
|
||||
<th>{% trans "Mailbox" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for alarm in object.alarms.opened %}
|
||||
<tr>
|
||||
<td>{{ alarm.created|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
<td>{{ alarm.title }}</td>
|
||||
<td>{{ alarm.mailbox|default:"" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td cospan="3">
|
||||
<div class="alert alert-info">{% trans "No opened alarm" %}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -1,224 +0,0 @@
|
||||
{% extends "admin/header.html" %}
|
||||
|
||||
{% load admin_tags i18n lib_tags %}
|
||||
|
||||
{% block extra_css %}
|
||||
{{ block.super }}
|
||||
{% extra_static_content "domains" "css" user %}
|
||||
{% endblock %}
|
||||
|
||||
{% block leftcol %}
|
||||
<ul class="nav nav-sidebar">
|
||||
{% domains_menu "domains" user False %}
|
||||
</ul>
|
||||
|
||||
{% get_extra_admin_content user "leftcol" selection %}
|
||||
{% endblock %}
|
||||
|
||||
{% block apparea %}
|
||||
<h2>{{ object }} {% if not object.enabled %} <small>{% trans "(disabled)" %}</small>{% endif %}</h2>
|
||||
<hr>
|
||||
|
||||
<div class="col-xs-12 col-sm-6">
|
||||
<div class="panel panel-info">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Summary" %}</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th width="40%">{% trans "Creation date" %}</th>
|
||||
<td>{{ domain.creation }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Last modification date" %}</th>
|
||||
<td>{{ domain.last_modification }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Domain aliases" %}</th>
|
||||
<td>{{ domain.domainalias_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a href="{% url 'admin:identity_list' %}#list/?searchquery=@{{ object.name }}&idtfilter=account">{% trans "Mailboxes" %}</a></th>
|
||||
<td>{{ domain.mailbox_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a href="{% url 'admin:identity_list' %}#list/?searchquery=@{{ object.name }}&idtfilter=alias">{% trans "Mailbox aliases" %}</a></th>
|
||||
<td>{{ domain.mbalias_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Quota" %}</a></th>
|
||||
<td>{{ domain.quota }} {% trans "MB" %}</td>
|
||||
</tr>
|
||||
{% if domain.quota %}
|
||||
<tr>
|
||||
<th>{% trans "Usage" %}</a></th>
|
||||
<td>
|
||||
<div class="progress">
|
||||
<div class="{% progress_color domain.used_quota_in_percent %}" style="width: {{ domain.used_quota_in_percent }}%" title="{{ domain.used_quota_in_percent }}% ({{ domain.used_quota }} {% trans "MB" %}) {% trans 'used' context 'quota' %}"></div>
|
||||
<div class="{% progress_color domain.allocated_quota_in_percent %}" style="width: {{ domain.allocated_quota_in_percent }}%" title="{{ domain.allocated_quota_in_percent }}% ({{ domain.allocated_quota }} {% trans "MB" %}) {% trans 'allocated' %}"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th>{% trans "Default mailbox quota" %}</a></th>
|
||||
<td>{{ domain.default_mailbox_quota }} {% trans "MB" %}</td>
|
||||
</tr>
|
||||
{% if domain.message_limit != None %}
|
||||
<tr>
|
||||
<th>{% trans "Message sending limit" %}</a></th>
|
||||
<td>{{ domain.message_limit }} {% trans "per day" %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Usage" %}</a></th>
|
||||
<td>
|
||||
<div class="progress">
|
||||
<div class="{% progress_color domain.sent_messages_in_percent %}" style="width: {{ domain.sent_messages_in_percent }}%" title="{{ domain.sent_messages_in_percent }}% ({{ domain.sent_messages }}) {% trans 'used' context 'message count' %}"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if object.domainalias_set.exists %}
|
||||
<div class="panel panel-info">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Domain aliases" %}</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
{% for domainalias in object.domainalias_set.all %}
|
||||
<tr><td>{{ domainalias }}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for template in templates.left %}
|
||||
{% include template %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 col-sm-6">
|
||||
<div class="panel panel-info">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "DNS" %}
|
||||
<a href="{% url 'dnstools:domain_dns_configuration' domain.pk %}"
|
||||
data-toggle="ajaxmodal"><span class="fa fa-info-circle"></span></a>
|
||||
</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
{% if enable_mx_checks or enable_dnsbl_checks %}
|
||||
<tr>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<td>
|
||||
{% include "admin/_domain_dnschecks_status.html" %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th>{% trans "DKIM key" %}</th>
|
||||
<td>
|
||||
{% if domain.dkim_public_key %}
|
||||
<button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#domain_dkim_key">
|
||||
{% trans "Show key" %}
|
||||
</button>
|
||||
<button data-toggle="modal" class="btn btn-default btn-xs" data-target="#dkim_regenerate_dialog"><span class="fa fa-refresh"></span></button>
|
||||
|
||||
<div class="modal fade" id="dkim_regenerate_dialog" tabindex="-1" role="dialog" aria-labelledby="dkim_regenerate_dialog" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="dkim_regenerate_dialog">{% trans "Warning" %}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% trans "DKIM keys already exist for this domain. Do you want to overwrite them?" %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">{% trans "No" %}</button>
|
||||
<a id="refresh_dkim_key" class="btn btn-danger" href="#" title="{% trans 'Generate new key' %}" data-dismiss="modal">{% trans "Yes" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% trans "Not generated" %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-info">
|
||||
<div class="panel-heading"><h3 class="panel-title">{% trans "Administrators" %}</h3></div>
|
||||
<table id="domadmins" class="table">
|
||||
<thead>
|
||||
<tr><th>{% trans "Username" %}</th><th>{% trans "Name" %}</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for da in domain.admins %}
|
||||
<tr>
|
||||
<td>{{ da.username }}</td>
|
||||
<td>{{ da.fullname }}</td>
|
||||
<td name="actions">
|
||||
{% if perms.admin.change_domain %}
|
||||
<a name="removeperm" href="{% url 'admin:permission_remove' %}?domid={{ domain.id }}&daid={{ da.id }}" title="{% trans 'Remove this permission' %}"><span class="fa fa-trash"></span></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3">{% trans "No domain administrator defined yet." %}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% for template in templates.right %}
|
||||
{% include template %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if domain.dkim_public_key %}
|
||||
<div class="modal fade" id="domain_dkim_key" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
|
||||
{% include "admin/_domain_dkim_key.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{{ block.super }}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
$('#refresh_dkim_key').click(function (evt) {
|
||||
evt.preventDefault();
|
||||
var $this = $(this);
|
||||
$.ajax({
|
||||
url: '/api/v1/domains/{{ domain.pk }}/',
|
||||
method: 'PATCH',
|
||||
data: {
|
||||
dkim_public_key: '',
|
||||
dkim_private_key_path: ''
|
||||
}
|
||||
}).done(function (response) {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
$('a[name="removeperm"]').click(function (evt) {
|
||||
evt.preventDefault();
|
||||
var $this = $(this);
|
||||
$.ajax({
|
||||
url: $this.attr('href'),
|
||||
method: 'DELETE'
|
||||
}).done(function () {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
});
|
||||
function copy(id) {
|
||||
navigator.clipboard.writeText(document.getElementById(id).textContent);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,12 +0,0 @@
|
||||
{% load i18n form_tags %}
|
||||
{% render_field form.name %}
|
||||
{% render_field form.type %}
|
||||
{% render_field form.quota %}
|
||||
{% render_field form.default_mailbox_quota %}
|
||||
{% render_field form.message_limit %}
|
||||
{% render_fields_group form "aliases" %}
|
||||
{% render_field form.enabled %}
|
||||
{% render_field form.enable_dns_checks %}
|
||||
{% render_field form.enable_dkim %}
|
||||
{% render_field form.dkim_key_selector %}
|
||||
{% render_field form.dkim_key_length %}
|
||||
@@ -1,10 +0,0 @@
|
||||
{% load i18n %}
|
||||
{% block table_header %}
|
||||
<th id="name" class="sortable" data-sort_order="name" width="">{% trans "Name" %}</th>
|
||||
<th id="aliases" width="">{% trans "Alias(es)" %}</th>
|
||||
<th id="tags" class="sortable" data-sort_order="tags" width="">{% trans "Tags" %}</th>
|
||||
{% if enable_mx_checks or enable_dnsbl_checks %}
|
||||
<th id="dns" width="">{% trans "DNS status" %}</th>
|
||||
{% endif %}
|
||||
<th id="actions" width="">{% trans "Actions" %}</th>
|
||||
{% endblock %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% load i18n form_tags %}
|
||||
|
||||
{% if form.fields %}
|
||||
{% render_field form.create_dom_admin %}
|
||||
{% render_field form.dom_admin_username appended_text="@" %}
|
||||
{% render_field form.random_password %}
|
||||
{% render_field form.with_mailbox %}
|
||||
{% render_field form.create_aliases %}
|
||||
{% else %}
|
||||
<div class="alert alert-warning">{% trans "You can't create a new domain administrator" %}</div>
|
||||
{% endif %}
|
||||
@@ -1,33 +0,0 @@
|
||||
{% extends "admin/listing.html" %}
|
||||
|
||||
{% load i18n admin_tags lib_tags static%}
|
||||
|
||||
{% block pagetitle %}{% trans "Domains" %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
{{ block.super }}
|
||||
{% extra_static_content "domains" "css" user %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{{ block.super }}
|
||||
<script src="{% static 'transport/js/transport.js' %}" type="text/javascript"></script>
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
admin = new Domains({
|
||||
load_page_url: "{% url 'admin:domain_page' %}"
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% extra_static_content "domains" "js" user %}
|
||||
{% endblock %}
|
||||
|
||||
{% block toolbar_right %}
|
||||
{% if perms.admin.add_domain %}
|
||||
<a href="{% url 'admin:domain_add' %}" class="btn btn-primary" data-toggle="ajaxmodal" modalcb="admin.newdomain_cb">
|
||||
<span class="fa fa-plus"></span> {% trans "Add" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block actions %}{% domains_menu "domains" user %}{% endblock %}
|
||||
@@ -1,6 +0,0 @@
|
||||
{% load i18n %}
|
||||
<th class="sortable" data-sort_order="queue_id">{% trans "Queue ID" %}</th>
|
||||
<th class="sortable" data-sort_order="date">{% trans "Date" %}</th>
|
||||
<th class="sortable" data-sort_order="sender">{% trans "From" %}</th>
|
||||
<th class="sortable" data-sort_order="rcpt">{% trans "To" %}</th>
|
||||
<th class="sortable" data-sort_order="status">{% trans "Status" %}</th>
|
||||
@@ -1,9 +0,0 @@
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.queue_id }}</td>
|
||||
<td>{{ log.date|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
<td><span title="{{ log.sender }}">{{ log.sender|truncatechars:30 }}</span></td>
|
||||
<td>{{ log.rcpt }}</td>
|
||||
<td>{{ log.status }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -1,6 +0,0 @@
|
||||
{% load i18n %}
|
||||
<th width="50%" class="sortable" data-sort_order="name">{% trans "Domain" %}</th>
|
||||
<th class="sortable" data-sort_order="quota">{% trans "Limit" %}</th>
|
||||
<th class="sortable" data-sort_order="quota_value__used">{% trans "Used" %}</th>
|
||||
<th class="sortable" data-sort_order="allocated_quota">{% trans "Allocated" %}</th>
|
||||
<th class="sortable" data-sort_order="quota_usage" id="quotabar">{% trans "Usage (%)" %}</th>
|
||||
@@ -1,16 +0,0 @@
|
||||
{% load lib_tags i18n admin_tags %}
|
||||
|
||||
{% for domain in domains %}
|
||||
<tr>
|
||||
<td name="name"><a href="{% url 'admin:domain_detail' domain.id %}">{{ domain }}</a></td>
|
||||
<td>{{ domain.quota }}M</td>
|
||||
<td>{{ domain.used_quota }}M</td>
|
||||
<td>{{ domain.allocated_quota }}M</td>
|
||||
<td name="quota">
|
||||
<div class="progress">
|
||||
<div class="{% progress_color domain.used_quota_in_percent %}" style="width: {{ domain.used_quota_in_percent }}%" title="{{ domain.used_quota_in_percent }}% ({{ domain.used_quota }} {% trans "MB" %}) {% trans 'used' context 'quota' %}"></div>
|
||||
<div class="{% progress_color domain.allocated_quota_in_percent %}" style="width: {{ domain.allocated_quota_in_percent }}%" title="{{ domain.allocated_quota_in_percent }}% ({{ domain.allocated_quota }} {% trans "MB" %}) {% trans 'allocated' %}"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -1,17 +0,0 @@
|
||||
{% load i18n admin_tags lib_tags %}
|
||||
|
||||
{% for domain in domains %}
|
||||
<tr id="{{ domain.id }}" class="{% if not domain.enabled %}text-muted{% endif %}" name="{{ forloop.counter }}">
|
||||
<td name="name"><a href="{% url 'admin:domain_detail' domain.id %}">{{ domain }}</a>{% if not domain.enabled %} ({% trans "disabled" %}){% endif %}</td>
|
||||
<td name="aliases">{% domain_aliases domain %} </td>
|
||||
<td name="tags">{% render_tags domain.tags %}</td>
|
||||
{% if enable_mx_checks or enable_dnsbl_checks %}
|
||||
<td name="dns">
|
||||
<small>
|
||||
{% include "admin/_domain_dnschecks_status.html" %}
|
||||
</small>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td name="actions">{% domain_actions user domain %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% load i18n form_tags %}
|
||||
<h2>{% trans "Forward" %} <small>{% trans "Automatically forward emails to another address" %}</small></h2>
|
||||
<hr>
|
||||
<form class="form-horizontal" method="POST" action="{% url 'user_forward' %}"
|
||||
id="uprefs_form">
|
||||
{% csrf_token %}
|
||||
{% render_form form %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary col-lg-offset-8 col-md-offset-8 col-sm-offset-7 col-lg-1 col-md-1 col-sm-2" id="update" name="update">{% trans 'Update' %}</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,12 +0,0 @@
|
||||
{% extends "twocols.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/admin.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script type="text/javascript" src="{% static 'js/history.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'js/listing.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'admin/js/admin.js' %}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,38 +0,0 @@
|
||||
{% extends "admin/listing.html" %}
|
||||
{% load admin_tags lib_tags i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block pagetitle %}{% trans "Identities" %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
{{ block.super }}
|
||||
{% extra_static_content "identities" "css" user %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{{ block.super }}
|
||||
<script type="text/javascript" src="{% static 'js/autocompleter.js' %}"></script>
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
admin = new Identities({
|
||||
domain_list_url: "{% url 'admin:domain_flat_list' %}",
|
||||
load_page_url: "{% url 'admin:identity_page' %}"
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% extra_static_content "identities" "js" user %}
|
||||
{% endblock %}
|
||||
|
||||
{% block toolbar_right %}
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="fa fa-plus"></span> {% trans "Add" %} <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a data-toggle="ajaxmodal" modalcb="admin.newaccount_cb" href="{% url 'admin:account_add' %}"><span class="fa fa-user"></span> {% trans "Account" %}</a></li>
|
||||
<li><a data-toggle="ajaxmodal" modalcb="admin.aliasform_cb" href="{% url 'admin:alias_add' %}"><span class="fa fa-mail-forward"></span> {% trans "Alias" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block actions %}{% identities_menu user "identities" %}{% endblock %}
|
||||
@@ -1,5 +0,0 @@
|
||||
{% load i18n %}
|
||||
<th width="50%" class="sortable" data-sort_order="address">{% trans "Address" %}</th>
|
||||
<th class="sortable" data-sort_order="quota_value__bytes">{% trans "Value" %}</th>
|
||||
<th class="sortable" data-sort_order="quota">{% trans "Limit" %}</th>
|
||||
<th class="sortable" data-sort_order="quota_usage" id="quotabar">{% trans "Usage (%)" %}</th>
|
||||
@@ -1,15 +0,0 @@
|
||||
{% load lib_tags admin_tags %}
|
||||
|
||||
{% for mbox in mboxes %}
|
||||
<tr>
|
||||
<td>{% identity_modify_link mbox.user "mail" %}</td>
|
||||
<td>{{ mbox.get_quota }}M</td>
|
||||
<td>{{ mbox.quota }}M</td>
|
||||
<td name="quota">
|
||||
<div id="quotabar" rel="tooltip" title="{{ mbox.get_quota_in_percent }}%"
|
||||
class="progress">
|
||||
<div class="{% progress_color mbox.get_quota_in_percent %}" style="width: {{ mbox.get_quota_in_percent }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -1,13 +0,0 @@
|
||||
{% load i18n admin_tags lib_tags %}
|
||||
|
||||
{% for ident in identities %}
|
||||
{% check_identity_status ident as identity_is_enabled %}
|
||||
<tr id="{{ ident.id }}" class="{% if not identity_is_enabled %}text-muted{% endif %}" name="{{ forloop.counter }}">
|
||||
<td name="identity">
|
||||
<a href="{{ ident.get_absolute_url }}">{{ ident }}</a>{% if not identity_is_enabled %} {% trans "(disabled)" %}{% endif %}
|
||||
</td>
|
||||
<td name="name_or_rcpt">{{ ident.name_or_rcpt }}</td>
|
||||
<td name="tags">{% render_tags ident.tags %}</td>
|
||||
<td name="actions">{% identity_actions user ident %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -1,5 +0,0 @@
|
||||
{% load i18n %}
|
||||
<th id="identity" class="sortable" data-sort_order="identity" width="">{% trans "Name" %}</th>
|
||||
<th id="name_or_rcpt" class="sortable" data-sort_order="name_or_rcpt" width="">{% trans "Fullname/Recipient" %}</th>
|
||||
<th id="tags" class="sortable" data-sort_order="tags" width="">{% trans "Tags" %}</th>
|
||||
<th id="actions" width="">{% trans "Actions" %}</th>
|
||||
@@ -1,16 +0,0 @@
|
||||
{% extends "admin/importform.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block helptext %}
|
||||
{% blocktrans trimmed %}
|
||||
Provide a CSV file where lines respect one of the following formats:
|
||||
<ul>
|
||||
<li><em>domain; name; quota; default mailbox quota; enabled</em></li>
|
||||
<li><em>domainalias; name; targeted domain; enabled</em></li>
|
||||
<li><em>relaydomain; name; target host; target port; service; enabled; verify recipients</em></li>
|
||||
</ul>
|
||||
<p>The first element of each line is mandatory and must be equal to one of the previous values.</p>
|
||||
<p>You can use a different character as separator.</p>
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
@@ -1,11 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
|
||||
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head profile="http://www.w3.org/2005/10/profile">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<script type="text/javascript">
|
||||
parent.admin.importdone("{{ status }}", "{{ msg|escapejs }}");
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
{% extends "admin/importform.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block helptext %}
|
||||
{% blocktrans trimmed %}
|
||||
Provide a CSV file where lines respect one of the following formats:
|
||||
<ul>
|
||||
<li><em>account; loginname; password; first name; last name; enabled; group; address; quota; [, domain, ...]</em></li>
|
||||
<li><em>alias; address; enabled; recipient; recipient; ...</em></li>
|
||||
</ul>
|
||||
<p>The first element of each line is mandatory and must be equal to one of the previous values.</p>
|
||||
<p>You can use a different character as separator.</p>
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
@@ -1,16 +0,0 @@
|
||||
{% extends "common/generic_modal_form.html" %}
|
||||
{% load form_tags i18n %}
|
||||
{% load static %}
|
||||
{% block formbody %}
|
||||
<div class="alert alert-info">
|
||||
<h4>{% trans "Help" %}</h4>
|
||||
{% block helptext %}{% endblock %}
|
||||
</div>
|
||||
{% render_form form %}
|
||||
<iframe id="import_target" name="import_target" src="#"
|
||||
style="width:0;height:0;border:0px solid #fff;"></iframe>
|
||||
<div id="import_status" style="text-align: center;display:none">
|
||||
<img src="{% static 'css/spinner.gif' %}" /> {% trans "Importing..." %}
|
||||
</div>
|
||||
<div id="import_result"></div>
|
||||
{% endblock %}
|
||||
@@ -1,50 +0,0 @@
|
||||
{% extends "admin/header.html" %}{% load i18n admin_tags %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/jquery.sortable.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{{ block.super }}
|
||||
<script type="text/javascript" src="{% static 'js/creation_wizard.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'js/dynamic_input.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'js/jquery.sortable.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'js/jquery.infinitescroll.js' %}"></script>
|
||||
<script type="text/javascript">
|
||||
var admin;
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block leftcol %}
|
||||
<ul class="nav nav-sidebar">
|
||||
{% block actions %}{% endblock %}
|
||||
</ul>
|
||||
|
||||
{% get_extra_admin_content user "leftcol" selection %}
|
||||
{% endblock %}
|
||||
|
||||
{% block apparea %}
|
||||
<div class="row">
|
||||
<div class="col-sm-5">
|
||||
{% include "admin/searchform.html" %}
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
{% block toolbar_right %}{% endblock %}
|
||||
</div>
|
||||
<div id="taglist" class="col-sm-4"> </div>
|
||||
</div>
|
||||
|
||||
<div id="listing">
|
||||
<table id="objects_table" class="table {{ styles }}">
|
||||
<thead>
|
||||
<tr>
|
||||
{% block table_header %}{% endblock %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% load i18n %}
|
||||
{% if target == "permissions" %}
|
||||
<option value="">{% trans "Choose a value" %}</option>
|
||||
{% endif %}
|
||||
{% for mb in mailboxes %}
|
||||
{% if target == "permissions" %}
|
||||
<option value='{{ mb.id }}'>{{ mb.name }}</option>
|
||||
{% else %}
|
||||
<option value='{{ mb.id }}'>{{ mb.full_address }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% load form_tags %}
|
||||
|
||||
{% render_field form.email %}
|
||||
{% if form.create_alias_with_old_address %}
|
||||
{% render_field form.create_alias_with_old_address %}
|
||||
{% endif %}
|
||||
{% include "common/generic_field.html" with field=form.quota help_display_mode="tooltip" label_width="col-sm-4" activator=True activator_value=form.quota_act.value deactivate_if_empty=False activator_label=_("Use domain default value") activator_size="col-sm-5" %}
|
||||
{% render_field form.message_limit %}
|
||||
{% render_field form.is_send_only %}
|
||||
{% render_fields_group form "aliases" %}
|
||||
{% render_fields_group form "senderaddress" %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% for message in messages %}
|
||||
<tr>
|
||||
<td name="name">{{ message.id }}</td>
|
||||
<td>{{ message.date }}</td>
|
||||
<td>{{ message.from }}</td>
|
||||
<td>{{ message.to }}</td>
|
||||
<td name="quota">
|
||||
{{ message.status }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -1,6 +0,0 @@
|
||||
{% load i18n %}
|
||||
<th class="sortable" data-sort_order="name">{% trans "ID" %}</th>
|
||||
<th class="sortable" data-sort_order="quota">{% trans "Date" %}</th>
|
||||
<th class="sortable" data-sort_order="quota_value__used">{% trans "From" %}</th>
|
||||
<th class="sortable" data-sort_order="allocated_quota">{% trans "To" %}</th>
|
||||
<th class="sortable" data-sort_order="quota_usage" id="quotabar">{% trans "Status" %}</th>
|
||||
@@ -1,41 +0,0 @@
|
||||
{% extends "common/generic_modal.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block modalbody %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Address" %}</th>
|
||||
<th>{% trans "Updated" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for mx in domain.mxrecord_set.all %}
|
||||
<tr>
|
||||
<td>{{ mx.name }}</td>
|
||||
<td>
|
||||
{% if mx.is_managed %}
|
||||
{% if not mx.managed %}
|
||||
<a class="label label-danger help"
|
||||
rel="popover"
|
||||
data-content="{% trans 'This IP does not match any of the valid MXs defined in parameters' %}"
|
||||
>{{ mx.address }}</a>
|
||||
{% else %}
|
||||
<span class="label label-success">{{ mx.address }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ mx.address }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ mx.updated|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3">{% trans "No MX record found for this domain." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -1,6 +0,0 @@
|
||||
{% extends "common/generic_modal_form.html" %}{% load form_tags %}{% block formbody %}
|
||||
{% render_field form.name %}
|
||||
{% render_field form.quota appended_text=_("MB") %}
|
||||
{% render_fields_group form "aliases" %}
|
||||
{% render_field form.enabled %}
|
||||
{% endblock %}
|
||||
@@ -1,2 +0,0 @@
|
||||
{% load form_tags %}
|
||||
{% render_fields_group form "domains" %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% load i18n %}
|
||||
<form id="searchform" class="form-horizontal" role="form">
|
||||
<div class="form-group has-feedback">
|
||||
<div class="col-sm-12">
|
||||
<input class="form-control" type="text"
|
||||
value={% if squery %}"{{ squery }}"{% else %}"{% trans 'Search' %}"{% endif %}
|
||||
id="searchquery" name="searchquery" />
|
||||
<span class="fa fa-search form-control-feedback"></span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,332 +0,0 @@
|
||||
"""Admin extension tags."""
|
||||
|
||||
from functools import reduce
|
||||
|
||||
from django import template
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _, gettext_lazy
|
||||
|
||||
from modoboa.core import signals as core_signals
|
||||
from modoboa.lib.templatetags.lib_tags import render_link
|
||||
from modoboa.lib.web_utils import render_actions
|
||||
from .. import signals
|
||||
|
||||
register = template.Library()
|
||||
|
||||
genders = {"Enabled": (gettext_lazy("enabled_m"), gettext_lazy("enabled_f"))}
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def domains_menu(selection, user, ajax_mode=True):
|
||||
"""Specific menu for domain related operations.
|
||||
|
||||
Corresponds to the menu visible on the left column when you go to
|
||||
*Domains*.
|
||||
|
||||
:param str selection: menu entry currently selected
|
||||
:param ``User`` user: connected user
|
||||
:rtype: str
|
||||
:return: rendered menu (as HTML)
|
||||
"""
|
||||
nav_classes = "navigation"
|
||||
if ajax_mode:
|
||||
domain_list_url = "list/"
|
||||
quota_list_url = "quotas/"
|
||||
logs_url = "logs/"
|
||||
nav_classes += " ajaxnav"
|
||||
else:
|
||||
domain_list_url = reverse("admin:domain_list")
|
||||
quota_list_url = domain_list_url + "#quotas/"
|
||||
logs_url = domain_list_url + "#logs/"
|
||||
entries = [
|
||||
{
|
||||
"name": "domains",
|
||||
"label": _("List domains"),
|
||||
"img": "fa fa-user",
|
||||
"class": "ajaxnav navigation",
|
||||
"url": domain_list_url,
|
||||
},
|
||||
{
|
||||
"name": "quotas",
|
||||
"label": _("List quotas"),
|
||||
"img": "fa fa-hdd-o",
|
||||
"class": "ajaxnav navigation",
|
||||
"url": quota_list_url,
|
||||
},
|
||||
{
|
||||
"name": "logs",
|
||||
"label": _("Message logs"),
|
||||
"img": "fa fa-list",
|
||||
"class": "ajaxnav navigation",
|
||||
"url": logs_url,
|
||||
},
|
||||
]
|
||||
if user.has_perm("admin.add_domain"):
|
||||
extra_entries = signals.extra_domain_menu_entries.send(
|
||||
sender="domains_menu", user=user
|
||||
)
|
||||
for entry in extra_entries:
|
||||
entries += entry[1]
|
||||
entries += [
|
||||
{
|
||||
"name": "import",
|
||||
"label": _("Import"),
|
||||
"img": "fa fa-folder-open",
|
||||
"url": reverse("admin:domain_import"),
|
||||
"modal": True,
|
||||
"modalcb": "admin.importform_cb",
|
||||
},
|
||||
{
|
||||
"name": "export",
|
||||
"label": _("Export"),
|
||||
"img": "fa fa-share-alt",
|
||||
"url": reverse("admin:domain_export"),
|
||||
"modal": True,
|
||||
"modalcb": "admin.exportform_cb",
|
||||
},
|
||||
]
|
||||
|
||||
return render_to_string(
|
||||
"common/menulist.html",
|
||||
{"entries": entries, "selection": selection, "user": user},
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def identities_menu(user, selection=None, ajax_mode=True):
|
||||
"""Menu specific to the Identities page.
|
||||
|
||||
:param ``User`` user: the connecter user
|
||||
:rtype: str
|
||||
:return: the rendered menu
|
||||
"""
|
||||
nav_classes = "navigation"
|
||||
if ajax_mode:
|
||||
identity_list_url = "list/"
|
||||
quota_list_url = "quotas/"
|
||||
nav_classes += " ajaxnav"
|
||||
else:
|
||||
identity_list_url = reverse("admin:identity_list")
|
||||
quota_list_url = identity_list_url + "#quotas/"
|
||||
entries = [
|
||||
{
|
||||
"name": "identities",
|
||||
"label": _("List identities"),
|
||||
"img": "fa fa-user",
|
||||
"class": nav_classes,
|
||||
"url": identity_list_url,
|
||||
},
|
||||
{
|
||||
"name": "quotas",
|
||||
"label": _("List quotas"),
|
||||
"img": "fa fa-hdd-o",
|
||||
"class": nav_classes,
|
||||
"url": quota_list_url,
|
||||
},
|
||||
{
|
||||
"name": "import",
|
||||
"label": _("Import"),
|
||||
"img": "fa fa-folder-open",
|
||||
"url": reverse("admin:identity_import"),
|
||||
"modal": True,
|
||||
"modalcb": "admin.importform_cb",
|
||||
},
|
||||
{
|
||||
"name": "export",
|
||||
"label": _("Export"),
|
||||
"img": "fa fa-share-alt",
|
||||
"url": reverse("admin:identity_export"),
|
||||
"modal": True,
|
||||
"modalcb": "admin.exportform_cb",
|
||||
},
|
||||
]
|
||||
|
||||
return render_to_string("common/menulist.html", {"entries": entries, "user": user})
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def domain_actions(user, domain):
|
||||
actions = [
|
||||
{
|
||||
"name": "listidentities",
|
||||
"url": "{}#list/?searchquery=@{}".format(
|
||||
reverse("admin:identity_list"), domain.name
|
||||
),
|
||||
"title": _("View the domain's identities"),
|
||||
"img": "fa fa-user",
|
||||
},
|
||||
]
|
||||
if domain.alarms.opened().exists():
|
||||
actions.append(
|
||||
{
|
||||
"name": "listalarms",
|
||||
"url": reverse("admin:domain_alarms", args=[domain.pk]),
|
||||
"title": _("View domain's alarms"),
|
||||
"img": "fa fa-bell",
|
||||
}
|
||||
)
|
||||
if user.has_perm("admin.change_domain"):
|
||||
actions.append(
|
||||
{
|
||||
"name": "editdomain",
|
||||
"title": _("Edit {}").format(domain),
|
||||
"url": reverse("admin:domain_change", args=[domain.pk]),
|
||||
"modal": True,
|
||||
"modalcb": "admin.domainform_cb",
|
||||
"img": "fa fa-edit",
|
||||
}
|
||||
)
|
||||
if user.has_perm("admin.delete_domain"):
|
||||
actions.append(
|
||||
{
|
||||
"name": "deldomain",
|
||||
"url": reverse("admin:domain_delete", args=[domain.id]),
|
||||
"title": _("Delete %s?") % domain.name,
|
||||
"img": "fa fa-trash",
|
||||
}
|
||||
)
|
||||
|
||||
responses = signals.extra_domain_actions.send(sender=None, user=user, domain=domain)
|
||||
for _receiver, response in responses:
|
||||
if response:
|
||||
actions += response
|
||||
|
||||
return render_actions(actions)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def identity_actions(user, ident):
|
||||
name = ident.__class__.__name__
|
||||
objid = ident.id
|
||||
if name == "User":
|
||||
actions = []
|
||||
result = core_signals.extra_account_actions.send(
|
||||
sender="identity_actions", account=ident
|
||||
)
|
||||
for action in result:
|
||||
actions += action[1]
|
||||
url = reverse("admin:account_change", args=[objid]) + "?active_tab=default"
|
||||
actions += [
|
||||
{
|
||||
"name": "changeaccount",
|
||||
"url": url,
|
||||
"img": "fa fa-edit",
|
||||
"modal": True,
|
||||
"modalcb": "admin.editaccount_cb",
|
||||
"title": _("Edit {}").format(ident.username),
|
||||
},
|
||||
{
|
||||
"name": "delaccount",
|
||||
"url": reverse("admin:account_delete", args=[objid]),
|
||||
"img": "fa fa-trash",
|
||||
"title": _("Delete %s?") % ident.username,
|
||||
},
|
||||
]
|
||||
else:
|
||||
actions = [
|
||||
{
|
||||
"name": "changealias",
|
||||
"url": reverse("admin:alias_change", args=[objid]),
|
||||
"img": "fa fa-edit",
|
||||
"modal": True,
|
||||
"modalcb": "admin.aliasform_cb",
|
||||
"title": _("Edit {}").format(ident),
|
||||
},
|
||||
{
|
||||
"name": "delalias",
|
||||
"url": "{}?selection={}".format(reverse("admin:alias_delete"), objid),
|
||||
"img": "fa fa-trash",
|
||||
"title": _("Delete %s?") % ident.address,
|
||||
},
|
||||
]
|
||||
return render_actions(actions)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def check_identity_status(identity):
|
||||
"""Check if identity is enabled or not."""
|
||||
if identity.__class__.__name__ == "User":
|
||||
if hasattr(identity, "mailbox") and not identity.mailbox.domain.enabled:
|
||||
return False
|
||||
elif not identity.is_active:
|
||||
return False
|
||||
elif not identity.enabled or not identity.domain.enabled:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def domain_aliases(domain):
|
||||
"""Display domain aliases of this domain.
|
||||
|
||||
:param domain:
|
||||
:rtype: str
|
||||
"""
|
||||
if not domain.aliases.count():
|
||||
return "---"
|
||||
res = ""
|
||||
for alias in domain.aliases.all():
|
||||
res += f"{alias.name}<br/>"
|
||||
return mark_safe(res)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def identity_modify_link(identity, active_tab="default"):
|
||||
"""Return the appropriate modification link.
|
||||
|
||||
According to the identity type, a specific modification link (URL)
|
||||
must be used.
|
||||
|
||||
:param identity: a ``User`` or ``Alias`` instance
|
||||
:param str active_tab: the tab to display
|
||||
:rtype: str
|
||||
"""
|
||||
linkdef = {"label": identity.identity, "modal": True}
|
||||
if identity.__class__.__name__ == "User":
|
||||
linkdef["url"] = reverse("admin:account_change", args=[identity.id])
|
||||
linkdef["url"] += f"?active_tab={active_tab}"
|
||||
linkdef["modalcb"] = "admin.editaccount_cb"
|
||||
else:
|
||||
linkdef["url"] = reverse("admin:alias_change", args=[identity.id])
|
||||
linkdef["modalcb"] = "admin.aliasform_cb"
|
||||
return render_link(linkdef)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def domadmin_actions(daid, domid):
|
||||
actions = [
|
||||
{
|
||||
"name": "removeperm",
|
||||
"url": "{}?domid={}&daid={}".format(
|
||||
reverse("admin:permission_remove"), domid, daid
|
||||
),
|
||||
"img": "fa fa-trash",
|
||||
"title": _("Remove this permission"),
|
||||
}
|
||||
]
|
||||
return render_actions(actions)
|
||||
|
||||
|
||||
@register.filter
|
||||
def gender(value, target):
|
||||
if value in genders:
|
||||
trans = target == "m" and genders[value][0] or genders[value][1]
|
||||
if trans.find("_") == -1:
|
||||
return trans
|
||||
return value
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def get_extra_admin_content(user, target, currentpage):
|
||||
results = signals.extra_admin_content.send(
|
||||
sender="get_extra_admin_content",
|
||||
user=user,
|
||||
location=target,
|
||||
currentpage=currentpage,
|
||||
)
|
||||
if not results:
|
||||
return ""
|
||||
results = reduce(lambda a, b: a + b, [result[1] for result in results])
|
||||
return mark_safe("".join(results))
|
||||
+124
-615
@@ -6,12 +6,12 @@ from django.urls import reverse
|
||||
from modoboa.core import factories as core_factories
|
||||
from modoboa.core.models import User
|
||||
from modoboa.core.tests import test_ldap
|
||||
from modoboa.lib.tests import NO_LDAP, ModoTestCase
|
||||
from modoboa.lib.tests import NO_LDAP, ModoAPITestCase
|
||||
from modoboa.limits import utils as limits_utils
|
||||
from .. import factories, models
|
||||
|
||||
|
||||
class AuthenticationTestCase(ModoTestCase):
|
||||
class AuthenticationTestCase(ModoAPITestCase):
|
||||
"""Check authentication."""
|
||||
|
||||
@classmethod
|
||||
@@ -34,10 +34,10 @@ class AuthenticationTestCase(ModoTestCase):
|
||||
data = {"username": self.mb.full_address, "password": password}
|
||||
response = self.client.post(reverse("core:login"), data)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.url.endswith(reverse("core:user_index")))
|
||||
self.assertEqual(response.url, "/")
|
||||
|
||||
|
||||
class AccountTestCase(ModoTestCase):
|
||||
class AccountTestCase(ModoAPITestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls): # NOQA:N802
|
||||
@@ -45,545 +45,43 @@ class AccountTestCase(ModoTestCase):
|
||||
super().setUpTestData()
|
||||
factories.populate_database()
|
||||
|
||||
def test_crud(self):
|
||||
values = {
|
||||
"username": "tester@test.com",
|
||||
"first_name": "Tester",
|
||||
"last_name": "Toto",
|
||||
"password1": "Toto1234",
|
||||
"password2": "Toto1234",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"email": "tester@test.com",
|
||||
"stepid": "step2",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values)
|
||||
|
||||
account = User.objects.get(username="tester@test.com")
|
||||
mb = account.mailbox
|
||||
self.assertEqual(mb.full_address, "tester@test.com")
|
||||
self.assertEqual(mb.quota, 10)
|
||||
self.assertTrue(mb.enabled)
|
||||
self.assertEqual(mb.quota_value.username, "tester@test.com")
|
||||
self.assertEqual(account.username, mb.full_address)
|
||||
self.assertTrue(account.check_password("Toto1234"))
|
||||
self.assertEqual(account.first_name, "Tester")
|
||||
self.assertEqual(account.last_name, "Toto")
|
||||
self.assertEqual(mb.domain.mailbox_count, 3)
|
||||
# Check if self alias has been created
|
||||
self.assertTrue(
|
||||
models.AliasRecipient.objects.select_related("alias")
|
||||
.filter(
|
||||
alias__address=mb.full_address,
|
||||
address=mb.full_address,
|
||||
alias__internal=True,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
|
||||
values.update(
|
||||
{
|
||||
"username": "pouet@test.com",
|
||||
"language": "en",
|
||||
"secondary_email": "homer@simpson.com",
|
||||
"phone_number": "+33612345678",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
)
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.id]), values)
|
||||
account.refresh_from_db()
|
||||
self.assertEqual(account.secondary_email, values["secondary_email"])
|
||||
mb = models.Mailbox.objects.get(pk=mb.pk)
|
||||
self.assertEqual(mb.full_address, "pouet@test.com")
|
||||
self.assertEqual(mb.quota_value.username, "pouet@test.com")
|
||||
# Check if self alias has been updated
|
||||
self.assertTrue(
|
||||
models.AliasRecipient.objects.select_related("alias")
|
||||
.filter(
|
||||
alias__address=mb.full_address,
|
||||
address=mb.full_address,
|
||||
alias__internal=True,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
self.ajax_post(reverse("admin:account_delete", args=[account.id]), {})
|
||||
# Check if self alias has been deleted
|
||||
self.assertFalse(
|
||||
models.AliasRecipient.objects.select_related("alias")
|
||||
.filter(
|
||||
alias__address=mb.full_address,
|
||||
address=mb.full_address,
|
||||
alias__internal=True,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
|
||||
@override_settings(LANGUAGE_CODE="fr")
|
||||
def test_create_account_default_lang(self):
|
||||
"""Check if default language is applied."""
|
||||
values = {
|
||||
"username": "tester@test.com",
|
||||
"first_name": "Tester",
|
||||
"last_name": "Toto",
|
||||
"password1": "Toto1234",
|
||||
"password2": "Toto1234",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"email": "tester@test.com",
|
||||
"stepid": "step2",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values)
|
||||
account = User.objects.get(username=values["username"])
|
||||
self.assertEqual(account.language, "fr")
|
||||
|
||||
def test_create_account_with_existing_alias(self):
|
||||
alias = models.Alias.objects.get(address="alias@test.com")
|
||||
alias.set_recipients(["user@external.com"])
|
||||
values = {
|
||||
"email": "tester@test.com",
|
||||
"username": "tester@test.com",
|
||||
"role": "SimpleUsers",
|
||||
"random_password": "on",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"stepid": "step2",
|
||||
"aliases": "alias@test.com",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values)
|
||||
self.assertTrue(alias.aliasrecipient_set.count() == 2)
|
||||
|
||||
def test_create_alias_on_existing_account(self):
|
||||
values = {
|
||||
"email": "tester@test.com",
|
||||
"username": "tester@test.com",
|
||||
"role": "SimpleUsers",
|
||||
"random_password": "on",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"stepid": "step2",
|
||||
"aliases": "user@test.com",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values)
|
||||
# Check that internal alias has not been updated
|
||||
alias = models.Alias.objects.get(address="user@test.com", internal=True)
|
||||
self.assertTrue(alias.aliasrecipient_set.count() == 1)
|
||||
alias = models.Alias.objects.get(address="user@test.com", internal=False)
|
||||
self.assertTrue(alias.aliasrecipient_set.count() == 1)
|
||||
|
||||
def test_create_disabled_account(self):
|
||||
"""Check if account and alias are disabled."""
|
||||
values = {
|
||||
"username": "tester@test.com",
|
||||
"first_name": "Tester",
|
||||
"last_name": "Toto",
|
||||
"password1": "Toto1234",
|
||||
"password2": "Toto1234",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"is_active": False,
|
||||
"email": "tester@test.com",
|
||||
"stepid": "step2",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values)
|
||||
account = User.objects.get(username=values["username"])
|
||||
self.assertEqual(account.enabled, False)
|
||||
alias = models.Alias.objects.get(address=account.email)
|
||||
self.assertEqual(alias.enabled, False)
|
||||
|
||||
def test_aliases_update_on_disable(self):
|
||||
"""Check if aliases are updated when account is disabled."""
|
||||
"""Check if aliases are disabled when account is disabled."""
|
||||
account = User.objects.get(username="user@test.com")
|
||||
internal = models.Alias.objects.get(address=account.email, internal=True)
|
||||
forward = models.Alias.objects.create(address=account.email, internal=False)
|
||||
factories.AliasRecipientFactory.create(
|
||||
address=account.mailbox.full_address,
|
||||
alias=forward,
|
||||
r_mailbox=account.mailbox,
|
||||
)
|
||||
values = {
|
||||
"username": "user@test.com",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"mailbox": {
|
||||
"use_domain_quota": True,
|
||||
},
|
||||
"is_active": False,
|
||||
"email": "user@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.id]), values)
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[account.pk]), values, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
internal.refresh_from_db()
|
||||
self.assertFalse(internal.enabled)
|
||||
forward.refresh_from_db()
|
||||
self.assertFalse(forward.enabled)
|
||||
values["is_active"] = True
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.id]), values)
|
||||
internal.refresh_from_db()
|
||||
self.assertTrue(internal.enabled)
|
||||
forward.refresh_from_db()
|
||||
self.assertTrue(forward.enabled)
|
||||
|
||||
def test_aliases_update_on_rename(self):
|
||||
"""Check if aliases are updated on mailbox rename."""
|
||||
account = User.objects.get(username="user@test.com")
|
||||
values = {
|
||||
"username": "new_user@test.com",
|
||||
"first_name": account.first_name,
|
||||
"last_name": account.last_name,
|
||||
"role": account.role,
|
||||
"quota_act": True,
|
||||
"is_active": account.is_active,
|
||||
"email": "new_user@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
"create_alias_with_old_address": False,
|
||||
"aliases_1": "alias@test.com",
|
||||
}
|
||||
url = reverse("admin:account_change", args=[account.pk])
|
||||
self.ajax_post(url, values)
|
||||
qset = account.mailbox.aliasrecipient_set.filter(alias__internal=False)
|
||||
for alr in qset:
|
||||
self.assertEqual(alr.address, values["email"])
|
||||
|
||||
def test_create_alias_on_rename(self):
|
||||
"""Check if alias is automatically created."""
|
||||
account = User.objects.get(username="user@test.com")
|
||||
values = {
|
||||
"username": "new_user@test.com",
|
||||
"first_name": account.first_name,
|
||||
"last_name": account.last_name,
|
||||
"role": account.role,
|
||||
"quota_act": True,
|
||||
"is_active": account.is_active,
|
||||
"email": "new_user@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
"create_alias_with_old_address": False,
|
||||
}
|
||||
url = reverse("admin:account_change", args=[account.pk])
|
||||
# Rename while option is set to False -> no alias created
|
||||
self.ajax_post(url, values)
|
||||
self.assertFalse(
|
||||
models.AliasRecipient.objects.filter(
|
||||
address="new_user@test.com",
|
||||
alias__address="user@test.com",
|
||||
alias__internal=False,
|
||||
).exists()
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[account.pk]), values, format="json"
|
||||
)
|
||||
# Now rename while option set to True -> alias created
|
||||
values.update(
|
||||
{
|
||||
"username": "user@test.com",
|
||||
"email": "user@test.com",
|
||||
"create_alias_with_old_address": True,
|
||||
}
|
||||
)
|
||||
self.ajax_post(url, values)
|
||||
self.assertTrue(
|
||||
models.AliasRecipient.objects.filter(
|
||||
address="user@test.com",
|
||||
alias__address="new_user@test.com",
|
||||
alias__internal=False,
|
||||
).exists()
|
||||
)
|
||||
# Change domain while option set to True -> alias created
|
||||
values.update(
|
||||
{
|
||||
"username": "new_user@test2.com",
|
||||
"email": "new_user@test2.com",
|
||||
}
|
||||
)
|
||||
self.ajax_post(url, values)
|
||||
self.assertTrue(
|
||||
models.AliasRecipient.objects.filter(
|
||||
address="new_user@test2.com",
|
||||
alias__address="user@test.com",
|
||||
alias__domain__name="test.com",
|
||||
alias__internal=False,
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_password_constraints(self):
|
||||
"""Check password constraints."""
|
||||
values = {
|
||||
"username": "tester@test.com",
|
||||
"first_name": "Tester",
|
||||
"last_name": "Toto",
|
||||
"password1": "",
|
||||
"password2": "",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"email": "tester@test.com",
|
||||
"stepid": "step2",
|
||||
}
|
||||
resp = self.ajax_post(reverse("admin:account_add"), values, 400)
|
||||
self.assertEqual(resp["form_errors"]["password1"][0], "This field is required.")
|
||||
values["password1"] = "Toto1234"
|
||||
values["password2"] = "Toto12345"
|
||||
resp = self.ajax_post(reverse("admin:account_add"), values, 400)
|
||||
self.assertEqual(
|
||||
resp["form_errors"]["password2"][0], "The two password fields didn't match."
|
||||
)
|
||||
|
||||
values["password1"] = "toto1234"
|
||||
values["password2"] = "toto1234"
|
||||
resp = self.ajax_post(reverse("admin:account_add"), values, 400)
|
||||
self.assertEqual(
|
||||
resp["form_errors"]["password2"][0],
|
||||
"Password must contain at least 1 uppercase letter.",
|
||||
)
|
||||
|
||||
def test_random_password(self):
|
||||
"""Try to create an account with a random password."""
|
||||
values = {
|
||||
"username": "tester@test.com",
|
||||
"first_name": "Tester",
|
||||
"last_name": "Toto",
|
||||
"random_password": True,
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"email": "tester@test.com",
|
||||
"stepid": "step2",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values)
|
||||
|
||||
account = User.objects.get(username=values["username"])
|
||||
password = account.password
|
||||
values["language"] = "en"
|
||||
values["subject"] = "subject"
|
||||
values["content"] = "content"
|
||||
# Since 'random_password' is still True, a new password should
|
||||
# be generated
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.pk]), values)
|
||||
account.refresh_from_db()
|
||||
self.assertNotEqual(password, account.password)
|
||||
password = account.password
|
||||
|
||||
values["random_password"] = False
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.pk]), values)
|
||||
account.refresh_from_db()
|
||||
self.assertEqual(password, account.password)
|
||||
|
||||
def test_delete_default_superadmin(self):
|
||||
"""Delete default superadmin."""
|
||||
sadmin2 = core_factories.UserFactory(username="admin2", is_superuser=True)
|
||||
sadmin = User.objects.get(username="admin")
|
||||
self.client.force_login(sadmin2)
|
||||
self.ajax_post(reverse("admin:account_delete", args=[sadmin.pk]), {})
|
||||
values = {
|
||||
"username": "user@test.com",
|
||||
"role": "DomainAdmins",
|
||||
"is_active": True,
|
||||
"email": "user@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
account = User.objects.get(username="user@test.com")
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.pk]), values)
|
||||
|
||||
def test_sender_address(self):
|
||||
"""Check if sender addresses are saved."""
|
||||
account = User.objects.get(username="user@test.com")
|
||||
values = {
|
||||
"username": "user@test.com",
|
||||
"first_name": "Tester",
|
||||
"last_name": "Toto",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"email": "user@test.com",
|
||||
"senderaddress": "test@titi.com",
|
||||
"senderaddress_1": "toto@go.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.pk]), values)
|
||||
self.assertEqual(
|
||||
models.SenderAddress.objects.filter(mailbox__address="user").count(), 2
|
||||
)
|
||||
del values["senderaddress_1"]
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.pk]), values)
|
||||
self.assertEqual(
|
||||
models.SenderAddress.objects.filter(mailbox__address="user").count(), 1
|
||||
)
|
||||
|
||||
def test_sender_address_as_domainadmin(self):
|
||||
"""Check that restrictions are applied."""
|
||||
admin = User.objects.get(username="admin@test.com")
|
||||
self.client.force_login(admin)
|
||||
account = User.objects.get(username="user@test.com")
|
||||
values = {
|
||||
"username": "user@test.com",
|
||||
"first_name": "Tester",
|
||||
"last_name": "Toto",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"email": "user@test.com",
|
||||
"senderaddress": "test@titi.com",
|
||||
"senderaddress_1": "toto@test2.com",
|
||||
}
|
||||
response = self.ajax_post(
|
||||
reverse("admin:account_change", args=[account.pk]), values, 400
|
||||
)
|
||||
self.assertEqual(
|
||||
response["form_errors"]["senderaddress_1"][0],
|
||||
"You don't have access to this domain",
|
||||
)
|
||||
|
||||
def test_conflicts(self):
|
||||
"""Check if unicity constraints are respected."""
|
||||
values = {
|
||||
"username": "user@test.com",
|
||||
"password1": "Toto1234",
|
||||
"password2": "Toto1234",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"email": "user@test.com",
|
||||
"stepid": "step2",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values, status=400)
|
||||
|
||||
values.update({"username": "fakeuser@test.com", "email": "fakeuser@test.com"})
|
||||
self.ajax_post(reverse("admin:account_add"), values)
|
||||
account = User.objects.get(username="fakeuser@test.com")
|
||||
values = {
|
||||
"username": "user@test.com",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"email": "user@test.com",
|
||||
}
|
||||
self.ajax_post(
|
||||
reverse("admin:account_change", args=[account.pk]), values, status=400
|
||||
)
|
||||
|
||||
def test_utf8_username(self):
|
||||
"""Create an account with non-ASCII characters."""
|
||||
values = {
|
||||
"username": "téster@test.com",
|
||||
"first_name": "Tester",
|
||||
"last_name": "Toto",
|
||||
"password1": "Toto1234",
|
||||
"password2": "Toto1234",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"email": "téster@test.com",
|
||||
"stepid": "step2",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values)
|
||||
|
||||
def _set_quota(self, email, value, expected_status=200):
|
||||
account = User.objects.get(username=email)
|
||||
values = {
|
||||
"username": email,
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": False,
|
||||
"is_active": True,
|
||||
"quota": value,
|
||||
"email": email,
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(
|
||||
reverse("admin:account_change", args=[account.id]),
|
||||
values,
|
||||
status=expected_status,
|
||||
)
|
||||
|
||||
def test_set_nul_quota_as_superadmin(self):
|
||||
self._set_quota("user@test.com", 0)
|
||||
|
||||
def test_set_nul_quota_as_domainadmin(self):
|
||||
"""Check cases where a domain admin set unlimited quota."""
|
||||
self.client.logout()
|
||||
self.assertTrue(self.client.login(username="admin@test.com", password="toto"))
|
||||
# Fails because domain has a quota
|
||||
self._set_quota("user@test.com", 0, 400)
|
||||
self.client.logout()
|
||||
self.assertTrue(self.client.login(username="admin@test2.com", password="toto"))
|
||||
# Ok because domain has no quota
|
||||
self._set_quota("user@test2.com", 0)
|
||||
|
||||
def test_domain_quota(self):
|
||||
"""Check domain quota."""
|
||||
dom = models.Domain.objects.get(name="test.com")
|
||||
dom.quota = 100
|
||||
dom.save(update_fields=["quota"])
|
||||
# 2 x 10MB
|
||||
self.assertEqual(dom.allocated_quota, 20)
|
||||
self._set_quota("user@test.com", 80)
|
||||
del dom.allocated_quota
|
||||
# 10 + 80 < 100 => ok
|
||||
self.assertEqual(dom.allocated_quota, 90)
|
||||
# 30 + 80 > 100 => failure
|
||||
self._set_quota("admin@test.com", 30, 400)
|
||||
|
||||
def test_master_user(self):
|
||||
"""Validate the master user mode."""
|
||||
values = {
|
||||
"username": "masteruser",
|
||||
"role": "SuperAdmins",
|
||||
"quota_act": False,
|
||||
"is_active": True,
|
||||
"master_user": True,
|
||||
"stepid": "step2",
|
||||
"password1": "Toto1234",
|
||||
"password2": "Toto1234",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values)
|
||||
self.assertTrue(User.objects.get(username="masteruser").master_user)
|
||||
|
||||
values = {
|
||||
"username": "testuser",
|
||||
"role": "DomainAdmins",
|
||||
"quota_act": False,
|
||||
"is_active": True,
|
||||
"master_user": True,
|
||||
"stepid": "step2",
|
||||
"password1": "Toto1234",
|
||||
"password2": "Toto1234",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values, status=400)
|
||||
|
||||
def test_account_detail_view(self):
|
||||
"""Test account detail view."""
|
||||
self.set_global_parameter("enable_admin_limits", False, app="limits")
|
||||
account = User.objects.get(username="admin@test.com")
|
||||
url = reverse("admin:account_detail", args=[account.pk])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Summary", response.content.decode())
|
||||
self.assertIn("Administered domains", response.content.decode())
|
||||
self.assertNotIn("Resources usage", response.content.decode())
|
||||
self.set_global_parameter("enable_admin_limits", True, app="limits")
|
||||
response = self.client.get(url)
|
||||
self.assertIn("Resources usage", response.content.decode())
|
||||
|
||||
def test_quota_list_view(self):
|
||||
"""Test quota list view."""
|
||||
models.Quota.objects.filter(username="user@test.com").update(bytes=5 * 1048576)
|
||||
url = reverse("admin:identity_quota_list")
|
||||
response = self.ajax_get(url)
|
||||
self.assertIn("5M", response["rows"])
|
||||
self.assertIn('title="50%"', response["rows"])
|
||||
self.assertIn("user@test.com", response["rows"])
|
||||
old_rows = response["rows"]
|
||||
|
||||
response = self.ajax_get(f"{url}?sort_order=-quota_value__bytes")
|
||||
self.assertNotEqual(old_rows, response["rows"])
|
||||
old_rows = response["rows"]
|
||||
|
||||
response = self.ajax_get(f"{url}?sort_order=-quota_usage")
|
||||
self.assertEqual(old_rows, response["rows"])
|
||||
|
||||
response = self.ajax_get(f"{url}?sort_order=-unknown", status=400)
|
||||
internal.refresh_from_db()
|
||||
self.assertFalse(internal.enabled)
|
||||
forward.refresh_from_db()
|
||||
self.assertFalse(forward.enabled)
|
||||
|
||||
|
||||
@skipIf(NO_LDAP, "No ldap module installed")
|
||||
@@ -593,7 +91,7 @@ class AccountTestCase(ModoTestCase):
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
)
|
||||
)
|
||||
class LDAPAccountTestCase(test_ldap.LDAPTestCaseMixin, ModoTestCase):
|
||||
class LDAPAccountTestCase(test_ldap.LDAPTestCaseMixin, ModoAPITestCase):
|
||||
"""Check LDAP related code."""
|
||||
|
||||
def test_autocreate_disabled(self):
|
||||
@@ -607,7 +105,7 @@ class LDAPAccountTestCase(test_ldap.LDAPTestCaseMixin, ModoTestCase):
|
||||
self.assertFalse(models.Mailbox.objects.filter(address="testuser").exists())
|
||||
|
||||
|
||||
class PermissionsTestCase(ModoTestCase):
|
||||
class PermissionsTestCase(ModoAPITestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls): # NOQA:N802
|
||||
@@ -630,32 +128,34 @@ class PermissionsTestCase(ModoTestCase):
|
||||
"username": self.user.username,
|
||||
"role": "DomainAdmins",
|
||||
"is_active": self.user.is_active,
|
||||
"email": "user@test.com",
|
||||
"quota_act": True,
|
||||
"mailbox": {
|
||||
"use_domain_quota": True,
|
||||
},
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
self.client.logout()
|
||||
|
||||
def test_domain_admins(self):
|
||||
factories.DomainFactory(name="test2.com")
|
||||
|
||||
self.ajax_post(
|
||||
reverse("admin:account_change", args=[self.user.id]), self.values
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[self.user.id]),
|
||||
self.values,
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.user.role, "DomainAdmins")
|
||||
|
||||
self.values["mailboxes_limit"] = "0"
|
||||
self.values["mailbox_aliases_limit"] = "0"
|
||||
self.values["domains"] = ""
|
||||
self.values["domains_1"] = "test.com"
|
||||
self.values["domains_2"] = "test2.com"
|
||||
self.ajax_post(
|
||||
reverse("admin:account_change", args=[self.user.id]), self.values
|
||||
self.values["resources"] = [
|
||||
{"name": "mailboxes", "max_value": 0},
|
||||
{"name": "mailbox_aliases", "max_value": 0},
|
||||
]
|
||||
self.values["domains"] = ["test.com", "test2.com"]
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[self.user.id]),
|
||||
self.values,
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
self.user, models.Domain.objects.get(name="test.com").admins.all()
|
||||
)
|
||||
@@ -663,10 +163,13 @@ class PermissionsTestCase(ModoTestCase):
|
||||
self.user, models.Domain.objects.get(name="test2.com").admins.all()
|
||||
)
|
||||
|
||||
del self.values["domains_2"]
|
||||
self.ajax_post(
|
||||
reverse("admin:account_change", args=[self.user.id]), self.values
|
||||
self.values["domains"].remove("test2.com")
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[self.user.id]),
|
||||
self.values,
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
self.user, models.Domain.objects.get(name="test.com").admins.all()
|
||||
)
|
||||
@@ -675,9 +178,12 @@ class PermissionsTestCase(ModoTestCase):
|
||||
)
|
||||
|
||||
self.values["role"] = "SimpleUsers"
|
||||
self.ajax_post(
|
||||
reverse("admin:account_change", args=[self.user.id]), self.values
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[self.user.id]),
|
||||
self.values,
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(self.user.groups.filter(name="DomainAdmins").exists())
|
||||
self.assertNotIn(
|
||||
self.user, models.Domain.objects.get(name="test.com").admins.all()
|
||||
@@ -685,74 +191,73 @@ class PermissionsTestCase(ModoTestCase):
|
||||
|
||||
def test_superusers(self):
|
||||
self.values["role"] = "SuperAdmins"
|
||||
self.ajax_post(
|
||||
reverse("admin:account_change", args=[self.user.id]), self.values
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[self.user.id]),
|
||||
self.values,
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(User.objects.get(username="user@test.com").is_superuser, True)
|
||||
|
||||
self.values["role"] = "SimpleUsers"
|
||||
self.ajax_post(
|
||||
reverse("admin:account_change", args=[self.user.id]), self.values
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[self.user.id]),
|
||||
self.values,
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(User.objects.get(username="user@test.com").is_superuser, False)
|
||||
|
||||
def test_self_modif(self):
|
||||
self.client.logout()
|
||||
self.assertTrue(self.client.login(username="admin@test.com", password="toto"))
|
||||
admin = User.objects.get(username="admin@test.com")
|
||||
dadmin = User.objects.get(username="admin@test.com")
|
||||
self.authenticate_user(dadmin)
|
||||
self.assertTrue(dadmin.can_access(models.Domain.objects.get(name="test.com")))
|
||||
|
||||
values = {
|
||||
"username": "admin@test.com",
|
||||
"first_name": "Admin",
|
||||
"password1": "",
|
||||
"password2": "",
|
||||
"quota": 10,
|
||||
"role": "DomainAdmins",
|
||||
"mailbox": {
|
||||
"quota": 10,
|
||||
},
|
||||
"is_active": True,
|
||||
"email": "admin@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
"domains": ["test.com"],
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[admin.id]), values)
|
||||
self.assertEqual(admin.role, "DomainAdmins")
|
||||
self.assertTrue(admin.can_access(models.Domain.objects.get(name="test.com")))
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[dadmin.id]), values, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(dadmin.role, "DomainAdmins")
|
||||
self.assertTrue(dadmin.can_access(models.Domain.objects.get(name="test.com")))
|
||||
|
||||
values["role"] = "SuperAdmins"
|
||||
self.ajax_post(reverse("admin:account_change", args=[admin.id]), values)
|
||||
admin.refresh_from_db()
|
||||
self.assertEqual(admin.role, "DomainAdmins")
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[dadmin.id]), values, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["role"][0], "Invalid choice")
|
||||
|
||||
self.client.logout()
|
||||
self.client.login(username=self.reseller.username, password="toto")
|
||||
self.authenticate_user(self.reseller)
|
||||
self.assertTrue(self.reseller.can_access(self.reseller))
|
||||
values = {
|
||||
"username": self.reseller.username,
|
||||
"first_name": "Reseller",
|
||||
"password1": "",
|
||||
"password2": "",
|
||||
"is_active": True,
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
"role": "Resellers",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[self.reseller.pk]), values)
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[self.reseller.id]), values, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.reseller.role, "Resellers")
|
||||
values["role"] = "SuperAdmins"
|
||||
self.ajax_post(reverse("admin:account_change", args=[self.reseller.pk]), values)
|
||||
self.assertEqual(self.reseller.role, "Resellers")
|
||||
|
||||
def test_domadmin_access(self):
|
||||
self.client.logout()
|
||||
self.assertEqual(
|
||||
self.client.login(username="admin@test.com", password="toto"), True
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[self.reseller.id]), values, format="json"
|
||||
)
|
||||
response = self.client.get(reverse("admin:domain_list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("admin:account_change", args=[self.user.id]),
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
)
|
||||
self.assertNotEqual(response["Content-Type"], "application/json")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["role"][0], "Invalid choice")
|
||||
|
||||
def test_domainadmin_deletes_superadmin(self):
|
||||
"""Check domain admins restrictions about super admins
|
||||
@@ -765,49 +270,55 @@ class PermissionsTestCase(ModoTestCase):
|
||||
"username": "superadmin2@test.com",
|
||||
"first_name": "Super",
|
||||
"last_name": "Admin",
|
||||
"password1": "Toto1234",
|
||||
"password2": "Toto1234",
|
||||
"password": "Toto1234",
|
||||
"role": "SuperAdmins",
|
||||
"is_active": True,
|
||||
"email": "superadmin2@test.com",
|
||||
"stepid": "step2",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values)
|
||||
response = self.client.post(reverse("v2:account-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
account = User.objects.get(username="superadmin2@test.com")
|
||||
self.client.logout()
|
||||
self.client.login(username="admin@test.com", password="toto")
|
||||
self.ajax_post(reverse("admin:account_delete", args=[account.id]), {}, 403)
|
||||
dadmin = User.objects.get(username="admin@test.com")
|
||||
self.authenticate_user(dadmin)
|
||||
response = self.client.post(
|
||||
reverse("v2:account-delete", args=[account.id]), {}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_domainadmin_dlist_local_domain_not_owned(self):
|
||||
"""Check if a domain admin can use a local mailbox he can't
|
||||
access as a recipient in a distribution list"""
|
||||
dadmin = User.objects.get(username="admin@test.com")
|
||||
self.authenticate_user(dadmin)
|
||||
values = {
|
||||
"address": "all@test.com",
|
||||
"recipients": "user@test.com",
|
||||
"recipients_1": "user@test2.com",
|
||||
"recipients": ["user@test.com", "user@test2.com"],
|
||||
"enabled": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:alias_add"), values)
|
||||
response = self.client.post(reverse("v2:alias-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
def test_domainadmin_master_user(self):
|
||||
"""Check domain administrator is not allowed to access this feature."""
|
||||
dadmin = User.objects.get(username="admin@test.com")
|
||||
self.authenticate_user(dadmin)
|
||||
values = {
|
||||
"username": "user10@test.com",
|
||||
"first_name": "Test",
|
||||
"last_name": "Test",
|
||||
"password1": "Toto1234",
|
||||
"password2": "Toto1234",
|
||||
"password": "Toto1234",
|
||||
"role": "SimpleUsers",
|
||||
"is_active": True,
|
||||
"master_user": True,
|
||||
"email": "user10@test.com",
|
||||
"stepid": "step2",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values, status=400)
|
||||
response = self.client.post(reverse("v2:account-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
user = User.objects.get(username="user10@test.com")
|
||||
self.assertFalse(user.master_user)
|
||||
|
||||
def test_domadmins_permissions(self):
|
||||
"""
|
||||
Check that two domain admins in the same domains see the same
|
||||
Check that two domain admins in the same domain see the same
|
||||
content.
|
||||
"""
|
||||
dom = models.Domain.objects.get(name="test.com")
|
||||
@@ -819,19 +330,17 @@ class PermissionsTestCase(ModoTestCase):
|
||||
user__password="{PLAIN}toto",
|
||||
)
|
||||
dom.add_admin(mb.user)
|
||||
self.client.logout()
|
||||
self.assertTrue(self.client.login(username="admin@test.com", password="toto"))
|
||||
dadmin = User.objects.get(username="admin@test.com")
|
||||
self.authenticate_user(dadmin)
|
||||
values = {
|
||||
"username": "new@test.com",
|
||||
"password1": "Toto1234",
|
||||
"password2": "Toto1234",
|
||||
"password": "Toto1234",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"mailbox": {"use_domain_quota": True},
|
||||
"is_active": True,
|
||||
"email": "new@test.com",
|
||||
"stepid": "step2",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values)
|
||||
response = self.client.post(reverse("v2:account-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
new_mb = models.Mailbox.objects.get(user__username="new@test.com")
|
||||
new_mb = models.Mailbox.objects.get(user__username=values["username"])
|
||||
self.assertTrue(mb.user.can_access(new_mb))
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
from django.urls import reverse
|
||||
|
||||
from modoboa.core.models import User
|
||||
from modoboa.lib.tests import ModoTestCase
|
||||
from modoboa.lib.tests import ModoAPITestCase
|
||||
from .. import factories
|
||||
from ..models import Alias, AliasRecipient, Domain
|
||||
from ..models import Alias, AliasRecipient
|
||||
|
||||
|
||||
class AliasTestCase(ModoTestCase):
|
||||
class AliasTestCase(ModoAPITestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls): # NOQA:N802
|
||||
@@ -16,44 +16,15 @@ class AliasTestCase(ModoTestCase):
|
||||
super().setUpTestData()
|
||||
factories.populate_database()
|
||||
|
||||
def test_alias(self):
|
||||
user = User.objects.get(username="user@test.com")
|
||||
values = {
|
||||
"username": "user@test.com",
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"email": "user@test.com",
|
||||
"aliases": "toto@test.com",
|
||||
"aliases_1": "titi@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[user.id]), values)
|
||||
self.assertEqual(
|
||||
AliasRecipient.objects.filter(
|
||||
alias__internal=False, r_mailbox=user.mailbox
|
||||
).count(),
|
||||
2,
|
||||
)
|
||||
del values["aliases_1"]
|
||||
self.ajax_post(reverse("admin:account_change", args=[user.id]), values)
|
||||
self.assertEqual(
|
||||
AliasRecipient.objects.filter(
|
||||
alias__internal=False, r_mailbox=user.mailbox
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_alias_with_duplicated_recipient(self):
|
||||
"""Check for duplicates."""
|
||||
values = {
|
||||
"address": "badalias@test.com",
|
||||
"recipients": "user@test.com",
|
||||
"recipients_1": "user@test.com",
|
||||
"recipients": ["user@test.com", "user@test.com"],
|
||||
"enabled": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:alias_add"), values)
|
||||
response = self.client.post(reverse("v2:alias-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
alias = Alias.objects.get(address="badalias@test.com")
|
||||
self.assertEqual(alias.recipients_count, 1)
|
||||
|
||||
@@ -61,56 +32,32 @@ class AliasTestCase(ModoTestCase):
|
||||
"""Try to create an upper case alias."""
|
||||
user = User.objects.get(username="user@test.com")
|
||||
values = {
|
||||
"username": "user@test.com",
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"email": "user@test.com",
|
||||
"aliases": "Toto@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
"address": "Toto@test.com",
|
||||
"recipients": ["user@test.com"],
|
||||
"enabled": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[user.id]), values)
|
||||
response = self.client.post(reverse("v2:alias-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
mb = user.mailbox
|
||||
self.assertEqual(
|
||||
AliasRecipient.objects.filter(alias__internal=False, r_mailbox=mb).count(),
|
||||
1,
|
||||
2,
|
||||
)
|
||||
self.assertEqual(
|
||||
AliasRecipient.objects.get(
|
||||
alias__internal=False, r_mailbox=mb
|
||||
).alias.address,
|
||||
"toto@test.com",
|
||||
self.assertTrue(
|
||||
AliasRecipient.objects.filter(
|
||||
alias__internal=False, r_mailbox=mb, alias__address="toto@test.com"
|
||||
).exists()
|
||||
)
|
||||
|
||||
values = {
|
||||
"address": "Titi@test.com",
|
||||
"recipients": "user@test.com",
|
||||
"enabled": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:alias_add"), values)
|
||||
self.assertTrue(Alias.objects.filter(address="titi@test.com").exists())
|
||||
|
||||
def test_append_alias_with_tag(self):
|
||||
"""Try to create a alias with tag in recipient address"""
|
||||
user = User.objects.get(username="user@test.com")
|
||||
values = {
|
||||
"username": "user@test.com",
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"email": "user@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[user.id]), values)
|
||||
|
||||
values = {
|
||||
"address": "foobar@test.com",
|
||||
"recipients": "user+spam@test.com",
|
||||
"recipients": ["user+spam@test.com"],
|
||||
"enabled": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:alias_add"), values)
|
||||
response = self.client.post(reverse("v2:alias-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
alias = Alias.objects.get(address="foobar@test.com")
|
||||
self.assertTrue(
|
||||
alias.aliasrecipient_set.filter(
|
||||
@@ -124,22 +71,20 @@ class AliasTestCase(ModoTestCase):
|
||||
"""Test alias with non-ASCII characters."""
|
||||
values = {
|
||||
"address": "testé@test.com",
|
||||
"recipients": "user@test.com",
|
||||
"recipients_1": "admin@test.com",
|
||||
"recipients_2": "ext@titi.com",
|
||||
"recipients": ["user@test.com", "admin@test.com", "ext@titi.com"],
|
||||
"enabled": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:alias_add"), values)
|
||||
response = self.client.post(reverse("v2:alias-list"), values, format="json")
|
||||
self.assertTrue(response.status_code, 201)
|
||||
|
||||
def test_dlist(self):
|
||||
values = {
|
||||
"address": "all@test.com",
|
||||
"recipients": "user@test.com",
|
||||
"recipients_1": "admin@test.com",
|
||||
"recipients_2": "ext@titi.com",
|
||||
"recipients": ["user@test.com", "admin@test.com", "ext@titi.com"],
|
||||
"enabled": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:alias_add"), values)
|
||||
response = self.client.post(reverse("v2:alias-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
user = User.objects.get(username="user@test.com")
|
||||
mb = user.mailbox
|
||||
self.assertEqual(
|
||||
@@ -154,39 +99,29 @@ class AliasTestCase(ModoTestCase):
|
||||
)
|
||||
dlist = Alias.objects.get(address="all@test.com")
|
||||
self.assertEqual(dlist.recipients_count, 3)
|
||||
del values["recipients_1"]
|
||||
self.ajax_post(reverse("admin:alias_change", args=[dlist.id]), values)
|
||||
values["recipients"].remove("admin@test.com")
|
||||
response = self.client.put(
|
||||
reverse("v2:alias-detail", args=[dlist.id]), values, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(dlist.recipients_count, 2)
|
||||
|
||||
self.ajax_delete(
|
||||
"{}?selection={}".format(reverse("admin:alias_delete"), dlist.id)
|
||||
)
|
||||
self.assertRaises(Alias.DoesNotExist, Alias.objects.get, address="all@test.com")
|
||||
|
||||
def test_forward(self):
|
||||
values = {"address": "forward2@test.com", "recipients": "rcpt@dest.com"}
|
||||
self.ajax_post(reverse("admin:alias_add"), values)
|
||||
fwd = Alias.objects.get(address="forward2@test.com")
|
||||
self.assertEqual(fwd.recipients_count, 1)
|
||||
|
||||
values["recipients"] = "rcpt2@dest.com"
|
||||
self.ajax_post(reverse("admin:alias_change", args=[fwd.id]), values)
|
||||
self.assertEqual(fwd.recipients_count, 1)
|
||||
|
||||
self.ajax_delete(reverse("admin:alias_delete") + f"?selection={fwd.id}", {})
|
||||
self.assertRaises(
|
||||
Alias.DoesNotExist, Alias.objects.get, address="forward2@test.com"
|
||||
)
|
||||
|
||||
def test_forward_and_local_copies(self):
|
||||
values = {"address": "user@test.com", "recipients": "rcpt@dest.com"}
|
||||
self.ajax_post(reverse("admin:alias_add"), values)
|
||||
values = {
|
||||
"address": "user@test.com",
|
||||
"recipients": ["rcpt@dest.com"],
|
||||
"enabled": True,
|
||||
}
|
||||
response = self.client.post(reverse("v2:alias-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
fwd = Alias.objects.get(address="user@test.com", internal=False)
|
||||
self.assertEqual(fwd.recipients_count, 1)
|
||||
|
||||
values["recipients"] = "rcpt@dest.com"
|
||||
values["recipients_1"] = "user@test.com"
|
||||
self.ajax_post(reverse("admin:alias_change", args=[fwd.id]), values)
|
||||
values["recipients"] = ["rcpt@dest.com", "user@test.com"]
|
||||
response = self.client.put(
|
||||
reverse("v2:alias-detail", args=[fwd.id]), values, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
fwd = Alias.objects.get(pk=fwd.pk)
|
||||
self.assertEqual(fwd.aliasrecipient_set.count(), 2)
|
||||
self.assertEqual(fwd.aliasrecipient_set.filter(r_alias__isnull=True).count(), 2)
|
||||
@@ -195,26 +130,11 @@ class AliasTestCase(ModoTestCase):
|
||||
"""Test creation of a wildcard alias."""
|
||||
values = {
|
||||
"address": "@test.com",
|
||||
"recipients": "user@test.com",
|
||||
"recipients": ["user@test.com"],
|
||||
"enabled": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:alias_add"), values)
|
||||
|
||||
def test_random_alias(self):
|
||||
"""Test creation of a random alias."""
|
||||
alias_count = Alias.objects.count()
|
||||
values = {
|
||||
"random_address": True,
|
||||
"domain": Domain.objects.get(name="test.com").pk,
|
||||
"recipients": "user@test.com",
|
||||
"enabled": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:alias_add"), values)
|
||||
self.assertEqual(Alias.objects.count(), alias_count + 1)
|
||||
|
||||
del values["domain"]
|
||||
content = self.ajax_post(reverse("admin:alias_add"), values, 400)
|
||||
self.assertIn("domain", content["form_errors"])
|
||||
response = self.client.post(reverse("v2:alias-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
def test_distribution_list_deletion_on_user_update_bug(self):
|
||||
"""This test demonstrates an issue with distribution list being
|
||||
@@ -224,61 +144,45 @@ class AliasTestCase(ModoTestCase):
|
||||
"""
|
||||
values = {
|
||||
"address": "list@test.com",
|
||||
"recipients": "user@test.com",
|
||||
"recipients_1": "admin@test.com",
|
||||
"recipients": ["user@test.com", "admin@test.com"],
|
||||
"enabled": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:alias_add"), values)
|
||||
response = self.client.post(reverse("v2:alias-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
user = User.objects.get(username="user@test.com")
|
||||
values = {
|
||||
"username": user.username,
|
||||
"first_name": "Tester",
|
||||
"last_name": "Toto",
|
||||
"password1": "Toto1234",
|
||||
"password2": "Toto1234",
|
||||
"password": "Toto1234",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"mailbox": {
|
||||
"use_domain_quota": True,
|
||||
},
|
||||
"is_active": True,
|
||||
"email": user.email,
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[user.id]), values)
|
||||
|
||||
self.assertEqual(Alias.objects.filter(address="list@test.com").count(), 1)
|
||||
|
||||
def test_alias_detail_view(self):
|
||||
"""Test alias detail view."""
|
||||
account = Alias.objects.get(address="postmaster@test.com")
|
||||
url = reverse("admin:alias_detail", args=[account.pk])
|
||||
response = self.client.get(url)
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[user.id]), values, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Summary", response.content.decode())
|
||||
self.assertIn("Recipients", response.content.decode())
|
||||
self.assertTrue(Alias.objects.filter(address="list@test.com").exists())
|
||||
|
||||
def test_domainadmin_restrictions(self):
|
||||
"""Check that restrictions are applied."""
|
||||
admin = User.objects.get(username="admin@test.com")
|
||||
self.client.force_login(admin)
|
||||
self.authenticate_user(admin)
|
||||
|
||||
user = User.objects.get(username="user@test.com")
|
||||
values = {
|
||||
"username": "user@test.com",
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"email": "user@test.com",
|
||||
"aliases": "toto@test.com",
|
||||
"aliases_1": "titi@test2.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
"address": "titi@test2.com",
|
||||
"recipients": ["user@test.com"],
|
||||
"enabled": True,
|
||||
}
|
||||
response = self.ajax_post(
|
||||
reverse("admin:account_change", args=[user.id]), values, 400
|
||||
)
|
||||
response = self.client.post(reverse("v2:alias-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(
|
||||
response["form_errors"]["aliases_1"][0],
|
||||
"You don't have access to this domain",
|
||||
response.json()["address"][0],
|
||||
"Permission denied.",
|
||||
)
|
||||
|
||||
@@ -3,30 +3,21 @@
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
import dns.resolver
|
||||
from testfixtures import compare
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.html import escape
|
||||
|
||||
from modoboa.core import factories as core_factories
|
||||
from modoboa.core.models import User
|
||||
from modoboa.core.tests.test_views import SETTINGS_SAMPLE
|
||||
from modoboa.lib.tests import ModoTestCase
|
||||
from modoboa.maillog import factories as ml_factories
|
||||
from modoboa.lib.tests import ModoAPITestCase, SETTINGS_SAMPLE
|
||||
|
||||
from .. import constants
|
||||
from . import utils
|
||||
from .. import factories
|
||||
from ..models import Alarm, Alias, Domain
|
||||
|
||||
|
||||
class DomainTestCase(ModoTestCase):
|
||||
class DomainTestCase(ModoAPITestCase):
|
||||
"""Test case for Domain."""
|
||||
|
||||
@classmethod
|
||||
@@ -35,135 +26,21 @@ class DomainTestCase(ModoTestCase):
|
||||
super().setUpTestData()
|
||||
factories.populate_database()
|
||||
|
||||
def test_create(self):
|
||||
"""Test the creation of a domain."""
|
||||
values = {
|
||||
"name": "pouet.com",
|
||||
"quota": 1000,
|
||||
"default_mailbox_quota": 100,
|
||||
"create_dom_admin": False,
|
||||
"type": "domain",
|
||||
"stepid": "step3",
|
||||
}
|
||||
self.ajax_post(reverse("admin:domain_add"), values)
|
||||
dom = Domain.objects.get(name="pouet.com")
|
||||
self.assertEqual(dom.name, "pouet.com")
|
||||
self.assertEqual(dom.quota, 1000)
|
||||
self.assertEqual(dom.default_mailbox_quota, 100)
|
||||
self.assertEqual(dom.enabled, False)
|
||||
self.assertFalse(dom.admins)
|
||||
|
||||
def test_create_utf8(self):
|
||||
"""Test the creation of a domain with non-ASCII characters."""
|
||||
values = {
|
||||
"name": "pouét.com",
|
||||
"quota": 1000,
|
||||
"default_mailbox_quota": 100,
|
||||
"create_dom_admin": False,
|
||||
"type": "domain",
|
||||
"stepid": "step3",
|
||||
}
|
||||
self.ajax_post(reverse("admin:domain_add"), values)
|
||||
dom = Domain.objects.get(name="pouét.com")
|
||||
self.assertEqual(dom.name, "pouét.com")
|
||||
self.assertEqual(dom.quota, 1000)
|
||||
self.assertEqual(dom.default_mailbox_quota, 100)
|
||||
self.assertEqual(dom.enabled, False)
|
||||
self.assertFalse(dom.admins)
|
||||
|
||||
def test_create_with_template(self):
|
||||
"""Test the creation of a domain with a template."""
|
||||
values = {
|
||||
"name": "pouet.com",
|
||||
"quota": 1000,
|
||||
"default_mailbox_quota": 100,
|
||||
"create_dom_admin": True,
|
||||
"dom_admin_username": "toto",
|
||||
"with_mailbox": True,
|
||||
"create_aliases": True,
|
||||
"type": "domain",
|
||||
"stepid": "step3",
|
||||
}
|
||||
self.ajax_post(reverse("admin:domain_add"), values)
|
||||
dom = Domain.objects.get(name="pouet.com")
|
||||
da = User.objects.get(username="toto@pouet.com")
|
||||
self.assertIn(da, dom.admins)
|
||||
al = Alias.objects.get(address="postmaster@pouet.com")
|
||||
self.assertTrue(al.aliasrecipient_set.filter(r_mailbox=da.mailbox).exists())
|
||||
self.assertTrue(da.can_access(al))
|
||||
|
||||
values = {
|
||||
"name": "pouet2.com",
|
||||
"quota": 1000,
|
||||
"default_mailbox_quota": 100,
|
||||
"create_dom_admin": True,
|
||||
"dom_admin_username": "postmaster",
|
||||
"create_aliases": True,
|
||||
"type": "domain",
|
||||
"stepid": "step3",
|
||||
"with_mailbox": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:domain_add"), values)
|
||||
self.assertTrue(User.objects.filter(username="postmaster@pouet2.com").exists())
|
||||
self.assertFalse(
|
||||
Alias.objects.filter(
|
||||
address="postmaster@pouet2.com", internal=False
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_create_with_template_and_random_password(self):
|
||||
"""Test domain creation with administrator and random password."""
|
||||
values = {
|
||||
"name": "pouet.com",
|
||||
"quota": 1000,
|
||||
"default_mailbox_quota": 100,
|
||||
"create_dom_admin": True,
|
||||
"dom_admin_username": "toto",
|
||||
"with_mailbox": True,
|
||||
"create_aliases": True,
|
||||
"type": "domain",
|
||||
"stepid": "step3",
|
||||
"random_password": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:domain_add"), values)
|
||||
self.assertFalse(
|
||||
self.client.login(username="admin@pouet.com", password="password")
|
||||
)
|
||||
|
||||
def test_create_with_template_and_no_mailbox(self):
|
||||
"""Test domain creation with administrator but no mailbox."""
|
||||
values = {
|
||||
"name": "pouet.com",
|
||||
"quota": 1000,
|
||||
"default_mailbox_quota": 100,
|
||||
"create_dom_admin": True,
|
||||
"dom_admin_username": "toto",
|
||||
"with_mailbox": False,
|
||||
"create_aliases": True,
|
||||
"type": "domain",
|
||||
"stepid": "step3",
|
||||
}
|
||||
self.ajax_post(reverse("admin:domain_add"), values)
|
||||
dom = Domain.objects.get(name="pouet.com")
|
||||
da = User.objects.get(username="toto@pouet.com")
|
||||
self.assertIn(da, dom.admins)
|
||||
self.assertFalse(hasattr(da, "mailbox"))
|
||||
self.assertFalse(Alias.objects.filter(address="postmaster@pouet.com").exists())
|
||||
|
||||
def test_create_with_template_and_empty_quota(self):
|
||||
"""Test the creation of a domain with a template and no quota"""
|
||||
values = {
|
||||
"name": "pouet.com",
|
||||
"quota": 0,
|
||||
"default_mailbox_quota": 0,
|
||||
"create_dom_admin": True,
|
||||
"dom_admin_username": "toto",
|
||||
"create_aliases": True,
|
||||
"domain_admin": {
|
||||
"username": "toto",
|
||||
"with_mailbox": True,
|
||||
"with_aliases": True,
|
||||
},
|
||||
"type": "domain",
|
||||
"stepid": "step3",
|
||||
"with_mailbox": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:domain_add"), values)
|
||||
response = self.client.post(reverse("v2:domain-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
dom = Domain.objects.get(name="pouet.com")
|
||||
da = User.objects.get(username="toto@pouet.com")
|
||||
self.assertIn(da, dom.admins)
|
||||
@@ -171,45 +48,23 @@ class DomainTestCase(ModoTestCase):
|
||||
self.assertTrue(al.aliasrecipient_set.filter(r_mailbox=da.mailbox).exists())
|
||||
self.assertTrue(da.can_access(al))
|
||||
|
||||
def test_create_with_template_and_custom_password(self):
|
||||
"""Test creation of a domain with a template and custom password."""
|
||||
password = "Toto1000"
|
||||
self.set_global_parameter("default_password", password, app="core")
|
||||
values = {
|
||||
"name": "pouet.com",
|
||||
"quota": 0,
|
||||
"default_mailbox_quota": 0,
|
||||
"create_dom_admin": True,
|
||||
"dom_admin_username": "toto",
|
||||
"create_aliases": True,
|
||||
"type": "domain",
|
||||
"stepid": "step3",
|
||||
"with_mailbox": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:domain_add"), values)
|
||||
dom = Domain.objects.get(name="pouet.com")
|
||||
da = User.objects.get(username="toto@pouet.com")
|
||||
self.assertIn(da, dom.admins)
|
||||
al = Alias.objects.get(address="postmaster@pouet.com")
|
||||
self.assertTrue(al.aliasrecipient_set.filter(r_mailbox=da.mailbox).exists())
|
||||
self.assertTrue(da.can_access(al))
|
||||
self.assertTrue(self.client.login(username="toto@pouet.com", password=password))
|
||||
|
||||
def test_quota_constraints(self):
|
||||
"""Check quota constraints."""
|
||||
values = {
|
||||
"name": "pouet.com",
|
||||
"quota": 10,
|
||||
"default_mailbox_quota": 100,
|
||||
"create_dom_admin": True,
|
||||
"dom_admin_username": "toto",
|
||||
"create_aliases": True,
|
||||
"domain_admin": {
|
||||
"username": "toto",
|
||||
"with_mailbox": True,
|
||||
"with_aliases": True,
|
||||
},
|
||||
"type": "domain",
|
||||
"stepid": "step3",
|
||||
}
|
||||
response = self.ajax_post(reverse("admin:domain_add"), values, status=400)
|
||||
response = self.client.post(reverse("v2:domain-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(
|
||||
response["form_errors"]["default_mailbox_quota"][0],
|
||||
response.json()["default_mailbox_quota"][0],
|
||||
"Cannot be greater than domain quota",
|
||||
)
|
||||
|
||||
@@ -217,91 +72,15 @@ class DomainTestCase(ModoTestCase):
|
||||
values["name"] = dom.name
|
||||
values["quota"] = 10
|
||||
values["default_mailbox_quota"] = 100
|
||||
response = self.ajax_post(
|
||||
reverse("admin:domain_change", args=[dom.pk]), values, status=400
|
||||
response = self.client.put(
|
||||
reverse("v2:domain-detail", args=[dom.pk]), values, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(
|
||||
response["form_errors"]["default_mailbox_quota"][0],
|
||||
response.json()["default_mailbox_quota"][0],
|
||||
"Cannot be greater than domain quota",
|
||||
)
|
||||
|
||||
def test_create_using_default_quota(self):
|
||||
"""Check that default value is used for creation."""
|
||||
self.set_global_parameter("default_domain_quota", 500)
|
||||
self.set_global_parameter("default_mailbox_quota", 50)
|
||||
response = self.client.get(reverse("admin:domain_add"))
|
||||
self.assertContains(response, 'value="500"')
|
||||
self.assertContains(response, 'value="50"')
|
||||
|
||||
@mock.patch.object(dns.resolver.Resolver, "resolve")
|
||||
@mock.patch("socket.getaddrinfo")
|
||||
def test_create_and_check_mx(self, mock_getaddrinfo, mock_query):
|
||||
"""Check for authorized MX record."""
|
||||
mock_query.side_effect = utils.mock_dns_query_result
|
||||
mock_getaddrinfo.side_effect = utils.mock_ip_query_result
|
||||
|
||||
reseller = core_factories.UserFactory(
|
||||
username="reseller", groups=("Resellers",)
|
||||
)
|
||||
self.client.force_login(reseller)
|
||||
|
||||
self.set_global_parameter("enable_admin_limits", False, app="limits")
|
||||
self.set_global_parameter("valid_mxs", "192.0.2.1 2001:db8::1")
|
||||
self.set_global_parameter("domains_must_have_authorized_mx", True)
|
||||
|
||||
values = {
|
||||
"name": "no-mx.example.com",
|
||||
"quota": 0,
|
||||
"default_mailbox_quota": 0,
|
||||
"create_dom_admin": True,
|
||||
"dom_admin_username": "toto",
|
||||
"create_aliases": True,
|
||||
"type": "domain",
|
||||
"stepid": "step3",
|
||||
"with_mailbox": True,
|
||||
}
|
||||
self.ajax_post(reverse("admin:domain_add"), values, 400)
|
||||
self.assertFalse(Domain.objects.filter(name=values["name"]).exists())
|
||||
|
||||
values["name"] = "pouet.com"
|
||||
mock_getaddrinfo.side_effect = utils.mock_ip_query_result
|
||||
self.ajax_post(reverse("admin:domain_add"), values)
|
||||
|
||||
def test_modify(self):
|
||||
"""Test the modification of a domain
|
||||
|
||||
Rename 'test.com' domain to 'pouet.com'
|
||||
"""
|
||||
values = {
|
||||
"name": "pouet.com",
|
||||
"quota": 1000,
|
||||
"default_mailbox_quota": 100,
|
||||
"type": "domain",
|
||||
"enabled": True,
|
||||
}
|
||||
dom = Domain.objects.get(name="test.com")
|
||||
self.ajax_post(reverse("admin:domain_change", args=[dom.id]), values)
|
||||
dom = Domain.objects.get(name="pouet.com")
|
||||
self.assertEqual(dom.name, "pouet.com")
|
||||
self.assertTrue(dom.enabled)
|
||||
|
||||
# Check if aliases were renamed too
|
||||
self.assertTrue(dom.alias_set.filter(address="postmaster@pouet.com").exists())
|
||||
|
||||
def test_delete(self):
|
||||
"""Test the removal of a domain."""
|
||||
dom = Domain.objects.get(name="test.com")
|
||||
self.ajax_post(reverse("admin:domain_delete", args=[dom.id]))
|
||||
with self.assertRaises(Domain.DoesNotExist):
|
||||
Domain.objects.get(pk=1)
|
||||
self.assertTrue(User.objects.filter(username="user@test.com").exists())
|
||||
self.set_global_parameter("auto_account_removal", True)
|
||||
dom = Domain.objects.get(name="test2.com")
|
||||
self.ajax_post(reverse("admin:domain_delete", args=[dom.id]))
|
||||
with self.assertRaises(Domain.DoesNotExist):
|
||||
Domain.objects.get(pk=1)
|
||||
self.assertFalse(User.objects.filter(username="admin@test2.com").exists())
|
||||
|
||||
def test_domain_counters(self):
|
||||
"""Check counters at domain level."""
|
||||
domain = Domain.objects.get(name="test.com")
|
||||
@@ -310,122 +89,8 @@ class DomainTestCase(ModoTestCase):
|
||||
self.assertEqual(domain.mbalias_count, 3)
|
||||
self.assertEqual(domain.identities_count, 5)
|
||||
|
||||
def test_domain_flat_list(self):
|
||||
"""Test the 'domain_flat_list' view."""
|
||||
response = self.client.get(reverse("admin:domain_flat_list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = response.json()
|
||||
self.assertIn("test.com", content)
|
||||
self.assertIn("test2.com", content)
|
||||
|
||||
def test_domain_detail_view(self):
|
||||
"""Test Domain detail view."""
|
||||
self.set_global_parameter("enable_domain_limits", False, app="limits")
|
||||
domain = Domain.objects.get(name="test.com")
|
||||
url = reverse("admin:domain_detail", args=[domain.pk])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Summary", response.content.decode())
|
||||
self.assertIn("Administrators", response.content.decode())
|
||||
self.assertIn("Usage", response.content.decode())
|
||||
self.assertNotIn("Resources usage", response.content.decode())
|
||||
self.set_global_parameter("enable_domain_limits", True, app="limits")
|
||||
response = self.client.get(url)
|
||||
self.assertIn("Resources usage", response.content.decode())
|
||||
|
||||
# Try a domain with no quota
|
||||
domain = Domain.objects.filter(quota=0).first()
|
||||
url = reverse("admin:domain_detail", args=[domain.pk])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn("Usage", response.content.decode())
|
||||
|
||||
# Try with a fresh domain (see #1174)
|
||||
domain = factories.DomainFactory(name="simpson.com", quota=100)
|
||||
url = reverse("admin:domain_detail", args=[domain.pk])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Usage", response.content.decode())
|
||||
|
||||
user = User.objects.get(username="user@test.com")
|
||||
self.client.force_login(user)
|
||||
domain = Domain.objects.get(name="test2.com")
|
||||
url = reverse("admin:domain_detail", args=[domain.pk])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_domain_quota_list_view(self):
|
||||
"""Test quota list view."""
|
||||
factories.DomainFactory(name="test3.com", quota=100)
|
||||
url = reverse("admin:domain_quota_list")
|
||||
response = self.ajax_get(url)
|
||||
self.assertIn("50M", response["rows"])
|
||||
self.assertIn('title="40% (20 MB) allocated"', response["rows"])
|
||||
self.assertIn("test.com", response["rows"])
|
||||
|
||||
old_rows = response["rows"]
|
||||
response = self.ajax_get(f"{url}?sort_order=-name")
|
||||
self.assertNotEqual(old_rows, response["rows"])
|
||||
|
||||
response = self.ajax_get(f"{url}?sort_order=allocated_quota")
|
||||
self.assertNotEqual(old_rows, response["rows"])
|
||||
|
||||
def test_domain_logs_list_view(self):
|
||||
"""Test logs list view."""
|
||||
domain = Domain.objects.get(name="test.com")
|
||||
ml_factories.MaillogFactory(from_domain=domain)
|
||||
ml_factories.MaillogFactory(
|
||||
to_domain=domain,
|
||||
status="received",
|
||||
date=timezone.now() + relativedelta(days=1),
|
||||
)
|
||||
ml_factories.MaillogFactory(date=timezone.now() + relativedelta(days=2))
|
||||
ml_factories.MaillogFactory(date=timezone.now() + relativedelta(days=3))
|
||||
url = reverse("admin:domain_logs_list")
|
||||
response = self.ajax_get(url)
|
||||
self.assertIn("ID1", response["rows"])
|
||||
|
||||
old_rows = response["rows"]
|
||||
response = self.ajax_get(f"{url}?sort_order=-sender")
|
||||
self.assertNotEqual(old_rows, response["rows"])
|
||||
|
||||
response = self.ajax_get(f"{url}?searchquery=ID1")
|
||||
self.assertNotEqual(old_rows, response["rows"])
|
||||
|
||||
admin = User.objects.get(username="admin@test.com")
|
||||
self.client.force_login(admin)
|
||||
url = reverse("admin:domain_logs_list")
|
||||
response = self.ajax_get(url)
|
||||
self.assertNotEqual(old_rows, response["rows"])
|
||||
|
||||
def test_statitics_widget(self):
|
||||
"""Test statistics display in dashboard."""
|
||||
url = reverse("core:dashboard")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Global statistics", response.content.decode("utf-8"))
|
||||
|
||||
self.client.force_login(User.objects.get(username="admin@test.com"))
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = response.content.decode("utf-8")
|
||||
self.assertNotIn("Global statistics", content)
|
||||
self.assertIn("Per-domain statistics", content)
|
||||
|
||||
def test_page_loader(self):
|
||||
"""Test page loader view."""
|
||||
factories.AlarmFactory(
|
||||
domain__name="test.com", mailbox=None, title="Test alarm"
|
||||
)
|
||||
url = reverse("admin:domain_page")
|
||||
response = self.ajax_get(url)
|
||||
self.assertIn("handle_mailboxes", response)
|
||||
self.assertIn("listalarms", response["rows"])
|
||||
response = self.ajax_get(f"{url}?objtype=quota")
|
||||
self.assertIn("progress-bar", response["rows"])
|
||||
|
||||
|
||||
class DKIMTestCase(ModoTestCase):
|
||||
class DKIMTestCase(ModoAPITestCase):
|
||||
"""Test case for DKIM."""
|
||||
|
||||
@classmethod
|
||||
@@ -443,20 +108,14 @@ class DKIMTestCase(ModoTestCase):
|
||||
|
||||
def test_global_settings(self):
|
||||
"""Check validation rules."""
|
||||
url = reverse("core:parameters")
|
||||
settings = SETTINGS_SAMPLE.copy()
|
||||
settings["admin-dkim_keys_storage_dir"] = "/wrong"
|
||||
response = self.client.post(url, settings, format="json")
|
||||
url = reverse("v2:parameter-global-detail", args=["admin"])
|
||||
settings = SETTINGS_SAMPLE["admin"].copy()
|
||||
settings["dkim_keys_storage_dir"] = "/wrong"
|
||||
response = self.client.put(url, settings, format="json")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
compare(
|
||||
response.json(),
|
||||
{
|
||||
"form_errors": {"dkim_keys_storage_dir": ["Directory not found."]},
|
||||
"prefix": "admin",
|
||||
},
|
||||
)
|
||||
settings["admin-dkim_keys_storage_dir"] = self.workdir
|
||||
response = self.client.post(url, settings, format="json")
|
||||
compare(response.json(), {"dkim_keys_storage_dir": ["Directory not found."]})
|
||||
settings["dkim_keys_storage_dir"] = self.workdir
|
||||
response = self.client.put(url, settings, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_dkim_key_creation(self):
|
||||
@@ -465,26 +124,21 @@ class DKIMTestCase(ModoTestCase):
|
||||
"name": "pouet.com",
|
||||
"quota": 1000,
|
||||
"default_mailbox_quota": 100,
|
||||
"create_dom_admin": False,
|
||||
"type": "domain",
|
||||
"stepid": "step3",
|
||||
"enable_dkim": True,
|
||||
"dkim_key_selector": "",
|
||||
}
|
||||
response = self.ajax_post(reverse("admin:domain_add"), values, 400)
|
||||
url = reverse("v2:domain-list")
|
||||
response = self.client.post(url, values, format="json")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(
|
||||
response["form_errors"]["enable_dkim"][0],
|
||||
response.json()["enable_dkim"][0],
|
||||
"DKIM keys storage directory not configured",
|
||||
)
|
||||
|
||||
self.set_global_parameter("dkim_keys_storage_dir", self.workdir)
|
||||
response = self.ajax_post(reverse("admin:domain_add"), values, 400)
|
||||
self.assertEqual(
|
||||
response["form_errors"]["dkim_key_selector"][0], "This field is required."
|
||||
)
|
||||
|
||||
values["dkim_key_selector"] = "default"
|
||||
self.ajax_post(reverse("admin:domain_add"), values)
|
||||
response = self.client.post(url, values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
# Try generating a key to a protected directory
|
||||
self.set_global_parameter("dkim_keys_storage_dir", "/I_DONT_EXIST")
|
||||
@@ -516,9 +170,6 @@ class DKIMTestCase(ModoTestCase):
|
||||
self.assertTrue(os.path.exists(key_path))
|
||||
|
||||
domain.refresh_from_db()
|
||||
url = reverse("admin:domain_detail", args=[domain.pk])
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, escape(domain.bind_format_dkim_public_key))
|
||||
|
||||
# Try generating DKIM key for a targetted domain
|
||||
domain.dkim_private_key_path = ""
|
||||
@@ -536,7 +187,9 @@ class DKIMTestCase(ModoTestCase):
|
||||
"default_mailbox_quota": 100,
|
||||
"type": "domain",
|
||||
}
|
||||
self.ajax_post(reverse("admin:domain_change", args=[domain.id]), values)
|
||||
response = self.client.put(
|
||||
reverse("v2:domain-detail", args=[domain.id]), values, format="json"
|
||||
)
|
||||
domain.refresh_from_db()
|
||||
self.assertEqual(domain.dkim_private_key_path, "")
|
||||
|
||||
@@ -547,19 +200,22 @@ class DKIMTestCase(ModoTestCase):
|
||||
"name": "pouet.com",
|
||||
"quota": 1000,
|
||||
"default_mailbox_quota": 100,
|
||||
"create_dom_admin": False,
|
||||
"type": "domain",
|
||||
"stepid": "step3",
|
||||
"enable_dkim": True,
|
||||
"dkim_key_selector": "default",
|
||||
}
|
||||
self.ajax_post(reverse("admin:domain_add"), values)
|
||||
url = reverse("v2:domain-list")
|
||||
response = self.client.post(url, values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
call_command("modo", "manage_dkim_keys")
|
||||
key_path = os.path.join(self.workdir, "{}.pem".format(values["name"]))
|
||||
self.assertTrue(os.path.exists(key_path))
|
||||
dom = Domain.objects.get(name="pouet.com")
|
||||
values["dkim_key_length"] = 4096
|
||||
self.ajax_post(reverse("admin:domain_change", args=[dom.pk]), values)
|
||||
response = self.client.put(
|
||||
reverse("v2:domain-detail", args=[dom.id]), values, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
dom.refresh_from_db()
|
||||
self.assertEqual(dom.dkim_private_key_path, "")
|
||||
os.unlink(key_path)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from django.urls import reverse
|
||||
|
||||
from modoboa.lib.tests import ModoTestCase
|
||||
from .. import factories
|
||||
from ..models import Alias, Domain, DomainAlias
|
||||
@@ -28,21 +26,3 @@ class DomainAliasTestCase(ModoTestCase):
|
||||
|
||||
domal.delete()
|
||||
self.assertFalse(Alias.objects.filter(address=f"@{domal.name}").exists())
|
||||
|
||||
def test_form(self):
|
||||
dom = Domain.objects.get(name="test.com")
|
||||
values = {
|
||||
"name": dom.name,
|
||||
"quota": dom.quota,
|
||||
"default_mailbox_quota": dom.default_mailbox_quota,
|
||||
"enabled": dom.enabled,
|
||||
"aliases": "domalias.net",
|
||||
"aliases_1": "domaliasé.com",
|
||||
"type": "domain",
|
||||
}
|
||||
self.ajax_post(reverse("admin:domain_change", args=[dom.id]), values)
|
||||
self.assertEqual(dom.domainalias_set.count(), 2)
|
||||
|
||||
del values["aliases_1"]
|
||||
self.ajax_post(reverse("admin:domain_change", args=[dom.id]), values)
|
||||
self.assertEqual(dom.domainalias_set.count(), 1)
|
||||
|
||||
@@ -7,11 +7,11 @@ from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from modoboa.lib.tests import ModoTestCase
|
||||
from modoboa.lib.tests import ModoAPITestCase
|
||||
from .. import factories, models
|
||||
|
||||
|
||||
class ExportTestCase(ModoTestCase):
|
||||
class ExportTestCase(ModoAPITestCase):
|
||||
"""Test case for export operations."""
|
||||
|
||||
@classmethod
|
||||
@@ -21,37 +21,20 @@ class ExportTestCase(ModoTestCase):
|
||||
factories.populate_database()
|
||||
|
||||
def __export_domains(self, domfilter=""):
|
||||
self.client.get(
|
||||
"{}?domfilter={}".format(reverse("admin:_domain_list"), domfilter)
|
||||
)
|
||||
return self.client.post(
|
||||
reverse("admin:domain_export"), {"filename": "test.csv"}
|
||||
)
|
||||
return self.client.get(reverse("v2:domain-export"))
|
||||
|
||||
def __export_identities(self, idtfilter="", grpfilter=""):
|
||||
self.client.get(
|
||||
reverse("admin:_identity_list")
|
||||
+ f"?grpfilter={grpfilter}&idtfilter={idtfilter}"
|
||||
)
|
||||
return self.client.post(
|
||||
reverse("admin:identity_export"), {"filename": "test.csv"}
|
||||
)
|
||||
return self.client.get(reverse("v2:identities-export"))
|
||||
|
||||
def test_export_domains(self):
|
||||
def test_export_domains_management_command(self):
|
||||
"""Check domain export."""
|
||||
dom = models.Domain.objects.get(name="test.com")
|
||||
factories.DomainAliasFactory(name="alias.test", target=dom)
|
||||
response = self.__export_domains()
|
||||
expected_response = [
|
||||
"domain;test.com;50;10;True",
|
||||
"domainalias;alias.test;test.com;True",
|
||||
"domain;test2.com;0;0;True",
|
||||
]
|
||||
self.assertCountEqual(
|
||||
expected_response, force_str(response.content.strip()).split("\r\n")
|
||||
)
|
||||
|
||||
# Test management command too.
|
||||
stdout_backup, sys.stdout = sys.stdout, StringIO()
|
||||
call_command("modo", "export", "domains")
|
||||
response = sys.stdout.getvalue()
|
||||
@@ -60,59 +43,51 @@ class ExportTestCase(ModoTestCase):
|
||||
expected_response, force_str(response.strip()).split("\r\n")
|
||||
)
|
||||
|
||||
def test_export_identities(self):
|
||||
response = self.__export_identities()
|
||||
expected_response = "account;admin;;;;True;SuperAdmins;;\r\naccount;admin@test.com;{PLAIN}toto;;;True;DomainAdmins;admin@test.com;10;test.com\r\naccount;admin@test2.com;{PLAIN}toto;;;True;DomainAdmins;admin@test2.com;10;test2.com\r\naccount;user@test.com;{PLAIN}toto;;;True;SimpleUsers;user@test.com;10\r\naccount;user@test2.com;{PLAIN}toto;;;True;SimpleUsers;user@test2.com;10\r\nalias;alias@test.com;True;user@test.com\r\nalias;forward@test.com;True;user@external.com\r\nalias;postmaster@test.com;True;test@truc.fr;toto@titi.com\r\n" # NOQA:E501
|
||||
received_content = force_str(response.content.strip()).split("\r\n")
|
||||
# Empty admin password because it is hashed using SHA512-CRYPT
|
||||
admin_row = received_content[0].split(";")
|
||||
admin_row[2] = ""
|
||||
received_content[0] = ";".join(admin_row)
|
||||
self.assertCountEqual(expected_response.strip().split("\r\n"), received_content)
|
||||
# FIXME: filters are not available yet in the API
|
||||
|
||||
def test_export_simpleusers(self):
|
||||
factories.MailboxFactory(
|
||||
user__username="toto@test.com",
|
||||
user__first_name="Léon",
|
||||
user__groups=("SimpleUsers",),
|
||||
address="toto",
|
||||
domain__name="test.com",
|
||||
)
|
||||
response = self.__export_identities(
|
||||
idtfilter="account", grpfilter="SimpleUsers"
|
||||
)
|
||||
expected_response = "account;user@test.com;{PLAIN}toto;;;True;SimpleUsers;user@test.com;10\r\naccount;user@test2.com;{PLAIN}toto;;;True;SimpleUsers;user@test2.com;10\r\naccount;toto@test.com;{PLAIN}toto;Léon;;True;SimpleUsers;toto@test.com;10" # NOQA:E501
|
||||
self.assertCountEqual(
|
||||
expected_response.split("\r\n"),
|
||||
force_str(response.content.strip()).split("\r\n"),
|
||||
)
|
||||
# def test_export_simpleusers(self):
|
||||
# factories.MailboxFactory(
|
||||
# user__username="toto@test.com",
|
||||
# user__first_name="Léon",
|
||||
# user__groups=("SimpleUsers",),
|
||||
# address="toto",
|
||||
# domain__name="test.com",
|
||||
# )
|
||||
# response = self.__export_identities(
|
||||
# idtfilter="account", grpfilter="SimpleUsers"
|
||||
# )
|
||||
# expected_response = "account;user@test.com;{PLAIN}toto;;;True;SimpleUsers;user@test.com;10\r\naccount;user@test2.com;{PLAIN}toto;;;True;SimpleUsers;user@test2.com;10\r\naccount;toto@test.com;{PLAIN}toto;Léon;;True;SimpleUsers;toto@test.com;10" # NOQA:E501
|
||||
# self.assertCountEqual(
|
||||
# expected_response.split("\r\n"),
|
||||
# force_str(response.content.strip()).split("\r\n"),
|
||||
# )
|
||||
|
||||
def test_export_superadmins(self):
|
||||
"""A test to validate we only export 1 super admin.
|
||||
# def test_export_superadmins(self):
|
||||
# """A test to validate we only export 1 super admin.
|
||||
|
||||
The password is removed since it is hashed using SHA512-CRYPT.
|
||||
"""
|
||||
response = self.__export_identities(
|
||||
idtfilter="account", grpfilter="SuperAdmins"
|
||||
)
|
||||
elements = response.content.decode().strip().split(";")
|
||||
self.assertEqual(len(elements), 9)
|
||||
elements[2] = ""
|
||||
self.assertEqual(";".join(elements), "account;admin;;;;True;SuperAdmins;;")
|
||||
# The password is removed since it is hashed using SHA512-CRYPT.
|
||||
# """
|
||||
# response = self.__export_identities(
|
||||
# idtfilter="account", grpfilter="SuperAdmins"
|
||||
# )
|
||||
# elements = response.content.decode().strip().split(";")
|
||||
# self.assertEqual(len(elements), 9)
|
||||
# elements[2] = ""
|
||||
# self.assertEqual(";".join(elements), "account;admin;;;;True;SuperAdmins;;")
|
||||
|
||||
def test_export_domainadmins(self):
|
||||
response = self.__export_identities(
|
||||
idtfilter="account", grpfilter="DomainAdmins"
|
||||
)
|
||||
expected_response = "account;admin@test.com;{PLAIN}toto;;;True;DomainAdmins;admin@test.com;10;test.com\r\naccount;admin@test2.com;{PLAIN}toto;;;True;DomainAdmins;admin@test2.com;10;test2.com" # NOQA:E501
|
||||
self.assertCountEqual(
|
||||
expected_response.split("\r\n"),
|
||||
force_str(response.content.strip()).split("\r\n"),
|
||||
)
|
||||
# def test_export_domainadmins(self):
|
||||
# response = self.__export_identities(
|
||||
# idtfilter="account", grpfilter="DomainAdmins"
|
||||
# )
|
||||
# expected_response = "account;admin@test.com;{PLAIN}toto;;;True;DomainAdmins;admin@test.com;10;test.com\r\naccount;admin@test2.com;{PLAIN}toto;;;True;DomainAdmins;admin@test2.com;10;test2.com" # NOQA:E501
|
||||
# self.assertCountEqual(
|
||||
# expected_response.split("\r\n"),
|
||||
# force_str(response.content.strip()).split("\r\n"),
|
||||
# )
|
||||
|
||||
def test_export_aliases(self):
|
||||
response = self.__export_identities(idtfilter="alias")
|
||||
self.assertEqual(
|
||||
response.content.decode().strip(),
|
||||
"alias;alias@test.com;True;user@test.com\r\nalias;forward@test.com;True;user@external.com\r\nalias;postmaster@test.com;True;test@truc.fr;toto@titi.com", # NOQA:E501
|
||||
)
|
||||
# def test_export_aliases(self):
|
||||
# response = self.__export_identities(idtfilter="alias")
|
||||
# self.assertEqual(
|
||||
# response.content.decode().strip(),
|
||||
# "alias;alias@test.com;True;user@test.com\r\nalias;forward@test.com;True;user@external.com\r\nalias;postmaster@test.com;True;test@truc.fr;toto@titi.com", # NOQA:E501
|
||||
# )
|
||||
|
||||
@@ -10,13 +10,13 @@ from django.urls import reverse
|
||||
|
||||
from modoboa.core import factories as core_factories
|
||||
from modoboa.core.models import User
|
||||
from modoboa.lib.tests import ModoTestCase
|
||||
from modoboa.lib.tests import ModoAPITestCase
|
||||
from . import utils
|
||||
from .. import factories
|
||||
from ..models import Alias, Domain, DomainAlias
|
||||
from ..models import Alias, Domain
|
||||
|
||||
|
||||
class ImportTestCase(ModoTestCase):
|
||||
class ImportTestCase(ModoAPITestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls): # NOQA:N802
|
||||
@@ -26,37 +26,9 @@ class ImportTestCase(ModoTestCase):
|
||||
cls.localconfig.save()
|
||||
factories.populate_database()
|
||||
|
||||
def test_domains_import(self):
|
||||
response = self.client.get(reverse("admin:domain_import"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Provide a CSV", response.content.decode())
|
||||
|
||||
f = ContentFile(
|
||||
b"""domain; domain1.com; 1000; 100; True
|
||||
domain; domain2.com; 1000; 200; False
|
||||
domainalias; domalias1.com; domain1.com; True
|
||||
""",
|
||||
name="domains.csv",
|
||||
)
|
||||
self.client.post(reverse("admin:domain_import"), {"sourcefile": f})
|
||||
admin = User.objects.get(username="admin")
|
||||
dom = Domain.objects.get(name="domain1.com")
|
||||
self.assertEqual(dom.quota, 1000)
|
||||
self.assertEqual(dom.default_mailbox_quota, 100)
|
||||
self.assertTrue(dom.enabled)
|
||||
self.assertTrue(admin.is_owner(dom))
|
||||
domalias = DomainAlias.objects.get(name="domalias1.com")
|
||||
self.assertEqual(domalias.target, dom)
|
||||
self.assertTrue(dom.enabled)
|
||||
self.assertTrue(admin.is_owner(domalias))
|
||||
dom = Domain.objects.get(name="domain2.com")
|
||||
self.assertEqual(dom.default_mailbox_quota, 200)
|
||||
self.assertFalse(dom.enabled)
|
||||
self.assertTrue(admin.is_owner(dom))
|
||||
|
||||
def test_domain_import_bad_syntax(self):
|
||||
"""Check errors handling."""
|
||||
url = reverse("admin:domain_import")
|
||||
url = reverse("v2:domain-import-from-csv")
|
||||
f = ContentFile("domain; domain1.com; 100; True", name="domains.csv")
|
||||
response = self.client.post(url, {"sourcefile": f})
|
||||
self.assertContains(response, "Invalid line")
|
||||
@@ -82,17 +54,18 @@ domainalias; domalias1.com; domain1.com; True
|
||||
reseller = core_factories.UserFactory(
|
||||
username="reseller", groups=("Resellers",)
|
||||
)
|
||||
self.client.force_login(reseller)
|
||||
self.authenticate_user(reseller)
|
||||
self.set_global_parameter("valid_mxs", "192.0.2.1 2001:db8::1")
|
||||
self.set_global_parameter("domains_must_have_authorized_mx", True)
|
||||
|
||||
url = reverse("v2:domain-import-from-csv")
|
||||
f = ContentFile(b"domain; test3.com; 100; 1; True", name="domains.csv")
|
||||
resp = self.client.post(reverse("admin:domain_import"), {"sourcefile": f})
|
||||
resp = self.client.post(url, {"sourcefile": f})
|
||||
self.assertContains(resp, "test3.com: no authorized MX record found for domain")
|
||||
|
||||
mock_getaddrinfo.side_effect = utils.mock_ip_query_result
|
||||
f = ContentFile(b"domain; domain1.com; 100; 1; True", name="domains.csv")
|
||||
resp = self.client.post(reverse("admin:domain_import"), {"sourcefile": f})
|
||||
resp = self.client.post(url, {"sourcefile": f})
|
||||
self.assertTrue(Domain.objects.filter(name="domain1.com").exists())
|
||||
|
||||
def test_import_domains_with_conflict(self):
|
||||
@@ -102,76 +75,10 @@ domainalias;test.alias;test.com;True
|
||||
""",
|
||||
name="domains.csv",
|
||||
)
|
||||
resp = self.client.post(reverse("admin:domain_import"), {"sourcefile": f})
|
||||
url = reverse("v2:domain-import-from-csv")
|
||||
resp = self.client.post(url, {"sourcefile": f})
|
||||
self.assertIn("Object already exists: domainalias", resp.content.decode())
|
||||
|
||||
def test_identities_import(self):
|
||||
response = self.client.get(reverse("admin:identity_import"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Provide a CSV", response.content.decode())
|
||||
|
||||
f = ContentFile(
|
||||
"""
|
||||
account; user1@test.com; toto; User; One; True; SimpleUsers; user1@test.com; 0
|
||||
account; Truc@test.com; toto; René; Truc; True; DomainAdmins; truc@test.com; 5; test.com
|
||||
alias; alias1@test.com; True; user1@test.com
|
||||
forward; alias2@test.com; True; user1+ext@test.com
|
||||
forward; fwd1@test.com; True; user@extdomain.com
|
||||
dlist; dlist@test.com; True; user1@test.com; user@extdomain.com
|
||||
""",
|
||||
name="identities.csv",
|
||||
) # NOQA:E501
|
||||
self.client.post(
|
||||
reverse("admin:identity_import"), {"sourcefile": f, "crypt_password": True}
|
||||
)
|
||||
admin = User.objects.get(username="admin")
|
||||
u1 = User.objects.get(username="user1@test.com")
|
||||
mb1 = u1.mailbox
|
||||
self.assertTrue(admin.is_owner(u1))
|
||||
self.assertEqual(u1.email, "user1@test.com")
|
||||
self.assertEqual(u1.first_name, "User")
|
||||
self.assertEqual(u1.last_name, "One")
|
||||
self.assertTrue(u1.is_active)
|
||||
self.assertEqual(u1.role, "SimpleUsers")
|
||||
self.assertTrue(mb1.use_domain_quota)
|
||||
self.assertEqual(mb1.quota, 0)
|
||||
self.assertTrue(admin.is_owner(mb1))
|
||||
self.assertEqual(mb1.full_address, "user1@test.com")
|
||||
self.assertTrue(self.client.login(username="user1@test.com", password="toto"))
|
||||
|
||||
da = User.objects.get(username="truc@test.com")
|
||||
damb = da.mailbox
|
||||
self.assertEqual(da.first_name, "René")
|
||||
self.assertEqual(da.role, "DomainAdmins")
|
||||
self.assertEqual(damb.quota, 5)
|
||||
self.assertFalse(damb.use_domain_quota)
|
||||
self.assertEqual(damb.full_address, "truc@test.com")
|
||||
dom = Domain.objects.get(name="test.com")
|
||||
self.assertIn(da, dom.admins)
|
||||
u = User.objects.get(username="user@test.com")
|
||||
self.assertTrue(da.can_access(u))
|
||||
|
||||
al = Alias.objects.get(address="alias1@test.com")
|
||||
self.assertTrue(al.aliasrecipient_set.filter(r_mailbox=u1.mailbox).exists())
|
||||
self.assertTrue(admin.is_owner(al))
|
||||
|
||||
fwd = Alias.objects.get(address="fwd1@test.com")
|
||||
self.assertTrue(
|
||||
fwd.aliasrecipient_set.filter(
|
||||
address="user@extdomain.com",
|
||||
r_mailbox__isnull=True,
|
||||
r_alias__isnull=True,
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(admin.is_owner(fwd))
|
||||
|
||||
dlist = Alias.objects.get(address="dlist@test.com")
|
||||
self.assertTrue(dlist.aliasrecipient_set.filter(r_mailbox=u1.mailbox).exists())
|
||||
self.assertTrue(
|
||||
dlist.aliasrecipient_set.filter(address="user@extdomain.com").exists()
|
||||
)
|
||||
self.assertTrue(admin.is_owner(dlist))
|
||||
|
||||
def test_import_for_nonlocal_domain(self):
|
||||
"""Try to import an account for nonlocal domain."""
|
||||
f = ContentFile(
|
||||
@@ -180,9 +87,8 @@ account; user1@nonlocal.com; toto; User; One; True; SimpleUsers; user1@nonlocal.
|
||||
""",
|
||||
name="identities.csv",
|
||||
) # NOQA:E501
|
||||
self.client.post(
|
||||
reverse("admin:identity_import"), {"sourcefile": f, "crypt_password": True}
|
||||
)
|
||||
url = reverse("v2:identities-import-from-csv")
|
||||
self.client.post(url, {"sourcefile": f, "crypt_password": True})
|
||||
self.assertFalse(User.objects.filter(username="user1@nonlocal.com").exists())
|
||||
|
||||
def test_import_invalid_quota(self):
|
||||
@@ -192,24 +98,23 @@ account; user1@test.com; toto; User; One; True; SimpleUsers; user1@test.com; ; t
|
||||
""",
|
||||
name="identities.csv",
|
||||
) # NOQA:E501
|
||||
resp = self.client.post(
|
||||
reverse("admin:identity_import"), {"sourcefile": f, "crypt_password": True}
|
||||
)
|
||||
url = reverse("v2:identities-import-from-csv")
|
||||
resp = self.client.post(url, {"sourcefile": f, "crypt_password": True})
|
||||
self.assertIn("wrong quota value", resp.content.decode())
|
||||
|
||||
def test_import_domain_by_domainadmin(self):
|
||||
"""Check if a domain admin is not allowed to import a domain."""
|
||||
self.client.logout()
|
||||
self.client.login(username="admin@test.com", password="toto")
|
||||
admin = User.objects.get(username="admin@test.com")
|
||||
self.authenticate_user(admin)
|
||||
f = ContentFile(
|
||||
b"""
|
||||
domain; domain2.com; 1000; 200; False
|
||||
""",
|
||||
name="identities.csv",
|
||||
)
|
||||
resp = self.client.post(
|
||||
reverse("admin:identity_import"), {"sourcefile": f, "crypt_password": True}
|
||||
)
|
||||
url = reverse("v2:identities-import-from-csv")
|
||||
resp = self.client.post(url, {"sourcefile": f, "crypt_password": True})
|
||||
self.assertIn("You are not allowed to import domains", resp.content.decode())
|
||||
f = ContentFile(
|
||||
b"""
|
||||
@@ -217,16 +122,14 @@ domainalias; domalias1.com; test.com; True
|
||||
""",
|
||||
name="identities.csv",
|
||||
)
|
||||
resp = self.client.post(
|
||||
reverse("admin:identity_import"), {"sourcefile": f, "crypt_password": True}
|
||||
)
|
||||
resp = self.client.post(url, {"sourcefile": f, "crypt_password": True})
|
||||
self.assertIn(
|
||||
"You are not allowed to import domain aliases", resp.content.decode()
|
||||
)
|
||||
|
||||
def test_import_quota_too_big(self):
|
||||
self.client.logout()
|
||||
self.client.login(username="admin@test.com", password="toto")
|
||||
admin = User.objects.get(username="admin@test.com")
|
||||
self.authenticate_user(admin)
|
||||
f = ContentFile(
|
||||
b"""
|
||||
account; user1@test.com; toto; User; One; True; SimpleUsers; user1@test.com; 40
|
||||
@@ -234,7 +137,8 @@ account; user1@test.com; toto; User; One; True; SimpleUsers; user1@test.com; 40
|
||||
name="identities.csv",
|
||||
)
|
||||
resp = self.client.post(
|
||||
reverse("admin:identity_import"), {"sourcefile": f, "crypt_password": True}
|
||||
reverse("v2:identities-import-from-csv"),
|
||||
{"sourcefile": f, "crypt_password": True},
|
||||
)
|
||||
self.assertIn("test.com: domain quota exceeded", resp.content.decode())
|
||||
|
||||
@@ -246,7 +150,8 @@ account; user1@test.com; toto; User; One; True; SimpleUsers; user1@test.com
|
||||
name="identities.csv",
|
||||
)
|
||||
self.client.post(
|
||||
reverse("admin:identity_import"), {"sourcefile": f, "crypt_password": True}
|
||||
reverse("v2:identities-import-from-csv"),
|
||||
{"sourcefile": f, "crypt_password": True},
|
||||
)
|
||||
account = User.objects.get(username="user1@test.com")
|
||||
self.assertEqual(
|
||||
@@ -262,7 +167,7 @@ account; truc@test.com; toto; René; Truc; True; DomainAdmins; truc@test.com; 0;
|
||||
name="identities.csv",
|
||||
) # NOQA:E501
|
||||
self.client.post(
|
||||
reverse("admin:identity_import"),
|
||||
reverse("v2:identities-import-from-csv"),
|
||||
{"sourcefile": f, "crypt_password": True, "continue_if_exists": True},
|
||||
)
|
||||
admin = User.objects.get(username="admin")
|
||||
@@ -274,8 +179,8 @@ account; truc@test.com; toto; René; Truc; True; DomainAdmins; truc@test.com; 0;
|
||||
|
||||
Expected result: no
|
||||
"""
|
||||
self.client.logout()
|
||||
self.assertTrue(self.client.login(username="admin@test.com", password="toto"))
|
||||
admin = User.objects.get(username="admin@test.com")
|
||||
self.authenticate_user(admin)
|
||||
f = ContentFile(
|
||||
b"""
|
||||
account; sa@test.com; toto; Super; Admin; True; SuperAdmins; superadmin@test.com; 50
|
||||
@@ -283,7 +188,7 @@ account; sa@test.com; toto; Super; Admin; True; SuperAdmins; superadmin@test.com
|
||||
name="identities.csv",
|
||||
) # NOQA:E501
|
||||
self.client.post(
|
||||
reverse("admin:identity_import"),
|
||||
reverse("v2:identities-import-from-csv"),
|
||||
{"sourcefile": f, "crypt_password": True, "continue_if_exists": True},
|
||||
)
|
||||
with self.assertRaises(User.DoesNotExist):
|
||||
@@ -297,7 +202,7 @@ alias;user.alias@test.com;True;user@test.com;;;;;;;;;;;;;;;;
|
||||
name="identities.csv",
|
||||
)
|
||||
self.client.post(
|
||||
reverse("admin:identity_import"),
|
||||
reverse("v2:identities-import-from-csv"),
|
||||
{"sourcefile": f, "crypt_password": True, "continue_if_exists": True},
|
||||
)
|
||||
alias = Alias.objects.get(address="user.alias@test.com")
|
||||
@@ -312,24 +217,21 @@ alias;user@test.com;True;admin@test.com
|
||||
name="identities.csv",
|
||||
)
|
||||
self.client.post(
|
||||
reverse("admin:identity_import"), {"sourcefile": f, "crypt_password": True}
|
||||
reverse("v2:identities-import-from-csv"),
|
||||
{"sourcefile": f, "crypt_password": True},
|
||||
)
|
||||
self.assertTrue(
|
||||
Alias.objects.filter(address="user@test.com", internal=False).exists()
|
||||
)
|
||||
|
||||
def test_domains_import_utf8(self):
|
||||
response = self.client.get(reverse("admin:domain_import"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Provide a CSV", response.content.decode())
|
||||
|
||||
f = ContentFile(
|
||||
"""domain; dómªin1.com; 1000; 100; True
|
||||
dómain; dómªin2.com; 1000; 100; True
|
||||
""".encode(),
|
||||
name="dómains.csv",
|
||||
)
|
||||
self.client.post(reverse("admin:domain_import"), {"sourcefile": f})
|
||||
self.client.post(reverse("v2:domain-import-from-csv"), {"sourcefile": f})
|
||||
admin = User.objects.get(username="admin")
|
||||
dom = Domain.objects.get(name="dómªin1.com")
|
||||
self.assertEqual(dom.quota, 1000)
|
||||
@@ -412,7 +314,8 @@ alias; alias1@test.com; True; user1@test.com
|
||||
name="identities.csv",
|
||||
) # NOQA:E501
|
||||
resp = self.client.post(
|
||||
reverse("admin:identity_import"), {"sourcefile": f, "crypt_password": True}
|
||||
reverse("v2:identities-import-from-csv"),
|
||||
{"sourcefile": f, "crypt_password": True},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from modoboa.lib.tests import ModoTestCase
|
||||
from modoboa.lib.tests import ModoAPITestCase
|
||||
from .. import factories, models
|
||||
|
||||
|
||||
@override_settings(DOVECOT_LOOKUP_PATH=[f"{os.path.dirname(__file__)}/dovecot"])
|
||||
class MailboxOperationTestCase(ModoTestCase):
|
||||
class MailboxOperationTestCase(ModoAPITestCase):
|
||||
"""Test management command."""
|
||||
|
||||
@classmethod
|
||||
@@ -26,7 +25,6 @@ class MailboxOperationTestCase(ModoTestCase):
|
||||
def setUp(self):
|
||||
"""Initiate test env."""
|
||||
super().setUp()
|
||||
self.workdir = tempfile.mkdtemp()
|
||||
path = f"{self.workdir}/test.com/admin"
|
||||
os.makedirs(path)
|
||||
self.set_global_parameter("handle_mailboxes", True)
|
||||
@@ -44,7 +42,9 @@ class MailboxOperationTestCase(ModoTestCase):
|
||||
mb = models.Mailbox.objects.select_related("user").get(
|
||||
address="admin", domain__name="test.com"
|
||||
)
|
||||
self.ajax_post(reverse("admin:account_delete", args=[mb.user.pk]), {})
|
||||
self.client.post(
|
||||
reverse("v2:account-delete", args=[mb.user.pk]), {}, format="json"
|
||||
)
|
||||
call_command("handle_mailbox_operations")
|
||||
self.assertFalse(models.MailboxOperation.objects.exists())
|
||||
self.assertFalse(os.path.exists(mb.mail_home))
|
||||
@@ -63,10 +63,12 @@ class MailboxOperationTestCase(ModoTestCase):
|
||||
"is_active": True,
|
||||
"email": "admin2@test.com",
|
||||
"language": "en",
|
||||
"subject": "test",
|
||||
"content": "content",
|
||||
"mailbox": {"use_domain_quota": True},
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[mb.user.pk]), values)
|
||||
response = self.client.put(
|
||||
reverse("v2:account-detail", args=[mb.user.pk]), values, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
path = f"{self.workdir}/test.com/admin2"
|
||||
mail_home_mock.__get__ = mock.Mock(return_value=path)
|
||||
call_command("handle_mailbox_operations")
|
||||
@@ -79,7 +81,9 @@ class MailboxOperationTestCase(ModoTestCase):
|
||||
path = f"{self.workdir}/test.com/admin"
|
||||
mail_home_mock.__get__ = mock.Mock(return_value=path)
|
||||
domain = models.Domain.objects.get(name="test.com")
|
||||
self.ajax_post(reverse("admin:domain_delete", args=[domain.pk]))
|
||||
self.client.post(
|
||||
reverse("v2:domain-delete", args=[domain.pk]), {}, format="json"
|
||||
)
|
||||
call_command("handle_mailbox_operations")
|
||||
self.assertFalse(models.MailboxOperation.objects.exists())
|
||||
self.assertFalse(os.path.exists(path))
|
||||
|
||||
@@ -7,7 +7,6 @@ from testfixtures import LogCapture
|
||||
|
||||
from django.core import mail, management
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from modoboa.core import models as core_models, factories as core_factories
|
||||
@@ -241,10 +240,6 @@ class DNSBLTestCase(ModoTestCase):
|
||||
self.assertFalse(
|
||||
models.DNSBLResult.objects.filter(domain=self.domain3).exists()
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("admin:dnsbl_domain_detail", args=[self.domain.pk])
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(self.domain.uses_a_reserved_tld)
|
||||
self.assertTrue(self.domain2.uses_a_reserved_tld)
|
||||
|
||||
@@ -285,10 +280,6 @@ class DNSBLTestCase(ModoTestCase):
|
||||
self.assertEqual(models.DNSBLResult.objects.count(), 0)
|
||||
management.call_command("modo", "check_mx", "--no-dnsbl")
|
||||
self.assertFalse(models.DNSBLResult.objects.filter(domain=self.domain).exists())
|
||||
response = self.client.get(
|
||||
reverse("admin:dnsbl_domain_detail", args=[self.domain.pk])
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class DNSChecksTestCase(ModoTestCase):
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
|
||||
from modoboa.core.tests.test_views import SETTINGS_SAMPLE
|
||||
|
||||
from modoboa.lib.tests import ModoTestCase
|
||||
from modoboa.lib.tests import ModoAPITestCase, SETTINGS_SAMPLE
|
||||
from .. import factories
|
||||
|
||||
|
||||
class NeedDovecotUpdateTestCase(ModoTestCase):
|
||||
class NeedDovecotUpdateTestCase(ModoAPITestCase):
|
||||
"""Test need dovecot ldap update command."""
|
||||
|
||||
@classmethod
|
||||
@@ -25,7 +22,6 @@ class NeedDovecotUpdateTestCase(ModoTestCase):
|
||||
def setUp(self):
|
||||
"""Initiate initial env."""
|
||||
super().setUp()
|
||||
self.workdir = tempfile.mkdtemp()
|
||||
self.localconfig.need_dovecot_update = False
|
||||
self.localconfig.save()
|
||||
|
||||
@@ -34,11 +30,10 @@ class NeedDovecotUpdateTestCase(ModoTestCase):
|
||||
shutil.rmtree(self.workdir)
|
||||
|
||||
def test_update_dovecot_update_state_valid_form(self):
|
||||
url = reverse("core:parameters")
|
||||
settings = SETTINGS_SAMPLE.copy()
|
||||
response = self.client.post(url, settings, format="json")
|
||||
url = reverse("v2:parameter-global-detail", args=["core"])
|
||||
settings = SETTINGS_SAMPLE["core"].copy()
|
||||
response = self.client.put(url, settings, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), "Parameters saved")
|
||||
self.localconfig.refresh_from_db()
|
||||
self.assertTrue(self.localconfig.need_dovecot_update)
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from django.urls import reverse
|
||||
|
||||
from modoboa.core.models import User
|
||||
from modoboa.lib.tests import ModoTestCase
|
||||
from modoboa.lib.tests import ModoAPITestCase
|
||||
from .. import factories
|
||||
|
||||
|
||||
class PasswordSchemesTestCase(ModoTestCase):
|
||||
class PasswordSchemesTestCase(ModoAPITestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls): # NOQA:N802
|
||||
@@ -18,15 +18,14 @@ class PasswordSchemesTestCase(ModoTestCase):
|
||||
"username": "tester@test.com",
|
||||
"first_name": "Tester",
|
||||
"last_name": "Toto",
|
||||
"password1": "Toto1234",
|
||||
"password2": "Toto1234",
|
||||
"password": "Toto1234",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"email": "tester@test.com",
|
||||
"stepid": "step2",
|
||||
"mailbox": {"use_domain_quota": True},
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_add"), values)
|
||||
response = self.client.post(reverse("v2:account-list"), values, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
def _test_scheme(self, name, startpattern):
|
||||
self.set_global_parameter("password_scheme", name, app="core")
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""User related tests."""
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from modoboa.core.models import User
|
||||
from modoboa.lib.tests import ModoTestCase
|
||||
from ..factories import populate_database
|
||||
from ..models import Alias
|
||||
|
||||
|
||||
class ForwardTestCase(ModoTestCase):
|
||||
"""User forward test cases."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
populate_database()
|
||||
|
||||
def test_set_forward(self):
|
||||
self.client.logout()
|
||||
self.client.login(username="user@test.com", password="toto")
|
||||
url = reverse("user_forward")
|
||||
self.ajax_post(url, {"dest": "user@extdomain.com", "keepcopies": True})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = response.json()
|
||||
self.assertIn("user@extdomain.com", content["content"])
|
||||
self.assertNotIn("user@test.com", content["content"])
|
||||
forward = Alias.objects.get(address="user@test.com", internal=False)
|
||||
sadmin = User.objects.get(username="admin")
|
||||
self.assertTrue(sadmin.can_access(forward))
|
||||
domadmin = User.objects.get(username="admin@test.com")
|
||||
self.assertTrue(domadmin.can_access(forward))
|
||||
|
||||
selfalias = Alias.objects.get(address="user@test.com", internal=True)
|
||||
self.assertTrue(selfalias.enabled)
|
||||
|
||||
# Deactivate local copies
|
||||
self.ajax_post(url, {"dest": "user@extdomain.com", "keepcopies": False})
|
||||
selfalias.refresh_from_db()
|
||||
self.assertFalse(selfalias.enabled)
|
||||
|
||||
# Now, empty forward
|
||||
self.ajax_post(url, {"dest": "", "keepcopies": False})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = response.json()
|
||||
self.assertNotIn("user@extdomain.com", content["content"])
|
||||
selfalias.refresh_from_db()
|
||||
self.assertTrue(selfalias.enabled)
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Admin urls."""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
from .views import (
|
||||
alias as alias_views,
|
||||
domain as domain_views,
|
||||
export as export_views,
|
||||
identity as identity_views,
|
||||
import_ as import_views,
|
||||
)
|
||||
|
||||
app_name = "admin"
|
||||
|
||||
urlpatterns = [
|
||||
path("", domain_views.index, name="index"),
|
||||
path("domains/", domain_views.domains, name="domain_list"),
|
||||
path("domains/<int:pk>/", views.DomainDetailView.as_view(), name="domain_detail"),
|
||||
path(
|
||||
"domains/<int:pk>/alarms/",
|
||||
views.DomainAlarmsView.as_view(),
|
||||
name="domain_alarms",
|
||||
),
|
||||
path(
|
||||
"domains/<int:pk>/dnsbl/",
|
||||
views.DNSBLDomainDetailView.as_view(),
|
||||
name="dnsbl_domain_detail",
|
||||
),
|
||||
path(
|
||||
"domains/<int:pk>/mx/",
|
||||
views.MXDomainDetailView.as_view(),
|
||||
name="mx_domain_detail",
|
||||
),
|
||||
path("domains/list/", domain_views._domains, name="_domain_list"),
|
||||
path("domains/quotas/", domain_views.list_quotas, name="domain_quota_list"),
|
||||
path("domains/logs/", domain_views.list_logs, name="domain_logs_list"),
|
||||
path("domains/flatlist/", domain_views.domains_list, name="domain_flat_list"),
|
||||
path("domains/new/", domain_views.newdomain, name="domain_add"),
|
||||
path("domains/<int:dom_id>/edit/", domain_views.editdomain, name="domain_change"),
|
||||
path("domains/<int:dom_id>/delete/", domain_views.deldomain, name="domain_delete"),
|
||||
path("domains/page/", domain_views.get_next_page, name="domain_page"),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
path(
|
||||
"permissions/remove/",
|
||||
identity_views.remove_permission,
|
||||
name="permission_remove",
|
||||
),
|
||||
path("identities/", identity_views.identities, name="identity_list"),
|
||||
path("identities/list/", identity_views._identities, name="_identity_list"),
|
||||
path("identities/quotas/", identity_views.list_quotas, name="identity_quota_list"),
|
||||
path("identities/page/", identity_views.get_next_page, name="identity_page"),
|
||||
path("accounts/list/", identity_views.accounts_list, name="account_list"),
|
||||
path("accounts/new/", identity_views.newaccount, name="account_add"),
|
||||
path(
|
||||
"accounts/<int:pk>/", views.AccountDetailView.as_view(), name="account_detail"
|
||||
),
|
||||
path("accounts/<int:pk>/edit/", identity_views.editaccount, name="account_change"),
|
||||
path("accounts/<int:pk>/delete/", identity_views.delaccount, name="account_delete"),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
path("aliases/new/", alias_views.newalias, name="alias_add"),
|
||||
path("aliases/<int:pk>/", views.AliasDetailView.as_view(), name="alias_detail"),
|
||||
path("aliases/<int:alid>/edit/", alias_views.editalias, name="alias_change"),
|
||||
path("aliases/delete/", alias_views.delalias, name="alias_delete"),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
path("domains/import/", import_views.import_domains, name="domain_import"),
|
||||
path("identities/import/", import_views.import_identities, name="identity_import"),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
path("domains/export/", export_views.export_domains, name="domain_export"),
|
||||
path("identities/export/", export_views.export_identities, name="identity_export"),
|
||||
]
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Shortcuts."""
|
||||
|
||||
from .alias import AliasDetailView
|
||||
from .dns import DNSBLDomainDetailView, MXDomainDetailView
|
||||
from .domain import DomainAlarmsView, DomainDetailView
|
||||
from .identity import AccountDetailView
|
||||
|
||||
__all__ = [
|
||||
"AccountDetailView",
|
||||
"AliasDetailView",
|
||||
"DNSBLDomainDetailView",
|
||||
"DomainAlarmsView",
|
||||
"DomainDetailView",
|
||||
"MXDomainDetailView",
|
||||
]
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Alias related views."""
|
||||
|
||||
from reversion import revisions as reversion
|
||||
|
||||
from django.contrib.auth import mixins as auth_mixins
|
||||
from django.contrib.auth.decorators import login_required, permission_required
|
||||
from django.db import IntegrityError
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _, ngettext
|
||||
from django.views import generic
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from modoboa.core import signals as core_signals
|
||||
from modoboa.lib.exceptions import Conflict, PermDeniedException
|
||||
from modoboa.lib.web_utils import render_to_json_response
|
||||
from ..forms import AliasForm
|
||||
from ..models import Alias
|
||||
|
||||
|
||||
def _validate_alias(request, form, successmsg, callback=None):
|
||||
"""Alias validation
|
||||
|
||||
Common function shared between creation and modification actions.
|
||||
"""
|
||||
if form.is_valid():
|
||||
try:
|
||||
alias = form.save()
|
||||
except IntegrityError:
|
||||
raise Conflict(_("Alias with this name already exists")) from None
|
||||
if callback:
|
||||
callback(request.user, alias)
|
||||
return render_to_json_response(successmsg)
|
||||
|
||||
return render_to_json_response({"form_errors": form.errors}, status=400)
|
||||
|
||||
|
||||
def _new_alias(request, title, action, successmsg, tplname="admin/aliasform.html"):
|
||||
core_signals.can_create_object.send("new_alias", context=request.user, klass=Alias)
|
||||
if request.method == "POST":
|
||||
|
||||
def callback(user, alias):
|
||||
alias.post_create(user)
|
||||
|
||||
form = AliasForm(request.user, request.POST)
|
||||
return _validate_alias(request, form, successmsg, callback)
|
||||
|
||||
ctx = {
|
||||
"title": title,
|
||||
"action": action,
|
||||
"formid": "aliasform",
|
||||
"action_label": _("Create"),
|
||||
"action_classes": "submit",
|
||||
"form": AliasForm(request.user),
|
||||
}
|
||||
return render(request, tplname, ctx)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("admin.add_alias")
|
||||
@reversion.create_revision()
|
||||
def newalias(request):
|
||||
return _new_alias(
|
||||
request, _("New alias"), reverse("admin:alias_add"), _("Alias created")
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("admin.change_alias")
|
||||
@reversion.create_revision()
|
||||
def editalias(request, alid, tplname="admin/aliasform.html"):
|
||||
alias = Alias.objects.get(pk=alid)
|
||||
if not request.user.can_access(alias):
|
||||
raise PermDeniedException
|
||||
if request.method == "POST":
|
||||
successmsg = _("Alias modified")
|
||||
form = AliasForm(request.user, request.POST, instance=alias)
|
||||
return _validate_alias(request, form, successmsg)
|
||||
|
||||
ctx = {
|
||||
"action": reverse("admin:alias_change", args=[alias.id]),
|
||||
"formid": "aliasform",
|
||||
"title": alias.address,
|
||||
"action_label": _("Update"),
|
||||
"action_classes": "submit",
|
||||
"form": AliasForm(request.user, instance=alias),
|
||||
}
|
||||
return render(request, tplname, ctx)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("admin.delete_alias")
|
||||
@require_http_methods(["DELETE"])
|
||||
def delalias(request):
|
||||
selection = request.GET["selection"].split(",")
|
||||
for alid in selection:
|
||||
alias = Alias.objects.get(pk=alid)
|
||||
if not request.user.can_access(alias):
|
||||
raise PermDeniedException
|
||||
alias.delete()
|
||||
msg = ngettext("Alias deleted", "Aliases deleted", len(selection))
|
||||
return render_to_json_response(msg)
|
||||
|
||||
|
||||
class AliasDetailView(auth_mixins.PermissionRequiredMixin, generic.DetailView):
|
||||
"""DetailView for Alias."""
|
||||
|
||||
model = Alias
|
||||
permission_required = "admin.add_alias"
|
||||
|
||||
def has_permission(self):
|
||||
"""Check object-level access."""
|
||||
result = super().has_permission()
|
||||
if not result:
|
||||
return result
|
||||
return self.request.user.can_access(self.get_object())
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add information to context."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["selection"] = "identities"
|
||||
return context
|
||||
@@ -1,43 +0,0 @@
|
||||
"""DNSBL related views."""
|
||||
|
||||
from django.contrib.auth import mixins as auth_mixins
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import generic
|
||||
|
||||
from .. import models
|
||||
|
||||
|
||||
class MXDomainDetailView(auth_mixins.PermissionRequiredMixin, generic.DetailView):
|
||||
"""View to display MX records."""
|
||||
|
||||
model = models.Domain
|
||||
permission_required = "admin.view_domain"
|
||||
template_name = "admin/mx_domain_detail.html"
|
||||
|
||||
def get_queryset(self):
|
||||
"""Add some prefetching."""
|
||||
return super().get_queryset().prefetch_related("mxrecord_set")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add extra variables."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update({"title": _("MX records of {}").format(self.object.name)})
|
||||
return context
|
||||
|
||||
|
||||
class DNSBLDomainDetailView(auth_mixins.PermissionRequiredMixin, generic.DetailView):
|
||||
"""View to display DNSBL summary."""
|
||||
|
||||
model = models.Domain
|
||||
permission_required = "admin.view_domain"
|
||||
template_name = "admin/dnsbl_domain_detail.html"
|
||||
|
||||
def get_queryset(self):
|
||||
"""Add some prefetching."""
|
||||
return super().get_queryset().prefetch_related("dnsblresult_set")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add extra variables."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update({"title": _("DNSBL summary for {}").format(self.object.name)})
|
||||
return context
|
||||
@@ -1,307 +0,0 @@
|
||||
"""Domain related views."""
|
||||
|
||||
from functools import reduce
|
||||
|
||||
from reversion import revisions as reversion
|
||||
|
||||
from django.contrib.auth import mixins as auth_mixins
|
||||
from django.contrib.auth.decorators import (
|
||||
login_required,
|
||||
permission_required,
|
||||
user_passes_test,
|
||||
)
|
||||
from django.db.models import Q, Sum
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _, ngettext
|
||||
from django.views import generic
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from modoboa.core import signals as core_signals
|
||||
from modoboa.lib.exceptions import PermDeniedException
|
||||
from modoboa.lib.listing import get_listing_page, get_sort_order
|
||||
from modoboa.lib.web_utils import render_to_json_response
|
||||
from modoboa.maillog import models as ml_models
|
||||
|
||||
from .. import signals
|
||||
from ..forms import DomainForm, DomainWizard
|
||||
from ..lib import get_domains
|
||||
from ..models import Domain, Mailbox
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
return HttpResponseRedirect(reverse("admin:domain_list"))
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(
|
||||
lambda u: u.has_perm("admin.view_domain") or u.has_perm("admin.view_mailbox")
|
||||
)
|
||||
def _domains(request):
|
||||
sort_order, sort_dir = get_sort_order(request.GET, "name")
|
||||
extra_filters = signals.extra_domain_filters.send(sender="_domains")
|
||||
if extra_filters:
|
||||
extra_filters = reduce(
|
||||
lambda a, b: a + b, [result[1] for result in extra_filters]
|
||||
)
|
||||
filters = {
|
||||
flt: request.GET.get(flt, None)
|
||||
for flt in ["domfilter", "searchquery"] + extra_filters
|
||||
}
|
||||
request.session["domains_filters"] = filters
|
||||
domainlist = get_domains(request.user, **filters)
|
||||
if sort_order == "name":
|
||||
domainlist = sorted(
|
||||
domainlist, key=lambda d: getattr(d, sort_order), reverse=sort_dir == "-"
|
||||
)
|
||||
else:
|
||||
domainlist = sorted(
|
||||
domainlist, key=lambda d: d.tags[0]["name"], reverse=sort_dir == "-"
|
||||
)
|
||||
context = {
|
||||
"handle_mailboxes": request.localconfig.parameters.get_value(
|
||||
"handle_mailboxes", raise_exception=False
|
||||
),
|
||||
"auto_account_removal": request.localconfig.parameters.get_value(
|
||||
"auto_account_removal"
|
||||
),
|
||||
}
|
||||
page = get_listing_page(domainlist, request.GET.get("page", 1))
|
||||
parameters = request.localconfig.parameters
|
||||
dns_checks = {
|
||||
"enable_mx_checks": parameters.get_value("enable_mx_checks"),
|
||||
"enable_spf_checks": parameters.get_value("enable_spf_checks"),
|
||||
"enable_dkim_checks": parameters.get_value("enable_dkim_checks"),
|
||||
"enable_dmarc_checks": parameters.get_value("enable_dmarc_checks"),
|
||||
"enable_autoconfig_checks": (parameters.get_value("enable_autoconfig_checks")),
|
||||
"enable_dnsbl_checks": parameters.get_value("enable_dnsbl_checks"),
|
||||
}
|
||||
context["headers"] = render_to_string(
|
||||
"admin/domain_headers.html", dns_checks, request
|
||||
)
|
||||
if page is None:
|
||||
context["length"] = 0
|
||||
else:
|
||||
tpl_context = {"domains": page.object_list}
|
||||
tpl_context.update(dns_checks)
|
||||
context["rows"] = render_to_string(
|
||||
"admin/domains_table.html", tpl_context, request
|
||||
)
|
||||
context["pages"] = [page.number]
|
||||
return render_to_json_response(context)
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def domains(request, tplname="admin/domains.html"):
|
||||
if not request.user.has_perm("admin.view_domain"):
|
||||
if request.user.has_perm("admin.view_mailbox"):
|
||||
return HttpResponseRedirect(reverse("admin:identity_list"))
|
||||
return HttpResponseRedirect(reverse("core:user_index"))
|
||||
parameters = request.localconfig.parameters
|
||||
return render(
|
||||
request,
|
||||
tplname,
|
||||
{
|
||||
"selection": "domains",
|
||||
"enable_mx_checks": parameters.get_value("enable_mx_checks"),
|
||||
"enable_spf_checks": parameters.get_value("enable_spf_checks"),
|
||||
"enable_dkim_checks": parameters.get_value("enable_dkim_checks"),
|
||||
"enable_dmarc_checks": parameters.get_value("enable_dmarc_checks"),
|
||||
"enable_autoconfig_checks": (
|
||||
parameters.get_value("enable_autoconfig_checks")
|
||||
),
|
||||
"enable_dnsbl_checks": parameters.get_value("enable_dnsbl_checks"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(
|
||||
lambda u: u.has_perm("admin.view_domain")
|
||||
or u.has_perm("admin.view_mailbox")
|
||||
or u.has_perm("admin.add_domain")
|
||||
)
|
||||
def get_next_page(request):
|
||||
"""Return the next page of the domain or quota list."""
|
||||
objtype = request.GET.get("objtype", "domain")
|
||||
if objtype == "domain":
|
||||
return _domains(request)
|
||||
if objtype == "quota":
|
||||
return list_quotas(request)
|
||||
return list_logs(request)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("core.add_user")
|
||||
def domains_list(request):
|
||||
doms = [dom.name for dom in Domain.objects.get_for_admin(request.user)]
|
||||
return render_to_json_response(doms)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("admin.view_domain")
|
||||
def list_quotas(request):
|
||||
sort_order, sort_dir = get_sort_order(request.GET, "name")
|
||||
domains = Domain.objects.get_for_admin(request.user)
|
||||
domains = domains.exclude(quota=0)
|
||||
if sort_order in ["name", "quota"]:
|
||||
domains = domains.order_by(f"{sort_dir}{sort_order}")
|
||||
elif sort_order == "allocated_quota":
|
||||
domains = domains.annotate(allocated_quota=Sum("mailbox__quota")).order_by(
|
||||
f"{sort_dir}{sort_order}"
|
||||
)
|
||||
page = get_listing_page(domains, request.GET.get("page", 1))
|
||||
context = {
|
||||
"headers": render_to_string("admin/domains_quota_headers.html", {}, request)
|
||||
}
|
||||
if page is None:
|
||||
context["length"] = 0
|
||||
else:
|
||||
context["rows"] = render_to_string(
|
||||
"admin/domains_quotas.html", {"domains": page}, request
|
||||
)
|
||||
context["pages"] = [page.number]
|
||||
return render_to_json_response(context)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("admin.view_domain")
|
||||
def list_logs(request):
|
||||
"""List all Maillog entries."""
|
||||
sort_order, sort_dir = get_sort_order(request.GET, "date")
|
||||
search = request.GET.get("searchquery")
|
||||
if not request.user.is_superuser:
|
||||
domains = Domain.objects.get_for_admin(request.user)
|
||||
logs = ml_models.Maillog.objects.filter(
|
||||
Q(from_domain__in=domains) | Q(to_domain__in=domains)
|
||||
)
|
||||
else:
|
||||
logs = ml_models.Maillog.objects.all()
|
||||
logs = logs.order_by(f"{sort_dir}{sort_order}")
|
||||
if search:
|
||||
logs = logs.filter(
|
||||
Q(sender__icontains=search)
|
||||
| Q(rcpt__icontains=search)
|
||||
| Q(queue_id__icontains=search)
|
||||
| Q(status__icontains=search)
|
||||
)
|
||||
page = get_listing_page(logs, request.GET.get("page", 1))
|
||||
context = {
|
||||
"headers": render_to_string("admin/domains_log_headers.html", {}, request)
|
||||
}
|
||||
if page is None:
|
||||
context["length"] = 0
|
||||
else:
|
||||
context["rows"] = render_to_string(
|
||||
"admin/domains_logs.html", {"logs": page}, request
|
||||
)
|
||||
context["pages"] = [page.number]
|
||||
return render_to_json_response(context)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("admin.add_domain")
|
||||
@reversion.create_revision()
|
||||
def newdomain(request):
|
||||
core_signals.can_create_object.send("newdomain", context=request.user, klass=Domain)
|
||||
return DomainWizard(request).process()
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("admin.view_domain")
|
||||
@require_http_methods(["GET", "POST"])
|
||||
@reversion.create_revision()
|
||||
def editdomain(request, dom_id):
|
||||
"""Edit domain view."""
|
||||
domain = Domain.objects.get(pk=dom_id)
|
||||
if not request.user.can_access(domain):
|
||||
raise PermDeniedException
|
||||
|
||||
instances = {"general": domain}
|
||||
results = signals.get_domain_form_instances.send(
|
||||
sender="editdomain", user=request.user, domain=domain
|
||||
)
|
||||
for result in results:
|
||||
instances.update(result[1])
|
||||
return DomainForm(request, instances=instances).process()
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("admin.delete_domain")
|
||||
@require_http_methods(["POST"])
|
||||
def deldomain(request, dom_id):
|
||||
keepdir = request.POST.get("keepdir", "false") == "true"
|
||||
try:
|
||||
mb = Mailbox.objects.get(user__id=request.user.id)
|
||||
except Mailbox.DoesNotExist:
|
||||
mb = None
|
||||
|
||||
dom = Domain.objects.get(pk=dom_id)
|
||||
if not request.user.can_access(dom):
|
||||
raise PermDeniedException
|
||||
if mb and mb.domain == dom:
|
||||
raise PermDeniedException(_("You can't delete your own domain"))
|
||||
dom.delete(request.user, keepdir)
|
||||
|
||||
msg = ngettext("Domain deleted", "Domains deleted", 1)
|
||||
return render_to_json_response(msg)
|
||||
|
||||
|
||||
class DomainDetailView(auth_mixins.PermissionRequiredMixin, generic.DetailView):
|
||||
"""DetailView for Domain."""
|
||||
|
||||
model = Domain
|
||||
permission_required = "admin.view_domain"
|
||||
|
||||
def get_queryset(self):
|
||||
"""Add some prefetching."""
|
||||
return Domain.objects.get_for_admin(self.request.user).prefetch_related(
|
||||
"domainalias_set", "mailbox_set", "alias_set"
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Include extra widgets."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
result = signals.extra_domain_dashboard_widgets.send(
|
||||
self.__class__, user=self.request.user, domain=self.object
|
||||
)
|
||||
parameters = self.request.localconfig.parameters
|
||||
context.update(
|
||||
{
|
||||
"templates": {"left": [], "right": []},
|
||||
"enable_mx_checks": parameters.get_value("enable_mx_checks"),
|
||||
"enable_spf_checks": parameters.get_value("enable_spf_checks"),
|
||||
"enable_dkim_checks": parameters.get_value("enable_dkim_checks"),
|
||||
"enable_dmarc_checks": parameters.get_value("enable_dmarc_checks"),
|
||||
"enable_autoconfig_checks": (
|
||||
parameters.get_value("enable_autoconfig_checks")
|
||||
),
|
||||
"enable_dnsbl_checks": parameters.get_value("enable_dnsbl_checks"),
|
||||
}
|
||||
)
|
||||
for _receiver, widgets in result:
|
||||
for widget in widgets:
|
||||
context["templates"][widget["column"]].append(widget["template"])
|
||||
# FIXME: can raise conflicts...
|
||||
context.update(widget["context"])
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class DomainAlarmsView(auth_mixins.PermissionRequiredMixin, generic.DetailView):
|
||||
"""A view to list domain alarms."""
|
||||
|
||||
model = Domain
|
||||
permission_required = "admin.view_domain"
|
||||
template_name = "admin/domain_alarms.html"
|
||||
|
||||
def get_queryset(self):
|
||||
"""Add some prefetching."""
|
||||
return Domain.objects.get_for_admin(self.request.user).prefetch_related(
|
||||
"alarms"
|
||||
)
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Export related views."""
|
||||
|
||||
import csv
|
||||
from io import StringIO
|
||||
|
||||
from django.contrib.auth.decorators import (
|
||||
login_required,
|
||||
permission_required,
|
||||
user_passes_test,
|
||||
)
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from ..forms import ExportDataForm
|
||||
from ..lib import get_domains, get_identities
|
||||
|
||||
|
||||
def _export(content, filename):
|
||||
"""Export a csv file's content
|
||||
|
||||
:param content: the content to export (string)
|
||||
:param filename: the name that will appear into the response
|
||||
:return: an ``HttpResponse`` object
|
||||
"""
|
||||
resp = HttpResponse(content)
|
||||
resp["Content-Type"] = "text/csv"
|
||||
resp["Content-Length"] = len(content)
|
||||
resp["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
return resp
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(
|
||||
lambda u: u.has_perm("core.add_user") or u.has_perm("admin.add_alias")
|
||||
)
|
||||
def export_identities(request):
|
||||
ctx = {
|
||||
"title": _("Export identities"),
|
||||
"action_label": _("Export"),
|
||||
"action_classes": "submit",
|
||||
"formid": "exportform",
|
||||
"action": reverse("admin:identity_export"),
|
||||
}
|
||||
|
||||
if request.method == "POST":
|
||||
form = ExportDataForm(request.POST)
|
||||
form.is_valid()
|
||||
fp = StringIO()
|
||||
csvwriter = csv.writer(fp, delimiter=form.cleaned_data["sepchar"])
|
||||
identities = get_identities(
|
||||
request.user, **request.session["identities_filters"]
|
||||
)
|
||||
for ident in identities:
|
||||
ident.to_csv(csvwriter)
|
||||
content = fp.getvalue()
|
||||
fp.close()
|
||||
return _export(content, "modoboa-identities.csv")
|
||||
|
||||
ctx["form"] = ExportDataForm()
|
||||
return render(request, "common/generic_modal_form.html", ctx)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("admin.add_domain")
|
||||
def export_domains(request):
|
||||
ctx = {
|
||||
"title": _("Export domains"),
|
||||
"action_label": _("Export"),
|
||||
"action_classes": "submit",
|
||||
"formid": "exportform",
|
||||
"action": reverse("admin:domain_export"),
|
||||
}
|
||||
|
||||
if request.method == "POST":
|
||||
form = ExportDataForm(request.POST)
|
||||
form.is_valid()
|
||||
fp = StringIO()
|
||||
csvwriter = csv.writer(fp, delimiter=form.cleaned_data["sepchar"])
|
||||
for dom in get_domains(request.user, **request.session["domains_filters"]):
|
||||
dom.to_csv(csvwriter)
|
||||
content = fp.getvalue()
|
||||
fp.close()
|
||||
return _export(content, "modoboa-domains.csv")
|
||||
|
||||
ctx["form"] = ExportDataForm()
|
||||
return render(request, "common/generic_modal_form.html", ctx)
|
||||
@@ -1,238 +0,0 @@
|
||||
"""Identity related views."""
|
||||
|
||||
from reversion import revisions as reversion
|
||||
|
||||
from django.contrib.auth import mixins as auth_mixins
|
||||
from django.contrib.auth.decorators import (
|
||||
login_required,
|
||||
permission_required,
|
||||
user_passes_test,
|
||||
)
|
||||
from django.shortcuts import render
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext as _, ngettext
|
||||
from django.views import generic
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from modoboa.core.models import User
|
||||
from modoboa.lib.exceptions import BadRequest, PermDeniedException
|
||||
from modoboa.lib.listing import get_listing_page, get_sort_order
|
||||
from modoboa.lib.web_utils import render_to_json_response
|
||||
from .. import signals
|
||||
from ..forms import AccountForm, AccountWizard
|
||||
from ..lib import get_identities
|
||||
from ..models import Domain, Mailbox
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(
|
||||
lambda u: u.has_perm("core.add_user") or u.has_perm("admin.add_alias")
|
||||
)
|
||||
def _identities(request):
|
||||
filters = {
|
||||
fname: request.GET.get(fname, None)
|
||||
for fname in ["searchquery", "idtfilter", "grpfilter"]
|
||||
}
|
||||
request.session["identities_filters"] = filters
|
||||
idents_list = get_identities(request.user, **filters)
|
||||
sort_order, sort_dir = get_sort_order(
|
||||
request.GET, "identity", ["identity", "name_or_rcpt", "tags"]
|
||||
)
|
||||
if sort_order in ["identity", "name_or_rcpt"]:
|
||||
objects = sorted(
|
||||
idents_list, key=lambda o: getattr(o, sort_order), reverse=sort_dir == "-"
|
||||
)
|
||||
else:
|
||||
objects = sorted(
|
||||
idents_list, key=lambda o: o.tags[0]["label"], reverse=sort_dir == "-"
|
||||
)
|
||||
context = {
|
||||
"handle_mailboxes": request.localconfig.parameters.get_value(
|
||||
"handle_mailboxes", raise_exception=False
|
||||
)
|
||||
}
|
||||
page = get_listing_page(objects, request.GET.get("page", 1))
|
||||
context["headers"] = render_to_string("admin/identity_headers.html", {}, request)
|
||||
if page is None:
|
||||
context["length"] = 0
|
||||
else:
|
||||
context["rows"] = render_to_string(
|
||||
"admin/identities_table.html", {"identities": page.object_list}, request
|
||||
)
|
||||
context["pages"] = [page.number]
|
||||
return render_to_json_response(context)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("admin.add_mailbox")
|
||||
def list_quotas(request):
|
||||
from modoboa.lib.db_utils import db_type
|
||||
|
||||
sort_order, sort_dir = get_sort_order(request.GET, "address")
|
||||
mboxes = Mailbox.objects.get_for_admin(
|
||||
request.user, request.GET.get("searchquery", None)
|
||||
)
|
||||
mboxes = mboxes.exclude(quota=0)
|
||||
if sort_order in ["address", "quota"]:
|
||||
mboxes = mboxes.order_by(f"{sort_dir}{sort_order}")
|
||||
elif sort_order in ("quota_value__bytes", "quota_usage"):
|
||||
db_type = db_type()
|
||||
if db_type == "mysql":
|
||||
where = "CONCAT(admin_mailbox.address,'@',admin_domain.name)"
|
||||
else:
|
||||
where = "admin_mailbox.address||'@'||admin_domain.name"
|
||||
if sort_order == "quota_value__bytes":
|
||||
mboxes = mboxes.extra(
|
||||
select={"quota_value__bytes": "admin_quota.bytes"},
|
||||
where=[f"admin_quota.username={where}"],
|
||||
tables=["admin_quota", "admin_domain"],
|
||||
order_by=[f"{sort_dir}{sort_order}"],
|
||||
)
|
||||
else:
|
||||
if db_type == "postgres":
|
||||
select = (
|
||||
"(admin_quota.bytes::float / (CAST(admin_mailbox.quota "
|
||||
"AS BIGINT) * 1048576)) * 100"
|
||||
)
|
||||
else:
|
||||
select = (
|
||||
"(admin_quota.bytes * 1.0 / (admin_mailbox.quota "
|
||||
"* 1048576)) * 100"
|
||||
)
|
||||
mboxes = mboxes.extra(
|
||||
select={"quota_usage": select},
|
||||
where=[f"admin_quota.username={where}"],
|
||||
tables=["admin_quota", "admin_domain"],
|
||||
order_by=[f"{sort_dir}{sort_order}"],
|
||||
)
|
||||
else:
|
||||
raise BadRequest(_("Invalid request"))
|
||||
page = get_listing_page(mboxes, request.GET.get("page", 1))
|
||||
context = {
|
||||
"headers": render_to_string("admin/identities_quota_headers.html", {}, request)
|
||||
}
|
||||
if page is None:
|
||||
context["length"] = 0
|
||||
else:
|
||||
context["rows"] = render_to_string(
|
||||
"admin/identities_quotas.html", {"mboxes": page}, request
|
||||
)
|
||||
context["pages"] = [page.number]
|
||||
return render_to_json_response(context)
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(
|
||||
lambda u: u.has_perm("core.add_user")
|
||||
or u.has_perm("admin.add_alias")
|
||||
or u.has_perm("admin.add_mailbox")
|
||||
)
|
||||
def get_next_page(request):
|
||||
"""Return the next page of the identity list."""
|
||||
if request.GET.get("objtype", "identity") == "identity":
|
||||
return _identities(request)
|
||||
return list_quotas(request)
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(
|
||||
lambda u: u.has_perm("core.add_user") or u.has_perm("admin.add_alias")
|
||||
)
|
||||
@ensure_csrf_cookie
|
||||
def identities(request, tplname="admin/identities.html"):
|
||||
return render(request, tplname, {"selection": "identities", "deflocation": "list/"})
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("core.add_user")
|
||||
def accounts_list(request):
|
||||
accs = User.objects.filter(is_superuser=False).exclude(groups__name="SimpleUsers")
|
||||
res = [a.username for a in accs.all()]
|
||||
return render_to_json_response(res)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("core.add_user")
|
||||
@reversion.create_revision()
|
||||
def newaccount(request):
|
||||
"""Create a new account."""
|
||||
return AccountWizard(request).process()
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("core.change_user")
|
||||
@require_http_methods(["GET", "POST"])
|
||||
@reversion.create_revision()
|
||||
def editaccount(request, pk):
|
||||
account = User.objects.get(pk=pk)
|
||||
if not request.user.can_access(account):
|
||||
raise PermDeniedException
|
||||
mb = account.mailbox if hasattr(account, "mailbox") else None
|
||||
|
||||
instances = {"general": account, "profile": account, "mail": mb, "perms": account}
|
||||
results = signals.get_account_form_instances.send(
|
||||
sender="editaccount", user=request.user, account=account
|
||||
)
|
||||
for result in results:
|
||||
instances.update(result[1])
|
||||
return AccountForm(request, instances=instances).process()
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("core.delete_user")
|
||||
@require_http_methods(["POST"])
|
||||
def delaccount(request, pk):
|
||||
User.objects.get(pk=pk).delete()
|
||||
return render_to_json_response(ngettext("Account deleted", "Accounts deleted", 1))
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("admin.add_domain")
|
||||
@require_http_methods(["DELETE"])
|
||||
def remove_permission(request):
|
||||
domid = request.GET.get("domid", None)
|
||||
daid = request.GET.get("daid", None)
|
||||
if domid is None or daid is None:
|
||||
raise BadRequest(_("Invalid request"))
|
||||
try:
|
||||
account = User.objects.get(pk=daid)
|
||||
domain = Domain.objects.get(pk=domid)
|
||||
except (User.DoesNotExist, Domain.DoesNotExist):
|
||||
raise BadRequest(_("Invalid request")) from None
|
||||
if not request.user.can_access(account) or not request.user.can_access(domain):
|
||||
raise PermDeniedException
|
||||
domain.remove_admin(account)
|
||||
return render_to_json_response({})
|
||||
|
||||
|
||||
class AccountDetailView(auth_mixins.PermissionRequiredMixin, generic.DetailView):
|
||||
"""DetailView for Account."""
|
||||
|
||||
model = User
|
||||
permission_required = "core.add_user"
|
||||
template_name = "admin/account_detail.html"
|
||||
|
||||
def has_permission(self):
|
||||
"""Check object-level access."""
|
||||
result = super().has_permission()
|
||||
if not result:
|
||||
return result
|
||||
return self.request.user.can_access(self.get_object())
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add information to context."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
del context["user"]
|
||||
result = signals.extra_account_dashboard_widgets.send(
|
||||
self.__class__, user=self.request.user, account=self.object
|
||||
)
|
||||
context["templates"] = {"left": [], "right": []}
|
||||
for _receiver, widgets in result:
|
||||
for widget in widgets:
|
||||
context["templates"][widget["column"]].append(widget["template"])
|
||||
context.update(widget["context"])
|
||||
if self.object.role in ["Resellers", "DomainAdmins"]:
|
||||
context["domains"] = Domain.objects.get_for_admin(self.object)
|
||||
context["selection"] = "identities"
|
||||
return context
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Import related views."""
|
||||
|
||||
from reversion import revisions as reversion
|
||||
|
||||
from django.contrib.auth.decorators import (
|
||||
login_required,
|
||||
permission_required,
|
||||
user_passes_test,
|
||||
)
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from .. import lib
|
||||
from ..forms import ImportDataForm, ImportIdentitiesForm
|
||||
|
||||
|
||||
@reversion.create_revision()
|
||||
def importdata(request, formclass=ImportDataForm):
|
||||
"""Generic import function
|
||||
|
||||
As the process of importing data from a CSV file is the same
|
||||
whatever the type, we do a maximum of the work here.
|
||||
|
||||
:param request: a ``Request`` instance
|
||||
:param typ: a string indicating the object type being imported
|
||||
:return: a ``Response`` instance
|
||||
"""
|
||||
msg = None
|
||||
form = formclass(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
status, msg = lib.import_data(
|
||||
request.user, request.FILES["sourcefile"], form.cleaned_data
|
||||
)
|
||||
if status:
|
||||
return render(
|
||||
request, "admin/import_done.html", {"status": "ok", "msg": msg}
|
||||
)
|
||||
return render(request, "admin/import_done.html", {"status": "ko", "msg": msg})
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("admin.add_domain")
|
||||
def import_domains(request):
|
||||
if request.method == "POST":
|
||||
return importdata(request)
|
||||
|
||||
ctx = {
|
||||
"title": _("Import domains"),
|
||||
"action_label": _("Import"),
|
||||
"action_classes": "submit",
|
||||
"action": reverse("admin:domain_import"),
|
||||
"formid": "importform",
|
||||
"enctype": "multipart/form-data",
|
||||
"target": "import_target",
|
||||
"form": ImportDataForm(),
|
||||
}
|
||||
return render(request, "admin/import_domains_form.html", ctx)
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(
|
||||
lambda u: u.has_perm("core.add_user") or u.has_perm("admin.add_alias")
|
||||
)
|
||||
def import_identities(request):
|
||||
if request.method == "POST":
|
||||
return importdata(request, ImportIdentitiesForm)
|
||||
|
||||
ctx = {
|
||||
"title": _("Import identities"),
|
||||
"action_label": _("Import"),
|
||||
"action_classes": "submit",
|
||||
"action": reverse("admin:identity_import"),
|
||||
"formid": "importform",
|
||||
"enctype": "multipart/form-data",
|
||||
"target": "import_target",
|
||||
"form": ImportIdentitiesForm(),
|
||||
}
|
||||
return render(request, "admin/import_identities_form.html", ctx)
|
||||
@@ -1,64 +0,0 @@
|
||||
"""SimpleUsers views."""
|
||||
|
||||
from reversion import revisions as reversion
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from modoboa.lib.web_utils import render_to_json_response
|
||||
from ..forms import ForwardForm
|
||||
from ..lib import needs_mailbox
|
||||
from ..models import Alias
|
||||
|
||||
|
||||
@login_required
|
||||
@needs_mailbox()
|
||||
@reversion.create_revision()
|
||||
def forward(request, tplname="admin/forward.html"):
|
||||
mb = request.user.mailbox
|
||||
al = Alias.objects.filter(address=mb.full_address, internal=False).first()
|
||||
if request.method == "POST":
|
||||
form = ForwardForm(request.POST)
|
||||
if form.is_valid():
|
||||
recipients = form.cleaned_data["dest"]
|
||||
if not recipients:
|
||||
Alias.objects.filter(address=mb.full_address, internal=False).delete()
|
||||
# Make sure internal self-alias is enabled
|
||||
Alias.objects.filter(address=mb.full_address, internal=True).update(
|
||||
enabled=True
|
||||
)
|
||||
else:
|
||||
if al is None:
|
||||
al = Alias.objects.create(
|
||||
address=mb.full_address,
|
||||
domain=mb.domain,
|
||||
enabled=mb.user.is_active,
|
||||
)
|
||||
al.post_create(request.user)
|
||||
if form.cleaned_data["keepcopies"]:
|
||||
# Make sure internal self-alias is enabled
|
||||
Alias.objects.filter(address=mb.full_address, internal=True).update(
|
||||
enabled=True
|
||||
)
|
||||
else:
|
||||
# Deactivate internal self-alias to avoid storing
|
||||
# local copies...
|
||||
Alias.objects.filter(address=mb.full_address, internal=True).update(
|
||||
enabled=False
|
||||
)
|
||||
al.set_recipients(recipients)
|
||||
return render_to_json_response(_("Forward updated"))
|
||||
|
||||
return render_to_json_response({"form_errors": form.errors}, status=400)
|
||||
|
||||
form = ForwardForm()
|
||||
if al is not None and al.recipients:
|
||||
recipients = list(al.recipients)
|
||||
if al.aliasrecipient_set.filter(r_mailbox=mb).exists():
|
||||
form.fields["keepcopies"].initial = True
|
||||
recipients.remove(mb.full_address)
|
||||
form.fields["dest"].initial = "\n".join(recipients)
|
||||
return render_to_json_response(
|
||||
{"content": render_to_string(tplname, {"form": form}, request)}
|
||||
)
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"name": "bootstrap",
|
||||
"description": "The most popular front-end framework for developing responsive, mobile first projects on the web.",
|
||||
"keywords": [
|
||||
"css",
|
||||
"js",
|
||||
"less",
|
||||
"mobile-first",
|
||||
"responsive",
|
||||
"front-end",
|
||||
"framework",
|
||||
"web"
|
||||
],
|
||||
"homepage": "http://getbootstrap.com",
|
||||
"license": "MIT",
|
||||
"moduleType": "globals",
|
||||
"main": [
|
||||
"less/bootstrap.less",
|
||||
"dist/js/bootstrap.js"
|
||||
],
|
||||
"ignore": [
|
||||
"/.*",
|
||||
"_config.yml",
|
||||
"CNAME",
|
||||
"composer.json",
|
||||
"CONTRIBUTING.md",
|
||||
"docs",
|
||||
"js/tests",
|
||||
"test-infra"
|
||||
],
|
||||
"dependencies": {
|
||||
"jquery": "1.9.1 - 3"
|
||||
},
|
||||
"version": "3.3.7",
|
||||
"_release": "3.3.7",
|
||||
"_resolution": {
|
||||
"type": "version",
|
||||
"tag": "v3.3.7",
|
||||
"commit": "0b9c4a4007c44201dce9a6cc1a38407005c26c86"
|
||||
},
|
||||
"_source": "https://github.com/twbs/bootstrap.git",
|
||||
"_target": "3.3.7",
|
||||
"_originalSource": "bootstrap",
|
||||
"_direct": true
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
Bootstrap uses [GitHub's Releases feature](https://github.com/blog/1547-release-your-software) for its changelogs.
|
||||
|
||||
See [the Releases section of our GitHub project](https://github.com/twbs/bootstrap/releases) for changelogs for each release version of Bootstrap.
|
||||
|
||||
Release announcement posts on [the official Bootstrap blog](http://blog.getbootstrap.com) contain summaries of the most noteworthy changes made in each release.
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
group :development, :test do
|
||||
gem 'jekyll', '~> 3.1.2'
|
||||
gem 'jekyll-sitemap', '~> 0.11.0'
|
||||
end
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
addressable (2.4.0)
|
||||
colorator (0.1)
|
||||
ffi (1.9.14-x64-mingw32)
|
||||
jekyll (3.1.6)
|
||||
colorator (~> 0.1)
|
||||
jekyll-sass-converter (~> 1.0)
|
||||
jekyll-watch (~> 1.1)
|
||||
kramdown (~> 1.3)
|
||||
liquid (~> 3.0)
|
||||
mercenary (~> 0.3.3)
|
||||
rouge (~> 1.7)
|
||||
safe_yaml (~> 1.0)
|
||||
jekyll-sass-converter (1.4.0)
|
||||
sass (~> 3.4)
|
||||
jekyll-sitemap (0.11.0)
|
||||
addressable (~> 2.4.0)
|
||||
jekyll-watch (1.4.0)
|
||||
listen (~> 3.0, < 3.1)
|
||||
kramdown (1.11.1)
|
||||
liquid (3.0.6)
|
||||
listen (3.0.8)
|
||||
rb-fsevent (~> 0.9, >= 0.9.4)
|
||||
rb-inotify (~> 0.9, >= 0.9.7)
|
||||
mercenary (0.3.6)
|
||||
rb-fsevent (0.9.7)
|
||||
rb-inotify (0.9.7)
|
||||
ffi (>= 0.5.0)
|
||||
rouge (1.11.1)
|
||||
safe_yaml (1.0.4)
|
||||
sass (3.4.22)
|
||||
|
||||
PLATFORMS
|
||||
x64-mingw32
|
||||
|
||||
DEPENDENCIES
|
||||
jekyll (~> 3.1.2)
|
||||
jekyll-sitemap (~> 0.11.0)
|
||||
|
||||
BUNDLED WITH
|
||||
1.12.5
|
||||
-511
@@ -1,511 +0,0 @@
|
||||
/*!
|
||||
* Bootstrap's Gruntfile
|
||||
* http://getbootstrap.com
|
||||
* Copyright 2013-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
*/
|
||||
|
||||
module.exports = function (grunt) {
|
||||
'use strict';
|
||||
|
||||
// Force use of Unix newlines
|
||||
grunt.util.linefeed = '\n';
|
||||
|
||||
RegExp.quote = function (string) {
|
||||
return string.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
};
|
||||
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var generateGlyphiconsData = require('./grunt/bs-glyphicons-data-generator.js');
|
||||
var BsLessdocParser = require('./grunt/bs-lessdoc-parser.js');
|
||||
var getLessVarsData = function () {
|
||||
var filePath = path.join(__dirname, 'less/variables.less');
|
||||
var fileContent = fs.readFileSync(filePath, { encoding: 'utf8' });
|
||||
var parser = new BsLessdocParser(fileContent);
|
||||
return { sections: parser.parseFile() };
|
||||
};
|
||||
var generateRawFiles = require('./grunt/bs-raw-files-generator.js');
|
||||
var generateCommonJSModule = require('./grunt/bs-commonjs-generator.js');
|
||||
var configBridge = grunt.file.readJSON('./grunt/configBridge.json', { encoding: 'utf8' });
|
||||
|
||||
Object.keys(configBridge.paths).forEach(function (key) {
|
||||
configBridge.paths[key].forEach(function (val, i, arr) {
|
||||
arr[i] = path.join('./docs/assets', val);
|
||||
});
|
||||
});
|
||||
|
||||
// Project configuration.
|
||||
grunt.initConfig({
|
||||
|
||||
// Metadata.
|
||||
pkg: grunt.file.readJSON('package.json'),
|
||||
banner: '/*!\n' +
|
||||
' * Bootstrap v<%= pkg.version %> (<%= pkg.homepage %>)\n' +
|
||||
' * Copyright 2011-<%= grunt.template.today("yyyy") %> <%= pkg.author %>\n' +
|
||||
' * Licensed under the <%= pkg.license %> license\n' +
|
||||
' */\n',
|
||||
jqueryCheck: configBridge.config.jqueryCheck.join('\n'),
|
||||
jqueryVersionCheck: configBridge.config.jqueryVersionCheck.join('\n'),
|
||||
|
||||
// Task configuration.
|
||||
clean: {
|
||||
dist: 'dist',
|
||||
docs: 'docs/dist'
|
||||
},
|
||||
|
||||
jshint: {
|
||||
options: {
|
||||
jshintrc: 'js/.jshintrc'
|
||||
},
|
||||
grunt: {
|
||||
options: {
|
||||
jshintrc: 'grunt/.jshintrc'
|
||||
},
|
||||
src: ['Gruntfile.js', 'package.js', 'grunt/*.js']
|
||||
},
|
||||
core: {
|
||||
src: 'js/*.js'
|
||||
},
|
||||
test: {
|
||||
options: {
|
||||
jshintrc: 'js/tests/unit/.jshintrc'
|
||||
},
|
||||
src: 'js/tests/unit/*.js'
|
||||
},
|
||||
assets: {
|
||||
src: ['docs/assets/js/src/*.js', 'docs/assets/js/*.js', '!docs/assets/js/*.min.js']
|
||||
}
|
||||
},
|
||||
|
||||
jscs: {
|
||||
options: {
|
||||
config: 'js/.jscsrc'
|
||||
},
|
||||
grunt: {
|
||||
src: '<%= jshint.grunt.src %>'
|
||||
},
|
||||
core: {
|
||||
src: '<%= jshint.core.src %>'
|
||||
},
|
||||
test: {
|
||||
src: '<%= jshint.test.src %>'
|
||||
},
|
||||
assets: {
|
||||
options: {
|
||||
requireCamelCaseOrUpperCaseIdentifiers: null
|
||||
},
|
||||
src: '<%= jshint.assets.src %>'
|
||||
}
|
||||
},
|
||||
|
||||
concat: {
|
||||
options: {
|
||||
banner: '<%= banner %>\n<%= jqueryCheck %>\n<%= jqueryVersionCheck %>',
|
||||
stripBanners: false
|
||||
},
|
||||
bootstrap: {
|
||||
src: [
|
||||
'js/transition.js',
|
||||
'js/alert.js',
|
||||
'js/button.js',
|
||||
'js/carousel.js',
|
||||
'js/collapse.js',
|
||||
'js/dropdown.js',
|
||||
'js/modal.js',
|
||||
'js/tooltip.js',
|
||||
'js/popover.js',
|
||||
'js/scrollspy.js',
|
||||
'js/tab.js',
|
||||
'js/affix.js'
|
||||
],
|
||||
dest: 'dist/js/<%= pkg.name %>.js'
|
||||
}
|
||||
},
|
||||
|
||||
uglify: {
|
||||
options: {
|
||||
compress: {
|
||||
warnings: false
|
||||
},
|
||||
mangle: true,
|
||||
preserveComments: /^!|@preserve|@license|@cc_on/i
|
||||
},
|
||||
core: {
|
||||
src: '<%= concat.bootstrap.dest %>',
|
||||
dest: 'dist/js/<%= pkg.name %>.min.js'
|
||||
},
|
||||
customize: {
|
||||
src: configBridge.paths.customizerJs,
|
||||
dest: 'docs/assets/js/customize.min.js'
|
||||
},
|
||||
docsJs: {
|
||||
src: configBridge.paths.docsJs,
|
||||
dest: 'docs/assets/js/docs.min.js'
|
||||
}
|
||||
},
|
||||
|
||||
qunit: {
|
||||
options: {
|
||||
inject: 'js/tests/unit/phantom.js'
|
||||
},
|
||||
files: 'js/tests/index.html'
|
||||
},
|
||||
|
||||
less: {
|
||||
compileCore: {
|
||||
options: {
|
||||
strictMath: true,
|
||||
sourceMap: true,
|
||||
outputSourceFiles: true,
|
||||
sourceMapURL: '<%= pkg.name %>.css.map',
|
||||
sourceMapFilename: 'dist/css/<%= pkg.name %>.css.map'
|
||||
},
|
||||
src: 'less/bootstrap.less',
|
||||
dest: 'dist/css/<%= pkg.name %>.css'
|
||||
},
|
||||
compileTheme: {
|
||||
options: {
|
||||
strictMath: true,
|
||||
sourceMap: true,
|
||||
outputSourceFiles: true,
|
||||
sourceMapURL: '<%= pkg.name %>-theme.css.map',
|
||||
sourceMapFilename: 'dist/css/<%= pkg.name %>-theme.css.map'
|
||||
},
|
||||
src: 'less/theme.less',
|
||||
dest: 'dist/css/<%= pkg.name %>-theme.css'
|
||||
}
|
||||
},
|
||||
|
||||
autoprefixer: {
|
||||
options: {
|
||||
browsers: configBridge.config.autoprefixerBrowsers
|
||||
},
|
||||
core: {
|
||||
options: {
|
||||
map: true
|
||||
},
|
||||
src: 'dist/css/<%= pkg.name %>.css'
|
||||
},
|
||||
theme: {
|
||||
options: {
|
||||
map: true
|
||||
},
|
||||
src: 'dist/css/<%= pkg.name %>-theme.css'
|
||||
},
|
||||
docs: {
|
||||
src: ['docs/assets/css/src/docs.css']
|
||||
},
|
||||
examples: {
|
||||
expand: true,
|
||||
cwd: 'docs/examples/',
|
||||
src: ['**/*.css'],
|
||||
dest: 'docs/examples/'
|
||||
}
|
||||
},
|
||||
|
||||
csslint: {
|
||||
options: {
|
||||
csslintrc: 'less/.csslintrc'
|
||||
},
|
||||
dist: [
|
||||
'dist/css/bootstrap.css',
|
||||
'dist/css/bootstrap-theme.css'
|
||||
],
|
||||
examples: [
|
||||
'docs/examples/**/*.css'
|
||||
],
|
||||
docs: {
|
||||
options: {
|
||||
ids: false,
|
||||
'overqualified-elements': false
|
||||
},
|
||||
src: 'docs/assets/css/src/docs.css'
|
||||
}
|
||||
},
|
||||
|
||||
cssmin: {
|
||||
options: {
|
||||
// TODO: disable `zeroUnits` optimization once clean-css 3.2 is released
|
||||
// and then simplify the fix for https://github.com/twbs/bootstrap/issues/14837 accordingly
|
||||
compatibility: 'ie8',
|
||||
keepSpecialComments: '*',
|
||||
sourceMap: true,
|
||||
sourceMapInlineSources: true,
|
||||
advanced: false
|
||||
},
|
||||
minifyCore: {
|
||||
src: 'dist/css/<%= pkg.name %>.css',
|
||||
dest: 'dist/css/<%= pkg.name %>.min.css'
|
||||
},
|
||||
minifyTheme: {
|
||||
src: 'dist/css/<%= pkg.name %>-theme.css',
|
||||
dest: 'dist/css/<%= pkg.name %>-theme.min.css'
|
||||
},
|
||||
docs: {
|
||||
src: [
|
||||
'docs/assets/css/ie10-viewport-bug-workaround.css',
|
||||
'docs/assets/css/src/pygments-manni.css',
|
||||
'docs/assets/css/src/docs.css'
|
||||
],
|
||||
dest: 'docs/assets/css/docs.min.css'
|
||||
}
|
||||
},
|
||||
|
||||
csscomb: {
|
||||
options: {
|
||||
config: 'less/.csscomb.json'
|
||||
},
|
||||
dist: {
|
||||
expand: true,
|
||||
cwd: 'dist/css/',
|
||||
src: ['*.css', '!*.min.css'],
|
||||
dest: 'dist/css/'
|
||||
},
|
||||
examples: {
|
||||
expand: true,
|
||||
cwd: 'docs/examples/',
|
||||
src: '**/*.css',
|
||||
dest: 'docs/examples/'
|
||||
},
|
||||
docs: {
|
||||
src: 'docs/assets/css/src/docs.css',
|
||||
dest: 'docs/assets/css/src/docs.css'
|
||||
}
|
||||
},
|
||||
|
||||
copy: {
|
||||
fonts: {
|
||||
expand: true,
|
||||
src: 'fonts/**',
|
||||
dest: 'dist/'
|
||||
},
|
||||
docs: {
|
||||
expand: true,
|
||||
cwd: 'dist/',
|
||||
src: [
|
||||
'**/*'
|
||||
],
|
||||
dest: 'docs/dist/'
|
||||
}
|
||||
},
|
||||
|
||||
connect: {
|
||||
server: {
|
||||
options: {
|
||||
port: 3000,
|
||||
base: '.'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
jekyll: {
|
||||
options: {
|
||||
bundleExec: true,
|
||||
config: '_config.yml',
|
||||
incremental: false
|
||||
},
|
||||
docs: {},
|
||||
github: {
|
||||
options: {
|
||||
raw: 'github: true'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
htmlmin: {
|
||||
dist: {
|
||||
options: {
|
||||
collapseBooleanAttributes: true,
|
||||
collapseWhitespace: true,
|
||||
conservativeCollapse: true,
|
||||
decodeEntities: false,
|
||||
minifyCSS: {
|
||||
compatibility: 'ie8',
|
||||
keepSpecialComments: 0
|
||||
},
|
||||
minifyJS: true,
|
||||
minifyURLs: false,
|
||||
processConditionalComments: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
removeOptionalAttributes: true,
|
||||
removeOptionalTags: true,
|
||||
removeRedundantAttributes: true,
|
||||
removeScriptTypeAttributes: true,
|
||||
removeStyleLinkTypeAttributes: true,
|
||||
removeTagWhitespace: false,
|
||||
sortAttributes: true,
|
||||
sortClassName: true
|
||||
},
|
||||
expand: true,
|
||||
cwd: '_gh_pages',
|
||||
dest: '_gh_pages',
|
||||
src: [
|
||||
'**/*.html',
|
||||
'!examples/**/*.html'
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
pug: {
|
||||
options: {
|
||||
pretty: true,
|
||||
data: getLessVarsData
|
||||
},
|
||||
customizerVars: {
|
||||
src: 'docs/_pug/customizer-variables.pug',
|
||||
dest: 'docs/_includes/customizer-variables.html'
|
||||
},
|
||||
customizerNav: {
|
||||
src: 'docs/_pug/customizer-nav.pug',
|
||||
dest: 'docs/_includes/nav/customize.html'
|
||||
}
|
||||
},
|
||||
|
||||
htmllint: {
|
||||
options: {
|
||||
ignore: [
|
||||
'Attribute "autocomplete" not allowed on element "button" at this point.',
|
||||
'Attribute "autocomplete" is only allowed when the input type is "color", "date", "datetime", "datetime-local", "email", "hidden", "month", "number", "password", "range", "search", "tel", "text", "time", "url", or "week".',
|
||||
'Element "img" is missing required attribute "src".'
|
||||
]
|
||||
},
|
||||
src: '_gh_pages/**/*.html'
|
||||
},
|
||||
|
||||
watch: {
|
||||
src: {
|
||||
files: '<%= jshint.core.src %>',
|
||||
tasks: ['jshint:core', 'qunit', 'concat']
|
||||
},
|
||||
test: {
|
||||
files: '<%= jshint.test.src %>',
|
||||
tasks: ['jshint:test', 'qunit']
|
||||
},
|
||||
less: {
|
||||
files: 'less/**/*.less',
|
||||
tasks: 'less'
|
||||
}
|
||||
},
|
||||
|
||||
'saucelabs-qunit': {
|
||||
all: {
|
||||
options: {
|
||||
build: process.env.TRAVIS_JOB_ID,
|
||||
throttled: 10,
|
||||
maxRetries: 3,
|
||||
maxPollRetries: 4,
|
||||
urls: ['http://127.0.0.1:3000/js/tests/index.html?hidepassed'],
|
||||
browsers: grunt.file.readYAML('grunt/sauce_browsers.yml')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
exec: {
|
||||
npmUpdate: {
|
||||
command: 'npm update'
|
||||
}
|
||||
},
|
||||
|
||||
compress: {
|
||||
main: {
|
||||
options: {
|
||||
archive: 'bootstrap-<%= pkg.version %>-dist.zip',
|
||||
mode: 'zip',
|
||||
level: 9,
|
||||
pretty: true
|
||||
},
|
||||
files: [
|
||||
{
|
||||
expand: true,
|
||||
cwd: 'dist/',
|
||||
src: ['**'],
|
||||
dest: 'bootstrap-<%= pkg.version %>-dist'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
// These plugins provide necessary tasks.
|
||||
require('load-grunt-tasks')(grunt, { scope: 'devDependencies' });
|
||||
require('time-grunt')(grunt);
|
||||
|
||||
// Docs HTML validation task
|
||||
grunt.registerTask('validate-html', ['jekyll:docs', 'htmllint']);
|
||||
|
||||
var runSubset = function (subset) {
|
||||
return !process.env.TWBS_TEST || process.env.TWBS_TEST === subset;
|
||||
};
|
||||
var isUndefOrNonZero = function (val) {
|
||||
return val === undefined || val !== '0';
|
||||
};
|
||||
|
||||
// Test task.
|
||||
var testSubtasks = [];
|
||||
// Skip core tests if running a different subset of the test suite
|
||||
if (runSubset('core') &&
|
||||
// Skip core tests if this is a Savage build
|
||||
process.env.TRAVIS_REPO_SLUG !== 'twbs-savage/bootstrap') {
|
||||
testSubtasks = testSubtasks.concat(['dist-css', 'dist-js', 'csslint:dist', 'test-js', 'docs']);
|
||||
}
|
||||
// Skip HTML validation if running a different subset of the test suite
|
||||
if (runSubset('validate-html') &&
|
||||
// Skip HTML5 validator on Travis when [skip validator] is in the commit message
|
||||
isUndefOrNonZero(process.env.TWBS_DO_VALIDATOR)) {
|
||||
testSubtasks.push('validate-html');
|
||||
}
|
||||
// Only run Sauce Labs tests if there's a Sauce access key
|
||||
if (typeof process.env.SAUCE_ACCESS_KEY !== 'undefined' &&
|
||||
// Skip Sauce if running a different subset of the test suite
|
||||
runSubset('sauce-js-unit') &&
|
||||
// Skip Sauce on Travis when [skip sauce] is in the commit message
|
||||
isUndefOrNonZero(process.env.TWBS_DO_SAUCE)) {
|
||||
testSubtasks.push('connect');
|
||||
testSubtasks.push('saucelabs-qunit');
|
||||
}
|
||||
grunt.registerTask('test', testSubtasks);
|
||||
grunt.registerTask('test-js', ['jshint:core', 'jshint:test', 'jshint:grunt', 'jscs:core', 'jscs:test', 'jscs:grunt', 'qunit']);
|
||||
|
||||
// JS distribution task.
|
||||
grunt.registerTask('dist-js', ['concat', 'uglify:core', 'commonjs']);
|
||||
|
||||
// CSS distribution task.
|
||||
grunt.registerTask('less-compile', ['less:compileCore', 'less:compileTheme']);
|
||||
grunt.registerTask('dist-css', ['less-compile', 'autoprefixer:core', 'autoprefixer:theme', 'csscomb:dist', 'cssmin:minifyCore', 'cssmin:minifyTheme']);
|
||||
|
||||
// Full distribution task.
|
||||
grunt.registerTask('dist', ['clean:dist', 'dist-css', 'copy:fonts', 'dist-js']);
|
||||
|
||||
// Default task.
|
||||
grunt.registerTask('default', ['clean:dist', 'copy:fonts', 'test']);
|
||||
|
||||
grunt.registerTask('build-glyphicons-data', function () { generateGlyphiconsData.call(this, grunt); });
|
||||
|
||||
// task for building customizer
|
||||
grunt.registerTask('build-customizer', ['build-customizer-html', 'build-raw-files']);
|
||||
grunt.registerTask('build-customizer-html', 'pug');
|
||||
grunt.registerTask('build-raw-files', 'Add scripts/less files to customizer.', function () {
|
||||
var banner = grunt.template.process('<%= banner %>');
|
||||
generateRawFiles(grunt, banner);
|
||||
});
|
||||
|
||||
grunt.registerTask('commonjs', 'Generate CommonJS entrypoint module in dist dir.', function () {
|
||||
var srcFiles = grunt.config.get('concat.bootstrap.src');
|
||||
var destFilepath = 'dist/js/npm.js';
|
||||
generateCommonJSModule(grunt, srcFiles, destFilepath);
|
||||
});
|
||||
|
||||
// Docs task.
|
||||
grunt.registerTask('docs-css', ['autoprefixer:docs', 'autoprefixer:examples', 'csscomb:docs', 'csscomb:examples', 'cssmin:docs']);
|
||||
grunt.registerTask('lint-docs-css', ['csslint:docs', 'csslint:examples']);
|
||||
grunt.registerTask('docs-js', ['uglify:docsJs', 'uglify:customize']);
|
||||
grunt.registerTask('lint-docs-js', ['jshint:assets', 'jscs:assets']);
|
||||
grunt.registerTask('docs', ['docs-css', 'lint-docs-css', 'docs-js', 'lint-docs-js', 'clean:docs', 'copy:docs', 'build-glyphicons-data', 'build-customizer']);
|
||||
grunt.registerTask('docs-github', ['jekyll:github', 'htmlmin']);
|
||||
|
||||
grunt.registerTask('prep-release', ['dist', 'docs', 'docs-github', 'compress']);
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
Before opening an issue:
|
||||
|
||||
- [Search for duplicate or closed issues](https://github.com/twbs/bootstrap/issues?utf8=%E2%9C%93&q=is%3Aissue)
|
||||
- [Validate](http://validator.w3.org/nu/) and [lint](https://github.com/twbs/bootlint#in-the-browser) any HTML to avoid common problems
|
||||
- Prepare a [reduced test case](https://css-tricks.com/reduced-test-cases/) for any bugs
|
||||
- Read the [contributing guidelines](https://github.com/twbs/bootstrap/blob/master/CONTRIBUTING.md)
|
||||
|
||||
When asking general "how to" questions:
|
||||
|
||||
- Please do not open an issue here
|
||||
- Instead, ask for help on [StackOverflow, IRC, or Slack](https://github.com/twbs/bootstrap/blob/master/README.md#community)
|
||||
|
||||
When reporting a bug, include:
|
||||
|
||||
- Operating system and version (Windows, Mac OS X, Android, iOS, Win10 Mobile)
|
||||
- Browser and version (Chrome, Firefox, Safari, IE, MS Edge, Opera 15+, Android Browser)
|
||||
- Reduced test cases and potential fixes using [JS Bin](https://jsbin.com)
|
||||
|
||||
When suggesting a feature, include:
|
||||
|
||||
- As much detail as possible for what we should add and why it's important to Bootstrap
|
||||
- Relevant links to prior art, screenshots, or live demos whenever possible
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2011-2016 Twitter, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
# [Bootstrap](http://getbootstrap.com)
|
||||
|
||||
[](https://bootstrap-slack.herokuapp.com)
|
||||

|
||||
[](https://www.npmjs.com/package/bootstrap)
|
||||
[](https://travis-ci.org/twbs/bootstrap)
|
||||
[](https://david-dm.org/twbs/bootstrap#info=devDependencies)
|
||||
[](https://www.nuget.org/packages/Bootstrap)
|
||||
[](https://saucelabs.com/u/bootstrap)
|
||||
|
||||
Bootstrap is a sleek, intuitive, and powerful front-end framework for faster and easier web development, created by [Mark Otto](https://twitter.com/mdo) and [Jacob Thornton](https://twitter.com/fat), and maintained by the [core team](https://github.com/orgs/twbs/people) with the massive support and involvement of the community.
|
||||
|
||||
To get started, check out <http://getbootstrap.com>!
|
||||
|
||||
|
||||
## Table of contents
|
||||
|
||||
* [Quick start](#quick-start)
|
||||
* [Bugs and feature requests](#bugs-and-feature-requests)
|
||||
* [Documentation](#documentation)
|
||||
* [Contributing](#contributing)
|
||||
* [Community](#community)
|
||||
* [Versioning](#versioning)
|
||||
* [Creators](#creators)
|
||||
* [Copyright and license](#copyright-and-license)
|
||||
|
||||
|
||||
## Quick start
|
||||
|
||||
Several quick start options are available:
|
||||
|
||||
* [Download the latest release](https://github.com/twbs/bootstrap/archive/v3.3.7.zip).
|
||||
* Clone the repo: `git clone https://github.com/twbs/bootstrap.git`.
|
||||
* Install with [Bower](http://bower.io): `bower install bootstrap`.
|
||||
* Install with [npm](https://www.npmjs.com): `npm install bootstrap@3`.
|
||||
* Install with [Meteor](https://www.meteor.com): `meteor add twbs:bootstrap`.
|
||||
* Install with [Composer](https://getcomposer.org): `composer require twbs/bootstrap`.
|
||||
|
||||
Read the [Getting started page](http://getbootstrap.com/getting-started/) for information on the framework contents, templates and examples, and more.
|
||||
|
||||
### What's included
|
||||
|
||||
Within the download you'll find the following directories and files, logically grouping common assets and providing both compiled and minified variations. You'll see something like this:
|
||||
|
||||
```
|
||||
bootstrap/
|
||||
├── css/
|
||||
│ ├── bootstrap.css
|
||||
│ ├── bootstrap.css.map
|
||||
│ ├── bootstrap.min.css
|
||||
│ ├── bootstrap.min.css.map
|
||||
│ ├── bootstrap-theme.css
|
||||
│ ├── bootstrap-theme.css.map
|
||||
│ ├── bootstrap-theme.min.css
|
||||
│ └── bootstrap-theme.min.css.map
|
||||
├── js/
|
||||
│ ├── bootstrap.js
|
||||
│ └── bootstrap.min.js
|
||||
└── fonts/
|
||||
├── glyphicons-halflings-regular.eot
|
||||
├── glyphicons-halflings-regular.svg
|
||||
├── glyphicons-halflings-regular.ttf
|
||||
├── glyphicons-halflings-regular.woff
|
||||
└── glyphicons-halflings-regular.woff2
|
||||
```
|
||||
|
||||
We provide compiled CSS and JS (`bootstrap.*`), as well as compiled and minified CSS and JS (`bootstrap.min.*`). CSS [source maps](https://developer.chrome.com/devtools/docs/css-preprocessors) (`bootstrap.*.map`) are available for use with certain browsers' developer tools. Fonts from Glyphicons are included, as is the optional Bootstrap theme.
|
||||
|
||||
|
||||
## Bugs and feature requests
|
||||
|
||||
Have a bug or a feature request? Please first read the [issue guidelines](https://github.com/twbs/bootstrap/blob/master/CONTRIBUTING.md#using-the-issue-tracker) and search for existing and closed issues. If your problem or idea is not addressed yet, [please open a new issue](https://github.com/twbs/bootstrap/issues/new).
|
||||
|
||||
Note that **feature requests must target [Bootstrap v4](https://github.com/twbs/bootstrap/tree/v4-dev),** because Bootstrap v3 is now in maintenance mode and is closed off to new features. This is so that we can focus our efforts on Bootstrap v4.
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
Bootstrap's documentation, included in this repo in the root directory, is built with [Jekyll](http://jekyllrb.com) and publicly hosted on GitHub Pages at <http://getbootstrap.com>. The docs may also be run locally.
|
||||
|
||||
### Running documentation locally
|
||||
|
||||
1. If necessary, [install Jekyll](http://jekyllrb.com/docs/installation) and other Ruby dependencies with `bundle install`.
|
||||
**Note for Windows users:** Read [this unofficial guide](http://jekyll-windows.juthilo.com/) to get Jekyll up and running without problems.
|
||||
2. From the root `/bootstrap` directory, run `bundle exec jekyll serve` in the command line.
|
||||
4. Open `http://localhost:9001` in your browser, and voilà.
|
||||
|
||||
Learn more about using Jekyll by reading its [documentation](http://jekyllrb.com/docs/home/).
|
||||
|
||||
### Documentation for previous releases
|
||||
|
||||
Documentation for v2.3.2 has been made available for the time being at <http://getbootstrap.com/2.3.2/> while folks transition to Bootstrap 3.
|
||||
|
||||
[Previous releases](https://github.com/twbs/bootstrap/releases) and their documentation are also available for download.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
Please read through our [contributing guidelines](https://github.com/twbs/bootstrap/blob/master/CONTRIBUTING.md). Included are directions for opening issues, coding standards, and notes on development.
|
||||
|
||||
Moreover, if your pull request contains JavaScript patches or features, you must include [relevant unit tests](https://github.com/twbs/bootstrap/tree/master/js/tests). All HTML and CSS should conform to the [Code Guide](https://github.com/mdo/code-guide), maintained by [Mark Otto](https://github.com/mdo).
|
||||
|
||||
**Bootstrap v3 is now closed off to new features.** It has gone into maintenance mode so that we can focus our efforts on [Bootstrap v4](https://github.com/twbs/bootstrap/tree/v4-dev), the future of the framework. Pull requests which add new features (rather than fix bugs) should target [Bootstrap v4 (the `v4-dev` git branch)](https://github.com/twbs/bootstrap/tree/v4-dev) instead.
|
||||
|
||||
Editor preferences are available in the [editor config](https://github.com/twbs/bootstrap/blob/master/.editorconfig) for easy use in common text editors. Read more and download plugins at <http://editorconfig.org>.
|
||||
|
||||
|
||||
## Community
|
||||
|
||||
Get updates on Bootstrap's development and chat with the project maintainers and community members.
|
||||
|
||||
* Follow [@getbootstrap on Twitter](https://twitter.com/getbootstrap).
|
||||
* Read and subscribe to [The Official Bootstrap Blog](http://blog.getbootstrap.com).
|
||||
* Join [the official Slack room](https://bootstrap-slack.herokuapp.com).
|
||||
* Chat with fellow Bootstrappers in IRC. On the `irc.freenode.net` server, in the `##bootstrap` channel.
|
||||
* Implementation help may be found at Stack Overflow (tagged [`twitter-bootstrap-3`](https://stackoverflow.com/questions/tagged/twitter-bootstrap-3)).
|
||||
* Developers should use the keyword `bootstrap` on packages which modify or add to the functionality of Bootstrap when distributing through [npm](https://www.npmjs.com/browse/keyword/bootstrap) or similar delivery mechanisms for maximum discoverability.
|
||||
|
||||
|
||||
## Versioning
|
||||
|
||||
For transparency into our release cycle and in striving to maintain backward compatibility, Bootstrap is maintained under [the Semantic Versioning guidelines](http://semver.org/). Sometimes we screw up, but we'll adhere to those rules whenever possible.
|
||||
|
||||
See [the Releases section of our GitHub project](https://github.com/twbs/bootstrap/releases) for changelogs for each release version of Bootstrap. Release announcement posts on [the official Bootstrap blog](http://blog.getbootstrap.com) contain summaries of the most noteworthy changes made in each release.
|
||||
|
||||
|
||||
## Creators
|
||||
|
||||
**Mark Otto**
|
||||
|
||||
* <https://twitter.com/mdo>
|
||||
* <https://github.com/mdo>
|
||||
|
||||
**Jacob Thornton**
|
||||
|
||||
* <https://twitter.com/fat>
|
||||
* <https://github.com/fat>
|
||||
|
||||
|
||||
## Copyright and license
|
||||
|
||||
Code and documentation copyright 2011-2016 Twitter, Inc. Code released under [the MIT license](https://github.com/twbs/bootstrap/blob/master/LICENSE). Docs released under [Creative Commons](https://github.com/twbs/bootstrap/blob/master/docs/LICENSE).
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "bootstrap",
|
||||
"description": "The most popular front-end framework for developing responsive, mobile first projects on the web.",
|
||||
"keywords": [
|
||||
"css",
|
||||
"js",
|
||||
"less",
|
||||
"mobile-first",
|
||||
"responsive",
|
||||
"front-end",
|
||||
"framework",
|
||||
"web"
|
||||
],
|
||||
"homepage": "http://getbootstrap.com",
|
||||
"license": "MIT",
|
||||
"moduleType": "globals",
|
||||
"main": [
|
||||
"less/bootstrap.less",
|
||||
"dist/js/bootstrap.js"
|
||||
],
|
||||
"ignore": [
|
||||
"/.*",
|
||||
"_config.yml",
|
||||
"CNAME",
|
||||
"composer.json",
|
||||
"CONTRIBUTING.md",
|
||||
"docs",
|
||||
"js/tests",
|
||||
"test-infra"
|
||||
],
|
||||
"dependencies": {
|
||||
"jquery": "1.9.1 - 3"
|
||||
}
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
/*!
|
||||
* Bootstrap v3.3.7 (http://getbootstrap.com)
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
*/
|
||||
.btn-default,
|
||||
.btn-primary,
|
||||
.btn-success,
|
||||
.btn-info,
|
||||
.btn-warning,
|
||||
.btn-danger {
|
||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, .2);
|
||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.btn-default:active,
|
||||
.btn-primary:active,
|
||||
.btn-success:active,
|
||||
.btn-info:active,
|
||||
.btn-warning:active,
|
||||
.btn-danger:active,
|
||||
.btn-default.active,
|
||||
.btn-primary.active,
|
||||
.btn-success.active,
|
||||
.btn-info.active,
|
||||
.btn-warning.active,
|
||||
.btn-danger.active {
|
||||
-webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
|
||||
box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
|
||||
}
|
||||
.btn-default.disabled,
|
||||
.btn-primary.disabled,
|
||||
.btn-success.disabled,
|
||||
.btn-info.disabled,
|
||||
.btn-warning.disabled,
|
||||
.btn-danger.disabled,
|
||||
.btn-default[disabled],
|
||||
.btn-primary[disabled],
|
||||
.btn-success[disabled],
|
||||
.btn-info[disabled],
|
||||
.btn-warning[disabled],
|
||||
.btn-danger[disabled],
|
||||
fieldset[disabled] .btn-default,
|
||||
fieldset[disabled] .btn-primary,
|
||||
fieldset[disabled] .btn-success,
|
||||
fieldset[disabled] .btn-info,
|
||||
fieldset[disabled] .btn-warning,
|
||||
fieldset[disabled] .btn-danger {
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-default .badge,
|
||||
.btn-primary .badge,
|
||||
.btn-success .badge,
|
||||
.btn-info .badge,
|
||||
.btn-warning .badge,
|
||||
.btn-danger .badge {
|
||||
text-shadow: none;
|
||||
}
|
||||
.btn:active,
|
||||
.btn.active {
|
||||
background-image: none;
|
||||
}
|
||||
.btn-default {
|
||||
text-shadow: 0 1px 0 #fff;
|
||||
background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%);
|
||||
background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0));
|
||||
background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #dbdbdb;
|
||||
border-color: #ccc;
|
||||
}
|
||||
.btn-default:hover,
|
||||
.btn-default:focus {
|
||||
background-color: #e0e0e0;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-default:active,
|
||||
.btn-default.active {
|
||||
background-color: #e0e0e0;
|
||||
border-color: #dbdbdb;
|
||||
}
|
||||
.btn-default.disabled,
|
||||
.btn-default[disabled],
|
||||
fieldset[disabled] .btn-default,
|
||||
.btn-default.disabled:hover,
|
||||
.btn-default[disabled]:hover,
|
||||
fieldset[disabled] .btn-default:hover,
|
||||
.btn-default.disabled:focus,
|
||||
.btn-default[disabled]:focus,
|
||||
fieldset[disabled] .btn-default:focus,
|
||||
.btn-default.disabled.focus,
|
||||
.btn-default[disabled].focus,
|
||||
fieldset[disabled] .btn-default.focus,
|
||||
.btn-default.disabled:active,
|
||||
.btn-default[disabled]:active,
|
||||
fieldset[disabled] .btn-default:active,
|
||||
.btn-default.disabled.active,
|
||||
.btn-default[disabled].active,
|
||||
fieldset[disabled] .btn-default.active {
|
||||
background-color: #e0e0e0;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #245580;
|
||||
}
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus {
|
||||
background-color: #265a88;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-primary:active,
|
||||
.btn-primary.active {
|
||||
background-color: #265a88;
|
||||
border-color: #245580;
|
||||
}
|
||||
.btn-primary.disabled,
|
||||
.btn-primary[disabled],
|
||||
fieldset[disabled] .btn-primary,
|
||||
.btn-primary.disabled:hover,
|
||||
.btn-primary[disabled]:hover,
|
||||
fieldset[disabled] .btn-primary:hover,
|
||||
.btn-primary.disabled:focus,
|
||||
.btn-primary[disabled]:focus,
|
||||
fieldset[disabled] .btn-primary:focus,
|
||||
.btn-primary.disabled.focus,
|
||||
.btn-primary[disabled].focus,
|
||||
fieldset[disabled] .btn-primary.focus,
|
||||
.btn-primary.disabled:active,
|
||||
.btn-primary[disabled]:active,
|
||||
fieldset[disabled] .btn-primary:active,
|
||||
.btn-primary.disabled.active,
|
||||
.btn-primary[disabled].active,
|
||||
fieldset[disabled] .btn-primary.active {
|
||||
background-color: #265a88;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-success {
|
||||
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);
|
||||
background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641));
|
||||
background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #3e8f3e;
|
||||
}
|
||||
.btn-success:hover,
|
||||
.btn-success:focus {
|
||||
background-color: #419641;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-success:active,
|
||||
.btn-success.active {
|
||||
background-color: #419641;
|
||||
border-color: #3e8f3e;
|
||||
}
|
||||
.btn-success.disabled,
|
||||
.btn-success[disabled],
|
||||
fieldset[disabled] .btn-success,
|
||||
.btn-success.disabled:hover,
|
||||
.btn-success[disabled]:hover,
|
||||
fieldset[disabled] .btn-success:hover,
|
||||
.btn-success.disabled:focus,
|
||||
.btn-success[disabled]:focus,
|
||||
fieldset[disabled] .btn-success:focus,
|
||||
.btn-success.disabled.focus,
|
||||
.btn-success[disabled].focus,
|
||||
fieldset[disabled] .btn-success.focus,
|
||||
.btn-success.disabled:active,
|
||||
.btn-success[disabled]:active,
|
||||
fieldset[disabled] .btn-success:active,
|
||||
.btn-success.disabled.active,
|
||||
.btn-success[disabled].active,
|
||||
fieldset[disabled] .btn-success.active {
|
||||
background-color: #419641;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-info {
|
||||
background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
|
||||
background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2));
|
||||
background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #28a4c9;
|
||||
}
|
||||
.btn-info:hover,
|
||||
.btn-info:focus {
|
||||
background-color: #2aabd2;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-info:active,
|
||||
.btn-info.active {
|
||||
background-color: #2aabd2;
|
||||
border-color: #28a4c9;
|
||||
}
|
||||
.btn-info.disabled,
|
||||
.btn-info[disabled],
|
||||
fieldset[disabled] .btn-info,
|
||||
.btn-info.disabled:hover,
|
||||
.btn-info[disabled]:hover,
|
||||
fieldset[disabled] .btn-info:hover,
|
||||
.btn-info.disabled:focus,
|
||||
.btn-info[disabled]:focus,
|
||||
fieldset[disabled] .btn-info:focus,
|
||||
.btn-info.disabled.focus,
|
||||
.btn-info[disabled].focus,
|
||||
fieldset[disabled] .btn-info.focus,
|
||||
.btn-info.disabled:active,
|
||||
.btn-info[disabled]:active,
|
||||
fieldset[disabled] .btn-info:active,
|
||||
.btn-info.disabled.active,
|
||||
.btn-info[disabled].active,
|
||||
fieldset[disabled] .btn-info.active {
|
||||
background-color: #2aabd2;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-warning {
|
||||
background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
|
||||
background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316));
|
||||
background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #e38d13;
|
||||
}
|
||||
.btn-warning:hover,
|
||||
.btn-warning:focus {
|
||||
background-color: #eb9316;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-warning:active,
|
||||
.btn-warning.active {
|
||||
background-color: #eb9316;
|
||||
border-color: #e38d13;
|
||||
}
|
||||
.btn-warning.disabled,
|
||||
.btn-warning[disabled],
|
||||
fieldset[disabled] .btn-warning,
|
||||
.btn-warning.disabled:hover,
|
||||
.btn-warning[disabled]:hover,
|
||||
fieldset[disabled] .btn-warning:hover,
|
||||
.btn-warning.disabled:focus,
|
||||
.btn-warning[disabled]:focus,
|
||||
fieldset[disabled] .btn-warning:focus,
|
||||
.btn-warning.disabled.focus,
|
||||
.btn-warning[disabled].focus,
|
||||
fieldset[disabled] .btn-warning.focus,
|
||||
.btn-warning.disabled:active,
|
||||
.btn-warning[disabled]:active,
|
||||
fieldset[disabled] .btn-warning:active,
|
||||
.btn-warning.disabled.active,
|
||||
.btn-warning[disabled].active,
|
||||
fieldset[disabled] .btn-warning.active {
|
||||
background-color: #eb9316;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-danger {
|
||||
background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
|
||||
background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a));
|
||||
background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #b92c28;
|
||||
}
|
||||
.btn-danger:hover,
|
||||
.btn-danger:focus {
|
||||
background-color: #c12e2a;
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
.btn-danger:active,
|
||||
.btn-danger.active {
|
||||
background-color: #c12e2a;
|
||||
border-color: #b92c28;
|
||||
}
|
||||
.btn-danger.disabled,
|
||||
.btn-danger[disabled],
|
||||
fieldset[disabled] .btn-danger,
|
||||
.btn-danger.disabled:hover,
|
||||
.btn-danger[disabled]:hover,
|
||||
fieldset[disabled] .btn-danger:hover,
|
||||
.btn-danger.disabled:focus,
|
||||
.btn-danger[disabled]:focus,
|
||||
fieldset[disabled] .btn-danger:focus,
|
||||
.btn-danger.disabled.focus,
|
||||
.btn-danger[disabled].focus,
|
||||
fieldset[disabled] .btn-danger.focus,
|
||||
.btn-danger.disabled:active,
|
||||
.btn-danger[disabled]:active,
|
||||
fieldset[disabled] .btn-danger:active,
|
||||
.btn-danger.disabled.active,
|
||||
.btn-danger[disabled].active,
|
||||
fieldset[disabled] .btn-danger.active {
|
||||
background-color: #c12e2a;
|
||||
background-image: none;
|
||||
}
|
||||
.thumbnail,
|
||||
.img-thumbnail {
|
||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.dropdown-menu > li > a:hover,
|
||||
.dropdown-menu > li > a:focus {
|
||||
background-color: #e8e8e8;
|
||||
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
||||
background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
|
||||
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.dropdown-menu > .active > a,
|
||||
.dropdown-menu > .active > a:hover,
|
||||
.dropdown-menu > .active > a:focus {
|
||||
background-color: #2e6da4;
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.navbar-default {
|
||||
background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%);
|
||||
background-image: -o-linear-gradient(top, #fff 0%, #f8f8f8 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8));
|
||||
background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-radius: 4px;
|
||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.navbar-default .navbar-nav > .open > a,
|
||||
.navbar-default .navbar-nav > .active > a {
|
||||
background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
|
||||
background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2));
|
||||
background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
|
||||
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.navbar-brand,
|
||||
.navbar-nav > li > a {
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, .25);
|
||||
}
|
||||
.navbar-inverse {
|
||||
background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%);
|
||||
background-image: -o-linear-gradient(top, #3c3c3c 0%, #222 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222));
|
||||
background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
||||
background-repeat: repeat-x;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.navbar-inverse .navbar-nav > .open > a,
|
||||
.navbar-inverse .navbar-nav > .active > a {
|
||||
background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);
|
||||
background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f));
|
||||
background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
|
||||
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
|
||||
}
|
||||
.navbar-inverse .navbar-brand,
|
||||
.navbar-inverse .navbar-nav > li > a {
|
||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, .25);
|
||||
}
|
||||
.navbar-static-top,
|
||||
.navbar-fixed-top,
|
||||
.navbar-fixed-bottom {
|
||||
border-radius: 0;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.navbar .navbar-nav .open .dropdown-menu > .active > a,
|
||||
.navbar .navbar-nav .open .dropdown-menu > .active > a:hover,
|
||||
.navbar .navbar-nav .open .dropdown-menu > .active > a:focus {
|
||||
color: #fff;
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
}
|
||||
.alert {
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, .2);
|
||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
|
||||
}
|
||||
.alert-success {
|
||||
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
|
||||
background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc));
|
||||
background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #b2dba1;
|
||||
}
|
||||
.alert-info {
|
||||
background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
|
||||
background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0));
|
||||
background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #9acfea;
|
||||
}
|
||||
.alert-warning {
|
||||
background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
|
||||
background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0));
|
||||
background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #f5e79e;
|
||||
}
|
||||
.alert-danger {
|
||||
background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
|
||||
background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3));
|
||||
background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #dca7a7;
|
||||
}
|
||||
.progress {
|
||||
background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
|
||||
background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5));
|
||||
background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar {
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-success {
|
||||
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);
|
||||
background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44));
|
||||
background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-info {
|
||||
background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
|
||||
background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5));
|
||||
background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-warning {
|
||||
background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
|
||||
background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f));
|
||||
background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-danger {
|
||||
background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);
|
||||
background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c));
|
||||
background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.progress-bar-striped {
|
||||
background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
||||
background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
||||
background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
||||
}
|
||||
.list-group {
|
||||
border-radius: 4px;
|
||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
||||
}
|
||||
.list-group-item.active,
|
||||
.list-group-item.active:hover,
|
||||
.list-group-item.active:focus {
|
||||
text-shadow: 0 -1px 0 #286090;
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #2b669a;
|
||||
}
|
||||
.list-group-item.active .badge,
|
||||
.list-group-item.active:hover .badge,
|
||||
.list-group-item.active:focus .badge {
|
||||
text-shadow: none;
|
||||
}
|
||||
.panel {
|
||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
|
||||
}
|
||||
.panel-default > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
||||
background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
|
||||
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-primary > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-success > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
|
||||
background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6));
|
||||
background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-info > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
|
||||
background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3));
|
||||
background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-warning > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
|
||||
background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc));
|
||||
background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.panel-danger > .panel-heading {
|
||||
background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
|
||||
background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc));
|
||||
background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.well {
|
||||
background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
|
||||
background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
|
||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5));
|
||||
background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);
|
||||
background-repeat: repeat-x;
|
||||
border-color: #dcdcdc;
|
||||
-webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
|
||||
}
|
||||
/*# sourceMappingURL=bootstrap-theme.css.map */
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user