Merge pull request #3537 from modoboa/cleanup/remove_old_interface

Remove legacy interface
This commit is contained in:
Antoine Nguyen
2025-06-08 15:50:44 +02:00
committed by GitHub
1876 changed files with 18991 additions and 383531 deletions
@@ -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',
},
]
+19 -41
View File
@@ -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()
+18
View File
@@ -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):
+2 -1
View File
@@ -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)
+42 -7
View File
@@ -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
+164 -3
View File
@@ -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
View File
@@ -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,
)
-29
View File
@@ -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",
]
-679
View File
@@ -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"))
-138
View File
@@ -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
-481
View File
@@ -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"))
-22
View File
@@ -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"]
-34
View File
@@ -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
-42
View File
@@ -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
View File
@@ -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()
-20
View File
@@ -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.
+1 -46
View File
@@ -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"
)
+1 -1
View File
@@ -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:
+2 -1
View File
@@ -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()
-13
View File
@@ -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
-49
View File
@@ -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;
}
-724
View File
@@ -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">&times;</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">&times;</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>
-12
View File
@@ -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>
-332
View File
@@ -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
View File
@@ -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))
+64 -160
View File
@@ -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.",
)
+45 -389
View File
@@ -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)
-20
View File
@@ -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)
+48 -73
View File
@@ -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
# )
+35 -132
View File
@@ -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)
+13 -9
View File
@@ -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))
-9
View File
@@ -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)
+6 -7
View File
@@ -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")
-49
View File
@@ -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)
-79
View File
@@ -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"),
]
-15
View File
@@ -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",
]
-122
View File
@@ -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
-43
View File
@@ -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
-307
View File
@@ -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"
)
-88
View File
@@ -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)
-238
View File
@@ -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
-79
View File
@@ -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)
-64
View File
@@ -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
View File
@@ -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
}
-5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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']);
};
-22
View File
@@ -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
View File
@@ -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
View File
@@ -1,142 +0,0 @@
# [Bootstrap](http://getbootstrap.com)
[![Slack](https://bootstrap-slack.herokuapp.com/badge.svg)](https://bootstrap-slack.herokuapp.com)
![Bower version](https://img.shields.io/bower/v/bootstrap.svg)
[![npm version](https://img.shields.io/npm/v/bootstrap.svg)](https://www.npmjs.com/package/bootstrap)
[![Build Status](https://img.shields.io/travis/twbs/bootstrap/master.svg)](https://travis-ci.org/twbs/bootstrap)
[![devDependency Status](https://img.shields.io/david/dev/twbs/bootstrap.svg)](https://david-dm.org/twbs/bootstrap#info=devDependencies)
[![NuGet](https://img.shields.io/nuget/v/bootstrap.svg)](https://www.nuget.org/packages/Bootstrap)
[![Selenium Test Status](https://saucelabs.com/browser-matrix/bootstrap.svg)](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
View File
@@ -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