diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..9e4c369c7 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: python +python: + - "2.7" + +env: + - DJANGO=1.5 + +install: + - pip install -q Django==$DJANGO --use-mirrors + - python setup.py -q install + - pip install -q MySQL-python --use-mirrors + - pip install -q pexpect --use-mirrors + +before_script: + - mysql -e 'create database modoboa_test;' + +script: + - PYTHONPATH=tests python -m unittest install_from_scratch.DeployTest.test_silent diff --git a/README.rst b/README.rst index 956639ad1..c9631545a 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ Modoboa ####### -|latest-version| |downloads| +|travis| |latest-version| |downloads| Modoboa is a mail hosting and management platform including a modern and simplified Web User Interface. It provides useful components such @@ -26,12 +26,12 @@ frameworks. Main features ************* - * Administration panel - * `Amavisd-new `_ frontend - * Webmail - * Per-user Sieve filters - * Autoreply messages for Postfix - * Graphical statistics about email traffic +* Administration panel +* `Amavisd-new `_ frontend +* Webmail +* Per-user Sieve filters +* Autoreply messages for Postfix +* Graphical statistics about email traffic ************* Documentation @@ -54,10 +54,10 @@ External code The following external libraries are provided with Modoboa: -* jQuery version 1.9.1 (http://www.jquery.org/) -* jQuery-UI 1.10+ (http://jqueryui.com/) -* Bootstrap, from Twitter version 2.3.1 (http://twitter.github.com/bootstrap/) -* Bootstrap datepicker (https://bitbucket.org/eternicode/bootstrap-datepicker) +* `jQuery version 1.9.1 `_ +* `jQuery-UI 1.10+ `_ +* `Bootstrap, from Twitter version 2.3.2 `_ +* `Bootstrap datetimepicker `_ .. |latest-version| image:: https://pypip.in/v/modoboa/badge.png :alt: Latest version on Pypi @@ -65,9 +65,9 @@ The following external libraries are provided with Modoboa: .. |downloads| image:: https://pypip.in/d/modoboa/badge.png :alt: Downloads from Pypi :target: https://crate.io/packages/modoboa/ - +.. |travis| image:: https://travis-ci.org/tonioo/modoboa.png?branch=master + :target: https://travis-ci.org/tonioo/modoboa .. image:: https://d2weczhvl823v0.cloudfront.net/tonioo/modoboa/trend.png :alt: Bitdeli badge :target: https://bitdeli.com/free - diff --git a/doc/extending/events.rst b/doc/extending/events.rst index 76bcf0493..8ffa83b55 100644 --- a/doc/extending/events.rst +++ b/doc/extending/events.rst @@ -11,10 +11,10 @@ Introduction Modoboa provides a simple API to interact with events. It understands two kinds of events: -* Those that return a value -* Those that return nothing +* Those returning a value +* Those returning nothing -Listening to a specific event is achieved as follow:: +Listening to a specific event is achieved as follows:: from modoboa.lib import events @@ -41,12 +41,32 @@ To stop listening to as specific event, you must use the The parameters are the same than those used with ``register``. +To unregister all events declared by a specific extension, use the +``unregister_extension`` function:: + + events.unregister_extension([name]) + +``name`` is the extension's name but it is optional. Leave it empty to +let the function guess the name. + Read further to get a complete list and description of all available events. **************** Supported events **************** +AccountAutoCreated +================== + +Raised when a new account is automatically created (example: LDAP +synchronization). + +*Callback prototype*:: + + def callback(account): pass + +* ``account`` is the newly created account (``User`` instance) + AccountCreated ============== @@ -69,6 +89,32 @@ Raised when an existing account is deleted. * ``account`` is the account that is going to be deleted * ``byuser`` is the adminstrator deleting ``account`` + +AccountExported +=============== + +Raised when an account is exported to CSV. + +*Callback prototype*:: + + def callback(account): pass + +* ``account`` is the account being exported + +Must return a list of values to include in the export. + +AccountImported +=============== + +Raised when an account is imported from CSV. + +*Callback prototype*:: + + def callback(user, account, row): pass + +* ``user`` is the user importing the account +* ``account`` is the account being imported +* ``row`` is a list containing what remains from the CSV definition AccountModified =============== @@ -83,18 +129,6 @@ Raised when an existing account is modified. * ``newaccount`` is the account after the modification -ExtraAccountActions -=================== - -Raised when the account list is displayed. Let developers define new -actions to act on a specific user. - -*Callback prototype*:: - - def callback(account): pass - -* ``account`` is the account being listed - AdminMenuDisplay ================ @@ -127,6 +161,21 @@ Raised just before a user tries to create a new object. Return ``True`` or ``False`` to indicate if this user can respectively create or not create a new ``Domain`` object. +CheckDomainName +=============== + +Raised before the unicity of a domain name is checked. By default, +modoboa prevents duplicate names between domains and domain aliases +but extensions have the possibility to extend this rule using this +event. + +*Callback prototype*:: + + def callback(): pass + +Must return a list of 2uple, each one containing a model class and an +associated label. + CheckExtraAccountForm ===================== @@ -144,52 +193,6 @@ this account is concerned by a specific form. Callbacks listening to this event must return a list containing one Boolean. -CreateDomain -============ - -Raised when a new domain is created. - -*Callback prototype*:: - - def callback(user, domain): pass - -* ``user`` corresponds to the ``User`` object creating the domain (its owner) -* ``domain`` is a ``Domain`` instance - -CreateMailbox -============= - -Raised when a new mailbox is created. - -*Callback prototype*:: - - def callback(user, mailbox): pass - -* ``user`` is the new mailbox's owner (``User`` instance) -* ``mailbox`` is the new mailbox (``Mailbox`` instance) - -DeleteDomain -============ - -Raised when an existing domain is about to be deleted. - -*Callback prototype*:: - - def callback(domain): pass - -* ``domain`` is a ``Domain`` instance - -DeleteMailbox -============= - -Raised when an existing mailbox is about to be deleted. - -*Callback prototype*:: - - def callback(mailbox): pass - -* ``mailbox`` is a ``Mailbox`` instance - DomainAliasCreated ================== @@ -213,6 +216,29 @@ Raised when an existing domain alias is about to be deleted. * ``domain_alias`` is a ``DomainAlias`` instance +DomainCreated +============= + +Raised when a new domain is created. + +*Callback prototype*:: + + def callback(user, domain): pass + +* ``user`` corresponds to the ``User`` object creating the domain (its owner) +* ``domain`` is a ``Domain`` instance + +DomainDeleted +============= + +Raised when an existing domain is about to be deleted. + +*Callback prototype*:: + + def callback(domain): pass + +* ``domain`` is a ``Domain`` instance + DomainModified ============== @@ -223,7 +249,20 @@ Raised when a domain has been modified. def callback(domain): pass * ``domain`` is the modified ``Domain`` instance, it contains an extra - ``oldname`` field which contains the old domain name + ``oldname`` field which contains the old name + +DomainOwnershipRemoved +====================== + +Raised before the ownership of a domain is removed from its original +creator. + +*Callback prototype*:: + + def callback(owner, domain): pass + +* ``owner`` is the original creator +* ``domain`` is the ``Domain`` instance being modified ExtDisabled =========== @@ -247,6 +286,18 @@ Raised just after an extension has been activated. * ``extension`` is an ``Extension`` instance +ExtraAccountActions +=================== + +Raised when the account list is displayed. Let developers define new +actions to act on a specific user. + +*Callback prototype*:: + + def callback(account): pass + +* ``account`` is the account being listed + ExtraAccountForm ================ @@ -289,6 +340,37 @@ Let extensions add extra content into the admin panel. Callbacks listening to this event must return a list of string. +ExtraDomainEntries +================== + +Raised to request extra entries to display inside the *domains* +listing. + +*Callback prototype*:: + + def callback(user, domfilter, searchquery, **extrafilters): pass + +* ``user`` is the ``User`` instance corresponding to the currently + logged in user +* ``domfilter`` is a string indicating which domain type the user needs +* ``searchquery`` is a string containing a search query +* ``extrafilters`` is a set of keyword arguments that may contain additional filters + +Must return a valid ``QuerySet``. + +ExtraDomainFilters +================== + +Raised to request extra filters for the *domains* listing page. For +example, the *postfix_relay_domains* extension let users filter +entries based on service types. + +*Callback prototype*:: + + def callback(): pass + +Must return a list of valid filter names (string). + ExtraDomainForm =============== @@ -304,6 +386,78 @@ tabs). * ``domain`` is the domain beeing modified (``Domain`` instance) +Callbacks listening to the event must return a list of dictionnaries, +each one must contain at least three keys:: + + {"id" : "", + "title" : "", + "cls" : TheFormClassName} + +ExtraDomainImportHelp +===================== + +Raised to request extra help text to display inside the domain import +form. + +*Callback prototype*:: + + def callback(): pass + +Must return a list a string. + +ExtraDomainMenuEntries +====================== + +Raised to request extra entries to include in the left menu of the +*domains* listing page. + +*Callback prototype*:: + + def callback(user): pass + +* ``user`` is the ``User`` instance corresponding to the currently + logged in user + +Must return a list of dictionaries. Each dictionary must contain at +least three keys:: + + {"name": "", + "label": "", + "url": ""} + +.. _extraformfields: + +ExtraFormFields +=============== + +Raised to request extra fields to include in a django form. + +*Callback prototype*:: + + def callback(form_name, instance=None): pass + +* ``form_name`` is a string used to distinguish a specific form +* ``instance`` is a django model instance related to ``form_name`` + +Must return a list of 2uple, each one containing the following +information:: + + ('field name', ) + +ExtraRelayDomainForm +==================== + +Let extensions add new forms to the relay domain edition form (the one +with tabs). + +*Callback prototype*:: + + def callback(user, rdomain): pass + +* ``user`` is the ``User`` instance corresponding to the currently + logged in user +* ``rdomain`` is the relay domain being modified (``RelayDomain`` instance) + Callbacks listening to the event must return a list of dictionnaries, each one must contain at least three keys:: @@ -344,6 +498,24 @@ forms. * ``domain`` is the ``Domain`` instance beeing modified +* ``instances`` is a dictionnary where the callback will add + information needed to fill a specific form + +FillRelayDomainInstances +======================== + +When a relay domain is being modified, this event is raised to fill extra +forms. + +*Callback prototype*:: + + def callback(user, rdomain, instances): pass + +* ``user`` is the ``User`` instance corresponding to the currently + logged in user + +* ``rdomain`` is the ``RelayDomain`` instance being modified + * ``instances`` is a dictionnary where the callback will add information needed to fill a specific form @@ -364,6 +536,73 @@ Some places in the interface let plugins add their own announcement Callbacks listening to this event must return a list of string. +GetDomainActions +================ + +Raised to request the list of actions available for the *domains* +listing entry being displayed. + +*Callback prototype*:: + + def callback(user, rdomain): pass + +* ``user`` is the ``User`` instance corresponding to the currently + logged in user +* ``rdomain`` is the ``RelayDomain`` instance being displayed + +Must return a list of dictionaries, each dictionary containing at +least the following entries:: + + {"name": "", + "url": "", + "title": "", + "img": ""} + +GetDomainModifyLink +=================== + +Raised to request the modification url of the *domains* listing entry +being displayed. + +*Callback prototype*:: + + def callback(domain): pass + +* ``domain`` is a model instance (``RelayDomain`` for example) + +Must return a dictionary containing at least the following entry:: + + {'url': ''} + +GetExtraLimitTemplates +====================== + +Raised to request extra limit templates. For example, the +*postfix_relay_domains* extension define a template to limit the +number of relay domains an administrator can create. + +*Callback prototype*:: + + def callback(): pass + +Must return a list of set. Each set must contain at least three entries:: + + [('', '', '')] + +GetExtraParameters +================== + +Raised to request extra parameters for a given parameters form. + +*Callback prototype*:: + + def callback(application, level): pass + +* ``application`` is the name of the form's application (ie. admin, amavis, etc.) +* ``level`` is the form's level: ``A`` for admin or ``U`` for user + +Must return a dictionary. Each entry must be a valid Django form field. + .. _getextraroles: GetExtraRoles @@ -397,6 +636,27 @@ template but need javascript stuff. Callbacks listening to this event must return a list of string. +ImportObject +============ + +Raised to request the function handling an object being imported from CSV. + +*Callback prototype*:: + + def callback(objtype): pass + +``objtype`` is the type of object being imported + +Must return a list of function. A valid import function must respect +the following prototype:: + + def import_function(user, row, formopts): pass + +* ``user`` is the ``User`` instance corresponding to the currently + logged in user +* ``row`` is a string containing the object's definition (CSV format) +* ``formopts`` is a dictionary that may contain options + MailboxAliasCreated =================== @@ -420,8 +680,31 @@ Raised when an existing mailbox alias is about to be deleted. * ``mailbox_alias`` is an ``Alias`` instance -ModifyMailbox -============= +MailboxCreated +============== + +Raised when a new mailbox is created. + +*Callback prototype*:: + + def callback(user, mailbox): pass + +* ``user`` is the new mailbox's owner (``User`` instance) +* ``mailbox`` is the new mailbox (``Mailbox`` instance) + +MailboxDeleted +============== + +Raised when an existing mailbox is about to be deleted. + +*Callback prototype*:: + + def callback(mailbox): pass + +* ``mailbox`` is a ``Mailbox`` instance + +MailboxModified +=============== Raised when an existing mailbox is modified. @@ -506,3 +789,103 @@ contain at least the following keys:: {"name" : "a_name_without_spaces", "label" : _("The menu label"), "url" : reverse("your_view")} + +RelayDomainAliasCreated +======================= + +Raised when a new relay domain alias is created. + +*Callback prototype*:: + + def callback(user, rdomain_alias): pass + +* ``user`` is the new relay domain alias owner (``User`` instance) +* ``rdomain_alias`` is the new relay domain alias (``DomainAlias`` instance) + +RelayDomainAliasDeleted +======================= + +Raised when an existing relay domain alias is about to be deleted. + +*Callback prototype*:: + + def callback(rdomain_alias): pass + +* ``rdomain_alias`` is a ``RelayDomainAlias`` instance + +RelayDomainCreated +================== + +Raised when a new relay domain is created. + +*Callback prototype*:: + + def callback(user, rdomain): pass + +* ``user`` corresponds to the ``User`` object creating the relay domain (its owner) +* ``rdomain`` is a ``RelayDomain`` instance + +RelayDomainDeleted +================== + +Raised when an existing relay domain is about to be deleted. + +*Callback prototype*:: + + def callback(rdomain): pass + +* ``rdomain`` is a ``RelayDomain`` instance + +RelayDomainModified +=================== + +Raised when a relay domain has been modified. + +*Callback prototype*:: + + def callback(rdomain): pass + +* ``rdomain`` is the modified ``RelayDomain`` instance, it contains an + extra ``oldname`` field which contains the old name + +RoleChanged +=========== + +Raised when the role of an account is about to be changed. + +*Callback prototype*:: + + def callback(account, role): pass + +* ``account`` is the account being modified +* ``role`` is the new role (string) + +SaveExtraFormFields +=================== + +Raised to save extra fields declared using :ref:`extraformfields`. + +*Callback prototype*:: + + def callback(form_name, instance, values): pass + +* ``form_name`` is a string used to distinguish a specific form +* ``instance`` is a django model instance related to ``form_name`` +* ``values`` is a dictionary containing the form's values + +UserCanSetRole +============== + +Raised to check if a user is allowed to set a given role to an +account. + +*Callback prototype*:: + + def callback(account, role): pass + +* ``user`` is the ``User`` instance corresponding to the currently + logged in user +* ``role`` is the role ``user`` tries to set + +Must return a list containing ``True`` or ``False`` to indicate if +this user can is allowed to set ``role``. diff --git a/doc/getting_started/plugins.rst b/doc/getting_started/plugins.rst index cb47c879f..1bc6c2def 100644 --- a/doc/getting_started/plugins.rst +++ b/doc/getting_started/plugins.rst @@ -32,6 +32,20 @@ associated value to -1. Default limits applied to new administrators can be changed through the *Modoboa > Parameters > Limits* page. +***************************** +Postfix relay domains support +***************************** + +This plugin adds the support for relay domains using postfix. This +feature is useful when the MTA managed by Modoboa is not the final +destination of one or many domains. + +If activated, two new objects will be available from the *domains* +listing page: *relay domain* and *relay domain alias*. + +This extension is compatible with the *amavis* and *limits* +ones. Resellers will be able to create both new objects. + .. _amavis_frontend: ******************** diff --git a/doc/getting_started/upgrade.rst b/doc/getting_started/upgrade.rst index dfde23e49..0e968d949 100644 --- a/doc/getting_started/upgrade.rst +++ b/doc/getting_started/upgrade.rst @@ -53,6 +53,8 @@ Finally, follow the common procedure:: 1.1.0: FIXME ============ +#. Add ``'modoboa.extensions.postfix_relay_domains'`` to ``MODOBOA_APPS`` + Due to code refactoring, some modifications need to be done into :file:`settings.py`: diff --git a/doc/integration/imap_and_smtp.rst b/doc/integration/imap_and_smtp.rst index 521371b0c..0eef997d5 100644 --- a/doc/integration/imap_and_smtp.rst +++ b/doc/integration/imap_and_smtp.rst @@ -138,11 +138,11 @@ MySQL users default_pass_scheme = CRYPT - password_query = SELECT email AS user, password FROM admin_user WHERE email='%u' and is_active=1 + password_query = SELECT email AS user, password FROM core_user WHERE email='%u' and is_active=1 user_query = SELECT '/%d/%n' AS home, as uid, as gid, concat('*:bytes=', mb.quota, 'M') AS quota_rule FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id WHERE mb.address='%n' AND dom.name='%d' - iterate_query = SELECT email AS username FROM admin_user + iterate_query = SELECT email AS username FROM core_user .. _dovecot_pg_queries: @@ -157,11 +157,11 @@ PostgreSQL users default_pass_scheme = CRYPT - password_query = SELECT email AS user, password FROM admin_user WHERE email='%u' and is_active + password_query = SELECT email AS user, password FROM core_user WHERE email='%u' and is_active user_query = SELECT '/%d/%n' AS home, as uid, as gid, '*:bytes=' || mb.quota || 'M' AS quota_rule FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id WHERE mb.address='%n' AND dom.name='%d' - iterate_query = SELECT email AS username FROM admin_user + iterate_query = SELECT email AS username FROM core_user SQLite users ------------ @@ -174,11 +174,11 @@ SQLite users default_pass_scheme = CRYPT - password_query = SELECT email AS user, password FROM admin_user WHERE email='%u' and is_active=1 + password_query = SELECT email AS user, password FROM core_user WHERE email='%u' and is_active=1 user_query = SELECT '/%d/%n' AS home, as uid, as gid, ('*:bytes=' || mb.quota || 'M') AS quota_rule FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id WHERE mb.address='%n' AND dom.name='%d' - iterate_query = SELECT email AS username FROM admin_user + iterate_query = SELECT email AS username FROM core_user .. note:: @@ -325,7 +325,7 @@ work for Modoboa. You should use the following one instead: DECLARE mboxid INTEGER; BEGIN - SELECT admin_mailbox.id INTO STRICT mboxid FROM admin_mailbox INNER JOIN admin_user ON admin_mailbox.user_id=admin_user.id WHERE admin_user.username=NEW.username; + SELECT admin_mailbox.id INTO STRICT mboxid FROM admin_mailbox INNER JOIN core_user ON admin_mailbox.user_id=core_user.id WHERE core_user.username=NEW.username; UPDATE admin_quota SET mbox_id = mboxid WHERE username = NEW.username; RETURN NULL; diff --git a/modoboa/core/__init__.py b/modoboa/core/__init__.py index 544b3a1b3..db00fdef5 100644 --- a/modoboa/core/__init__.py +++ b/modoboa/core/__init__.py @@ -15,6 +15,7 @@ base_events = [ "RoleChanged", "GetExtraRoles", "PasswordChange", + "UserCanSetRole", "UserMenuDisplay", "AdminMenuDisplay", @@ -32,12 +33,21 @@ base_events = [ "ExtraAdminContent", "ExtraUprefsRoutes", - "ExtraUprefsJS" + "ExtraUprefsJS", + + "GetExtraParameters", + "ExtraFormFields", + "SaveExtraFormFields" ] -def load_settings(): - from .app_settings import GeneralParametersForm, UserSettings +def load_core_settings(): + """Load core settings. + + This function must be manually called (see :file:`urls.py`) in + order to load base settings. + """ + from modoboa.core.app_settings import GeneralParametersForm, UserSettings parameters.register(GeneralParametersForm, ugettext_lazy("General")) parameters.register(UserSettings, ugettext_lazy("General")) diff --git a/modoboa/core/app_settings.py b/modoboa/core/app_settings.py index edb9bfe48..6ed5b9178 100644 --- a/modoboa/core/app_settings.py +++ b/modoboa/core/app_settings.py @@ -46,15 +46,26 @@ class GeneralParametersForm(parameters.AdminParametersForm): password_scheme = forms.ChoiceField( label=ugettext_lazy("Default password scheme"), - choices=[("crypt", "crypt"), - ("md5", "md5"), - ("md5crypt", "md5crypt"), - ("sha256", "sha256"), - ("plain", "plain")], - initial="md5crypt", + choices=[("sha512crypt", "sha512crypt"), + ("sha256crypt", "sha256crypt"), + ("md5crypt", ugettext_lazy("md5crypt (weak)")), + ("sha256", ugettext_lazy("sha256 (weak)")), + ("md5", ugettext_lazy("md5 (weak)")), + ("crypt", ugettext_lazy("crypt (weak)")), + ("plain", ugettext_lazy("plain (weak)"))], + initial="sha512crypt", help_text=ugettext_lazy("Scheme used to crypt mailbox passwords") ) + rounds_number = forms.IntegerField( + label=ugettext_lazy("Rounds"), + initial=70000, + help_text=ugettext_lazy( + "Number of rounds to use (only used by sha256crypt and " + "sha512crypt). Must be between 1000 and 999999999, inclusive." + ) + ) + secret_key = forms.CharField( label=ugettext_lazy("Secret key"), initial=random_key(), @@ -107,9 +118,9 @@ class GeneralParametersForm(parameters.AdminParametersForm): ) ldap_search_base = forms.CharField( - label=ugettext_lazy("Search base"), + label=ugettext_lazy("Users search base"), initial="", - help_text=ugettext_lazy("The distinguished name of the search base"), + help_text=ugettext_lazy("The distinguished name of the search base used to find users"), required=False ) @@ -139,6 +150,25 @@ class GeneralParametersForm(parameters.AdminParametersForm): help_text=ugettext_lazy("Tell if the LDAP server is an Active Directory one") ) + ldap_admin_groups = forms.CharField( + label=ugettext_lazy("Administrator groups"), + initial="", + help_text=ugettext_lazy( + "Members of those LDAP Posix groups will be created as domain " + "administrators. Use ';' characters to separate groups." + ), + required=False + ) + + ldap_groups_search_base = forms.CharField( + label=ugettext_lazy("Groups search base"), + initial="", + help_text=ugettext_lazy( + "The distinguished name of the search base used to find groups" + ), + required=False + ) + sep3 = SeparatorField(label=ugettext_lazy("Miscellaneous")) log_maximum_age = forms.IntegerField( @@ -173,7 +203,9 @@ class GeneralParametersForm(parameters.AdminParametersForm): "ldap_search_filter": "ldap_auth_method=searchbind", "ldap_user_dn_template": "ldap_auth_method=directbind", "ldap_password_attribute": "authentication_type=ldap", - "ldap_is_active_directory": "authentication_type=ldap" + "ldap_is_active_directory": "authentication_type=ldap", + "ldap_admin_groups": "authentication_type=ldap", + "ldap_groups_search_base": "authentication_type=ldap", } def __init__(self, *args, **kwargs): @@ -191,10 +223,16 @@ class GeneralParametersForm(parameters.AdminParametersForm): tpl = self.cleaned_data["ldap_user_dn_template"] try: test = tpl % {"user": "toto"} - except ValueError: + except (KeyError, ValueError): raise forms.ValidationError(_("Invalid syntax")) return tpl + def clean_rounds_number(self): + value = self.cleaned_data["rounds_number"] + if value < 1000 or value > 999999999: + raise forms.ValidationError(_("Invalid rounds number")) + return value + def clean(self): """Custom validation method @@ -226,7 +264,7 @@ class GeneralParametersForm(parameters.AdminParametersForm): """ try: import ldap - from django_auth_ldap.config import LDAPSearch + from django_auth_ldap.config import LDAPSearch, PosixGroupType ldap_available = True except ImportError: ldap_available = False @@ -243,6 +281,11 @@ class GeneralParametersForm(parameters.AdminParametersForm): ldap_uri = 'ldaps://' if values["ldap_secured"] == "yes" else "ldap://" ldap_uri += "%s:%s" % (values["ldap_server_address"], values["ldap_server_port"]) setattr(settings, "AUTH_LDAP_SERVER_URI", ldap_uri) + setattr(settings, "AUTH_LDAP_GROUP_TYPE", PosixGroupType()) + setattr(settings, "AUTH_LDAP_GROUP_SEARCH", LDAPSearch( + values["ldap_groups_search_base"], ldap.SCOPE_SUBTREE, + "(objectClass=posixGroup)" + )) if values["ldap_auth_method"] == "searchbind": setattr(settings, "AUTH_LDAP_BIND_DN", values["ldap_bind_dn"]) setattr(settings, "AUTH_LDAP_BIND_PASSWORD", values["ldap_bind_password"]) @@ -253,7 +296,6 @@ class GeneralParametersForm(parameters.AdminParametersForm): setattr(settings, "AUTH_LDAP_USER_SEARCH", search) else: setattr(settings, "AUTH_LDAP_USER_DN_TEMPLATE", values["ldap_user_dn_template"]) - if values["ldap_is_active_directory"] == "yes": if not hasattr(settings, "AUTH_LDAP_GLOBAL_OPTIONS"): setattr(settings, "AUTH_LDAP_GLOBAL_OPTIONS", { diff --git a/modoboa/core/commands/postfix_maps.py b/modoboa/core/commands/postfix_maps.py index eda26f4fc..021ceae9b 100644 --- a/modoboa/core/commands/postfix_maps.py +++ b/modoboa/core/commands/postfix_maps.py @@ -2,6 +2,8 @@ import getpass import inspect import os +import sys +import datetime from django.template import Context, Template from . import Command @@ -46,6 +48,12 @@ query = {{ query|safe }} query=getattr(v, args.dbtype))) ) with open("%s/%s" % (args.destdir, v.filename), "w") as fp: + print >> fp, """# This file was generated on %s by running: +# %s %s +# DO NOT EDIT! +""" % (datetime.datetime.now().isoformat(), + os.path.basename(sys.argv[0]), + ' '.join(sys.argv[1:])) print >> fp, content diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index f86ad1e39..b7f36115f 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -106,6 +106,7 @@ TEMPLATE_LOADERS = ( MIDDLEWARE_CLASSES = ( 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.transaction.TransactionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', @@ -154,6 +155,7 @@ MODOBOA_APPS = ( 'modoboa.lib', # Modoboa extensions here. 'modoboa.extensions.admin', + 'modoboa.extensions.postfix_relay_domains', 'modoboa.extensions.limits', 'modoboa.extensions.postfix_autoreply', 'modoboa.extensions.webmail', diff --git a/modoboa/core/exceptions.py b/modoboa/core/exceptions.py deleted file mode 100644 index 063db7ad9..000000000 --- a/modoboa/core/exceptions.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -:mod:`exceptions` --- Custom exceptions of the admin. application ------------------------------------------------------------------ -""" - -from modoboa.lib.exceptions import ModoboaException - - -class AdminError(ModoboaException): - """ - Just a Custom exception to identify errors coming from the admin - application. - """ - pass diff --git a/modoboa/core/extensions.py b/modoboa/core/extensions.py index 1eedc56a8..07073a9d2 100644 --- a/modoboa/core/extensions.py +++ b/modoboa/core/extensions.py @@ -38,7 +38,7 @@ class ExtensionsPool(object): """The extensions manager""" def __init__(self): - self.extensions = dict() + self.extensions = {} def register_extension(self, ext, show=True): self.extensions[ext.name] = dict(cls=ext, show=show) @@ -50,6 +50,13 @@ class ExtensionsPool(object): self.extensions[name]["instance"] = self.extensions[name]["cls"]() return self.extensions[name]["instance"] + def is_extension_enabled(self, name): + from modoboa.core.models import Extension + + if not name in self.extensions: + return False + return Extension.objects.get(name=name).enabled + def get_extension_infos(self, name): instance = self.get_extension(name) if instance is None: @@ -57,13 +64,33 @@ class ExtensionsPool(object): return instance.infos() def load_all(self): + """Load all enabled extensions. + + Each extension must be loaded in order to integrate with + Modoboa. Only enabled and special extensions are loaded but + urls are always returned. The reason is urls are imported only + once so must know all of them when the python process + starts. Otherwise, it would lead to unexpected 404 errors :p + + :return: a list of url maps + """ from modoboa.core.models import Extension + result = [] for ext in settings.MODOBOA_APPS: __import__(ext) - result = [] - for extname in self.extensions.keys(): + extname = ext.split('.')[-1] extinstance = self.get_extension(extname) + if extinstance is None: + continue + try: + baseurl = extinstance.url \ + if extinstance.url is not None else extname + result += [(r'^%s/' % (baseurl), + include("%s.urls" % extinstance.__module__))] + except ImportError: + # No urls for this extension + pass if not extinstance.always_active: try: ext = Extension.objects.get(name=extname) @@ -72,21 +99,16 @@ class ExtensionsPool(object): except Extension.DoesNotExist: continue extinstance.load() - try: - baseurl = extinstance.url if extinstance.url is not None else extname - result += [(r'^%s/' % (baseurl), - include("%s.urls" % extinstance.__module__))] - except ImportError: - # No urls for this extension - pass return result def list_all(self): + """List all defined extensions. + """ result = [] for extname, extdef in self.extensions.iteritems(): if not extdef["show"]: continue - infos = extdef["instance"].infos() + infos = self.get_extension_infos(extname) infos["id"] = extname result += [infos] return sorted(result, key=lambda i: i["name"]) diff --git a/modoboa/core/fixtures/initial_users.json b/modoboa/core/fixtures/initial_users.json index bf60843ba..0986e6fb0 100644 --- a/modoboa/core/fixtures/initial_users.json +++ b/modoboa/core/fixtures/initial_users.json @@ -1 +1 @@ -[{"pk": 1, "model": "core.user", "fields": {"username": "admin", "first_name": "", "last_name": "", "is_active": true, "is_superuser": true, "is_staff": false, "last_login": "2012-01-26 15:19:25", "groups": [], "user_permissions": [], "password": "{CRYPT}dTTsGDkA5ZHKg", "email": "", "date_joined": "2012-01-26 15:19:25"}},{"pk": 1, "model": "core.objectaccess", "fields": {"is_owner": true, "user": 1, "content_type": ["core", "user"], "object_id": 1}}] \ No newline at end of file +[{"pk": 1, "model": "core.user", "fields": {"username": "admin", "first_name": "", "last_name": "", "is_active": true, "is_superuser": true, "is_staff": false, "last_login": "2012-01-26T15:19:26+01:00", "groups": [], "user_permissions": [], "password": "{CRYPT}dTTsGDkA5ZHKg", "email": "", "date_joined": "2012-01-26T15:19:26+01:00"}},{"pk": 1, "model": "core.objectaccess", "fields": {"is_owner": true, "user": 1, "content_type": ["core", "user"], "object_id": 1}}] \ No newline at end of file diff --git a/modoboa/core/migrations/0003_update_md5crypt_passwords.py b/modoboa/core/migrations/0003_update_md5crypt_passwords.py new file mode 100644 index 000000000..95c9cf0c3 --- /dev/null +++ b/modoboa/core/migrations/0003_update_md5crypt_passwords.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models + + +class Migration(DataMigration): + + def forwards(self, orm): + "Write your forwards methods here." + for user in orm['core.User'].objects.all(): + if user.password.startswith('$1$'): + user.password = '{MD5-CRYPT}%s' % user.password + user.save() + + def backwards(self, orm): + "Write your backwards methods here." + for user in orm['core.User'].objects.all(): + if user.password.startswith('{MD5-CRYPT}'): + user.password = user.password.replace('{MD5-CRYPT}', '') + user.save() + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'core.extension': { + 'Meta': {'object_name': 'Extension'}, + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '150'}) + }, + u'core.log': { + 'Meta': {'object_name': 'Log'}, + 'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.CharField', [], {'max_length': '15'}), + 'logger': ('django.db.models.fields.CharField', [], {'max_length': '30'}), + 'message': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + u'core.objectaccess': { + 'Meta': {'unique_together': "(('user', 'content_type', 'object_id'),)", 'object_name': 'ObjectAccess'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_owner': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['core.User']"}) + }, + u'core.user': { + 'Meta': {'ordering': "['username']", 'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '254', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_local': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '254'}) + } + } + + complete_apps = ['core'] + symmetrical = True diff --git a/modoboa/core/migrations/0004_auto__chg_field_user_password.py b/modoboa/core/migrations/0004_auto__chg_field_user_password.py new file mode 100644 index 000000000..6695deeb3 --- /dev/null +++ b/modoboa/core/migrations/0004_auto__chg_field_user_password.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Changing field 'User.password' + db.alter_column(u'core_user', 'password', self.gf('django.db.models.fields.CharField')(max_length=256)) + + def backwards(self, orm): + + # Changing field 'User.password' + db.alter_column(u'core_user', 'password', self.gf('django.db.models.fields.CharField')(max_length=128)) + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'core.extension': { + 'Meta': {'object_name': 'Extension'}, + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '150'}) + }, + u'core.log': { + 'Meta': {'object_name': 'Log'}, + 'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.CharField', [], {'max_length': '15'}), + 'logger': ('django.db.models.fields.CharField', [], {'max_length': '30'}), + 'message': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + u'core.objectaccess': { + 'Meta': {'unique_together': "(('user', 'content_type', 'object_id'),)", 'object_name': 'ObjectAccess'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_owner': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['core.User']"}) + }, + u'core.user': { + 'Meta': {'ordering': "['username']", 'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '254', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_local': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '256'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '254'}) + } + } + + complete_apps = ['core'] \ No newline at end of file diff --git a/modoboa/core/models.py b/modoboa/core/models.py index 4af548ad3..390752594 100644 --- a/modoboa/core/models.py +++ b/modoboa/core/models.py @@ -1,35 +1,35 @@ import sys import os import re -import hashlib -import crypt -import base64 -from random import Random +import logging import reversion from django.db import models +from django.db.models.signals import post_delete from django.dispatch import receiver from django.utils import timezone from django.utils.translation import ugettext as _, ugettext_lazy -from django.utils.crypto import constant_time_compare from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes import generic from django.contrib.auth.models import ( - UserManager, Group, AbstractBaseUser, PermissionsMixin + UserManager, Group, PermissionsMixin +) +from django.contrib.auth.hashers import make_password, is_password_usable +from modoboa.lib import events, parameters +from modoboa.lib.exceptions import ( + PermDeniedException, InternalError, BadRequest ) -from modoboa.lib import events, md5crypt, parameters -from modoboa.lib.exceptions import PermDeniedException from modoboa.lib.sysutils import exec_cmd from modoboa.core.extensions import exts_pool -from modoboa.core.exceptions import AdminError +from modoboa.core.password_hashers import get_password_hasher try: - from modoboa.lib.ldaputils import * + from modoboa.lib.ldaputils import LDAPAuthBackend ldap_available = True except ImportError: ldap_available = False -class User(AbstractBaseUser, PermissionsMixin): +class User(PermissionsMixin): """Custom User model. It overloads the way passwords are stored into the database. The @@ -46,6 +46,10 @@ class User(AbstractBaseUser, PermissionsMixin): is_active = models.BooleanField(default=True) date_joined = models.DateTimeField(default=timezone.now) is_local = models.BooleanField(default=True) + password = models.CharField(ugettext_lazy('password'), max_length=256) + last_login = models.DateTimeField( + ugettext_lazy('last login'), default=timezone.now + ) objects = UserManager() @@ -55,7 +59,7 @@ class User(AbstractBaseUser, PermissionsMixin): class Meta: ordering = ["username"] - password_expr = re.compile(r'(\{(\w+)\}|(\$1\$))(.+)') + password_expr = re.compile(r'\{([\w\-]+)\}(.+)') def delete(self, fromuser, *args, **kwargs): """Custom delete method @@ -72,7 +76,9 @@ class User(AbstractBaseUser, PermissionsMixin): get_object_owner, grant_access_to_object, ungrant_access_to_object if fromuser == self: - raise AdminError(_("You can't delete your own account")) + raise PermDeniedException( + _("You can't delete your own account") + ) if not fromuser.can_access(self): raise PermDeniedException @@ -90,34 +96,7 @@ class User(AbstractBaseUser, PermissionsMixin): scheme = parameters.get_admin("PASSWORD_SCHEME") if type(raw_value) is unicode: raw_value = raw_value.encode("utf-8") - if scheme == "crypt": - salt = "".join(Random().sample(string.letters + string.digits, 2)) - result = crypt.crypt(raw_value, salt) - prefix = "{CRYPT}" - elif scheme == "md5": - obj = hashlib.md5(raw_value) - result = obj.hexdigest() - prefix = "{MD5}" - # The md5crypt scheme is the only supported method that has both: - # (a) a salt ("crypt" has this too), - # (b) supports passwords lengths of more than 8 characters (all except - # "crypt"). - elif scheme == "md5crypt": - # The salt may vary from 12 to 48 bits. (Using all six bytes here - # with a subset of characters means we get only 35 random bits.) - salt = "".join(Random().sample(string.letters + string.digits, 6)) - result = md5crypt(raw_value, salt) - prefix = "" # the result already has $1$ prepended to it - # to signify what this is - elif scheme == "sha256": - obj = hashlib.sha256(raw_value) - result = base64.b64encode(obj.digest()) - prefix = "{SHA256}" - else: - scheme = "plain" - result = raw_value - prefix = "{PLAIN}" - return "%s%s" % (prefix, result) + return get_password_hasher(scheme.upper())().encrypt(raw_value) def set_password(self, raw_value, curvalue=None): """Password update @@ -129,44 +108,61 @@ class User(AbstractBaseUser, PermissionsMixin): :param raw_value: the new password's value :param curvalue: the current password (for LDAP authentication) """ - if parameters.get_admin("AUTHENTICATION_TYPE") == "local": + if self.is_local: self.password = self._crypt_password(raw_value) else: if not ldap_available: - raise AdminError( + raise InternalError( _("Failed to update password: LDAP module not installed") ) - - ab = LDAPAuthBackend() - try: - ab.update_user_password(self.username, curvalue, raw_value) - except LDAPException, e: - raise AdminError(_("Failed to update password: %s" % str(e))) + LDAPAuthBackend().update_user_password( + self.username, curvalue, raw_value + ) events.raiseEvent( "PasswordUpdated", self, raw_value, self.pk is None ) def check_password(self, raw_value): - m = self.password_expr.match(self.password) - if m is None: + match = self.password_expr.match(self.password) + if match is None: return False if type(raw_value) is unicode: raw_value = raw_value.encode("utf-8") - scheme = (m.group(2) or m.group(3)).lower() - val2 = m.group(4) - if scheme == u"crypt": - val1 = crypt.crypt(raw_value, val2) - elif scheme == u"md5": - val1 = hashlib.md5(raw_value).hexdigest() - elif scheme == u"sha256": - val1 = base64.b64encode(hashlib.sha256(raw_value).digest()) - elif scheme == u"$1$": # md5crypt - salt, hashed = val2.split('$') - val1 = md5crypt(raw_value, str(salt)) - val2 = self.password # re-add scheme for comparison below - else: - val1 = raw_value - return constant_time_compare(val1, val2) + scheme = match.group(1) + val2 = match.group(2) + hasher = get_password_hasher(scheme) + return hasher().verify(raw_value, val2) + + def get_username(self): + "Return the identifying username for this User" + return getattr(self, self.USERNAME_FIELD) + + def __str__(self): + return self.get_username() + + def natural_key(self): + return (self.get_username(),) + + def is_anonymous(self): + """ + Always returns False. This is a way of comparing User objects to + anonymous users. + """ + return False + + def is_authenticated(self): + """ + Always return True. This is a way to tell if the user has been + authenticated in templates. + """ + return True + + def set_unusable_password(self): + # Sets a value that will never be a valid hash + self.password = make_password(None) + + def has_usable_password(self): + return is_password_usable(self.password) @property def tags(self): @@ -197,7 +193,7 @@ class User(AbstractBaseUser, PermissionsMixin): try: return self.groups.all()[0].name except IndexError: - return "SimpleUsers" + return "---" @property def enabled(self): @@ -240,9 +236,10 @@ class User(AbstractBaseUser, PermissionsMixin): def can_access(self, obj): """Check if the user can access a specific object - This function is recursive : if the given user hasn't got direct - access to this object and if he has got access other ``User`` - objects, we check if one of those users owns the object. + This function is recursive: if the given user hasn't got + direct access to this object and if he has got access to other + ``User`` objects, we check if one of those users owns the + object. :param obj: a admin object :return: a boolean @@ -307,18 +304,20 @@ class User(AbstractBaseUser, PermissionsMixin): self.post_create(creator) def from_csv(self, user, row, crypt_password=True): - """Create a new account from a CSV file entry + """Create a new account from a CSV file entry. The expected order is the following:: - "account", loginname, password, first name, last name, enabled, group, address[, domain, ...] + "account", loginname, password, first name, last name, enabled, group + + Additional fields can be added using the *AccountImported* event. :param user: a ``core.User`` instance :param row: a list containing the expected information :param crypt_password: """ if len(row) < 7: - raise AdminError(_("Invalid line")) + raise BadRequest(_("Invalid line")) role = row[6].strip() if not user.is_superuser and not role in ["SimpleUsers", "DomainAdmins"]: raise PermDeniedException( @@ -327,11 +326,11 @@ class User(AbstractBaseUser, PermissionsMixin): self.username = row[1].strip() if role == "SimpleUsers": if (len(row) < 8 or not row[7].strip()): - raise AdminError( + raise BadRequest( _("The simple user '%s' must have a valid email address" % self.username) ) if self.username != row[7].strip(): - raise AdminError( + raise BadRequest( _("username and email fields must not differ for '%s'" % self.username) ) @@ -349,6 +348,12 @@ class User(AbstractBaseUser, PermissionsMixin): events.raiseEvent("AccountImported", user, self, row[7:]) def to_csv(self, csvwriter): + """Export this account. + + The CSV format is used to export. + + :param csvwriter: csv object + """ row = ["account", self.username.encode("utf-8"), self.password.encode("utf-8"), self.first_name.encode("utf-8"), self.last_name.encode("utf-8"), self.is_active, self.group, self.email.encode("utf-8")] @@ -358,7 +363,7 @@ class User(AbstractBaseUser, PermissionsMixin): reversion.register(User) -def populate_callback(user): +def populate_callback(user, group='SimpleUsers'): """Populate callback If the LDAP authentication backend is in use, this callback will @@ -371,7 +376,7 @@ def populate_callback(user): from modoboa.lib.permissions import grant_access_to_object sadmins = User.objects.filter(is_superuser=True) - user.set_role("SimpleUsers") + user.set_role(group) user.post_create(sadmins[0]) for su in sadmins[1:]: grant_access_to_object(su, user) @@ -460,24 +465,44 @@ class Log(models.Model): @receiver(reversion.post_revision_commit) def post_revision_commit(sender, **kwargs): - import logging - if kwargs["revision"].user is None: return logger = logging.getLogger("modoboa.admin") for version in kwargs["versions"]: - if version.type == reversion.models.VERSION_ADD: + prev_revisions = reversion.get_for_object(version.object) + if prev_revisions.count() == 1: action = _("added") level = "info" - elif version.type == reversion.models.VERSION_CHANGE: + else: action = _("modified") level = "warning" - else: - action = _("deleted") - level = "critical" message = _("%(object)s '%(name)s' %(action)s by user %(user)s") % { "object": unicode(version.content_type).capitalize(), "name": version.object_repr, "action": action, "user": kwargs["revision"].user.username } getattr(logger, level)(message) + + +@receiver(post_delete) +def post_delete_hook(sender, instance, **kwargs): + """Custom post-delete hook. + + We want to know who was responsible for an object deletion. + """ + from reversion.models import Version + + if not reversion.is_registered(sender): + return + del_list = reversion.get_deleted(sender) + try: + version = del_list.get(object_id=instance.id) + except Version.DoesNotExist: + return + logger = logging.getLogger("modoboa.admin") + msg = _("%(object)s '%(name)s' %(action)s by user %(user)s") % { + "object": unicode(version.content_type).capitalize(), + "name": version.object_repr, "action": _("deleted"), + "user": version.revision.user.username + } + logger.critical(msg) diff --git a/modoboa/core/password_hashers/__init__.py b/modoboa/core/password_hashers/__init__.py new file mode 100644 index 000000000..c26f9d3fd --- /dev/null +++ b/modoboa/core/password_hashers/__init__.py @@ -0,0 +1,26 @@ +""" +Password hashers for Modoboa. +""" +from modoboa.core.password_hashers.base import ( + PLAINHasher, CRYPTHasher, MD5Hasher, SHA256Hasher +) +from modoboa.core.password_hashers.advanced import ( + MD5CRYPTHasher, SHA256CRYPTHasher, SHA512CRYPTHasher +) + + +def get_password_hasher(scheme): + """Retrieve the hasher corresponding to :keyword:`scheme`. + + If no class is found, `PLAINHasher` is returned. + + :param str scheme: a valid scheme name + :rtype: PasswordHasher sub class + :return: The hasher class + """ + try: + scheme = scheme.replace('-', '') + hasher = globals()['%sHasher' % scheme] + except AttributeError: + hasher = PLAINHasher + return hasher diff --git a/modoboa/core/password_hashers/advanced.py b/modoboa/core/password_hashers/advanced.py new file mode 100644 index 000000000..3bb632ad2 --- /dev/null +++ b/modoboa/core/password_hashers/advanced.py @@ -0,0 +1,72 @@ +""" +Advanced (ie. stronger) password hashers. + +This module relies on `passlib` to provide more secure hashers. +""" +from passlib.hash import md5_crypt, sha256_crypt, sha512_crypt +from modoboa.core.password_hashers.base import PasswordHasher +from modoboa.lib import parameters + + +class MD5CRYPTHasher(PasswordHasher): + """ + MD5-CRYPT password hasher. + + This scheme can't be considered as secure anymore. + """ + @property + def scheme(self): + return '{MD5-CRYPT}' if self._target == 'local' else '{CRYPT}' + + def _b64encode(self, pwhash): + return pwhash + + def _encrypt(self, clearvalue, salt=None): + return md5_crypt.encrypt(clearvalue) + + def verify(self, clearvalue, hashed_value): + return md5_crypt.verify(clearvalue, hashed_value) + + +class SHA256CRYPTHasher(PasswordHasher): + """ + SHA256-CRYPT password hasher. + + Supports rounds and is a good compromise between security and + performance. + """ + @property + def scheme(self): + return '{SHA256-CRYPT}' if self._target == 'local' else '{CRYPT}' + + def _b64encode(self, pwhash): + return pwhash + + def _encrypt(self, clearvalue, salt=None): + rounds = int(parameters.get_admin('ROUNDS_NUMBER')) + return sha256_crypt.encrypt(clearvalue, rounds=rounds) + + def verify(self, clearvalue, hashed_value): + return sha256_crypt.verify(clearvalue, hashed_value) + + +class SHA512CRYPTHasher(PasswordHasher): + """ + SHA512-CRYPT password hasher. + + Supports rounds and is the strongest scheme provided by + Modoboa. Requires more resource than SHA256-CRYPT. + """ + @property + def scheme(self): + return '{SHA512-CRYPT}' if self._target == 'local' else '{CRYPT}' + + def _b64encode(self, pwhash): + return pwhash + + def _encrypt(self, clearvalue, salt=None): + rounds = int(parameters.get_admin('ROUNDS_NUMBER')) + return sha512_crypt.encrypt(clearvalue, rounds=rounds) + + def verify(self, clearvalue, hashed_value): + return sha512_crypt.verify(clearvalue, hashed_value) diff --git a/modoboa/core/password_hashers/base.py b/modoboa/core/password_hashers/base.py new file mode 100644 index 000000000..f264fd2d2 --- /dev/null +++ b/modoboa/core/password_hashers/base.py @@ -0,0 +1,125 @@ +""" +Base password hashers. + +Contains weak hashers (the original ones) available with Modoboa. +""" +import crypt +import hashlib +import base64 +import string +from random import Random +from django.utils.crypto import constant_time_compare + + +class PasswordHasher(object): + """ + Base class of all hashers. + """ + def __init__(self, target='local'): + self._target = target + + def _encrypt(self, clearvalue, salt=None): + raise NotImplementedError + + def _b64encode(self, pwhash): + """Encode :keyword:`pwhash` using base64 if needed. + + :param str pwhash: password hash + :return: base64 encoded hash or original hash + """ + if self._target == 'ldap': + return base64.b64encode(pwhash) + return pwhash + + def encrypt(self, clearvalue): + """Encrypt a password. + + The format used to store passwords is the same than dovecot's one:: + + {SCHEME} + + may differ according to the used scheme. + + :param str clearvalue: clear password + :rtype: str + :return: encrypted password + """ + pwhash = self._b64encode(self._encrypt(clearvalue)) + return '%s%s' % (self.scheme, pwhash) + + def verify(self, clearvalue, hashed_value): + """Verify a password against a hashed value. + + :param str clearvalue: clear password + :param str hashed_value: password previously hashed + :return: True if passwords match, False otherwise + """ + return constant_time_compare( + self._encrypt(clearvalue, hashed_value), + hashed_value + ) + + +class PLAINHasher(PasswordHasher): + """ + Plain (ie. clear) password hasher. + """ + @property + def scheme(self): + return '{PLAIN}' + + def _encrypt(self, clearvalue, salt=None): + return clearvalue + + +class CRYPTHasher(PasswordHasher): + """ + crypt password hasher. + + Uses python `crypt` standard module. + """ + @property + def scheme(self): + return '{CRYPT}' + + def _encrypt(self, clearvalue, salt=None): + if salt is None: + salt = "".join(Random().sample(string.letters + string.digits, 2)) + return crypt.crypt(clearvalue, salt) + + +class MD5Hasher(PasswordHasher): + """ + MD5 password hasher. + + Uses python `hashlib` standard module. + """ + @property + def scheme(self): + return '{MD5}' + + def _encrypt(self, clearvalue, salt=None): + obj = hashlib.md5(clearvalue) + return obj.hexdigest() + + +class SHA256Hasher(PasswordHasher): + """ + SHA256 password hasher. + + Uses python `hashlib` and `base64` standard modules. + """ + @property + def scheme(self): + return '{SHA256}' + + def _encrypt(self, clearvalue, salt=None): + return hashlib.sha256(clearvalue).digest() + + def _b64encode(self, pwhash): + """Encode :keyword:`pwhash` using base64. + + :param str pwhash: password hash + :return: base64 encoded hash + """ + return base64.b64encode(pwhash) diff --git a/modoboa/core/templatetags/core_tags.py b/modoboa/core/templatetags/core_tags.py index 91238b489..e1b5ebce1 100644 --- a/modoboa/core/templatetags/core_tags.py +++ b/modoboa/core/templatetags/core_tags.py @@ -31,36 +31,6 @@ def core_menu(selection, user): ) -@register.simple_tag -def settings_menu(selection, user): - entries = [ - {"name": "extensions", - "class": "ajaxlink", - "url": "extensions/", - "label": _("Extensions"), - "img": ""}, - {"name": "info", - "class": "ajaxlink", - "url": "info/", - "label": _("Information")}, - {"name": "logs", - "class": "ajaxlink", - "url": "logs/", - "label": _("Logs")}, - {"name": "parameters", - "class": "ajaxlink", - "url": "parameters/", - "img": "", - "label": _("Parameters")}, - ] - return render_to_string('common/menu.html', { - "entries": entries, - "css": "nav nav-list", - "selection": selection, - "user": user - }) - - @register.simple_tag def extensions_menu(selection, user): menu = events.raiseQueryEvent("UserMenuDisplay", "top_menu", user) @@ -83,7 +53,7 @@ def admin_menu(selection, user): "label": _("Information")}, {"name": "logs", "class": "ajaxlink", - "url": "logs/", + "url": "logs/?sort_order=-date_created", "label": _("Logs")}, {"name": "parameters", "class": "ajaxlink", @@ -166,7 +136,9 @@ def tohtml(message): @register.simple_tag def visirule(field): - if not hasattr(field.form, "visirules") or not field.html_name in field.form.visirules: + if not hasattr(field, 'form') or \ + not hasattr(field.form, "visirules") or \ + not field.html_name in field.form.visirules: return "" rule = field.form.visirules[field.html_name] return " data-visibility-field='%s' data-visibility-value='%s' " \ @@ -230,18 +202,6 @@ def get_modoboa_logo(): return logo -@register.simple_tag -def extra_head_content(user): - tpl = template.Template( - "{% for sc in static_content %}{{ sc|safe }}{% endfor %}" - ) - return tpl.render( - template.Context({ - 'static_content': events.raiseQueryEvent("GetStaticContent", user) - }) - ) - - @register.simple_tag def load_optionalmenu(user): menu = events.raiseQueryEvent("UserMenuDisplay", "top_menu_middle", user) diff --git a/modoboa/core/tests.py b/modoboa/core/tests.py index 5b3491ba2..80de9db01 100644 --- a/modoboa/core/tests.py +++ b/modoboa/core/tests.py @@ -19,9 +19,9 @@ class ProfileTestCase(ModoTestCase): * The default admin changes his password (no associated Mailbox) * A normal user changes his password """ - self.check_ajax_post(reverse("modoboa.core.views.user.profile"), - {"oldpassword": "password", - "newpassword": "titi", "confirmation": "titi"}) + self.ajax_post(reverse("modoboa.core.views.user.profile"), + {"oldpassword": "password", + "newpassword": "titi", "confirmation": "titi"}) self.clt.logout() self.assertEqual( @@ -31,12 +31,12 @@ class ProfileTestCase(ModoTestCase): self.clt.login(username="user@test.com", password="toto"), True ) - self.check_ajax_post( + self.ajax_post( reverse("modoboa.core.views.user.profile"), {"oldpassword": "toto", "newpassword": "tutu", "confirmation": "tutu"} ) self.clt.logout() - self.assertEqual( - self.clt.login(username="user@test.com", password="tutu"), True + self.assertTrue( + self.clt.login(username="user@test.com", password="tutu") ) diff --git a/modoboa/core/views/admin.py b/modoboa/core/views/admin.py index fdd377cb2..26f97e0a9 100644 --- a/modoboa/core/views/admin.py +++ b/modoboa/core/views/admin.py @@ -6,7 +6,7 @@ from django.contrib.auth.decorators import ( ) from modoboa.lib import parameters from modoboa.lib.webutils import ( - ajax_simple_response, _render_to_string + _render_to_string, render_to_json_response ) from modoboa.lib.listing import get_sort_order, get_listing_page from modoboa.core.models import Extension, Log @@ -24,8 +24,7 @@ def viewsettings(request, tplname='core/settings_header.html'): @login_required @user_passes_test(lambda u: u.is_superuser) def viewparameters(request, tplname='core/parameters.html'): - return ajax_simple_response({ - "status": "ok", + return render_to_json_response({ "left_selection": "parameters", "content": _render_to_string(request, tplname, { "forms": parameters.get_admin_forms @@ -42,12 +41,10 @@ def saveparameters(request): form.save() form.to_django_settings() continue - return ajax_simple_response({ - "status": "ko", "prefix": form.app, "errors": form.errors - }) - return ajax_simple_response(dict( - status="ok", respmsg=_("Parameters saved") - )) + return render_to_json_response( + {'form_errors': form.errors, 'prefix': form.app}, status=400 + ) + return render_to_json_response(_("Parameters saved")) @login_required @@ -68,8 +65,7 @@ def viewextensions(request, tplname='core/extensions.html'): ext["selection"] = False tbl = ExtensionsTable(request, exts) - return ajax_simple_response({ - "status": "ok", + return render_to_json_response({ "content": _render_to_string(request, tplname, {"extensions": tbl}) }) @@ -82,7 +78,7 @@ def saveextensions(request): for k in request.POST.keys(): if k.startswith("select_"): parts = k.split("_", 1) - dbext = Extension.objects.get(name=parts[1]) + dbext = Extension.objects.get(name=parts[1]) if not dbext in actived_exts: dbext.on() else: @@ -91,16 +87,13 @@ def saveextensions(request): if not ext in found: ext.off() - return ajax_simple_response(dict( - status="ok", respmsg=_("Modifications applied.") - )) + return render_to_json_response(_("Modifications applied.")) @login_required @user_passes_test(lambda u: u.is_superuser) def information(request, tplname="core/information.html"): - return ajax_simple_response({ - "status": "ok", + return render_to_json_response({ "content": render_to_string(tplname, {}) }) @@ -118,8 +111,7 @@ def logs(request, tplname="core/logs.html"): Log.objects.all().order_by("%s%s" % (sort_dir, sort_order)), request.GET.get("page", 1) ) - return ajax_simple_response({ - "status": "ok", + return render_to_json_response({ "content": render_to_string(tplname, { "logs": page.object_list, }), diff --git a/modoboa/core/views/user.py b/modoboa/core/views/user.py index c42054526..d3cb2e8ac 100644 --- a/modoboa/core/views/user.py +++ b/modoboa/core/views/user.py @@ -3,7 +3,7 @@ from django.utils.translation import ugettext as _ from django.contrib.auth.decorators import login_required from modoboa.lib import events, parameters from modoboa.lib.webutils import ( - ajax_simple_response, _render_to_string + _render_to_string, render_to_json_response ) from modoboa.lib.cryptutils import encrypt from modoboa.core.forms import ProfileForm @@ -32,17 +32,11 @@ def profile(request, tplname='core/user_profile.html'): form.save() if update_password and form.cleaned_data["confirmation"] != "": request.session["password"] = encrypt(form.cleaned_data["confirmation"]) - return ajax_simple_response(dict( - status="ok", respmsg=_("Profile updated") - )) - return ajax_simple_response({ - "status": "ko", - "errors": form.errors - }) + return render_to_json_response(_("Profile updated")) + return render_to_json_response({'form_errors': form.errors}, status=400) form = ProfileForm(update_password, instance=request.user) - return ajax_simple_response({ - "status": "ok", + return render_to_json_response({ "content": _render_to_string(request, tplname, { "form": form }) @@ -57,16 +51,13 @@ def preferences(request): if form.is_valid(): form.save() continue - return ajax_simple_response({ - "status": "ko", "prefix": form.app, "errors": form.errors - }) + return render_to_json_response({ + "prefix": form.app, "form_errors": form.errors + }, status=400) - return ajax_simple_response({ - "status": "ok", "respmsg": _("Preferences saved") - }) + return render_to_json_response(_("Preferences saved")) - return ajax_simple_response({ - "status": "ok", + return render_to_json_response({ "content": _render_to_string(request, "core/user_preferences.html", { "forms": parameters.get_user_forms(request.user) }) diff --git a/modoboa/demo/__init__.py b/modoboa/demo/__init__.py index 76f5c05b4..ca8931b96 100755 --- a/modoboa/demo/__init__.py +++ b/modoboa/demo/__init__.py @@ -43,12 +43,13 @@ def password_change(user): @events.observe("GetStaticContent") -def get_static_content(user): - if not user.mailbox_set.count(): +def get_static_content(caller, user): + if caller != 'top' and not user.mailbox_set.count(): return [] - return """""" +"""] diff --git a/modoboa/demo/views.py b/modoboa/demo/views.py index 19b9e14ea..18e2a8427 100755 --- a/modoboa/demo/views.py +++ b/modoboa/demo/views.py @@ -3,10 +3,11 @@ import os from django.conf import settings from django.contrib.auth.decorators import login_required from django.utils.translation import ugettext as _ -from modoboa.lib.webutils import ajax_simple_response +from modoboa.lib.webutils import render_to_json_response from modoboa.lib.emailutils import sendmail_simple, sendmail_fromfile from modoboa.lib.decorators import needs_mailbox + @login_required @needs_mailbox() def send_virus(request): @@ -14,8 +15,9 @@ def send_virus(request): os.path.join(settings.MODOBOA_DIR, "tmp/virus.msg")) if status: - return ajax_simple_response(dict(status="ok", respmsg=_("Message sent"))) - return ajax_simple_response(dict(status="ko", respmsg=error)) + return render_to_json_response(_("Message sent")) + return render_to_json_response(error, status=500) + @login_required @needs_mailbox() @@ -38,5 +40,5 @@ XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X You should send this test mail from an account outside of your network. """) if status: - return ajax_simple_response(dict(status="ok", respmsg=_("Message sent"))) - return ajax_simple_response(dict(status="ko", respmsg=error)) + return render_to_json_response(_("Message sent")) + return render_to_json_response(respmsg=error, status=500) diff --git a/modoboa/extensions/admin/__init__.py b/modoboa/extensions/admin/__init__.py index 79c8fad5d..38ca5504c 100644 --- a/modoboa/extensions/admin/__init__.py +++ b/modoboa/extensions/admin/__init__.py @@ -3,25 +3,30 @@ from django.utils.translation import ugettext as _, ugettext_lazy from django.core.urlresolvers import reverse from modoboa.core.extensions import ModoExtension, exts_pool from modoboa.lib import parameters, events -from modoboa.lib.exceptions import PermDeniedException +from modoboa.lib.exceptions import PermDeniedException, BadRequest from modoboa.lib.emailutils import split_mailbox -from modoboa.extensions.admin.exceptions import AdminError from modoboa.extensions.admin.models import ( Domain, DomainAlias, Mailbox, Alias ) - admin_events = [ - "CreateDomain", + "DomainCreated", "DomainModified", - "DeleteDomain", + "DomainDeleted", + "DomainOwnershipRemoved", + "ExtraDomainEntries", + "ExtraDomainMenuEntries", + "ExtraDomainFilters", + "GetDomainActions", + "GetDomainModifyLink", + "CheckDomainName", "DomainAliasCreated", "DomainAliasDeleted", - "CreateMailbox", - "DeleteMailbox", - "ModifyMailbox", + "MailboxCreated", + "MailboxDeleted", + "MailboxModified", "MailboxAliasCreated", "MailboxAliasDeleted", @@ -32,6 +37,9 @@ admin_events = [ "ExtraAccountForm", "CheckExtraAccountForm", "FillAccountInstances", + + "ExtraDomainImportHelp", + "ImportObject" ] @@ -125,13 +133,25 @@ def grant_access_to_all_objects(user, role): @events.observe("AccountExported") def export_admin_domains(admin): + result = [admin.mailbox_set.all()[0].quota] \ + if admin.mailbox_set.count() else [''] if admin.group != "DomainAdmins": - return [] - return [dom.name for dom in Domain.objects.get_for_admin(admin)] + return result + return result + [dom.name for dom in Domain.objects.get_for_admin(admin)] @events.observe("AccountImported") def import_account_mailbox(user, account, row): + """Handle extra fields when an account is imported. + + Expected fields: + + email address; quota; [domain; ...] + + :param User user: user importing the account + :param User account: account being imported + :param list rom: list of fields (strings) + """ account.email = row[0].strip() if account.email != "": account.save() @@ -139,17 +159,19 @@ def import_account_mailbox(user, account, row): try: domain = Domain.objects.get(name=domname) except Domain.DoesNotExist: - raise AdminError( + raise BadRequest( _("Account import failed (%s): domain does not exist" % account.username) ) if not user.can_access(domain): raise PermDeniedException + quota = int(row[1].strip()) + use_domain_quota = True if not quota else False mb = Mailbox(address=mailbox, domain=domain, - user=account, use_domain_quota=True) - mb.set_quota(override_rules=user.has_perm("admin.change_domain")) + user=account, use_domain_quota=use_domain_quota) + mb.set_quota(quota, override_rules=user.has_perm("admin.change_domain")) mb.save(creator=user) if account.group == "DomainAdmins": - for domname in row[1:]: + for domname in row[2:]: try: dom = Domain.objects.get(name=domname.strip()) except Domain.DoesNotExist: @@ -162,8 +184,10 @@ def account_auto_created(user): from modoboa.core.models import User from modoboa.lib.permissions import grant_access_to_object - sadmins = User.objects.filter(is_superuser=True) localpart, domname = split_mailbox(user.username) + if user.group != 'SimpleUsers' and domname is None: + return + sadmins = User.objects.filter(is_superuser=True) try: domain = Domain.objects.get(name=domname) except Domain.DoesNotExist: @@ -193,6 +217,11 @@ def user_logged_in(request, username, password): @events.observe("AccountDeleted") def account_deleted(account, byuser, **kwargs): + """'AccountDeleted' listener. + + When an account is deleted, we also need to remove its mailbox (if + any). + """ if not account.mailbox_set.count(): return mb = account.mailbox_set.all()[0] diff --git a/modoboa/extensions/admin/exceptions.py b/modoboa/extensions/admin/exceptions.py deleted file mode 100644 index d0414b27b..000000000 --- a/modoboa/extensions/admin/exceptions.py +++ /dev/null @@ -1,19 +0,0 @@ -# coding: utf-8 -""" -:mod:`exceptions` --- Custom exceptions of the admin. application ------------------------------------------------------------------ -""" - -from modoboa.lib.exceptions import ModoboaException - - -class AdminError(ModoboaException): - """ - Just a Custom exception to identify errors coming from the admin - application. - """ - pass - - -class BadDestination(Exception): - pass diff --git a/modoboa/extensions/admin/factories.py b/modoboa/extensions/admin/factories.py index 9ab089912..306d29914 100644 --- a/modoboa/extensions/admin/factories.py +++ b/modoboa/extensions/admin/factories.py @@ -13,11 +13,13 @@ class MailboxFactory(PermissionFactory): FACTORY_FOR = models.Mailbox quota = 10 + enabled = True class AliasFactory(PermissionFactory): FACTORY_FOR = models.Alias + enabled = True def populate_database(): """Create test data. diff --git a/modoboa/extensions/admin/fixtures/initial_users.json b/modoboa/extensions/admin/fixtures/initial_users.json deleted file mode 100644 index b67729ef2..000000000 --- a/modoboa/extensions/admin/fixtures/initial_users.json +++ /dev/null @@ -1 +0,0 @@ -[{"pk": 1, "model": "core.user", "fields": {"username": "admin", "first_name": "", "last_name": "", "is_active": true, "is_superuser": true, "is_staff": false, "is_local": true, "last_login": "2012-01-26 15:19:25", "groups": [], "user_permissions": [], "password": "{CRYPT}dTTsGDkA5ZHKg", "email": "", "date_joined": "2012-01-26 15:19:25"}},{"pk": 1, "model": "core.objectaccess", "fields": {"is_owner": true, "user": 1, "content_type": ["core", "user"], "object_id": 1}}] \ No newline at end of file diff --git a/modoboa/extensions/admin/fixtures/test_content.json b/modoboa/extensions/admin/fixtures/test_content.json deleted file mode 100644 index 98782351b..000000000 --- a/modoboa/extensions/admin/fixtures/test_content.json +++ /dev/null @@ -1 +0,0 @@ -[{"pk": 2, "model": "admin.user", "fields": {"username": "user@test.com", "is_active": "True", "is_superuser": "False", "password": "{PLAIN}toto", "groups": [2]}},{"pk": 3, "model": "admin.user", "fields": {"username": "admin@test.com", "is_active": "True", "is_superuser": "False", "password": "{PLAIN}toto", "groups": [1]}},{"pk": 4, "model": "admin.user", "fields": {"username": "superadmin@test.com", "is_active": "True", "is_superuser": "True", "password": "{PLAIN}toto", "groups": []}},{"pk": 1, "model": "admin.ObjectDates", "fields": {"creation": "2011-09-07 09:27:07.579603", "last_modification": "2011-09-07 09:27:07.579603"}}, {"pk": 2, "model": "admin.ObjectDates", "fields": {"creation": "2011-09-07 09:27:07.579603", "last_modification": "2011-09-07 09:27:07.579603"}}, {"pk": 3, "model": "admin.ObjectDates", "fields": {"creation": "2011-09-07 09:27:07.579603", "last_modification": "2011-09-07 09:27:07.579603"}}, {"pk": 1, "model": "admin.Domain", "fields": {"name" : "test.com", "quota" : "100", "dates": "1"}}, {"pk": 1, "model": "admin.Mailbox", "fields": {"address": "user", "domain": "1", "dates": "1", "quota": "10", "user": "2"}}, {"pk": 2, "model": "admin.Mailbox", "fields": {"address": "admin", "domain": "1", "dates": "2", "quota": "10", "user": "3"}}, {"pk": 3, "model": "admin.Mailbox", "fields": {"address": "superadmin", "domain": "1", "dates": "3", "quota": "10", "user": "4"}}, {"pk": "user@test.com", "model": "admin.Quota", "fields": {"mbox": 1, "username": "user@test.com", "bytes": 0, "messages": 0}}, {"pk": "admin@test.com", "model": "admin.Quota", "fields": {"mbox": 2, "username": "admin@test.com", "bytes": 0, "messages": 0}}, {"pk": "superadmin@test.com", "model": "admin.Quota", "fields": {"mbox": 3, "username": "superadmin@test.com", "bytes": 0, "messages": 0}},{"pk": 2, "model": "admin.objectaccess", "fields": {"is_owner": true, "user": 1, "content_type": ["admin", "user"], "object_id": 2}},{"pk": 3, "model": "admin.objectaccess", "fields": {"is_owner": true, "user": 1, "content_type": ["admin", "user"], "object_id": 3}}] diff --git a/modoboa/extensions/admin/forms/account.py b/modoboa/extensions/admin/forms/account.py index bcbc54c86..187a96894 100644 --- a/modoboa/extensions/admin/forms/account.py +++ b/modoboa/extensions/admin/forms/account.py @@ -2,27 +2,33 @@ from django import forms from django.utils.translation import ugettext as _, ugettext_lazy from django.http import QueryDict from modoboa.lib import events, parameters -from modoboa.lib.exceptions import PermDeniedException +from modoboa.lib.exceptions import PermDeniedException, Conflict, NotFound from modoboa.lib.permissions import get_account_roles from modoboa.lib.emailutils import split_mailbox from modoboa.lib.formutils import ( - DomainNameField, DynamicForm, TabForms + DomainNameField, DynamicForm, TabForms, YesNoField ) from modoboa.core.models import User -from modoboa.extensions.admin.exceptions import AdminError from modoboa.extensions.admin.models import ( Domain, Mailbox, Alias ) class AccountFormGeneral(forms.ModelForm): - username = forms.CharField(label=ugettext_lazy("Username")) + username = forms.CharField( + label=ugettext_lazy("Username"), + help_text=ugettext_lazy( + "The user's name. Must be a valid e-mail address for simple users." + ) + ) role = forms.ChoiceField( label=ugettext_lazy("Role"), choices=[('', ugettext_lazy("Choose"))], help_text=ugettext_lazy("What level of permission this user will have") ) - password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput) + password1 = forms.CharField( + label=_("Password"), widget=forms.PasswordInput + ) password2 = forms.CharField( label=ugettext_lazy("Confirmation"), widget=forms.PasswordInput, @@ -45,21 +51,21 @@ class AccountFormGeneral(forms.ModelForm): widget=forms.HiddenInput, required=False ) else: - self.fields["role"].choices = \ - [('', ugettext_lazy("Choose"))] + get_account_roles(user) + self.fields["role"].choices = [('', ugettext_lazy("Choose"))] + self.fields["role"].choices += \ + get_account_roles(user, kwargs['instance']) \ + if 'instance' in kwargs else get_account_roles(user) if "instance" in kwargs: - if len(args) \ + if args \ and (args[0].get("password1", "") == "" and args[0].get("password2", "") == ""): self.fields["password1"].required = False self.fields["password2"].required = False - - u = kwargs["instance"] - self.fields["role"].initial = u.group - - if not u.is_local \ - and parameters.get_admin("LDAP_AUTH_METHOD") == "directbind": + account = kwargs["instance"] + self.fields["role"].initial = account.group + if not account.is_local \ + and parameters.get_admin("LDAP_AUTH_METHOD", app="core") == "directbind": del self.fields["password1"] del self.fields["password2"] @@ -90,7 +96,7 @@ class AccountFormGeneral(forms.ModelForm): def save(self, commit=True): account = super(AccountFormGeneral, self).save(commit=False) if self.user == account and not self.cleaned_data["is_active"]: - raise AdminError(_("You can't disable your own account")) + raise PermDeniedException(_("You can't disable your own account")) if commit: if "password1" in self.cleaned_data \ and self.cleaned_data["password1"] != "": @@ -105,22 +111,35 @@ class AccountFormMail(forms.Form, DynamicForm): quota = forms.IntegerField( label=ugettext_lazy("Quota"), required=False, - help_text=_("Quota in MB for this mailbox. Define a custom value or use domain's default one. Leave empty to define an unlimited value (not allowed for domain administrators)."), + help_text=_("Quota in MB for this mailbox. 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": "span1"}) ) quota_act = forms.BooleanField(required=False) aliases = forms.EmailField( label=ugettext_lazy("Alias(es)"), required=False, - help_text=ugettext_lazy("Alias(es) of this mailbox. Indicate only one address per input, press ENTER to add a new input. Use the '*' character to create a 'catchall' alias (ex: *@domain.tld).") + help_text=ugettext_lazy( + "Alias(es) of this mailbox. Indicate only one address per input, " + "press ENTER to add a new input. Use the '*' character to create " + "a 'catchall' alias (ex: *@domain.tld)." + ) ) def __init__(self, *args, **kwargs): if "instance" in kwargs: self.mb = kwargs["instance"] del kwargs["instance"] + else: + self.mb = None super(AccountFormMail, self).__init__(*args, **kwargs) - if hasattr(self, "mb") and self.mb is not None: + self.extra_fields = [] + for fname, field in events.raiseQueryEvent('ExtraFormFields', 'mailform', self.mb): + self.fields[fname] = field + self.extra_fields.append(fname) + if self.mb is not None: self.fields["email"].required = True cpt = 1 for alias in self.mb.alias_set.all(): @@ -143,6 +162,54 @@ class AccountFormMail(forms.Form, DynamicForm): """Ensure lower case emails""" return self.cleaned_data["email"].lower() + def create_mailbox(self, user, account): + locpart, domname = split_mailbox(self.cleaned_data["email"]) + try: + domain = Domain.objects.get(name=domname) + except Domain.DoesNotExist: + raise NotFound(_("Domain does not exist")) + if not user.can_access(domain): + raise PermDeniedException + try: + Mailbox.objects.get(address=locpart, domain=domain) + except Mailbox.DoesNotExist: + pass + else: + raise Conflict( + _("Mailbox %s already exists" % self.cleaned_data["email"]) + ) + events.raiseEvent("CanCreate", user, "mailboxes") + self.mb = Mailbox(address=locpart, domain=domain, user=account, + use_domain_quota=self.cleaned_data["quota_act"]) + self.mb.set_quota(self.cleaned_data["quota"], + user.has_perm("admin.add_domain")) + self.mb.save(creator=user) + + def update_mailbox(self, user, account): + newaddress = None + if self.cleaned_data["email"] != self.mb.full_address: + newaddress = self.cleaned_data["email"] + elif account.group == "SimpleUsers" and account.username != self.mb.full_address: + newaddress = account.username + if newaddress is not None: + self.mb.old_full_address = self.mb.full_address + local_part, domname = split_mailbox(newaddress) + try: + domain = Domain.objects.get(name=domname) + except Domain.DoesNotExist: + raise NotFound(_("Domain does not exist")) + if not user.can_access(domain): + raise PermDeniedException + self.mb.rename(local_part, domain) + + self.mb.use_domain_quota = self.cleaned_data["quota_act"] + override_rules = True \ + if not self.mb.quota or user.has_perm("admin.add_domain") \ + else False + self.mb.set_quota(self.cleaned_data["quota"], override_rules) + self.mb.save() + events.raiseEvent('MailboxModified', self.mb) + def save(self, user, account): if self.cleaned_data["email"] == "": return None @@ -151,74 +218,46 @@ class AccountFormMail(forms.Form, DynamicForm): self.cleaned_data["quota"] = None if not hasattr(self, "mb") or self.mb is None: - locpart, domname = split_mailbox(self.cleaned_data["email"]) - try: - domain = Domain.objects.get(name=domname) - except Domain.DoesNotExist: - raise AdminError(_("Domain does not exist")) - if not user.can_access(domain): - raise PermDeniedException - try: - Mailbox.objects.get(address=locpart, domain=domain) - except Mailbox.DoesNotExist: - pass - else: - raise AdminError(_("Mailbox %s already exists" % self.cleaned_data["email"])) - events.raiseEvent("CanCreate", user, "mailboxes") - self.mb = Mailbox(address=locpart, domain=domain, user=account, - use_domain_quota=self.cleaned_data["quota_act"]) - self.mb.set_quota(self.cleaned_data["quota"], - user.has_perm("admin.add_domain")) - self.mb.save(creator=user) + self.create_mailbox(user, account) else: - newaddress = None - if self.cleaned_data["email"] != self.mb.full_address: - newaddress = self.cleaned_data["email"] - elif account.group == "SimpleUsers" and account.username != self.mb.full_address: - newaddress = account.username - if newaddress is not None: - local_part, domname = split_mailbox(newaddress) - try: - domain = Domain.objects.get(name=domname) - except Domain.DoesNotExist: - raise AdminError(_("Domain does not exist")) - if not user.can_access(domain): - raise PermDeniedException - self.mb.rename(local_part, domain) - - self.mb.use_domain_quota = self.cleaned_data["quota_act"] - override_rules = True \ - if not self.mb.quota or user.has_perm("admin.add_domain") \ - else False - self.mb.set_quota(self.cleaned_data["quota"], override_rules) - self.mb.save() + self.update_mailbox(user, account) + events.raiseEvent( + 'SaveExtraFormFields', 'mailform', self.mb, self.cleaned_data + ) account.email = self.cleaned_data["email"] account.save() + aliases = [] for name, value in self.cleaned_data.iteritems(): if not name.startswith("aliases"): continue if value == "": continue - local_part, domname = split_mailbox(value) - try: - self.mb.alias_set.get(address=local_part, domain__name=domname) - except Alias.DoesNotExist: - pass - else: - continue - events.raiseEvent("CanCreate", user, "mailbox_aliases") - al = Alias(address=local_part, enabled=account.is_active) - al.domain = Domain.objects.get(name=domname) - al.save(int_rcpts=[self.mb], creator=user) + aliases.append(value) for alias in self.mb.alias_set.all(): - if len(alias.get_recipients()) >= 2: - continue - if not len(filter(lambda name: self.cleaned_data[name] == alias.full_address, - self.cleaned_data.keys())): + if not alias.full_address in aliases: + if len(alias.get_recipients()) >= 2: + continue alias.delete() + else: + aliases.remove(alias.full_address) + if aliases: + events.raiseEvent( + "CanCreate", user, "mailbox_aliases", len(aliases) + ) + for alias in aliases: + local_part, domname = split_mailbox(alias) + try: + self.mb.alias_set.get(address=local_part, domain__name=domname) + except Alias.DoesNotExist: + pass + else: + continue + al = Alias(address=local_part, enabled=account.is_active) + al.domain = Domain.objects.get(name=domname) + al.save(int_rcpts=[self.mb], creator=user) return self.mb diff --git a/modoboa/extensions/admin/forms/alias.py b/modoboa/extensions/admin/forms/alias.py index 89bfe4e68..555b98802 100644 --- a/modoboa/extensions/admin/forms/alias.py +++ b/modoboa/extensions/admin/forms/alias.py @@ -1,11 +1,11 @@ from django import forms from django.utils.translation import ugettext as _, ugettext_lazy from django.http import QueryDict +from modoboa.lib.exceptions import BadRequest, NotFound, Conflict from modoboa.lib.emailutils import split_mailbox from modoboa.lib.formutils import ( DynamicForm ) -from modoboa.extensions.admin.exceptions import AdminError from modoboa.extensions.admin.models import ( Domain, Mailbox, Alias ) @@ -14,11 +14,17 @@ from modoboa.extensions.admin.models import ( class AliasForm(forms.ModelForm, DynamicForm): email = forms.EmailField( label=ugettext_lazy("Email address"), - help_text=ugettext_lazy("The distribution list address. Use the '*' character to create a 'catchall' address (ex: *@domain.tld).") + help_text=ugettext_lazy( + "The distribution list address. Use the '*' character to create a " + "'catchall' address (ex: *@domain.tld)." + ) ) recipients = forms.EmailField( label=ugettext_lazy("Recipients"), required=False, - help_text=ugettext_lazy("Mailbox(es) this alias will point to. Indicate only one address per input, press ENTER to add a new input.") + help_text=ugettext_lazy( + "Mailbox(es) this alias will point to. Indicate only one address " + "per input, press ENTER to add a new input." + ) ) class Meta: @@ -58,7 +64,9 @@ class AliasForm(forms.ModelForm, DynamicForm): except Domain.DoesNotExist: raise forms.ValidationError(_("Domain does not exist")) if not self.user.can_access(domain): - raise forms.ValidationError(_("You don't have access to this domain")) + raise forms.ValidationError( + _("You don't have access to this domain") + ) return self.cleaned_data["email"] def set_recipients(self): @@ -78,7 +86,9 @@ class AliasForm(forms.ModelForm, DynamicForm): continue local_part, domname = split_mailbox(v) if domname is None: - raise AdminError("%s %s" % (_("Invalid mailbox"), v)) + raise BadRequest( + u"%s %s" % (_("Invalid mailbox"), v) + ) try: domain = Domain.objects.get(name=domname) except Domain.DoesNotExist: @@ -94,20 +104,26 @@ class AliasForm(forms.ModelForm, DynamicForm): try: rcpt = Mailbox.objects.get(domain=domain, address=local_part) except Mailbox.DoesNotExist: - raise AdminError(_("Local recipient %s not found" % v)) + raise NotFound( + _("Local recipient %s not found" % v) + ) if rcpt in self.int_rcpts: - raise AdminError(_("Recipient %s already present" % v)) + raise Conflict( + _("Recipient %s already present" % v) + ) self.int_rcpts += [rcpt] total += 1 continue if v in self.ext_rcpts: - raise AdminError(_("Recipient %s already present" % v)) + raise Conflict( + _("Recipient %s already present" % v) + ) self.ext_rcpts += [v] total += 1 if total == 0: - raise AdminError(_("No recipient defined")) + raise BadRequest(_("No recipient defined")) def save(self, commit=True): alias = super(AliasForm, self).save(commit=False) diff --git a/modoboa/extensions/admin/forms/domain.py b/modoboa/extensions/admin/forms/domain.py index 2bc9a842a..c19b60d68 100644 --- a/modoboa/extensions/admin/forms/domain.py +++ b/modoboa/extensions/admin/forms/domain.py @@ -2,11 +2,11 @@ from django import forms from django.http import QueryDict from django.utils.translation import ugettext as _, ugettext_lazy from modoboa.lib import events +from modoboa.lib.exceptions import Conflict from modoboa.lib.formutils import ( DomainNameField, YesNoField, DynamicForm, TabForms ) from modoboa.core.models import User -from modoboa.extensions.admin.exceptions import AdminError from modoboa.extensions.admin.models import ( Domain, DomainAlias, Mailbox, Alias, Quota ) @@ -16,7 +16,10 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): aliases = DomainNameField( label=ugettext_lazy("Alias(es)"), required=False, - help_text=ugettext_lazy("Alias(es) of this domain. Indicate only one name per input, press ENTER to add a new input.") + help_text=ugettext_lazy( + "Alias(es) of this domain. Indicate only one name per input, " + "press ENTER to add a new input." + ) ) class Meta: @@ -41,35 +44,48 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): self._create_field(forms.CharField, name, dalias.name, 3) def clean(self): + """Custom fields validation. + + We want to prevent duplicate names between domains and domain + aliases. Extensions have the possibility to declare other + objects (see *CheckDomainName* event). + + The validation way is not very smart... + """ super(DomainFormGeneral, self).clean() - if len(self._errors): + if self._errors: raise forms.ValidationError(self._errors) cleaned_data = self.cleaned_data name = cleaned_data["name"] - - try: - DomainAlias.objects.get(name=name) - except DomainAlias.DoesNotExist: - pass - else: - self._errors["name"] = self.error_class([_("An alias with this name already exists")]) - del cleaned_data["name"] - + dtypes = events.raiseQueryEvent('CheckDomainName') + for dtype, label in [(DomainAlias, _('domain alias'))] + dtypes: + try: + dtype.objects.get(name=name) + except dtype.DoesNotExist: + pass + else: + self._errors["name"] = self.error_class( + [_("A %s with this name already exists" % unicode(label))] + ) + del cleaned_data["name"] + break for k in cleaned_data.keys(): if not k.startswith("aliases"): continue if cleaned_data[k] == "": del cleaned_data[k] continue - try: - Domain.objects.get(name=cleaned_data[k]) - except Domain.DoesNotExist: - pass - else: - self._errors[k] = self.error_class([_("A domain with this name already exists")]) + for dtype, label in [(Domain, _('domain'))] + dtypes: + try: + dtype.objects.get(name=cleaned_data[k]) + except dtype.DoesNotExist: + continue + self._errors[k] = self.error_class( + [_("A %s with this name already exists" % unicode(label))] + ) del cleaned_data[k] - + break return cleaned_data def update_mailbox_quotas(self, domain): @@ -91,7 +107,7 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): username = q['username'].replace('@%s' % self.oldname, '@%s' % domain.name) Quota.objects.filter(username=q['username']).update(username=username) - def save(self, user, commit=True): + def save(self, user, commit=True, domalias_post_create=False): """Custom save method Updating a domain may have consequences on other objects @@ -110,25 +126,29 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): d.save() Mailbox.objects.filter(domain=d, use_domain_quota=True) \ .update(quota=d.quota) + aliases = [] for k, v in self.cleaned_data.iteritems(): if not k.startswith("aliases"): continue if v in ["", None]: continue - try: - d.domainalias_set.get(name=v) - except DomainAlias.DoesNotExist: - pass - else: - continue - events.raiseEvent("CanCreate", user, "domain_aliases") - al = DomainAlias(name=v, target=d, enabled=d.enabled) - al.save(creator=user) - + aliases.append(v) for dalias in d.domainalias_set.all(): - if not len(filter(lambda name: self.cleaned_data[name] == dalias.name, - self.cleaned_data.keys())): + if not dalias.name in aliases: dalias.delete() + else: + aliases.remove(dalias.name) + if aliases: + events.raiseEvent("CanCreate", user, "domain_aliases", len(aliases)) + for alias in aliases: + try: + d.domainalias_set.get(name=alias) + except DomainAlias.DoesNotExist: + pass + else: + continue + al = DomainAlias(name=alias, target=d, enabled=d.enabled) + al.save(creator=user) if domalias_post_create else al.save() if old_mail_homes is not None: self.update_mailbox_quotas(d) @@ -148,7 +168,10 @@ class DomainFormOptions(forms.Form): dom_admin_username = forms.CharField( label=ugettext_lazy("Name"), initial="admin", - help_text=ugettext_lazy("The administrator's name. Don't include the domain's name here, it will be automatically appended."), + help_text=ugettext_lazy( + "The administrator's name. Don't include the domain's name here, " + "it will be automatically appended." + ), widget=forms.widgets.TextInput(attrs={"class": "input-small"}), required=False ) @@ -160,8 +183,11 @@ class DomainFormOptions(forms.Form): required=False ) - def __init__(self, *args, **kwargs): + def __init__(self, user, *args, **kwargs): super(DomainFormOptions, self).__init__(*args, **kwargs) + if False in events.raiseQueryEvent('UserCanSetRole', user, 'DomainAdmins'): + self.fields = {} + return if args: if args[0].get("create_dom_admin", "no") == "yes": self.fields["dom_admin_username"].required = True @@ -173,6 +199,8 @@ class DomainFormOptions(forms.Form): return self.cleaned_data["dom_admin_username"] def save(self, user, domain): + if not self.fields: + return if self.cleaned_data["create_dom_admin"] == "no": return username = "%s@%s" % (self.cleaned_data["dom_admin_username"], domain.name) @@ -181,7 +209,8 @@ class DomainFormOptions(forms.Form): except User.DoesNotExist: pass else: - raise AdminError(_("User '%s' already exists" % username)) + raise Conflict(_("User '%s' already exists" % username)) + events.raiseEvent("CanCreate", user, "mailboxes") da = User(username=username, email=username, is_active=True) da.set_password("password") da.save() @@ -193,6 +222,7 @@ class DomainFormOptions(forms.Form): mb.save(creator=user) if self.cleaned_data["create_aliases"] == "yes": + events.raiseEvent("CanCreate", user, "mailbox_aliases") al = Alias(address="postmaster", domain=domain, enabled=True) al.save(int_rcpts=[mb], creator=user) @@ -223,5 +253,6 @@ class DomainForm(TabForms): As forms interact with each other, it is easier to make custom code to save them. """ - for f in self.forms: + self.forms[0]['instance'].save(user, domalias_post_create=True) + for f in self.forms[1:]: f["instance"].save(user) diff --git a/modoboa/extensions/admin/forms/forward.py b/modoboa/extensions/admin/forms/forward.py index fa1e7a53a..9cf7eb643 100644 --- a/modoboa/extensions/admin/forms/forward.py +++ b/modoboa/extensions/admin/forms/forward.py @@ -1,7 +1,7 @@ from django import forms from django.utils.translation import ugettext as _, ugettext_lazy +from modoboa.lib.exceptions import BadRequest, PermDeniedException from modoboa.lib.emailutils import split_mailbox -from modoboa.extensions.admin.exceptions import BadDestination from modoboa.extensions.admin.models import ( Domain ) @@ -28,10 +28,14 @@ class ForwardForm(forms.Form): for d in rawdata.split(","): local_part, domname = split_mailbox(d) if not local_part or not domname or not len(domname): - raise BadDestination("Invalid mailbox syntax for %s" % d) + raise BadRequest("Invalid mailbox syntax for %s" % d) try: Domain.objects.get(name=domname) except Domain.DoesNotExist: self.dests += [d] else: - raise BadDestination(_("You can't define a forward to a local destination. Please ask your administrator to create an alias instead.")) + raise PermDeniedException( + _("You can't define a forward to a local destination. " + "Please ask your administrator to create an alias " + "instead.") + ) diff --git a/modoboa/extensions/admin/lib.py b/modoboa/extensions/admin/lib.py index 758371f13..d84a998d4 100644 --- a/modoboa/extensions/admin/lib.py +++ b/modoboa/extensions/admin/lib.py @@ -1,13 +1,17 @@ # coding: utf-8 from functools import wraps +from itertools import chain from django.db.models import Q from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.contrib.contenttypes.models import ContentType +from django.utils.translation import ugettext as _ from modoboa.core.models import User -from modoboa.lib import parameters -from modoboa.lib.exceptions import ModoboaException +from modoboa.lib import parameters, events +from modoboa.lib.exceptions import PermDeniedException from modoboa.lib.emailutils import split_mailbox -from modoboa.extensions.admin.models import Alias +from modoboa.extensions.admin.models import ( + Domain, Alias +) def needs_mailbox(): @@ -21,7 +25,7 @@ def needs_mailbox(): def wrapped_f(request, *args, **kwargs): if request.user.mailbox_set.count(): return f(request, *args, **kwargs) - raise ModoboaException() + raise PermDeniedException(_("A mailbox is required")) return wrapped_f return decorator @@ -46,6 +50,16 @@ def get_sort_order(qdict, default, allowed_values=None): def get_listing_page(objects, pagenum): + """Return specific a listing page. + + A page contains a limited number of elements (see + ITEMS_PER_PAGE). If the given page number is wrong, the first page + will be always returned. + + :param list objects: object list to paginate + :param int pagenum: page number + :return: a ``Page`` object + """ paginator = Paginator( objects, int(parameters.get_admin("ITEMS_PER_PAGE", app="core")) ) @@ -65,8 +79,6 @@ def get_identities(user, searchquery=None, idtfilter=None, grpfilter=None): :param list grpfilter: group names filters :return: a queryset """ - from itertools import chain - accounts = [] if idtfilter is None or not idtfilter or idtfilter == "account": ids = user.objectaccess_set \ @@ -104,3 +116,24 @@ def get_identities(user, searchquery=None, idtfilter=None, grpfilter=None): if idtfilter is not None and idtfilter: aliases = [al for al in aliases if al.type == idtfilter] return chain(accounts, aliases) + + +def get_domains(user, domfilter=None, searchquery=None, **extrafilters): + """Return all the domains the user can access. + + :param ``User`` user: user object + :param str searchquery: filter + :rtype: list + :return: a list of domains and/or relay domains + """ + domains = [] + if domfilter is None or not domfilter or domfilter == 'domain': + domains = Domain.objects.get_for_admin(user) + if searchquery is not None: + q = Q(name__contains=searchquery) + q |= Q(domainalias__name__contains=searchquery) + domains = domains.filter(q).distinct() + extra_domain_entries = events.raiseQueryEvent( + 'ExtraDomainEntries', user, domfilter, searchquery, **extrafilters + ) + return chain(domains, extra_domain_entries) diff --git a/modoboa/extensions/admin/management/commands/handle_mailbox_operations.py b/modoboa/extensions/admin/management/commands/handle_mailbox_operations.py index 56a5d24a1..056b4a55e 100644 --- a/modoboa/extensions/admin/management/commands/handle_mailbox_operations.py +++ b/modoboa/extensions/admin/management/commands/handle_mailbox_operations.py @@ -4,8 +4,8 @@ from optparse import make_option from django.core.management.base import BaseCommand from modoboa.lib import parameters from modoboa.lib.sysutils import exec_cmd +from modoboa.lib.exceptions import InternalError from modoboa.extensions.admin import AdminConsole -from modoboa.extensions.admin.exceptions import AdminError from modoboa.extensions.admin.models import MailboxOperation @@ -83,7 +83,7 @@ class Command(BaseCommand): continue try: f(ope) - except (OperationError, AdminError) as e: + except (OperationError, InternalError) as e: self.logger.critical('%s failed (reason: %s)', ope, str(e).encode('utf-8')) else: diff --git a/modoboa/extensions/admin/migrations/0037_rename_tables.py b/modoboa/extensions/admin/migrations/0037_rename_tables.py index 077918c22..6b08aabd7 100644 --- a/modoboa/extensions/admin/migrations/0037_rename_tables.py +++ b/modoboa/extensions/admin/migrations/0037_rename_tables.py @@ -75,6 +75,13 @@ class Migration(SchemaMigration): 'use_domain_quota': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['core.User']"}) }, + u'admin.mailboxoperation': { + 'Meta': {'object_name': 'MailboxOperation'}, + 'argument': ('django.db.models.fields.TextField', [], {}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mailbox': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.Mailbox']", 'null': 'True', 'blank': 'True'}), + 'type': ('django.db.models.fields.CharField', [], {'max_length': '20'}) + }, 'admin.objectdates': { 'Meta': {'object_name': 'ObjectDates'}, 'creation': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), diff --git a/modoboa/extensions/admin/models/__init__.py b/modoboa/extensions/admin/models/__init__.py index 8edb89898..641eff9ad 100644 --- a/modoboa/extensions/admin/models/__init__.py +++ b/modoboa/extensions/admin/models/__init__.py @@ -1,8 +1,10 @@ +from .base import AdminObject from .domain import Domain from .domain_alias import DomainAlias from .mailbox import Mailbox, Quota, MailboxOperation from .alias import Alias __all__ = [ - 'Domain', 'DomainAlias', 'Mailbox', 'Quota', 'Alias', 'MailboxOperation' + 'Domain', 'DomainAlias', 'Mailbox', 'Quota', 'Alias', + 'MailboxOperation', 'AdminObject' ] diff --git a/modoboa/extensions/admin/models/alias.py b/modoboa/extensions/admin/models/alias.py index 134bf62d8..e8720ef5e 100644 --- a/modoboa/extensions/admin/models/alias.py +++ b/modoboa/extensions/admin/models/alias.py @@ -1,19 +1,23 @@ import reversion from django.db import models from django.utils.translation import ugettext as _, ugettext_lazy -from modoboa.lib import events from modoboa.lib.emailutils import split_mailbox -from modoboa.lib.exceptions import PermDeniedException -from modoboa.extensions.admin.exceptions import AdminError -from .base import DatesAware +from modoboa.lib.exceptions import PermDeniedException, BadRequest +from .base import AdminObject from .domain import Domain from .mailbox import Mailbox -class Alias(DatesAware): +class Alias(AdminObject): + """ + Mailbox alias. + """ address = models.CharField( ugettext_lazy('address'), max_length=254, - help_text=ugettext_lazy("The alias address (without the domain part). For a 'catch-all' address, just enter an * character.") + help_text=ugettext_lazy( + "The alias address (without the domain part). For a 'catch-all' " + "address, just enter an * character." + ) ) domain = models.ForeignKey(Domain) mboxes = models.ManyToManyField( @@ -29,6 +33,7 @@ class Alias(DatesAware): ugettext_lazy('enabled'), help_text=ugettext_lazy("Check to activate this alias") ) + _objectname = 'MailboxAlias' class Meta: permissions = ( @@ -80,8 +85,7 @@ class Alias(DatesAware): def post_create(self, creator): from modoboa.lib.permissions import grant_access_to_object - grant_access_to_object(creator, self, is_owner=True) - events.raiseEvent("MailboxAliasCreated", creator, self) + super(Alias, self).post_create(creator) if creator.is_superuser: for admin in self.domain.admins: grant_access_to_object(admin, self) @@ -96,14 +100,7 @@ class Alias(DatesAware): del kwargs['int_rcpts'] else: int_rcpts = [] - if "creator" in kwargs: - creator = kwargs["creator"] - del kwargs["creator"] - else: - creator = None super(Alias, self).save(*args, **kwargs) - if creator is not None: - self.post_create(creator) curaliases = self.aliases.all() curmboxes = self.mboxes.all() for t in int_rcpts: @@ -120,13 +117,6 @@ class Alias(DatesAware): if not t in int_rcpts: self.mboxes.remove(t) - def delete(self): - from modoboa.lib.permissions import ungrant_access_to_object - - events.raiseEvent("MailboxAliasDeleted", self) - ungrant_access_to_object(self) - super(Alias, self).delete() - def get_recipients(self): """Return the recipients list @@ -153,12 +143,12 @@ class Alias(DatesAware): """ if len(row) < expected_elements: - raise AdminError(_("Invalid line: %s" % row)) + raise BadRequest(_("Invalid line: %s" % row)) localpart, domname = split_mailbox(row[1].strip()) try: domain = Domain.objects.get(name=domname) except Domain.DoesNotExist: - raise AdminError(_("Domain '%s' does not exist" % domname)) + raise BadRequest(_("Domain '%s' does not exist" % domname)) if not user.can_access(domain): raise PermDeniedException self.address = localpart @@ -185,7 +175,7 @@ class Alias(DatesAware): target = Mailbox.objects.get(address=localpart, domain__name=domname) except Mailbox.DoesNotExist: - raise AdminError(_("Local recipient %s not found" % rcpt)) + raise BadRequest(_("Local recipient %s not found" % rcpt)) int_rcpts += [target] self.save(int_rcpts=int_rcpts, ext_rcpts=ext_rcpts, creator=user) diff --git a/modoboa/extensions/admin/models/base.py b/modoboa/extensions/admin/models/base.py index ad94fee92..5f3fd61a3 100644 --- a/modoboa/extensions/admin/models/base.py +++ b/modoboa/extensions/admin/models/base.py @@ -1,4 +1,8 @@ from django.db import models +from modoboa.lib import events +from modoboa.lib.permissions import ( + grant_access_to_object, ungrant_access_to_object +) class ObjectDates(models.Model): @@ -27,7 +31,7 @@ class ObjectDates(models.Model): obj.dates = dates -class DatesAware(models.Model): +class AdminObject(models.Model): """Abstract model to support dates Inherit from this model to automatically add the "dates" feature @@ -35,14 +39,11 @@ class DatesAware(models.Model): saves. """ dates = models.ForeignKey(ObjectDates) + _objectname = None class Meta: abstract = True - def save(self, *args, **kwargs): - ObjectDates.set_for_object(self) - super(DatesAware, self).save(*args, **kwargs) - @property def creation(self): return self.dates.creation @@ -50,3 +51,29 @@ class DatesAware(models.Model): @property def last_modification(self): return self.dates.last_modification + + @property + def objectname(self): + if self._objectname is None: + return self.__class__.__name__ + return self._objectname + + def post_create(self, creator): + grant_access_to_object(creator, self, is_owner=True) + events.raiseEvent("%sCreated" % self.objectname, creator, self) + + def save(self, *args, **kwargs): + ObjectDates.set_for_object(self) + if "creator" in kwargs: + creator = kwargs["creator"] + del kwargs["creator"] + else: + creator = None + super(AdminObject, self).save(*args, **kwargs) + if creator is not None: + self.post_create(creator) + + def delete(self): + events.raiseEvent("%sDeleted" % self.objectname, self) + ungrant_access_to_object(self) + super(AdminObject, self).delete() diff --git a/modoboa/extensions/admin/models/domain.py b/modoboa/extensions/admin/models/domain.py index c4d7d5a36..e7467d592 100644 --- a/modoboa/extensions/admin/models/domain.py +++ b/modoboa/extensions/admin/models/domain.py @@ -4,9 +4,9 @@ from django.db.models.manager import Manager from django.utils.translation import ugettext as _, ugettext_lazy from django.contrib.contenttypes import generic from modoboa.lib import events, parameters +from modoboa.lib.exceptions import BadRequest from modoboa.core.models import User, ObjectAccess -from modoboa.extensions.admin.exceptions import AdminError -from .base import DatesAware +from .base import AdminObject class DomainManager(Manager): @@ -22,7 +22,9 @@ class DomainManager(Manager): return self.get_query_set().filter(owners__user=admin) -class Domain(DatesAware): +class Domain(AdminObject): + """Mail domain. + """ name = models.CharField(ugettext_lazy('name'), max_length=100, unique=True, help_text=ugettext_lazy("The domain name")) quota = models.IntegerField( @@ -56,6 +58,10 @@ class Domain(DatesAware): def mbalias_count(self): return self.alias_set.count() + @property + def tags(self): + return [{"name": "domain", "label": _("Domain"), "type": "dom"}] + @property def admins(self): """Return the domain administrators of this domain @@ -64,6 +70,10 @@ class Domain(DatesAware): """ return [oa.user for oa in self.owners.filter(user__is_superuser=False)] + @property + def aliases(self): + return self.domainalias_set + def add_admin(self, account): """Add a new administrator for this domain @@ -80,11 +90,15 @@ class Domain(DatesAware): grant_access_to_object(account, al) def remove_admin(self, account): - """Remove an administrator for this domain + """Remove an administrator of this domain. - :param User account: the administrotor to remove + :param User account: administrator to remove """ - from modoboa.lib.permissions import ungrant_access_to_object + from modoboa.lib.permissions import \ + ungrant_access_to_object, get_object_owner + + if get_object_owner(self) == account: + events.raiseEvent('DomainOwnershipRemoved', account, self) ungrant_access_to_object(self, account) for mb in self.mailbox_set.all(): if mb.user.has_perm("admin.add_domain"): @@ -92,60 +106,55 @@ class Domain(DatesAware): ungrant_access_to_object(mb, account) ungrant_access_to_object(mb.user, account) for al in self.alias_set.all(): - ungrant_access_to_object(al, account) + ungrant_access_to_object(al, account) def delete(self, fromuser, keepdir=False): - from modoboa.lib.permissions import \ - ungrant_access_to_object, ungrant_access_to_objects + """Custom delete method. + """ + from modoboa.lib.permissions import ungrant_access_to_objects from .mailbox import Quota if self.domainalias_set.count(): events.raiseEvent("DomainAliasDeleted", self.domainalias_set.all()) ungrant_access_to_objects(self.domainalias_set.all()) - if self.mailbox_set.count(): - Quota.objects.filter(username__contains='@%s' % self.name).delete() - events.raiseEvent("DeleteMailbox", self.mailbox_set.all()) - ungrant_access_to_objects(self.mailbox_set.all()) if self.alias_set.count(): - events.raiseEvent("MailboxAliasDelete", self.alias_set.all()) + events.raiseEvent("MailboxAliasDeleted", self.alias_set.all()) ungrant_access_to_objects(self.alias_set.all()) if parameters.get_admin("AUTO_ACCOUNT_REMOVAL") == "yes": for account in User.objects.filter(mailbox__domain__name=self.name): account.delete(fromuser, keepdir) - events.raiseEvent("DeleteDomain", self) - ungrant_access_to_object(self) + elif self.mailbox_set.count(): + Quota.objects.filter(username__contains='@%s' % self.name).delete() + events.raiseEvent("MailboxDeleted", self.mailbox_set.all()) + ungrant_access_to_objects(self.mailbox_set.all()) super(Domain, self).delete() def __str__(self): return self.name - def post_create(self, creator): - from modoboa.lib.permissions import grant_access_to_object - grant_access_to_object(creator, self, is_owner=True) - events.raiseEvent("CreateDomain", creator, self) - - def save(self, *args, **kwargs): - if "creator" in kwargs: - creator = kwargs["creator"] - del kwargs["creator"] - else: - creator = None - super(Domain, self).save(*args, **kwargs) - if creator is not None: - self.post_create(creator) - def from_csv(self, user, row): if len(row) < 4: - raise AdminError(_("Invalid line")) + raise BadRequest(_("Invalid line")) self.name = row[1].strip() try: self.quota = int(row[2].strip()) except ValueError: - raise AdminError(_("Invalid quota value for domain '%s'" % self.name)) + raise BadRequest(_("Invalid quota value for domain '%s'" % self.name)) self.enabled = (row[3].strip() == 'True') self.save(creator=user) def to_csv(self, csvwriter): csvwriter.writerow(["domain", self.name, self.quota, self.enabled]) + for dalias in self.domainalias_set.all(): + dalias.to_csv(csvwriter) + + def post_create(self, creator): + """Post creation actions. + + :param ``User`` creator: user whos created this domain + """ + super(Domain, self).post_create(creator) + for domalias in self.domainalias_set.all(): + domalias.post_create(creator) reversion.register(Domain) diff --git a/modoboa/extensions/admin/models/domain_alias.py b/modoboa/extensions/admin/models/domain_alias.py index 2e7727327..a24549b96 100644 --- a/modoboa/extensions/admin/models/domain_alias.py +++ b/modoboa/extensions/admin/models/domain_alias.py @@ -1,13 +1,12 @@ import reversion from django.db import models from django.utils.translation import ugettext as _, ugettext_lazy -from modoboa.lib import events -from modoboa.extensions.admin.exceptions import AdminError -from .base import DatesAware +from modoboa.lib.exceptions import BadRequest, Conflict +from .base import AdminObject from .domain import Domain -class DomainAlias(DatesAware): +class DomainAlias(AdminObject): name = models.CharField(ugettext_lazy("name"), max_length=100, unique=True, help_text=ugettext_lazy("The alias name")) target = models.ForeignKey( @@ -28,27 +27,6 @@ class DomainAlias(DatesAware): def __unicode__(self): return self.name - def post_create(self, creator): - from modoboa.lib.permissions import grant_access_to_object - grant_access_to_object(creator, self, is_owner=True) - events.raiseEvent("DomainAliasCreated", creator, self) - - def save(self, *args, **kwargs): - if "creator" in kwargs: - creator = kwargs["creator"] - del kwargs["creator"] - else: - creator = None - super(DomainAlias, self).save(*args, **kwargs) - if creator is not None: - self.post_create(creator) - - def delete(self): - from modoboa.lib.permissions import ungrant_access_to_object - events.raiseEvent("DomainAliasDeleted", self) - ungrant_access_to_object(self) - super(DomainAlias, self).delete() - def from_csv(self, user, row): """Create a domain alias from a CSV row @@ -58,13 +36,19 @@ class DomainAlias(DatesAware): :param row: a list containing the alias definition """ if len(row) < 4: - raise AdminError(_("Invalid line")) + raise BadRequest(_("Invalid line")) self.name = row[1].strip() + try: + Domain.objects.get(name=self.name) + except Domain.DoesNotExist: + pass + else: + raise Conflict(_("A domain %s already exists" % self.name)) domname = row[2].strip() try: self.target = Domain.objects.get(name=domname) except Domain.DoesNotExist: - raise AdminError(_("Unknown domain %s" % domname)) + raise BadRequest(_("Unknown domain %s" % domname)) self.enabled = row[3].strip() == 'True' self.save(creator=user) diff --git a/modoboa/extensions/admin/models/mailbox.py b/modoboa/extensions/admin/models/mailbox.py index 0150558f5..ed61adcbe 100644 --- a/modoboa/extensions/admin/models/mailbox.py +++ b/modoboa/extensions/admin/models/mailbox.py @@ -9,10 +9,10 @@ from django.utils.translation import ugettext as _, ugettext_lazy from django.db.models.signals import pre_delete from django.dispatch import receiver from modoboa.lib import parameters, events +from modoboa.lib.exceptions import BadRequest, InternalError from modoboa.lib.sysutils import exec_cmd from modoboa.core.models import User -from modoboa.extensions.admin.exceptions import AdminError -from .base import DatesAware +from .base import AdminObject from .domain import Domain @@ -46,7 +46,7 @@ class MailboxManager(Manager): return self.get_query_set().filter(qf) -class Mailbox(DatesAware): +class Mailbox(AdminObject): address = models.CharField( ugettext_lazy('address'), max_length=252, help_text=ugettext_lazy("Mailbox address (without the @domain.tld part)") @@ -110,7 +110,7 @@ class Mailbox(DatesAware): "doveadm user %s -f home" % self.full_address, **options ) if code: - raise AdminError(_("Failed to retrieve mailbox location (%s)" % output)) + raise InternalError(_("Failed to retrieve mailbox location (%s)" % output)) self.__mail_home = output.strip() return self.__mail_home @@ -158,13 +158,13 @@ class Mailbox(DatesAware): else: self.quota = 0 elif int(value) > self.domain.quota and not override_rules: - raise AdminError( + raise BadRequest( _("Quota is greater than the allowed domain's limit (%dM)" % self.domain.quota) ) else: self.quota = value if not self.quota and self.domain.quota and not override_rules: - raise AdminError(_("A quota is required")) + raise BadRequest(_("A quota is required")) def get_quota(self): """Get quota limit. @@ -186,8 +186,7 @@ class Mailbox(DatesAware): def post_create(self, creator): from modoboa.lib.permissions import grant_access_to_object - grant_access_to_object(creator, self, True) - events.raiseEvent("CreateMailbox", creator, self) + super(Mailbox, self).post_create(creator) if creator.is_superuser and not self.user.has_perm("admin.add_domain"): # A super user is creating a new mailbox. Give # access to that mailbox (and the associated @@ -199,14 +198,7 @@ class Mailbox(DatesAware): grant_access_to_object(admin, self.user) def save(self, *args, **kwargs): - if "creator" in kwargs: - creator = kwargs["creator"] - del kwargs["creator"] - else: - creator = None super(Mailbox, self).save(*args, **kwargs) - if creator is not None: - self.post_create(creator) try: q = self.quota_value except Quota.DoesNotExist: @@ -246,7 +238,7 @@ def mailbox_deleted_handler(sender, **kwargs): from modoboa.lib.permissions import ungrant_access_to_object mb = kwargs['instance'] - events.raiseEvent("DeleteMailbox", mb) + events.raiseEvent("MailboxDeleted", mb) ungrant_access_to_object(mb) for alias in mb.alias_set.all(): alias.mboxes.remove(mb) diff --git a/modoboa/extensions/admin/static/admin/js/admin.js b/modoboa/extensions/admin/static/admin/js/admin.js index 0e7c225be..0ed43c300 100644 --- a/modoboa/extensions/admin/static/admin/js/admin.js +++ b/modoboa/extensions/admin/static/admin/js/admin.js @@ -1,3 +1,8 @@ +/** + * @module admin + * @desc Utility classes for the admin application + */ + var Admin = function(options) { Listing.call(this, options); }; @@ -12,6 +17,7 @@ Admin.prototype = { Listing.prototype.initialize.call(this, options); this.options = $.extend({}, this.defaults, this.options); this.options.defcallback = $.proxy(this.list_cb, this); + this.tag_handlers = {}; this.navobj = new History(this.options); @@ -37,12 +43,61 @@ Admin.prototype = { list_cb: function(data) { $("#listing").html(data.table); this.update_listing(data); + $("a.filter").click($.proxy(this.filter_by_tag, this)); }, listen: function() { $("#searchform").submit($.proxy(this.do_search, this)); }, + register_tag_handler: function(name, handler) { + this.tag_handlers[name] = handler; + if (this.navobj.getparam(name + "filter") != undefined) { + var text = this.navobj.getparam(name + "filter"); + $("#searchform").parent().after(this.make_tag(text, name)); + } + }, + + generic_tag_handler: function(tag, $link) { + if (this.navobj.getparam(tag + "filter") == undefined && $link.hasClass(tag)) { + var text = $link.attr("name"); + this.navobj.setparam(tag + "filter", text).update(); + $("#searchform").parent().after(this.make_tag(text, tag)); + return true; + } + return false; + }, + + make_tag: function(text, type) { + var $tag = $("", {"name": type, "class" : "btn btn-mini", "html": text}); + var $i = $("", {"class" : "icon-remove"}).prependTo($tag); + + $tag.click($.proxy(this.remove_tag, this)); + return $tag; + }, + + remove_tag: function(e) { + var $tag = $(e.target); + + if ($tag.is("i")) { + $tag = $tag.parent(); + } + e.preventDefault(); + this.navobj.delparam($tag.attr("name") + "filter").update(); + $tag.remove(); + }, + + filter_by_tag: function(e) { + var $link = $(e.target); + e.preventDefault(); + + for (var name in this.tag_handlers) { + if (this.tag_handlers[name].apply(this, [name, $link])) { + break; + } + } + }, + do_search: function(e) { e.preventDefault(); var squery = $("#searchquery").val(); @@ -70,7 +125,7 @@ Admin.prototype = { $("#import_status").css("display", "none"); if (status == "ok") { $("#modalbox").modal('hide'); - this.reload_listing({respmsg: msg}); + this.reload_listing(msg); } else { $("#import_result").addClass("alert alert-error"); $("#import_result").html(msg); @@ -88,8 +143,8 @@ Admin.prototype = { reload_listing: function(data) { this.navobj.update(true); - if (data.respmsg) { - $("body").notify("success", data.respmsg, 2000); + if (data) { + $("body").notify("success", data, 2000); } } }; @@ -104,6 +159,11 @@ var Domains = function(options) { }; Domains.prototype = { + initialize: function(options) { + Admin.prototype.initialize.call(this, options); + this.register_tag_handler("dom", this.generic_tag_handler); + }, + list_cb: function(data) { Admin.prototype.list_cb.call(this, data); var deloptions = (data.handle_mailboxes) @@ -120,6 +180,7 @@ Domains.prototype = { checkboxes: deloptions, success_cb: $.proxy(this.reload_listing, this) }); + $(document).trigger('domain_listing_refresh'); }, change_inputs_state: function(value) { @@ -152,11 +213,11 @@ Domains.prototype = { domadminsform_init: function() { $("a[name=removeperm]").click(function(e) { - var $tr = $(this).parent().parent(); + var $tr = $(this).parents('tr'); simple_ajax_request.apply(this, [e, { ok_cb: function(resp) { $tr.remove(); - if (!$("#domadmins").children("tr").length) { + if (!$("#domadmins").find("tr").length) { $("#admins").html('
' + gettext("No domain administrator defined") + "
"); } @@ -174,7 +235,6 @@ Domains.prototype = { 1: this.optionsform_prefill }, error_callbacks: { - 1: this.generalform_init, 2: $.proxy(this.optionsform_init, this) }, success_callback: $.proxy(this.reload_listing, this) @@ -194,7 +254,6 @@ Domains.prototype = { }, this)); $(document).trigger('domform_init'); } - }; Domains.prototype = $.extend({}, Admin.prototype, Domains.prototype); @@ -209,14 +268,8 @@ var Identities = function(options) { Identities.prototype = { initialize: function(options) { Admin.prototype.initialize.call(this, options); - if (this.navobj.getparam("idtfilter") != undefined) { - var text = this.navobj.getparam("idtfilter"); - $("#searchform").parent().after(this.make_tag(text, "idt")); - } - if (this.navobj.getparam("grpfilter") != undefined) { - var text = this.navobj.getparam("grpfilter"); - $("#searchform").parent().after(this.make_tag(text, "grp")); - } + this.register_tag_handler("idt", this.generic_tag_handler); + this.register_tag_handler("grp", this.grp_tag_handler); }, listen: function() { @@ -237,7 +290,6 @@ Identities.prototype = { if (data.handle_mailboxes == "yes") { deloptions = {keepdir: gettext("Do not delete mailbox directory")}; } - $("a.filter").click($.proxy(this.filter_by_tag, this)); $("a[name=delaccount]").confirm({ question: function() { return this.$element.attr('title'); }, @@ -252,46 +304,20 @@ Identities.prototype = { }); }, - make_tag: function(text, type) { - var $tag = $("
", {"name": type, "class" : "btn btn-mini", "html": text}); - var $i = $("", {"class" : "icon-remove"}).prependTo($tag); - - $tag.click($.proxy(this.remove_tag, this)); - return $tag; - }, - - remove_tag: function(e) { - var $tag = $(e.target); - - if ($tag.is("i")) { - $tag = $tag.parent(); - } - e.preventDefault(); - this.navobj.delparam($tag.attr("name") + "filter").update(); - $tag.remove(); - }, - - filter_by_tag: function(e) { - var $link = $(e.target); - e.preventDefault(); - - if (this.navobj.getparam("idtfilter") == undefined && $link.hasClass("idt")) { - var text = $link.attr("name"); - this.navobj.setparam("idtfilter", text).update(); - $("#searchform").parent().after(this.make_tag(text, "idt")); - return; - } - if (this.navobj.getparam("grpfilter") == undefined && $link.hasClass("grp")) { + 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("grpfilter", text) + .setparam(tag + "filter", text) .update(); if ($("a[name=idt]").length == 0) { $("#searchform").parent().after(this.make_tag("account", "idt")); } - $("#searchform").parent().after(this.make_tag(text, "grp")); + $("#searchform").parent().after(this.make_tag(text, tag)); + return true; } + return false; }, simpleuser_mode: function() { @@ -368,20 +394,15 @@ Identities.prototype = { transition_callbacks: { 1: this.mailform_prefill }, - error_callbacks: { - 1: $.proxy(this.generalform_init, this), - 2: $.proxy(this.mailform_init, this) - }, success_callback: $.proxy(this.reload_listing, this) }); }, editaccount_cb: function() { this.accountform_init(); - $('.submit').one('click', $.proxy(function(e) { + $('.submit').on('click', $.proxy(function(e) { simple_ajax_form_post(e, { formid: "accountform", - error_cb: $.proxy(this.editaccount_cb, this), reload_on_success: false, success_cb: $.proxy(this.reload_listing, this) }); @@ -394,10 +415,9 @@ Identities.prototype = { choices: get_domains_list }); $("#id_recipients").dynamic_input(); - $(".submit").one('click', $.proxy(function(e) { + $(".submit").on('click', $.proxy(function(e) { simple_ajax_form_post(e, { formid: "aliasform", - error_cb: $.proxy(this.aliasform_cb, this), reload_on_success: false, success_cb: $.proxy(this.reload_listing, this) }); diff --git a/modoboa/extensions/admin/tables.py b/modoboa/extensions/admin/tables.py index 6ab552161..d8f4f1c8d 100644 --- a/modoboa/extensions/admin/tables.py +++ b/modoboa/extensions/admin/tables.py @@ -1,45 +1,6 @@ # coding: utf-8 -from django.utils.translation import ugettext as _, ugettext_lazy +from django.utils.translation import ugettext_lazy from modoboa.lib import tables -from templatetags.admin_tags import domain_actions - - -class DomainsTable(tables.Table): - tableid = "objects_table" - idkey = "id" - styles = "table" - - name = tables.LinkColumn( - "name", label=ugettext_lazy("Name"), - urlpattern="modoboa.extensions.admin.views.domain.editdomain", - title=_("Edit domain"), modal=True, modalcb="admin.domainform_cb", - sortable=True, sort_order="name" - ) - domaliases = tables.Column( - "domainalias_set", label=ugettext_lazy("Alias(es)"), safe=True, - sortable=True, sort_order="domainalias__name" - ) - actions = tables.ActionColumn("actions", label=ugettext_lazy("Actions"), - defvalue=domain_actions) - - cols_order = ["name", "domaliases", "actions"] - - def __init__(self, request, doms): - super(DomainsTable, self).__init__(request) - self.populate(self._rows_from_model(doms)) - - def parse_domainalias_set(self, aliases): - if not len(aliases.all()): - return "---" - res = "" - for da in aliases.all(): - res += "%s
" % da.name - return res - - def row_class(self, request, domain): - if not domain.enabled: - return "muted" - return "" class ExtensionsTable(tables.Table): diff --git a/modoboa/extensions/admin/templates/admin/dlistform.html b/modoboa/extensions/admin/templates/admin/aliasform.html similarity index 67% rename from modoboa/extensions/admin/templates/admin/dlistform.html rename to modoboa/extensions/admin/templates/admin/aliasform.html index 20f3cd51d..6a13e26c9 100644 --- a/modoboa/extensions/admin/templates/admin/dlistform.html +++ b/modoboa/extensions/admin/templates/admin/aliasform.html @@ -1,5 +1,4 @@ -{% extends "common/generic_modal_form.html" %} -{% load lib_tags %} +{% extends "common/generic_modal_form.html" %}{% load lib_tags %} {% block formbody %} {% render_field form.email %} {% render_fields_group form "recipients" %} diff --git a/modoboa/extensions/admin/templates/admin/domain_options_form.html b/modoboa/extensions/admin/templates/admin/domain_options_form.html index c8acba112..b1c050f85 100644 --- a/modoboa/extensions/admin/templates/admin/domain_options_form.html +++ b/modoboa/extensions/admin/templates/admin/domain_options_form.html @@ -1,4 +1,5 @@ -{% load i18n lib_tags %} +{% load i18n lib_tags %}{% if form.fields %} {% render_field form.create_dom_admin %} {% render_field_appended form.dom_admin_username "@" %} {% render_field form.create_aliases %} +{% else %}
{% trans "You can't create a new domain administrator" %}
{% endif %} diff --git a/modoboa/extensions/admin/templates/admin/domains.html b/modoboa/extensions/admin/templates/admin/domains.html index 65163e602..d32690232 100644 --- a/modoboa/extensions/admin/templates/admin/domains.html +++ b/modoboa/extensions/admin/templates/admin/domains.html @@ -7,5 +7,6 @@ $(document).ready(function() { admin = new Domains(); }); +{% extra_static_content "domains" user %} {% endblock %} {% block actions %}{% domains_menu "domains" user %}{% endblock %} diff --git a/modoboa/extensions/admin/templates/admin/domains_table.html b/modoboa/extensions/admin/templates/admin/domains_table.html new file mode 100644 index 000000000..c2e5ec105 --- /dev/null +++ b/modoboa/extensions/admin/templates/admin/domains_table.html @@ -0,0 +1,21 @@ +{% load i18n admin_tags lib_tags %} + + + + + + + + + + + {% for domain in domains %} + + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Alias(es)" %}{% trans "Tags" %}{% trans "Actions" %}
{% domain_modify_link domain %}{% domain_aliases domain %} {% render_tags domain.tags %}{% domain_actions user domain %}
diff --git a/modoboa/extensions/admin/templates/admin/identities.html b/modoboa/extensions/admin/templates/admin/identities.html index 82e172a6d..632a6f31c 100644 --- a/modoboa/extensions/admin/templates/admin/identities.html +++ b/modoboa/extensions/admin/templates/admin/identities.html @@ -20,5 +20,6 @@ $(document).ready(function() { admin = new Identities(); }); +{% extra_static_content "identities" user %} {% endblock %} {% block actions %}{% identities_menu user %}{% endblock %} diff --git a/modoboa/extensions/admin/templates/admin/listing.html b/modoboa/extensions/admin/templates/admin/listing.html index 513512d17..cbcbbbc86 100644 --- a/modoboa/extensions/admin/templates/admin/listing.html +++ b/modoboa/extensions/admin/templates/admin/listing.html @@ -1,4 +1,4 @@ -{% extends "admin/header.html" %}{% load i18n admin_tags lib_tags %} +{% extends "admin/header.html" %}{% load i18n admin_tags %} {% block html_head %} {{ block.super }} diff --git a/modoboa/extensions/admin/templates/admin/mailform.html b/modoboa/extensions/admin/templates/admin/mailform.html index 1b9374782..8660bee27 100644 --- a/modoboa/extensions/admin/templates/admin/mailform.html +++ b/modoboa/extensions/admin/templates/admin/mailform.html @@ -1,4 +1,5 @@ {% load lib_tags %} {% render_field form.email %} {% include "common/generic_field.html" with field=form.quota help_display_mode="tooltip" appended_text=_("MB") activator=True activator_value=form.quota_act.value deactivate_if_empty=False activator_label=_("Use domain default value") %} +{% render_extra_fields form %} {% render_fields_group form "aliases" %} diff --git a/modoboa/extensions/admin/templatetags/admin_tags.py b/modoboa/extensions/admin/templatetags/admin_tags.py index 6736fbbe9..3f934ddce 100644 --- a/modoboa/extensions/admin/templatetags/admin_tags.py +++ b/modoboa/extensions/admin/templatetags/admin_tags.py @@ -15,6 +15,16 @@ genders = { @register.simple_tag def domains_menu(selection, user): + """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) + """ if not user.has_perm("admin.add_domain"): return "" @@ -25,6 +35,9 @@ def domains_menu(selection, user): "modal": True, "modalcb": "admin.newdomain_cb", "url": reverse("modoboa.extensions.admin.views.domain.newdomain")}, + ] + entries += events.raiseQueryEvent("ExtraDomainMenuEntries", user) + entries += [ {"name": "import", "label": _("Import"), "img": "icon-folder-open", @@ -111,23 +124,28 @@ def identities_menu(user): @register.simple_tag -def domain_actions(user, domid): - from modoboa.extensions.admin.models import Domain +def domain_actions(user, domain): + actions = [] + if domain.__class__.__name__ == 'Domain': + actions = [ + {"name": "listidentities", + "url": reverse("modoboa.extensions.admin.views.identity.identities") + "#list/?searchquery=@%s" % domain.name, + "title": _("View the domain's identities"), + "img": "icon-user"} + ] + if user.has_perm("admin.delete_domain"): + actions.append({ + "name": "deldomain", + "url": reverse( + "modoboa.extensions.admin.views.domain.deldomain", + args=[domain.id] + ), + "title": _("Delete %s?" % domain.name), + "img": "icon-trash" + }) + else: + actions = events.raiseQueryEvent('GetDomainActions', user, domain) - domain = Domain.objects.get(pk=domid) - actions = [ - {"name": "listidentities", - "url": reverse("modoboa.extensions.admin.views.identity.identities") + "#list/?searchquery=@%s" % domain.name, - "title": _("View the domain's identities"), - "img": "icon-user"} - ] - if user.has_perm("admin.delete_domain"): - actions.append({ - "name": "deldomain", - "url": reverse("modoboa.extensions.admin.views.domain.deldomain", args=[domid]), - "title": _("Delete %s?" % domain.name), - "img": "icon-trash" - }) return render_actions(actions) @@ -153,8 +171,48 @@ def identity_actions(user, ident): return render_actions(actions) +@register.simple_tag +def domain_modify_link(domain): + linkdef = {"label": domain.name, "modal": True} + if domain.__class__.__name__ == "Domain": + linkdef["url"] = reverse( + "modoboa.extensions.admin.views.domain.editdomain", + args=[domain.id] + ) + linkdef["modalcb"] = "admin.domainform_cb" + else: + tmp = events.raiseDictEvent('GetDomainModifyLink', domain) + for key in ['url', 'modalcb']: + linkdef[key] = tmp[key] + return render_link(linkdef) + + +@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 += '%s
' % alias.name + return 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( diff --git a/modoboa/extensions/admin/tests/__init__.py b/modoboa/extensions/admin/tests/__init__.py index 9ee5833ef..8fc7ac21f 100644 --- a/modoboa/extensions/admin/tests/__init__.py +++ b/modoboa/extensions/admin/tests/__init__.py @@ -4,9 +4,11 @@ from .account import AccountTestCase, PermissionsTestCase from .alias import AliasTestCase from .import_ import ImportTestCase from .export import ExportTestCase +from .password_schemes import PasswordSchemesTestCase +from .user import ForwardTestCase __all__ = [ 'DomainTestCase', 'DomainAliasTestCase', 'AccountTestCase', 'PermissionsTestCase', 'AliasTestCase', 'ImportTestCase', - 'ExportTestCase' + 'ExportTestCase', 'PasswordSchemesTestCase', 'ForwardTestCase' ] diff --git a/modoboa/extensions/admin/tests/account.py b/modoboa/extensions/admin/tests/account.py index fa8773bc1..547e170ab 100644 --- a/modoboa/extensions/admin/tests/account.py +++ b/modoboa/extensions/admin/tests/account.py @@ -19,9 +19,9 @@ class AccountTestCase(ModoTestCase): username="tester@test.com", first_name="Tester", last_name="Toto", password1="toto", password2="toto", role="SimpleUsers", quota_act=True, - is_active=True, email="tester@test.com", stepid=2 + is_active=True, email="tester@test.com", stepid='step2' ) - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.newaccount"), values ) @@ -39,26 +39,26 @@ class AccountTestCase(ModoTestCase): self.assertEqual(mb.domain.mailbox_count, 3) values["username"] = "pouet@test.com" - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.editaccount", args=[account.id]), values ) mb = Mailbox.objects.get(pk=mb.id) self.assertEqual(mb.full_address, "pouet@test.com") - self.check_ajax_get( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.delaccount", args=[account.id]), {} ) - def _set_quota(self, email, value, expected_status="ok"): + 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 } - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.editaccount", args=[account.id]), values, status=expected_status @@ -70,7 +70,7 @@ class AccountTestCase(ModoTestCase): def test_set_nul_quota_as_domainadmin(self): self.clt.logout() self.assertTrue(self.clt.login(username="admin@test.com", password="toto")) - self._set_quota("user@test.com", 0, "ko") + self._set_quota("user@test.com", 0, 400) self.clt.logout() self.assertTrue(self.clt.login(username="admin@test2.com", password="toto")) self._set_quota("user@test2.com", 0) @@ -92,25 +92,24 @@ class PermissionsTestCase(ModoTestCase): self.clt.logout() def test_domain_admins(self): - self.values["role"] = "DomainAdmins" - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.editaccount", args=[self.user.id]), self.values ) - self.assertEqual(self.user.group == "DomainAdmins", True) + self.assertEqual(self.user.group, "DomainAdmins") self.values["role"] = "SimpleUsers" - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.editaccount", args=[self.user.id]), self.values ) - self.assertEqual(self.user.group == 'DomainAdmins', False) + self.assertNotEqual(self.user.group, 'DomainAdmins') def test_superusers(self): self.values["role"] = "SuperAdmins" - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.editaccount", args=[self.user.id]), self.values @@ -118,7 +117,7 @@ class PermissionsTestCase(ModoTestCase): self.assertEqual(User.objects.get(username="user@test.com").is_superuser, True) self.values["role"] = "SimpleUsers" - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.editaccount", args=[self.user.id]), self.values @@ -135,7 +134,7 @@ class PermissionsTestCase(ModoTestCase): password1="", password2="", quota=10, is_active=True, email="admin@test.com" ) - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.editaccount", args=[admin.id]), values @@ -144,7 +143,7 @@ class PermissionsTestCase(ModoTestCase): self.assertEqual(admin.can_access(Domain.objects.get(name="test.com")), True) values["role"] = "SuperAdmins" - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.editaccount", args=[admin.id]), values @@ -178,25 +177,23 @@ class PermissionsTestCase(ModoTestCase): username="superadmin2@test.com", first_name="Super", last_name="Admin", password1="toto", password2="toto", role="SuperAdmins", is_active=True, - email="superadmin2@test.com", stepid=2 + email="superadmin2@test.com", stepid='step2' ) - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.newaccount"), values ) account = User.objects.get(username="superadmin2@test.com") self.clt.logout() self.clt.login(username="admin@test.com", password="toto") - self.check_ajax_get( + self.ajax_get( reverse("modoboa.extensions.admin.views.identity.delaccount", args=[account.id]), - {}, - status="ko", respmsg="Permission denied" + {}, 403 ) - self.check_ajax_get( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.delaccount", - args=[account.id]), {}, - status="ko", respmsg="Permission denied" + args=[account.id]), {}, 403 ) def test_domainadmin_dlist_local_domain_not_owned(self): @@ -208,6 +205,6 @@ class PermissionsTestCase(ModoTestCase): recipients_1="user@test2.com", enabled=True ) - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.alias.newdlist"), values ) diff --git a/modoboa/extensions/admin/tests/alias.py b/modoboa/extensions/admin/tests/alias.py index 1cb9a277d..ff6fbc840 100644 --- a/modoboa/extensions/admin/tests/alias.py +++ b/modoboa/extensions/admin/tests/alias.py @@ -21,7 +21,7 @@ class AliasTestCase(ModoTestCase): is_active=user.is_active, email="user@test.com", aliases="toto@test.com", aliases_1="titi@test.com" ) - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.editaccount", args=[user.id]), values @@ -29,7 +29,7 @@ class AliasTestCase(ModoTestCase): self.assertEqual(user.mailbox_set.all()[0].alias_set.count(), 2) del values["aliases_1"] - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.identity.editaccount", args=[user.id]), values @@ -42,7 +42,7 @@ class AliasTestCase(ModoTestCase): recipients_1="admin@test.com", recipients_2="ext@titi.com", enabled=True) - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.alias.newdlist"), values ) user = User.objects.get(username="user@test.com") @@ -53,14 +53,14 @@ class AliasTestCase(ModoTestCase): dlist = Alias.objects.get(address="all", domain__name="test.com") self.assertEqual(len(dlist.get_recipients()), 3) del values["recipients_1"] - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.alias.editalias", args=[dlist.id]), values ) self.assertEqual(dlist.get_recipients_count(), 2) - self.check_ajax_get( + self.ajax_post( reverse("modoboa.extensions.admin.views.alias.delalias") + "?selection=%d" \ % dlist.id, {} ) @@ -69,21 +69,21 @@ class AliasTestCase(ModoTestCase): def test_forward(self): values = dict(email="forward2@test.com", recipients="rcpt@dest.com") - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.alias.newforward"), values ) fwd = Alias.objects.get(address="forward2", domain__name="test.com") self.assertEqual(fwd.get_recipients_count(), 1) values["recipients"] = "rcpt2@dest.com" - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.alias.editalias", args=[fwd.id]), values ) self.assertEqual(fwd.get_recipients_count(), 1) - self.check_ajax_get( + self.ajax_post( reverse("modoboa.extensions.admin.views.alias.delalias") + "?selection=%d" \ % fwd.id, {} ) @@ -92,7 +92,7 @@ class AliasTestCase(ModoTestCase): def test_forward_and_local_copies(self): values = dict(email="user@test.com", recipients="rcpt@dest.com") - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.alias.newforward"), values ) fwd = Alias.objects.get(address="user", domain__name="test.com") @@ -100,7 +100,7 @@ class AliasTestCase(ModoTestCase): values["recipients"] = "rcpt@dest.com" values["recipients_1"] = "user@test.com" - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.alias.editalias", args=[fwd.id]), values ) diff --git a/modoboa/extensions/admin/tests/domain.py b/modoboa/extensions/admin/tests/domain.py index 234e0f117..929afd3c8 100644 --- a/modoboa/extensions/admin/tests/domain.py +++ b/modoboa/extensions/admin/tests/domain.py @@ -19,9 +19,9 @@ class DomainTestCase(ModoTestCase): """ values = { "name": "pouet.com", "quota": 100, "create_dom_admin": "no", - "stepid": 2 + "stepid": 'step2' } - self.check_ajax_post(reverse("modoboa.extensions.admin.views.domain.newdomain"), values) + self.ajax_post(reverse("modoboa.extensions.admin.views.domain.newdomain"), values) dom = Domain.objects.get(name="pouet.com") self.assertEqual(dom.name, "pouet.com") self.assertEqual(dom.quota, 100) @@ -35,9 +35,9 @@ class DomainTestCase(ModoTestCase): values = { "name": "pouet.com", "quota": 100, "create_dom_admin": "yes", "dom_admin_username": "toto", "create_aliases": "yes", - "stepid": 2 + "stepid": 'step2' } - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.domain.newdomain"), values ) @@ -53,9 +53,9 @@ class DomainTestCase(ModoTestCase): values = { "name": "pouet.com", "quota": 0, "create_dom_admin": "yes", "dom_admin_username": "toto", "create_aliases": "yes", - "stepid": 2 + "stepid": 'step2' } - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.domain.newdomain"), values ) @@ -75,7 +75,7 @@ class DomainTestCase(ModoTestCase): "name": "pouet.com", "quota": 100, "enabled": True } dom = Domain.objects.get(name="test.com") - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.domain.editdomain", args=[dom.id]), values @@ -88,7 +88,7 @@ class DomainTestCase(ModoTestCase): """Test the removal of a domain """ dom = Domain.objects.get(name="test.com") - self.check_ajax_get( + self.ajax_post( reverse("modoboa.extensions.admin.views.domain.deldomain", args=[dom.id]), {} diff --git a/modoboa/extensions/admin/tests/domain_alias.py b/modoboa/extensions/admin/tests/domain_alias.py index d79e2a645..9930d0d16 100644 --- a/modoboa/extensions/admin/tests/domain_alias.py +++ b/modoboa/extensions/admin/tests/domain_alias.py @@ -31,7 +31,7 @@ class DomainAliasTestCase(ModoTestCase): dom = Domain.objects.get(name="test.com") values = dict(name=dom.name, quota=dom.quota, enabled=dom.enabled, aliases="domalias.net", aliases_1="domalias.com") - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.domain.editdomain", args=[dom.id]), values @@ -39,7 +39,7 @@ class DomainAliasTestCase(ModoTestCase): self.assertEqual(dom.domainalias_set.count(), 2) del values["aliases_1"] - self.check_ajax_post( + self.ajax_post( reverse("modoboa.extensions.admin.views.domain.editdomain", args=[dom.id]), values diff --git a/modoboa/extensions/admin/tests/export.py b/modoboa/extensions/admin/tests/export.py index 093865af4..c0e727e04 100644 --- a/modoboa/extensions/admin/tests/export.py +++ b/modoboa/extensions/admin/tests/export.py @@ -22,7 +22,7 @@ class ExportTestCase(ModoTestCase): def test_export_identities(self): response = self.__export_identities() - self.assertEqual(response.content, "account;admin;{CRYPT}dTTsGDkA5ZHKg;;;True;SuperAdmins;\r\naccount;admin@test.com;{PLAIN}toto;;;True;DomainAdmins;admin@test.com;test.com\r\naccount;admin@test2.com;{PLAIN}toto;;;True;DomainAdmins;admin@test2.com;test2.com\r\naccount;user@test.com;{PLAIN}toto;;;True;SimpleUsers;user@test.com\r\naccount;user@test2.com;{PLAIN}toto;;;True;SimpleUsers;user@test2.com\r\nalias;alias@test.com;False;user@test.com\r\nforward;forward@test.com;False;user@external.com\r\ndlist;postmaster@test.com;False;toto@titi.com;test@truc.fr\r\n") + self.assertEqual(response.content, "account;admin;{CRYPT}dTTsGDkA5ZHKg;;;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\nforward;forward@test.com;True;user@external.com\r\ndlist;postmaster@test.com;True;toto@titi.com;test@truc.fr\r\n") def test_export_simpleusers(self): response = self.__export_identities( @@ -30,7 +30,7 @@ class ExportTestCase(ModoTestCase): ) self.assertEqual( response.content.strip(), - "account;user@test.com;{PLAIN}toto;;;True;SimpleUsers;user@test.com\r\naccount;user@test2.com;{PLAIN}toto;;;True;SimpleUsers;user@test2.com" + "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" ) def test_export_superadmins(self): @@ -39,7 +39,7 @@ class ExportTestCase(ModoTestCase): ) self.assertEqual( response.content.strip(), - "account;admin;{CRYPT}dTTsGDkA5ZHKg;;;True;SuperAdmins;" + "account;admin;{CRYPT}dTTsGDkA5ZHKg;;;True;SuperAdmins;;" ) def test_export_domainadmins(self): @@ -48,26 +48,26 @@ class ExportTestCase(ModoTestCase): ) self.assertEqual( response.content.strip(), - "account;admin@test.com;{PLAIN}toto;;;True;DomainAdmins;admin@test.com;test.com\r\naccount;admin@test2.com;{PLAIN}toto;;;True;DomainAdmins;admin@test2.com;test2.com" + "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" ) def test_export_aliases(self): response = self.__export_identities(idtfilter="alias") self.assertEqual( response.content.strip(), - "alias;alias@test.com;False;user@test.com" + "alias;alias@test.com;True;user@test.com" ) def test_export_forwards(self): response = self.__export_identities(idtfilter="forward") self.assertEqual( response.content.strip(), - "forward;forward@test.com;False;user@external.com" + "forward;forward@test.com;True;user@external.com" ) def test_export_dlists(self): response = self.__export_identities(idtfilter="dlist") self.assertEqual( response.content.strip(), - "dlist;postmaster@test.com;False;toto@titi.com;test@truc.fr" + "dlist;postmaster@test.com;True;toto@titi.com;test@truc.fr" ) diff --git a/modoboa/extensions/admin/tests/import_.py b/modoboa/extensions/admin/tests/import_.py index 186f3ff1d..aeeba148e 100644 --- a/modoboa/extensions/admin/tests/import_.py +++ b/modoboa/extensions/admin/tests/import_.py @@ -40,10 +40,21 @@ domainalias; domalias1.com; domain1.com; True self.assertFalse(dom.enabled) self.assertTrue(admin.is_owner(dom)) + def test_import_domains_with_conflict(self): + f = ContentFile(b"""domain;test.alias;10;True +domainalias;test.alias;test.com;True +""", name="domains.csv") + resp = self.clt.post( + reverse("modoboa.extensions.admin.views.import.import_domains"), { + "sourcefile": f + } + ) + self.assertIn('A domain test.alias already exists', resp.content) + def test_identities_import(self): f = ContentFile(b""" -account; user1@test.com; toto; User; One; True; SimpleUsers; user1@test.com -account; truc@test.com; toto; René; Truc; True; DomainAdmins; truc@test.com; test.com +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; fwd1@test.com; True; user@extdomain.com dlist; dlist@test.com; True; user1@test.com; user@extdomain.com @@ -54,20 +65,26 @@ dlist; dlist@test.com; True; user1@test.com; user@extdomain.com ) admin = User.objects.get(username="admin") u1 = User.objects.get(username="user1@test.com") + mb1 = u1.mailbox_set.all()[0] 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.group, "SimpleUsers") - self.assertTrue(admin.is_owner(u1.mailbox_set.all()[0])) - self.assertEqual(u1.mailbox_set.all()[0].full_address, "user1@test.com") + 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.clt.login(username="user1@test.com", password="toto")) da = User.objects.get(username="truc@test.com") + damb = da.mailbox_set.all()[0] self.assertEqual(da.first_name, u"René") self.assertEqual(da.group, "DomainAdmins") - self.assertEqual(da.mailbox_set.all()[0].full_address, "truc@test.com") + 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") @@ -86,10 +103,22 @@ dlist; dlist@test.com; True; user1@test.com; user@extdomain.com self.assertIn("user@extdomain.com", dlist.extmboxes) self.assertTrue(admin.is_owner(dlist)) + def test_import_invalid_quota(self): + self.clt.logout() + self.clt.login(username="admin@test.com", password="toto") + f = ContentFile(b""" +account; user1@test.com; toto; User; One; True; SimpleUsers; user1@test.com; 20 +""", name="identities.csv") + resp = self.clt.post( + reverse("modoboa.extensions.admin.views.import.import_identities"), + {"sourcefile": f, "crypt_password": True} + ) + self.assertIn('Quota is greater than the allowed', resp.content) + def test_import_duplicate(self): f = ContentFile(b""" -account; admin@test.com; toto; Admin; ; True; DomainAdmins; admin@test.com; test.com -account; truc@test.com; toto; René; Truc; True; DomainAdmins; truc@test.com; test.com +account; admin@test.com; toto; Admin; ; True; DomainAdmins; admin@test.com; 0; test.com +account; truc@test.com; toto; René; Truc; True; DomainAdmins; truc@test.com; 0; test.com """, name="identities.csv") self.clt.post( reverse("modoboa.extensions.admin.views.import.import_identities"), @@ -108,7 +137,7 @@ account; truc@test.com; toto; René; Truc; True; DomainAdmins; truc@test.com; te self.clt.logout() self.assertTrue(self.clt.login(username="admin@test.com", password="toto")) f = ContentFile(b""" -account; sa@test.com; toto; Super; Admin; True; SuperAdmins; superadmin@test.com +account; sa@test.com; toto; Super; Admin; True; SuperAdmins; superadmin@test.com; 50 """, name="identities.csv") self.clt.post( reverse("modoboa.extensions.admin.views.import.import_identities"), diff --git a/modoboa/extensions/admin/tests/password_schemes.py b/modoboa/extensions/admin/tests/password_schemes.py new file mode 100644 index 000000000..8f6bee03a --- /dev/null +++ b/modoboa/extensions/admin/tests/password_schemes.py @@ -0,0 +1,55 @@ +from django.core.urlresolvers import reverse +from modoboa.core import load_core_settings +from modoboa.core.models import User +from modoboa.lib.tests import ModoTestCase +from modoboa.lib import parameters +from modoboa.extensions.admin import factories + + +class PasswordSchemesTestCase(ModoTestCase): + fixtures = ['initial_users.json'] + + def setUp(self): + super(PasswordSchemesTestCase, self).setUp() + factories.populate_database() + load_core_settings() + + def _create_account(self): + values = dict( + username="tester@test.com", first_name="Tester", last_name="Toto", + password1="toto", password2="toto", role="SimpleUsers", + quota_act=True, + is_active=True, email="tester@test.com", stepid='step2' + ) + self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.newaccount"), + values + ) + + def _test_scheme(self, name, startpattern): + parameters.save_admin('PASSWORD_SCHEME', name, app='core') + self._create_account() + account = User.objects.get(username='tester@test.com') + self.assertTrue(account.password.startswith(startpattern)) + self.assertTrue(account.check_password('toto')) + + def test_sha512crypt_scheme(self): + self._test_scheme('sha512crypt', '{SHA512-CRYPT}') + + def test_sha256crypt_scheme(self): + self._test_scheme('sha256crypt', '{SHA256-CRYPT}') + + def test_md5crypt_scheme(self): + self._test_scheme('md5crypt', '{MD5-CRYPT}') + + def test_sha256_scheme(self): + self._test_scheme('sha256', '{SHA256}') + + def test_md5_scheme(self): + self._test_scheme('md5', '{MD5}') + + def test_crypt(self): + self._test_scheme('crypt', '{CRYPT}') + + def test_plain(self): + self._test_scheme('plain', '{PLAIN}') diff --git a/modoboa/extensions/admin/tests/user.py b/modoboa/extensions/admin/tests/user.py new file mode 100644 index 000000000..b45915d49 --- /dev/null +++ b/modoboa/extensions/admin/tests/user.py @@ -0,0 +1,26 @@ +from django.core.urlresolvers import reverse +from modoboa.core.models import User +from modoboa.lib.tests import ModoTestCase +from modoboa.extensions.admin.factories import populate_database +from modoboa.extensions.admin.models import Alias + + +class ForwardTestCase(ModoTestCase): + fixtures = ['initial_users.json'] + + def setUp(self): + super(ForwardTestCase, self).setUp() + populate_database() + + def test_forward_permissions(self): + self.clt.logout() + self.clt.login(username='user@test.com', password='toto') + self.ajax_post( + reverse('modoboa.extensions.admin.views.user.forward'), + {'dest': 'user@extdomain.com', 'keepcopies': True} + ) + forward = Alias.objects.get(address='user', domain__name='test.com') + 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)) diff --git a/modoboa/extensions/admin/views/alias.py b/modoboa/extensions/admin/views/alias.py index b03c32fd3..c8410615f 100644 --- a/modoboa/extensions/admin/views/alias.py +++ b/modoboa/extensions/admin/views/alias.py @@ -6,59 +6,50 @@ from django.contrib.auth.decorators import ( login_required, permission_required ) from modoboa.lib import events -from modoboa.lib.webutils import ajax_response, ajax_simple_response -from modoboa.lib.exceptions import PermDeniedException -from modoboa.extensions.admin.exceptions import AdminError +from modoboa.lib.webutils import render_to_json_response +from modoboa.lib.exceptions import PermDeniedException, Conflict from modoboa.extensions.admin.forms import AliasForm from modoboa.extensions.admin.models import Alias -def _validate_alias(request, form, successmsg, tplname, commonctx, callback=None): +def _validate_alias(request, form, successmsg, callback=None): """Alias validation Common function shared between creation and modification actions. """ - error = None if form.is_valid(): form.set_recipients() try: alias = form.save() except IntegrityError: - raise AdminError(_("Alias with this name already exists")) + raise Conflict(_("Alias with this name already exists")) if callback: callback(request.user, alias) - return ajax_simple_response({"status": "ok", "respmsg": successmsg}) + return render_to_json_response(successmsg) - if "targets" in request.POST: - targets = request.POST.getlist("targets") - commonctx["targets"] = targets[:-1] - - commonctx["form"] = form - commonctx["error"] = error - return ajax_response(request, status="ko", template=tplname, **commonctx) + return render_to_json_response({'form_errors': form.errors}, status=400) def _new_alias(request, title, action, successmsg, - tplname="common/generic_modal_form.html"): + tplname="admin/aliasform.html"): events.raiseEvent("CanCreate", request.user, "mailbox_aliases") - ctx = { - "title": title, - "action": action, - "formid": "aliasform", - "action_label": _("Create"), - "action_classes": "submit" - } if request.method == "POST": def callback(user, alias): alias.post_create(user) form = AliasForm(request.user, request.POST) return _validate_alias( - request, form, successmsg, tplname, ctx, callback + request, form, successmsg, callback ) - form = AliasForm(request.user) - ctx["form"] = form + ctx = { + "title": title, + "action": action, + "formid": "aliasform", + "action_label": _("Create"), + "action_classes": "submit", + "form": AliasForm(request.user) + } return render(request, tplname, ctx) @@ -94,29 +85,29 @@ def newforward(request): @login_required @permission_required("admin.change_alias") -def editalias(request, alid, tplname="common/generic_modal_form.html"): +@transaction.commit_on_success +def editalias(request, alid, tplname="admin/aliasform.html"): alias = Alias.objects.get(pk=alid) if not request.user.can_access(alias): raise PermDeniedException - ctx = dict( - action=reverse(editalias, args=[alias.id]), - formid="aliasform", - title=alias.full_address, - action_label=_("Update"), - action_classes="submit" - ) - if len(alias.get_recipients()) >= 2: - successmsg = _("Distribution list modified") - elif alias.extmboxes != "": - successmsg = _("Forward modified") - else: - successmsg = _("Alias modified") if request.method == "POST": + if len(alias.get_recipients()) >= 2: + successmsg = _("Distribution list modified") + elif alias.extmboxes != "": + successmsg = _("Forward modified") + else: + successmsg = _("Alias modified") form = AliasForm(request.user, request.POST, instance=alias) - return _validate_alias(request, form, successmsg, tplname, ctx) + return _validate_alias(request, form, successmsg) - form = AliasForm(request.user, instance=alias) - ctx["form"] = form + ctx = { + 'action': reverse(editalias, args=[alias.id]), + 'formid': 'aliasform', + 'title': alias.full_address, + 'action_label': _('Update'), + 'action_classes': 'submit', + 'form': AliasForm(request.user, instance=alias) + } return render(request, tplname, ctx) @@ -141,4 +132,4 @@ def delalias(request): alias.delete() msg = ungettext(msg, msgs, len(selection)) - return ajax_simple_response({"status": "ok", "respmsg": msg}) + return render_to_json_response(msg) diff --git a/modoboa/extensions/admin/views/domain.py b/modoboa/extensions/admin/views/domain.py index 91f2c1669..2dd635c65 100644 --- a/modoboa/extensions/admin/views/domain.py +++ b/modoboa/extensions/admin/views/domain.py @@ -2,7 +2,6 @@ from django.http import HttpResponseRedirect from django.utils.translation import ugettext as _, ungettext from django.core.urlresolvers import reverse from django.db import transaction -from django.db.models import Q from django.shortcuts import render from django.views.decorators.csrf import ensure_csrf_cookie from django.contrib.auth.decorators import ( @@ -10,18 +9,19 @@ from django.contrib.auth.decorators import ( ) from modoboa.lib import parameters, events from modoboa.lib.webutils import ( - ajax_simple_response, _render_to_string + _render_to_string, render_to_json_response ) from modoboa.lib.formutils import CreationWizard -from modoboa.lib.exceptions import PermDeniedException +from modoboa.lib.exceptions import ( + ModoboaException, PermDeniedException, BadRequest +) from modoboa.lib.templatetags.lib_tags import pagination_bar -from modoboa.extensions.admin.tables import DomainsTable from modoboa.extensions.admin.lib import get_sort_order, get_listing_page -from modoboa.extensions.admin.exceptions import AdminError from modoboa.extensions.admin.models import Domain, Mailbox from modoboa.extensions.admin.forms import ( DomainForm, DomainFormGeneral, DomainFormOptions ) +from modoboa.extensions.admin.lib import get_domains @login_required @@ -35,17 +35,29 @@ def index(request): ) def _domains(request): sort_order, sort_dir = get_sort_order(request.GET, "name") - domains = Domain.objects.get_for_admin(request.user) - squery = request.GET.get("searchquery", None) - if squery is not None: - q = Q(name__contains=squery) - q |= Q(domainalias__name__contains=squery) - domains = domains.filter(q).distinct() - if sort_order in ["name", "domainalias__name"]: - domains = domains.order_by("%s%s" % (sort_dir, sort_order)) - page = get_listing_page(domains, request.GET.get("page", 1)) - return ajax_simple_response({ - "table": DomainsTable(request, page.object_list).render(), + filters = dict( + (flt, request.GET.get(flt, None)) + for flt in ['domfilter', 'searchquery'] + + events.raiseQueryEvent('ExtraDomainFilters') + ) + 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], + reverse=sort_dir == '-') + page = get_listing_page(domainlist, request.GET.get("page", 1)) + return render_to_json_response({ + "table": _render_to_string(request, 'admin/domains_table.html', { + 'domains': page.object_list, + 'tableid': 'domains' + }), "page": page.number, "paginbar": pagination_bar(page), "handle_mailboxes": parameters.get_admin("HANDLE_MAILBOXES", @@ -70,7 +82,7 @@ def domains(request, tplname="admin/domains.html"): @permission_required("core.add_user") def domains_list(request): doms = [dom.name for dom in Domain.objects.get_for_admin(request.user)] - return ajax_simple_response(doms) + return render_to_json_response(doms) @login_required @@ -79,50 +91,49 @@ def domains_list(request): def newdomain(request, tplname="common/wizard_forms.html"): events.raiseEvent("CanCreate", request.user, "domains") - def newdomain_cb(steps): - genform = steps[0]["form"] - genform.is_valid() - domain = genform.save(request.user) - domain.post_create(request.user) - steps[1]["form"].save(request.user, domain) - - commonctx = {"title": _("New domain"), - "action_label": _("Create"), - "action_classes": "submit", - "action": reverse(newdomain), - "formid": "domform"} - cwizard = CreationWizard(newdomain_cb) + cwizard = CreationWizard() cwizard.add_step(DomainFormGeneral, _("General"), [dict(classes="btn-inverse next", label=_("Next"))], formtpl="admin/domain_general_form.html") - cwizard.add_step(DomainFormOptions, _("Options"), - [dict(classes="btn-primary submit", label=_("Create")), - dict(classes="btn-inverse prev", label=_("Previous"))], - formtpl="admin/domain_options_form.html") + cwizard.add_step( + DomainFormOptions, _("Options"), + [dict(classes="btn-primary submit", label=_("Create")), + dict(classes="btn-inverse prev", label=_("Previous"))], + formtpl="admin/domain_options_form.html", + new_args=[request.user] + ) if request.method == "POST": retcode, data = cwizard.validate_step(request) if retcode == -1: - raise AdminError(data) + raise BadRequest(data) if retcode == 1: - return ajax_simple_response(dict( - status="ok", title=cwizard.get_title(data + 1), stepid=data - )) - if retcode == 2: - return ajax_simple_response( - dict(status="ok", respmsg=_("Domain created")) + return render_to_json_response( + {'title': cwizard.get_title(data + 1), 'stepid': data} ) + if retcode == 2: + genform = cwizard.steps[0]["form"] + domain = genform.save(request.user) + domain.post_create(request.user) + try: + cwizard.steps[1]["form"].save(request.user, domain) + except ModoboaException as e: + transaction.rollback() + raise + return render_to_json_response(_("Domain created")) + return render_to_json_response({ + 'stepid': data, 'form_errors': cwizard.errors + }, status=400) - from modoboa.lib.templatetags.lib_tags import render_form - return ajax_simple_response(dict( - status="ko", stepid=data, - form=render_form(cwizard.steps[data]["form"]) - )) - + ctx = {"title": _("New domain"), + "action_label": _("Create"), + "action_classes": "submit", + "action": reverse(newdomain), + "formid": "domform"} cwizard.create_forms() - commonctx.update(steps=cwizard.steps) - commonctx.update(subtitle="1. %s" % cwizard.steps[0]['title']) - return render(request, tplname, commonctx) + ctx.update(steps=cwizard.steps) + ctx.update(subtitle="1. %s" % cwizard.steps[0]['title']) + return render(request, tplname, ctx) @login_required @@ -133,44 +144,32 @@ def editdomain(request, dom_id, tplname="admin/editdomainform.html"): if not request.user.can_access(domain): raise PermDeniedException + instances = dict(general=domain) + events.raiseEvent("FillDomainInstances", request.user, domain, instances) + if request.method == "POST": + domain.oldname = domain.name + form = DomainForm(request.user, request.POST, instances=instances) + if form.is_valid(): + form.save(request.user) + events.raiseEvent("DomainModified", domain) + return render_to_json_response(_("Domain modified")) + return render_to_json_response({ + 'form_errors': form.errors + }, status=400) + domadmins = [u for u in domain.admins if request.user.can_access(u) and not u.is_superuser] if not request.user.is_superuser: domadmins = [u for u in domadmins if u.group == "DomainAdmins"] - - instances = dict(general=domain) - events.raiseEvent("FillDomainInstances", request.user, domain, instances) - - commonctx = {"title": domain.name, - "action_label": _("Update"), - "action_classes": "submit", - "action": reverse(editdomain, args=[dom_id]), - "formid": "domform", - "domain": domain} - if request.method == "POST": - error = None - domain.oldname = domain.name - form = DomainForm(request.user, request.POST, instances=instances) - if form.is_valid(): - try: - form.save(request.user) - except AdminError, e: - error = str(e) - else: - events.raiseEvent("DomainModified", domain) - return ajax_simple_response({ - "status": "ok", "respmsg": _("Domain modified") - }) - - commonctx["tabs"] = form - return ajax_simple_response({ - "status": "ko", "respmsg": error, - "content": _render_to_string(request, tplname, commonctx) - }) - - commonctx["tabs"] = DomainForm(request.user, instances=instances) - commonctx["domadmins"] = domadmins - return render(request, tplname, commonctx) + ctx = {"title": domain.name, + "action_label": _("Update"), + "action_classes": "submit", + "action": reverse(editdomain, args=[dom_id]), + "formid": "domform", + "domain": domain, + "tabs": DomainForm(request.user, instances=instances), + "domadmins": domadmins} + return render(request, tplname, ctx) @login_required @@ -187,8 +186,8 @@ def deldomain(request, dom_id): if not request.user.can_access(dom): raise PermDeniedException if mb and mb.domain == dom: - raise AdminError(_("You can't delete your own domain")) + raise PermDeniedException(_("You can't delete your own domain")) dom.delete(request.user, keepdir) msg = ungettext("Domain deleted", "Domains deleted", 1) - return ajax_simple_response({"status": "ok", "respmsg": msg}) + return render_to_json_response(msg) diff --git a/modoboa/extensions/admin/views/export.py b/modoboa/extensions/admin/views/export.py index 681a54bda..2c38e778f 100644 --- a/modoboa/extensions/admin/views/export.py +++ b/modoboa/extensions/admin/views/export.py @@ -8,8 +8,7 @@ from django.utils.translation import ugettext as _ from django.contrib.auth.decorators import ( login_required, permission_required, user_passes_test ) -from modoboa.extensions.admin.lib import get_identities -from modoboa.extensions.admin.models import Domain +from modoboa.extensions.admin.lib import get_domains, get_identities from modoboa.extensions.admin.forms import ( ExportIdentitiesForm, ExportDomainsForm ) @@ -73,10 +72,9 @@ def export_domains(request): form.is_valid() fp = cStringIO.StringIO() csvwriter = csv.writer(fp, delimiter=form.cleaned_data["sepchar"]) - for dom in Domain.objects.get_for_admin(request.user): + for dom in get_domains(request.user, + **request.session['domains_filters']): dom.to_csv(csvwriter) - for da in dom.domainalias_set.all(): - da.to_csv(csvwriter) content = fp.getvalue() fp.close() return _export(content, form.cleaned_data["filename"]) diff --git a/modoboa/extensions/admin/views/identity.py b/modoboa/extensions/admin/views/identity.py index a2f1e1bc4..8e8fea69a 100644 --- a/modoboa/extensions/admin/views/identity.py +++ b/modoboa/extensions/admin/views/identity.py @@ -7,9 +7,11 @@ from django.contrib.auth.decorators import ( ) from django.views.decorators.csrf import ensure_csrf_cookie from modoboa.lib import parameters, events -from modoboa.lib.exceptions import PermDeniedException +from modoboa.lib.exceptions import ( + ModoboaException, PermDeniedException, BadRequest +) from modoboa.lib.webutils import ( - ajax_simple_response, _render_to_string, ajax_response + _render_to_string, render_to_json_response ) from modoboa.lib.formutils import CreationWizard from modoboa.lib.templatetags.lib_tags import pagination_bar @@ -18,7 +20,6 @@ from modoboa.extensions.admin.models import Mailbox, Domain from modoboa.extensions.admin.lib import ( get_sort_order, get_listing_page, get_identities ) -from modoboa.extensions.admin.exceptions import AdminError from modoboa.extensions.admin.forms import ( AccountForm, AccountFormGeneral, AccountFormMail ) @@ -42,7 +43,7 @@ def _identities(request): objects = sorted(idents_list, key=lambda o: o.tags[0], reverse=sort_dir == '-') page = get_listing_page(objects, request.GET.get("page", 1)) - return ajax_simple_response({ + return render_to_json_response({ "table": _render_to_string(request, "admin/identities_table.html", { "identities": page.object_list, "tableid": "objects_table" @@ -72,7 +73,7 @@ def accounts_list(request): accs = User.objects.filter(is_superuser=False) \ .exclude(groups__name='SimpleUsers') res = [a.username for a in accs.all()] - return ajax_simple_response(res) + return render_to_json_response(res) @login_required @@ -99,10 +100,9 @@ def list_quotas(request, tplname="admin/quotas.html"): order_by=["%s%s" % (sort_dir, sort_order)] ) else: - raise AdminError(_("Invalid request")) + raise BadRequest(_("Invalid request")) page = get_listing_page(mboxes, request.GET.get("page", 1)) - return ajax_simple_response({ - "status": "ok", + return render_to_json_response({ "page": page.number, "paginbar": pagination_bar(page), "table": _render_to_string(request, tplname, { @@ -115,35 +115,15 @@ def list_quotas(request, tplname="admin/quotas.html"): @permission_required("core.add_user") @transaction.commit_on_success def newaccount(request, tplname='common/wizard_forms.html'): - def create_account(steps): - """Account creation callback + """Create a new account. - Called when all creation steps have been validated. - - :param steps: the steps data - """ - genform = steps[0]["form"] - genform.is_valid() - account = genform.save() - account.post_create(request.user) - - mailform = steps[1]["form"] - try: - mailform.save(request.user, account) - except AdminError: - # A bit uggly: transaction management doesn't work very - # well with nested functions. Need to wait for django 1.6 - # and atomicity. - account.delete(request.user, False) - raise - - ctx = dict( - title=_("New account"), - action=reverse(newaccount), - formid="newaccount_form", - submit_label=_("Create") - ) - cwizard = CreationWizard(create_account) + .. note:: An issue still remains int this code: if all validation + steps are successful but an error occurs after we call 'save', + the account will be created. It happens transaction management + doesn't work very well with nested functions. Need to wait for + django 1.6 and atomicity. + """ + cwizard = CreationWizard() cwizard.add_step(AccountFormGeneral, _("General"), [dict(classes="btn-inverse next", label=_("Next"))], new_args=[request.user]) @@ -155,22 +135,28 @@ def newaccount(request, tplname='common/wizard_forms.html'): if request.method == "POST": retcode, data = cwizard.validate_step(request) if retcode == -1: - raise AdminError(data) + raise BadRequest(data) if retcode == 1: - return ajax_simple_response(dict( - status="ok", title=cwizard.get_title(data + 1), stepid=data - )) + return render_to_json_response( + {'title': cwizard.get_title(data + 1), 'stepid': data} + ) if retcode == 2: - return ajax_simple_response(dict( - status="ok", respmsg=_("Account created") - )) - - from modoboa.lib.templatetags.lib_tags import render_form - return ajax_simple_response(dict( - status="ko", stepid=data, - form=render_form(cwizard.steps[data]["form"]) - )) + genform = cwizard.steps[0]["form"] + account = genform.save() + account.post_create(request.user) + mailform = cwizard.steps[1]["form"] + mailform.save(request.user, account) + return render_to_json_response(_("Account created")) + return render_to_json_response({ + 'stepid': data, 'form_errors': cwizard.errors + }, status=400) + ctx = { + 'title': _("New account"), + 'action': reverse(newaccount), + 'formid': 'newaccount_form', + 'submit_label': _("Create") + } cwizard.create_forms() ctx.update(steps=cwizard.steps) ctx.update(subtitle="1. %s" % cwizard.steps[0]['title']) @@ -191,14 +177,6 @@ def editaccount(request, accountid, tplname="common/tabforms.html"): instances = dict(general=account, mail=mb, perms=account) events.raiseEvent("FillAccountInstances", request.user, account, instances) - ctx = dict( - title=account.username, - formid="accountform", - action=reverse(editaccount, args=[accountid]), - action_label=_("Update"), - action_classes="submit" - ) - if request.method == "POST": classes = {} form = AccountForm(request.user, request.POST, @@ -209,15 +187,17 @@ def editaccount(request, accountid, tplname="common/tabforms.html"): if form.is_valid(optional_only=True): events.raiseEvent("AccountModified", account, form.account) form.save() - return ajax_simple_response( - dict(status="ok", respmsg=_("Account updated")) - ) - transaction.rollback() + return render_to_json_response(_("Account updated")) + return render_to_json_response({'form_errors': form.errors}, status=400) - ctx["tabs"] = form - return ajax_response(request, status="ko", template=tplname, **ctx) - - ctx["tabs"] = AccountForm(request.user, instances=instances) + ctx = { + 'title': account.username, + 'formid': 'accountform', + 'action': reverse(editaccount, args=[accountid]), + 'action_label': _('Update'), + 'action_classes': 'submit', + 'tabs': AccountForm(request.user, instances=instances) + } active_tab_id = request.GET.get("active_tab", "default") if active_tab_id != "default": ctx["tabs"].active_id = active_tab_id @@ -229,11 +209,10 @@ def editaccount(request, accountid, tplname="common/tabforms.html"): @transaction.commit_on_success def delaccount(request, accountid): keepdir = True if request.POST.get("keepdir", "false") == "true" else False - User.objects.get(pk=accountid).delete(request.user, keep_mb_dir=keepdir) - - msg = ungettext("Account deleted", "Accounts deleted", 1) - return ajax_simple_response({"status": "ok", "respmsg": msg}) + return render_to_json_response( + ungettext("Account deleted", "Accounts deleted", 1) + ) @login_required @@ -242,13 +221,13 @@ 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 AdminError(_("Invalid request")) + raise BadRequest(_("Invalid request")) try: account = User.objects.get(pk=daid) domain = Domain.objects.get(pk=domid) except (User.DoesNotExist, Domain.DoesNotExist): - raise AdminError(_("Invalid request")) + raise BadRequest(_("Invalid request")) if not request.user.can_access(account) or not request.user.can_access(domain): raise PermDeniedException domain.remove_admin(account) - return ajax_simple_response({"status": "ok"}) + return render_to_json_response({}) diff --git a/modoboa/extensions/admin/views/import.py b/modoboa/extensions/admin/views/import.py index 5384d682a..5ea28efa4 100644 --- a/modoboa/extensions/admin/views/import.py +++ b/modoboa/extensions/admin/views/import.py @@ -6,7 +6,8 @@ from django.db import transaction, IntegrityError from django.contrib.auth.decorators import ( login_required, permission_required, user_passes_test ) -from modoboa.lib.exceptions import ModoboaException +from modoboa.lib import events +from modoboa.lib.exceptions import ModoboaException, Conflict from modoboa.core.models import User from modoboa.extensions.admin.models import ( Domain, DomainAlias, Alias @@ -39,10 +40,11 @@ def import_domains(request):
  • domain; name; quota; enabled
  • domainalias; name; targeted domain; enabled
  • + %s

The first element of each line is mandatory and must be equal to one of the previous values.

You can use a different character as separator.

-""") +""" % ''.join([unicode(hlp) for hlp in events.raiseQueryEvent('ExtraDomainImportHelp')])) ctx = dict( title=_("Import domains"), @@ -100,8 +102,8 @@ def importdata(request, formclass=ImportDataForm): try: reader = csv.reader(request.FILES['sourcefile'], delimiter=form.cleaned_data['sepchar']) - except csv.Error, e: - error = str(e) + except csv.Error as inst: + error = str(inst) if error is None: try: @@ -112,13 +114,21 @@ def importdata(request, formclass=ImportDataForm): try: fct = globals()["import_%s" % row[0].strip()] except KeyError: - continue + fct = events.raiseQueryEvent( + 'ImportObject', row[0].strip() + ) + if not fct: + continue + fct = fct[0] try: fct(request.user, row, form.cleaned_data) except IntegrityError, e: if form.cleaned_data["continue_if_exists"]: continue - raise ModoboaException(_("Object already exists: %s" % row)) + raise Conflict( + _("Object already exists: %s" + % form.cleaned_data['sepchar'].join(row[:2])) + ) cpt += 1 msg = _("%d objects imported successfully" % cpt) return render(request, "admin/import_done.html", { @@ -142,7 +152,7 @@ def import_identities(request): helptext = _("""Provide a CSV file where lines respect one of the following formats:
    -
  • account; loginname; password; first name; last name; enabled; group; address[, domain, ...]
  • +
  • account; loginname; password; first name; last name; enabled; group; address; quota; [, domain, ...]
  • alias; address; enabled; internal recipient
  • forward; address; enabled; external recipient
  • dlist; address; enabled; recipient; recipient; ...
  • diff --git a/modoboa/extensions/admin/views/user.py b/modoboa/extensions/admin/views/user.py index 37fdd6d09..f836968b1 100644 --- a/modoboa/extensions/admin/views/user.py +++ b/modoboa/extensions/admin/views/user.py @@ -2,10 +2,9 @@ from django.contrib.auth.decorators import login_required from django.utils.translation import ugettext as _ from modoboa.lib.exceptions import ModoboaException from modoboa.lib.webutils import ( - ajax_response, ajax_simple_response, _render_to_string + render_to_json_response, _render_to_string ) from modoboa.extensions.admin.lib import needs_mailbox -from modoboa.extensions.admin.exceptions import BadDestination from modoboa.extensions.admin.models import Alias, Mailbox from modoboa.extensions.admin.forms import ForwardForm @@ -13,12 +12,7 @@ from modoboa.extensions.admin.forms import ForwardForm @login_required @needs_mailbox() def forward(request, tplname='admin/forward.html'): - try: - mb = request.user.mailbox_set.all()[0] - except IndexError: - raise ModoboaException( - _("You need a mailbox in order to define a forward") - ) + mb = request.user.mailbox_set.all()[0] try: al = Alias.objects.get(address=mb.address, domain__name=mb.domain.name) @@ -28,28 +22,23 @@ def forward(request, tplname='admin/forward.html'): form = ForwardForm(request.POST) error = None if form.is_valid(): - try: - if al is None: - al = Alias() - al.address = mb.address - al.domain = mb.domain - al.enabled = mb.user.is_active - intdests = [] - if form.cleaned_data["keepcopies"]: - intdests += [mb] - form.parse_dest() - al.save(int_rcpts=intdests, ext_rcpts=form.dests) - if request.user.group != "SimpleUsers": - al.post_create(request.user) - return ajax_response(request, respmsg=_("Forward updated")) - except BadDestination, e: - error = str(e) + if al is None: + al = Alias() + al.address = mb.address + al.domain = mb.domain + al.enabled = mb.user.is_active + intdests = [] + if form.cleaned_data["keepcopies"]: + intdests += [mb] + form.parse_dest() + al.save( + int_rcpts=intdests, ext_rcpts=form.dests, creator=request.user + ) + return render_to_json_response(_("Forward updated")) - return ajax_simple_response(dict( - status="ko", - errors=form.errors, - respmsg=error - )) + return render_to_json_response( + {'form_errors': form.errors}, status=400 + ) form = ForwardForm() if al is not None: @@ -60,8 +49,7 @@ def forward(request, tplname='admin/forward.html'): pass else: form.fields["keepcopies"].initial = True - return ajax_simple_response({ - "status": "ok", + return render_to_json_response({ "content": _render_to_string(request, tplname, { "form": form }) diff --git a/modoboa/extensions/amavis/__init__.py b/modoboa/extensions/amavis/__init__.py index 8a85d646c..552e006dd 100644 --- a/modoboa/extensions/amavis/__init__.py +++ b/modoboa/extensions/amavis/__init__.py @@ -8,9 +8,8 @@ Provides: * Per-domain settings """ -from django.utils.translation import ugettext as _, ugettext_lazy -from django.core.urlresolvers import reverse -from django.template import Template, Context +import sys +from django.utils.translation import ugettext_lazy from modoboa.lib import events, parameters from modoboa.core.extensions import ModoExtension, exts_pool @@ -30,141 +29,33 @@ class Amavis(ModoExtension): for existing domains to let Amavis consider them local. """ from modoboa.extensions.admin.models import Domain - from .models import Users, Policy + from modoboa.extensions.amavis.models import Users + from modoboa.extensions.amavis.lib import ( + create_user_and_policy, create_user_and_use_policy + ) for dom in Domain.objects.all(): try: Users.objects.get(email="@%s" % dom.name) except Users.DoesNotExist: - p = Policy.objects.create(policy_name=dom.name) - Users.objects.create(email="@%s" % dom.name, fullname=dom.name, - priority=7, policy=p) + create_user_and_policy(dom.name) + for domalias in dom.domainalias_set.all(): + try: + Users.objects.get(email='@%s' % domalias.name) + except Users.DoesNotExist: + create_user_and_use_policy(domalias.name, dom.name) def load(self): from .app_settings import ParametersForm, UserSettings + parameters.register(ParametersForm, "Amavis") parameters.register(UserSettings, ugettext_lazy("Quarantine")) + from modoboa.extensions.amavis import general_callbacks + if 'modoboa.extensions.amavis.general_callbacks' in sys.modules: + reload(general_callbacks) def destroy(self): - events.unregister("UserMenuDisplay", menu) + events.unregister_extension() parameters.unregister() exts_pool.register_extension(Amavis) - - -@events.observe("UserMenuDisplay") -def menu(target, user): - if target == "top_menu": - return [ - {"name": "quarantine", - "label": _("Quarantine"), - "url": reverse('modoboa.extensions.amavis.views.index')} - ] - return [] - - -@events.observe("CreateDomain") -def on_create_domain(user, domain): - from .models import Users, Policy - p = Policy.objects.create(policy_name=domain.name) - Users.objects.create(email="@%s" % domain.name, fullname=domain.name, - priority=7, policy=p) - - -@events.observe("DomainModified") -def on_domain_modified(domain): - if domain.oldname != domain.name: - from .models import Users - u = Users.objects.get(email="@%s" % domain.oldname) - u.email = "@%s" % domain.name - u.fullname = domain.name - u.policy.policy_name = domain.name - u.policy.save() - u.save() - - -@events.observe("DeleteDomain") -def on_delete_domain(domain): - from .models import Users - - try: - u = Users.objects.get(email="@%s" % domain.name) - except Users.DoesNotExist: - return - u.policy.delete() - u.delete() - - -@events.observe("GetStaticContent") -def extra_static_content(user): - if user.group == "SimpleUsers": - return [] - - tpl = Template("""""") - - return [tpl.render( - Context(dict( - url=reverse("modoboa.extensions.amavis.views.nbrequests"), - interval=int(parameters.get_admin("CHECK_REQUESTS_INTERVAL")) * 1000, - text=_("pending requests"), - user_can_release=parameters.get_admin("USER_CAN_RELEASE") - )) - )] - - -@events.observe("TopNotifications") -def display_requests(user): - from .sql_listing import get_wrapper - - if parameters.get_admin("USER_CAN_RELEASE") == "yes" \ - or user.group == "SimpleUsers": - return [] - nbrequests = get_wrapper().get_pending_requests(user) - - url = reverse("modoboa.extensions.amavis.views.index") - url += "#listing/?viewrequests=1" - tpl = Template('
    ') - css = "hidden" if nbrequests == 0 else "" - return [tpl.render(Context(dict( - label=_("%d pending requests" % nbrequests), url=url, css=css - )))] - - -@events.observe("ExtraDomainForm") -def extra_domain_form(user, domain): - from .forms import DomainPolicyForm - - if not user.has_perm("admin.view_domains"): - return [] - return [ - dict( - id="amavis", title=_("Content filter"), cls=DomainPolicyForm, - formtpl="amavis/domain_content_filter.html" - ) - ] - - -@events.observe("FillDomainInstances") -def fill_domain_instances(user, domain, instances): - if not user.has_perm("admin.view_domains"): - return - instances["amavis"] = domain diff --git a/modoboa/extensions/amavis/forms.py b/modoboa/extensions/amavis/forms.py index 721b3c5a1..1f9a270ad 100644 --- a/modoboa/extensions/amavis/forms.py +++ b/modoboa/extensions/amavis/forms.py @@ -1,7 +1,7 @@ # coding: utf-8 from django import forms -from models import Policy, Users from modoboa.lib.formutils import InlineRadioSelect +from modoboa.extensions.amavis.models import Policy, Users class DomainPolicyForm(forms.ModelForm): @@ -34,20 +34,21 @@ class DomainPolicyForm(forms.ModelForm): self.fields[f].required = False def save(self, user, commit=True): - p = super(DomainPolicyForm, self).save(commit=False) - for f in ['bypass_spam_checks', 'bypass_virus_checks', 'bypass_banned_checks']: - if getattr(p, f) == '': - setattr(p, f, None) + policy = super(DomainPolicyForm, self).save(commit=False) + for field in ['bypass_spam_checks', 'bypass_virus_checks', + 'bypass_banned_checks']: + if getattr(policy, field) == '': + setattr(policy, field, None) if self.cleaned_data['spam_subject_tag2_act']: - p.spam_subject_tag2 = None + policy.spam_subject_tag2 = None if commit: - p.save() + policy.save() try: - u = Users.objects.get(policy=p) + u = Users.objects.get(fullname=policy.policy_name) except Users.DoesNotExist: u = Users.objects.get(email="@%s" % self.domain.name) - u.policy = p - p.save() - return p + u.policy = policy + policy.save() + return policy diff --git a/modoboa/extensions/amavis/general_callbacks.py b/modoboa/extensions/amavis/general_callbacks.py new file mode 100644 index 000000000..8d9ad4ec6 --- /dev/null +++ b/modoboa/extensions/amavis/general_callbacks.py @@ -0,0 +1,147 @@ +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ +from django.template import Template, Context +from modoboa.lib import events, parameters +from modoboa.extensions.admin.models import DomainAlias +from modoboa.extensions.amavis.lib import ( + create_user_and_policy, update_user_and_policy, delete_user_and_policy, + create_user_and_use_policy, delete_user +) + + +@events.observe("UserMenuDisplay") +def menu(target, user): + if target == "top_menu": + return [ + {"name": "quarantine", + "label": _("Quarantine"), + "url": reverse('modoboa.extensions.amavis.views.index')} + ] + return [] + + +@events.observe("DomainCreated") +def on_domain_created(user, domain): + create_user_and_policy(domain.name) + + +@events.observe("DomainModified") +def on_domain_modified(domain): + update_user_and_policy(domain.oldname, domain.name) + + +@events.observe("DomainDeleted") +def on_domain_deleted(domain): + delete_user_and_policy(domain.name) + + +@events.observe("DomainAliasCreated") +def on_domain_alias_created(user, domainalias): + create_user_and_use_policy(domainalias.name, domainalias.target.name) + + +@events.observe("DomainAliasDeleted") +def on_domain_alias_deleted(domainaliases): + if isinstance(domainaliases, DomainAlias): + domainaliases = [domainaliases] + for domainalias in domainaliases: + delete_user(domainalias.name) + + +@events.observe("GetStaticContent") +def extra_static_content(caller, user): + if user.group == "SimpleUsers": + return [] + + if caller == 'top' and parameters.get_admin("USER_CAN_RELEASE") == 'no': + tpl = Template(""" +""") + return [tpl.render( + Context({ + 'url': reverse("modoboa.extensions.amavis.views.nbrequests"), + 'interval': int(parameters.get_admin("CHECK_REQUESTS_INTERVAL")) * 1000, + 'text': _("pending requests"), + }) + )] + + if caller == 'domains': + tpl = Template(""" +""") + + return [tpl.render(Context({}))] + return [] + + +@events.observe("TopNotifications") +def display_requests(user): + from .sql_listing import get_wrapper + + if parameters.get_admin("USER_CAN_RELEASE") == "yes" \ + or user.group == "SimpleUsers": + return [] + nbrequests = get_wrapper().get_pending_requests(user) + + url = reverse("modoboa.extensions.amavis.views.index") + url += "#listing/?viewrequests=1" + tpl = Template('') + css = "hidden" if nbrequests == 0 else "" + return [tpl.render(Context(dict( + label=_("%d pending requests" % nbrequests), url=url, css=css + )))] + + +def send_amavis_form(): + """ + """ + from .forms import DomainPolicyForm + return [{ + 'id': 'amavis', 'title': _("Content filter"), 'cls': DomainPolicyForm, + 'formtpl': 'amavis/domain_content_filter.html' + }] + + +@events.observe("ExtraDomainForm") +def extra_domain_form(user, domain): + if not user.has_perm("admin.view_domains"): + return [] + return send_amavis_form() + + +@events.observe('ExtraRelayDomainForm') +def extra_relaydomain_form(user, rdomain): + if not user.has_perm("postfix_relay_domains.add_relaydomain"): + return [] + return send_amavis_form() + + +@events.observe("FillDomainInstances") +def fill_domain_instances(user, domain, instances): + if not user.has_perm("admin.view_domains"): + return + instances["amavis"] = domain + + +@events.observe("FillRelayDomainInstances") +def fill_relaydomain_instances(user, rdomain, instances): + if not user.has_perm("postfix_relay_domains.add_relaydomain"): + return + instances["amavis"] = rdomain diff --git a/modoboa/extensions/amavis/lib.py b/modoboa/extensions/amavis/lib.py index 2a98f4ccf..f33e77249 100644 --- a/modoboa/extensions/amavis/lib.py +++ b/modoboa/extensions/amavis/lib.py @@ -7,7 +7,8 @@ from functools import wraps from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ from modoboa.lib import parameters -from modoboa.lib.exceptions import ModoboaException +from modoboa.lib.exceptions import InternalError +from modoboa.extensions.amavis.models import Users, Policy def selfservice(ssfunc=None): @@ -50,7 +51,7 @@ class AMrelease(object): self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.connect(path) except socket.error, err: - raise ModoboaException( + raise InternalError( _("Connection to amavis failed: %s" % str(err)) ) @@ -76,3 +77,71 @@ recipient=%s if re.search("250 [\d\.]+ Ok", answer): return True return False + + +def create_user_and_policy(name): + """Create records. + + Create two records (a user and a policy) using :keyword:`name` as + an identifier. + + :param str name: name + """ + policy = Policy.objects.create(policy_name=name) + Users.objects.create( + email="@%s" % name, fullname=name, + priority=7, policy=policy + ) + + +def create_user_and_use_policy(name, policy_name): + """Create a *users* record and use an existing policy. + + :param str name: user record name + :param str policy_name: policy name + """ + policy = Policy.objects.get(policy_name=policy_name) + Users.objects.create( + email="@%s" % name, fullname=name, + priority=7, policy=policy + ) + + +def update_user_and_policy(oldname, newname): + """Update records. + + :param str oldname: old name + :param str newname: new name + """ + if oldname == newname: + return + u = Users.objects.get(email="@%s" % oldname) + u.email = "@%s" % newname + u.fullname = newname + u.policy.policy_name = newname + u.policy.save() + u.save() + + +def delete_user_and_policy(name): + """Delete records. + + :param str name: identifier + """ + try: + u = Users.objects.get(email="@%s" % name) + except Users.DoesNotExist: + return + u.policy.delete() + u.delete() + + +def delete_user(name): + """Delete a *users* record. + + :param str name: user record name + """ + try: + Users.objects.get(email="@%s" % name).delete() + except Users.DoesNotExist: + pass diff --git a/modoboa/extensions/amavis/migrations/0002_create_records.py b/modoboa/extensions/amavis/migrations/0002_create_records.py index a9447f2e3..ba2c3ccf2 100644 --- a/modoboa/extensions/amavis/migrations/0002_create_records.py +++ b/modoboa/extensions/amavis/migrations/0002_create_records.py @@ -10,9 +10,12 @@ class Migration(DataMigration): def forwards(self, orm): "Write your forwards methods here." orm["amavis.Users"].objects.all().delete() - orm["amavis.Policy"].objects.create(id=1, policy_name='default', bypass_virus_checks=None, - bypass_spam_checks=None, bypass_banned_checks=None, - spam_subject_tag2=None) + if not orm['amavis.Policy'].objects.all(): + orm["amavis.Policy"].objects.create( + id=1, policy_name='default', bypass_virus_checks=None, + bypass_spam_checks=None, bypass_banned_checks=None, + spam_subject_tag2=None + ) for dom in orm["admin.Domain"].objects.all(): orm["amavis.Users"].objects.create(email="@%s" % dom.name, fullname=dom.name, priority=7, policy_id=1) diff --git a/modoboa/extensions/amavis/migrations/0003_create_domalias_records.py b/modoboa/extensions/amavis/migrations/0003_create_domalias_records.py new file mode 100644 index 000000000..f5fd72418 --- /dev/null +++ b/modoboa/extensions/amavis/migrations/0003_create_domalias_records.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models +from django.core.exceptions import ObjectDoesNotExist + +class Migration(DataMigration): + + def forwards(self, orm): + "Write your forwards methods here." + try: + ext = orm["core.Extension"].objects.get(name="amavis") + except ObjectDoesNotExist: + return + if not ext.enabled: + return + for domalias in orm["admin.DomainAlias"].objects.all(): + u = orm["amavis.Users"]( + email="@%s" % domalias.name, fullname=domalias.name, priority=7 + ) + u.policy = orm["amavis.Policy"].objects.get(policy_name=domalias.target.name) + u.save() + + def backwards(self, orm): + "Write your backwards methods here." + raise RuntimeError("Cannot revert this migration") + + models = { + 'admin.alias': { + 'Meta': {'ordering': "['domain__name', 'address']", 'unique_together': "(('address', 'domain'),)", 'object_name': 'Alias'}, + 'address': ('django.db.models.fields.CharField', [], {'max_length': '254'}), + 'aliases': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['admin.Alias']", 'symmetrical': 'False'}), + 'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.ObjectDates']"}), + 'domain': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.Domain']"}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'extmboxes': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mboxes': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['admin.Mailbox']", 'symmetrical': 'False'}) + }, + 'admin.domain': { + 'Meta': {'ordering': "['name']", 'object_name': 'Domain'}, + 'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.ObjectDates']"}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), + 'quota': ('django.db.models.fields.IntegerField', [], {}) + }, + 'admin.domainalias': { + 'Meta': {'object_name': 'DomainAlias'}, + 'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.ObjectDates']"}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), + 'target': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.Domain']"}) + }, + 'admin.mailbox': { + 'Meta': {'object_name': 'Mailbox'}, + 'address': ('django.db.models.fields.CharField', [], {'max_length': '252'}), + 'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.ObjectDates']"}), + 'domain': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.Domain']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'quota': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'use_domain_quota': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['core.User']"}) + }, + 'admin.mailboxoperation': { + 'Meta': {'object_name': 'MailboxOperation'}, + 'argument': ('django.db.models.fields.TextField', [], {}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mailbox': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.Mailbox']", 'null': 'True', 'blank': 'True'}), + 'type': ('django.db.models.fields.CharField', [], {'max_length': '20'}) + }, + 'admin.objectdates': { + 'Meta': {'object_name': 'ObjectDates'}, + 'creation': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_modification': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}) + }, + 'admin.quota': { + 'Meta': {'object_name': 'Quota'}, + 'bytes': ('django.db.models.fields.BigIntegerField', [], {'default': '0'}), + 'mbox': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'quota_value'", 'unique': 'True', 'null': 'True', 'to': "orm['admin.Mailbox']"}), + 'messages': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'username': ('django.db.models.fields.EmailField', [], {'max_length': '254', 'primary_key': 'True'}) + }, + u'amavis.maddr': { + 'Meta': {'object_name': 'Maddr', 'db_table': "u'maddr'", 'managed': 'False'}, + 'domain': ('django.db.models.fields.CharField', [], {'max_length': '765'}), + 'email': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'id': ('django.db.models.fields.BigIntegerField', [], {'primary_key': 'True'}), + 'partition_tag': ('django.db.models.fields.IntegerField', [], {'unique': 'True', 'null': 'True', 'blank': 'True'}) + }, + u'amavis.mailaddr': { + 'Meta': {'object_name': 'Mailaddr', 'db_table': "u'mailaddr'", 'managed': 'False'}, + 'email': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'id': ('django.db.models.fields.IntegerField', [], {'primary_key': 'True'}), + 'priority': ('django.db.models.fields.IntegerField', [], {}) + }, + u'amavis.msgrcpt': { + 'Meta': {'unique_together': "(('partition_tag', 'mail', 'rseqnum'),)", 'object_name': 'Msgrcpt', 'db_table': "u'msgrcpt'", 'managed': 'False'}, + 'bl': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), + 'bspam_level': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'content': ('django.db.models.fields.CharField', [], {'max_length': '3'}), + 'ds': ('django.db.models.fields.CharField', [], {'max_length': '3'}), + 'is_local': ('django.db.models.fields.CharField', [], {'max_length': '3'}), + 'mail': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['amavis.Msgs']", 'primary_key': 'True'}), + 'partition_tag': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), + 'rid': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['amavis.Maddr']", 'primary_key': 'True', 'db_column': "'rid'"}), + 'rs': ('django.db.models.fields.CharField', [], {'max_length': '3'}), + 'rseqnum': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'smtp_resp': ('django.db.models.fields.CharField', [], {'max_length': '765', 'blank': 'True'}), + 'wl': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}) + }, + u'amavis.msgs': { + 'Meta': {'unique_together': "(('partition_tag', 'mail_id'),)", 'object_name': 'Msgs', 'db_table': "u'msgs'", 'managed': 'False'}, + 'am_id': ('django.db.models.fields.CharField', [], {'max_length': '60'}), + 'client_addr': ('django.db.models.fields.CharField', [], {'max_length': '765', 'blank': 'True'}), + 'content': ('django.db.models.fields.CharField', [], {'max_length': '1', 'blank': 'True'}), + 'dsn_sent': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), + 'from_addr': ('django.db.models.fields.CharField', [], {'max_length': '765', 'blank': 'True'}), + 'host': ('django.db.models.fields.CharField', [], {'max_length': '765'}), + 'mail_id': ('django.db.models.fields.CharField', [], {'max_length': '12', 'primary_key': 'True'}), + 'message_id': ('django.db.models.fields.CharField', [], {'max_length': '765', 'blank': 'True'}), + 'originating': ('django.db.models.fields.CharField', [], {'max_length': '3'}), + 'partition_tag': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), + 'policy': ('django.db.models.fields.CharField', [], {'max_length': '765', 'blank': 'True'}), + 'quar_loc': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'quar_type': ('django.db.models.fields.CharField', [], {'max_length': '1', 'blank': 'True'}), + 'secret_id': ('django.db.models.fields.CharField', [], {'max_length': '12', 'blank': 'True'}), + 'sid': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['amavis.Maddr']", 'db_column': "'sid'"}), + 'size': ('django.db.models.fields.IntegerField', [], {}), + 'spam_level': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '765', 'blank': 'True'}), + 'time_iso': ('django.db.models.fields.CharField', [], {'max_length': '48'}), + 'time_num': ('django.db.models.fields.IntegerField', [], {}) + }, + u'amavis.policy': { + 'Meta': {'object_name': 'Policy', 'db_table': "u'policy'", 'managed': 'False'}, + 'addr_extension_bad_header': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'addr_extension_banned': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'addr_extension_spam': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'addr_extension_virus': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'archive_quarantine_to': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'bad_header_admin': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'bad_header_lover': ('django.db.models.fields.CharField', [], {'max_length': '3', 'null': 'True', 'blank': 'True'}), + 'bad_header_quarantine_to': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'banned_admin': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'banned_files_lover': ('django.db.models.fields.CharField', [], {'max_length': '3', 'null': 'True', 'blank': 'True'}), + 'banned_quarantine_to': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'banned_rulenames': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'bypass_banned_checks': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '3', 'null': 'True'}), + 'bypass_header_checks': ('django.db.models.fields.CharField', [], {'max_length': '3', 'null': 'True', 'blank': 'True'}), + 'bypass_spam_checks': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '3', 'null': 'True'}), + 'bypass_virus_checks': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '3', 'null': 'True'}), + 'clean_quarantine_to': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'disclaimer_options': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'forward_method': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'message_size_limit': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), + 'newvirus_admin': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'policy_name': ('django.db.models.fields.CharField', [], {'max_length': '96', 'blank': 'True'}), + 'sa_userconf': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'sa_username': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'spam_admin': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'spam_dsn_cutoff_level': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'spam_kill_level': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'spam_lover': ('django.db.models.fields.CharField', [], {'max_length': '3', 'null': 'True', 'blank': 'True'}), + 'spam_quarantine_cutoff_level': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'spam_quarantine_to': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'spam_subject_tag': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'spam_subject_tag2': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'spam_subject_tag3': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'spam_tag2_level': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'spam_tag3_level': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'spam_tag_level': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'unchecked_lover': ('django.db.models.fields.CharField', [], {'max_length': '3', 'null': 'True', 'blank': 'True'}), + 'unchecked_quarantine_to': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'virus_admin': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'virus_lover': ('django.db.models.fields.CharField', [], {'max_length': '3', 'null': 'True', 'blank': 'True'}), + 'virus_quarantine_to': ('django.db.models.fields.CharField', [], {'max_length': '192', 'null': 'True', 'blank': 'True'}), + 'warnbadhrecip': ('django.db.models.fields.CharField', [], {'max_length': '3', 'null': 'True', 'blank': 'True'}), + 'warnbannedrecip': ('django.db.models.fields.CharField', [], {'max_length': '3', 'null': 'True', 'blank': 'True'}), + 'warnvirusrecip': ('django.db.models.fields.CharField', [], {'max_length': '3', 'null': 'True', 'blank': 'True'}) + }, + u'amavis.quarantine': { + 'Meta': {'ordering': "['-mail__time_num']", 'unique_together': "(('partition_tag', 'mail', 'chunk_ind'),)", 'object_name': 'Quarantine', 'db_table': "u'quarantine'", 'managed': 'False'}, + 'chunk_ind': ('django.db.models.fields.IntegerField', [], {}), + 'mail': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['amavis.Msgs']", 'primary_key': 'True'}), + 'mail_text': ('django.db.models.fields.TextField', [], {}), + 'partition_tag': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + u'amavis.users': { + 'Meta': {'object_name': 'Users', 'db_table': "u'users'", 'managed': 'False'}, + 'email': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'fullname': ('django.db.models.fields.CharField', [], {'max_length': '765', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'policy': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['amavis.Policy']"}), + 'priority': ('django.db.models.fields.IntegerField', [], {}) + }, + u'amavis.wblist': { + 'Meta': {'object_name': 'Wblist', 'db_table': "u'wblist'", 'managed': 'False'}, + 'rid': ('django.db.models.fields.IntegerField', [], {'primary_key': 'True'}), + 'sid': ('django.db.models.fields.IntegerField', [], {'primary_key': 'True'}), + 'wb': ('django.db.models.fields.CharField', [], {'max_length': '30'}) + }, + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'core.extension': { + 'Meta': {'object_name': 'Extension'}, + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '150'}) + }, + u'core.log': { + 'Meta': {'object_name': 'Log'}, + 'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.CharField', [], {'max_length': '15'}), + 'logger': ('django.db.models.fields.CharField', [], {'max_length': '30'}), + 'message': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + u'core.objectaccess': { + 'Meta': {'unique_together': "(('user', 'content_type', 'object_id'),)", 'object_name': 'ObjectAccess'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_owner': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['core.User']"}) + }, + u'core.user': { + 'Meta': {'ordering': "['username']", 'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '254', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_local': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '254'}) + } + } + + complete_apps = ['core', 'admin', 'amavis'] + symmetrical = True diff --git a/modoboa/extensions/amavis/static/amavis/js/quarantine.js b/modoboa/extensions/amavis/static/amavis/js/quarantine.js index 699c37252..bb4b8ff57 100644 --- a/modoboa/extensions/amavis/static/amavis/js/quarantine.js +++ b/modoboa/extensions/amavis/static/amavis/js/quarantine.js @@ -117,9 +117,8 @@ Quarantine.prototype = { url: $link.attr("href"), data: "action=" + name + "&selection=" + selection.join(","), type: 'POST', - dataType: 'json', - success: $.proxy(this.action_cb, this) - }); + dataType: 'json' + }).done($.proxy(this.action_cb, this)); }, release_selection: function(e) { @@ -139,9 +138,8 @@ Quarantine.prototype = { } $.ajax({ url: $link.attr("href"), - dataType: 'json', - success: $.proxy(this.action_cb, this) - }); + dataType: 'json' + }).done($.proxy(this.action_cb, this)); }, release: function(e) { @@ -175,11 +173,11 @@ Quarantine.prototype = { if (!$rawheaders.length) { $.ajax({ url: $link.attr("href"), - success: $.proxy(function(data) { - $mailcontent.find("#table-container").append($(data)); - this.show_rawheaders($mailcontent); - }, this) - }); + global: false + }).done($.proxy(function(data) { + $mailcontent.find("#table-container").append($(data)); + this.show_rawheaders($mailcontent); + }, this)); return; } this.show_rawheaders($mailcontent); diff --git a/modoboa/extensions/amavis/static/amavis/js/selfservice.js b/modoboa/extensions/amavis/static/amavis/js/selfservice.js index 8aecf6d04..d634f9bfa 100644 --- a/modoboa/extensions/amavis/static/amavis/js/selfservice.js +++ b/modoboa/extensions/amavis/static/amavis/js/selfservice.js @@ -7,14 +7,9 @@ function send_action(e, message) { } $.ajax({ url: $link.attr("href"), - dataType: 'json', - success: function(data) { - if (data.status == "ok") { - $("body").notify("success", data.respmsg, 2000); - } else { - $("body").notify("error", data.respmsg); - } - } + dataType: 'json' + }).done(function(data) { + $("body").notify("success", data, 2000); }); } diff --git a/modoboa/extensions/amavis/views.py b/modoboa/extensions/amavis/views.py index 261110f2c..ba89903ee 100644 --- a/modoboa/extensions/amavis/views.py +++ b/modoboa/extensions/amavis/views.py @@ -1,22 +1,23 @@ # coding: utf-8 import email from django.shortcuts import render -from django.http import HttpResponseRedirect, HttpResponse, Http404 +from django.http import HttpResponseRedirect, Http404 from django.template import Template, Context -from django.utils import simplejson from django.utils.translation import ugettext as _, ungettext from django.core.urlresolvers import reverse from django.contrib.auth.decorators \ import login_required, user_passes_test from django.db.models import Q from modoboa.lib import parameters -from modoboa.lib.exceptions import ModoboaException +from modoboa.lib.exceptions import BadRequest from modoboa.lib.webutils import ( - getctx, ajax_response, ajax_simple_response + getctx, ajax_response, render_to_json_response ) from modoboa.lib.email_listing import parse_search_parameters from modoboa.extensions.admin.models import Mailbox, Domain -from templatetags.amavis_tags import quar_menu, viewm_menu +from modoboa.extensions.amavis.templatetags.amavis_tags import ( + quar_menu, viewm_menu +) from .lib import selfservice, AMrelease from .sql_listing import SQLlisting, SQLemail, get_wrapper from .models import Msgrcpt @@ -47,7 +48,7 @@ def empty_quarantine(request): content = "
    %s
    " % _("Empty quarantine") ctx = getctx("ok", level=2, listing=content, navbar="", menu=quar_menu(request.user)) - return HttpResponse(simplejson.dumps(ctx), mimetype="application/json") + return render_to_json_response(ctx) @login_required @@ -79,7 +80,7 @@ def _listing(request): rcptfilter = request.session["pattern"] continue else: - raise Exception("unsupported search criteria %s" % c) + raise BadRequest("unsupported search criteria %s" % c) flt = nfilter if flt is None else flt | nfilter msgs = get_wrapper().get_mails(request, rcptfilter) @@ -105,7 +106,7 @@ def _listing(request): navbar = lst.render_navbar(page, "listing/?") ctx = getctx("ok", listing=content, navbar=navbar, menu=quar_menu(request.user)) - return HttpResponse(simplejson.dumps(ctx), mimetype="application/json") + return render_to_json_response(ctx) @login_required @@ -176,7 +177,7 @@ def viewmail(request, mail_id): """).render(Context({"url": reverse(getmailcontent, args=[mail_id])})) menu = viewm_menu(request.user, mail_id, rcpt) ctx = getctx("ok", menu=menu, listing=content) - return HttpResponse(simplejson.dumps(ctx), mimetype="application/json") + return render_to_json_response(ctx) @login_required @@ -202,14 +203,14 @@ def check_mail_id(request, mail_id): def delete_selfservice(request, mail_id): rcpt = request.GET.get("rcpt", None) if rcpt is None: - raise ModoboaException(_("Invalid request")) + raise BadRequest(_("Invalid request")) try: msgrcpt = get_wrapper().get_recipient_message(rcpt, mail_id) msgrcpt.rs = 'D' msgrcpt.save() except Msgrcpt.DoesNotExist: - raise ModoboaException(_("Invalid request")) - return ajax_simple_response(dict(status="ok", respmsg=_("Message deleted"))) + raise BadRequest(_("Invalid request")) + return render_to_json_response(_("Message deleted")) @selfservice(delete_selfservice) @@ -241,13 +242,13 @@ def release_selfservice(request, mail_id): rcpt = request.GET.get("rcpt", None) secret_id = request.GET.get("secret_id", None) if rcpt is None or secret_id is None: - raise ModoboaException(_("Invalid request")) + raise BadRequest(_("Invalid request")) try: msgrcpt = get_wrapper().get_recipient_message(rcpt, mail_id) except Msgrcpt.DoesNotExist: - raise ModoboaException(_("Invalid request")) + raise BadRequest(_("Invalid request")) if secret_id != msgrcpt.mail.secret_id: - raise ModoboaException(_("Invalid request")) + raise BadRequest(_("Invalid request")) if parameters.get_admin("USER_CAN_RELEASE") == "no": msgrcpt.rs = 'p' msg = _("Request sent") @@ -258,9 +259,9 @@ def release_selfservice(request, mail_id): rcpt.rs = 'R' msg = _("Message released") else: - raise ModoboaException(result) + raise BadRequest(result) msgrcpt.save() - return ajax_simple_response(dict(status="ok", respmsg=msg)) + return render_to_json_response(msg) @selfservice(release_selfservice) @@ -329,4 +330,4 @@ def process(request): @user_passes_test(lambda u: u.group != 'SimpleUsers') def nbrequests(request): result = get_wrapper().get_pending_requests(request.user) - return ajax_simple_response(dict(status="ok", requests=result)) + return render_to_json_response({'requests': result}) diff --git a/modoboa/extensions/limits/__init__.py b/modoboa/extensions/limits/__init__.py index 2a22f37e8..2601c97e1 100644 --- a/modoboa/extensions/limits/__init__.py +++ b/modoboa/extensions/limits/__init__.py @@ -4,14 +4,17 @@ The *limits* extension ---------------------- """ +import sys from django.contrib.auth.models import Permission, Group from django.db import IntegrityError from django.contrib.contenttypes.models import ContentType -from django.utils.translation import ugettext as _, ugettext_lazy +from django.utils.translation import ugettext_lazy from modoboa.lib import events, parameters from modoboa.core.extensions import ModoExtension, exts_pool -from .forms import ResourcePoolForm -import controls + +levents = [ + 'GetExtraLimitTemplates' +] class Limits(ModoExtension): @@ -19,7 +22,8 @@ class Limits(ModoExtension): label = "Limits" version = "1.0" description = ugettext_lazy( - "Per administrator resources to limit the number of objects they can create" + "Per administrator resources to limit the number of objects " + "they can create" ) def init(self): @@ -47,94 +51,17 @@ class Limits(ModoExtension): def load(self): from modoboa.extensions.limits.app_settings import ParametersForm + from modoboa.extensions.limits import controls + parameters.register(ParametersForm, ugettext_lazy("Limits")) + events.declare(levents) + from modoboa.extensions.limits import general_callbacks + if 'modoboa.extensions.limits.general_callbacks' in sys.modules: + reload(general_callbacks) def destroy(self): + events.unregister_extension() parameters.unregister() Group.objects.get(name="Resellers").delete() exts_pool.register_extension(Limits) - - -@events.observe("GetExtraRoles") -def get_extra_roles(user): - if user.is_superuser: - return [("Resellers", _("Reseller")), ] - return [] - - -@events.observe("ExtraAdminContent") -def display_pool_usage(user, target, currentpage): - from django.template.loader import render_to_string - - if target != "leftcol" or user.is_superuser: - return [] - if currentpage == "identities": - names = ["mailboxes_limit", "mailbox_aliases_limit"] - if user.has_perm("admin.add_domain"): - names += ["domain_admins_limit"] - else: - names = ["domains_limit", "domain_aliases_limit"] - limits = user.limitspool.limit_set.filter(name__in=names, maxvalue__gt=0) - if len(limits) == 0: - return [] - return [render_to_string("limits/poolusage.html", dict(limits=limits))] - - -@events.observe("ExtraAccountForm") -def extra_account_form(user, account=None): - if not user.group in ["SuperAdmins", "Resellers"]: - return [] - if account is not None and not account.group in ["Resellers", "DomainAdmins"]: - return [] - - return [ - dict( - id="resources", title=_("Resources"), cls=ResourcePoolForm - ) - ] - - -@events.observe("CheckExtraAccountForm") -def check_form_access(account, form): - if form["id"] != "resources": - return [True] - if not account.belongs_to_group("Resellers") and \ - not account.belongs_to_group("DomainAdmins"): - return [False] - return [True] - - -@events.observe("FillAccountInstances") -def fill_account_instances(user, account, instances): - if not user.is_superuser and not user.belongs_to_group("Resellers"): - return - if not account.belongs_to_group("Resellers") and \ - not account.belongs_to_group("DomainAdmins"): - return - instances["resources"] = account - - -@events.observe("GetStaticContent") -def get_static_content(user): - if user.group == "SimpleUsers": - return [] - return """ - -""" diff --git a/modoboa/extensions/limits/app_settings.py b/modoboa/extensions/limits/app_settings.py index 6ce6665cb..e5b510cff 100644 --- a/modoboa/extensions/limits/app_settings.py +++ b/modoboa/extensions/limits/app_settings.py @@ -39,3 +39,7 @@ class ParametersForm(AdminParametersForm): help_text=_("Maximum number of allowed aliases for a new administrator"), widget=forms.widgets.TextInput(attrs={"class": "span1"}) ) + + def __init__(self, *args, **kwargs): + super(AdminParametersForm, self).__init__(*args, **kwargs) + self._load_extra_parameters('A') diff --git a/modoboa/extensions/limits/controls.py b/modoboa/extensions/limits/controls.py index c8c6829d1..34ff09159 100644 --- a/modoboa/extensions/limits/controls.py +++ b/modoboa/extensions/limits/controls.py @@ -6,43 +6,27 @@ """ from modoboa.lib import events from modoboa.lib.permissions import get_object_owner -from .lib import LimitReached -from .models import limits_tpl, LimitsPool +from .lib import LimitReached, inc_limit_usage, dec_limit_usage +from .models import LimitTemplates, LimitsPool -def check_limit(user, lname): +def check_limit(user, lname, count=1): """Check if a user has reached a defined limit We check if the user will not reach the given limit after this creation. - Raise a ``LimitReached`` exception if the limit is reached. - :param user: a ``User`` object + :param int count: the number of objects the user tries to create + :raises: ``LimitReached`` """ try: - if user.limitspool.will_be_reached(lname): + if user.limitspool.will_be_reached(lname, count): raise LimitReached(user.limitspool.get_limit(lname)) except LimitsPool.DoesNotExist: pass -def inc_limit(user, lname): - try: - user.limitspool.inc_curvalue(lname) - except LimitsPool.DoesNotExist: - pass - - -def dec_limit(user, lname): - if user is None: - return - try: - user.limitspool.dec_curvalue(lname) - except LimitsPool.DoesNotExist: - pass - - def move_pool_resource(owner, user): """Move resource from one pool to another @@ -54,7 +38,7 @@ def move_pool_resource(owner, user): except LimitsPool.DoesNotExist: return if not owner.is_superuser: - for ltpl in limits_tpl: + for ltpl in LimitTemplates().templates: l = user.limitspool.get_limit(ltpl[0]) if l.maxvalue < 0: continue @@ -66,15 +50,16 @@ def move_pool_resource(owner, user): user.limitspool.delete() -@events.observe('CreateDomain') +@events.observe('DomainCreated') def inc_nb_domains(user, domain): - inc_limit(user, 'domains_limit') + inc_limit_usage(user, 'domains_limit') -@events.observe('DeleteDomain') -def dec_nb_domains(domain): - owner = get_object_owner(domain) - dec_limit(owner, 'domains_limit') +@events.observe('DomainDeleted') +def dec_nb_domains(domain, owner=None): + if owner is None: + owner = get_object_owner(domain) + dec_limit_usage(owner, 'domains_limit') for domalias in domain.domainalias_set.all(): dec_nb_domaliases(domalias) for mailbox in domain.mailbox_set.all(): @@ -85,7 +70,7 @@ def dec_nb_domains(domain): @events.observe('DomainAliasCreated') def inc_nb_domaliases(user, domalias): - inc_limit(user, 'domain_aliases_limit') + inc_limit_usage(user, 'domain_aliases_limit') @events.observe('DomainAliasDeleted') @@ -96,15 +81,15 @@ def dec_nb_domaliases(domainaliases): domainaliases = [domainaliases] for domainalias in domainaliases: owner = get_object_owner(domainalias) - dec_limit(owner, 'domain_aliases_limit') + dec_limit_usage(owner, 'domain_aliases_limit') -@events.observe('CreateMailbox') +@events.observe('MailboxCreated') def inc_nb_mailboxes(user, mailbox): - inc_limit(user, 'mailboxes_limit') + inc_limit_usage(user, 'mailboxes_limit') -@events.observe('DeleteMailbox') +@events.observe('MailboxDeleted') def dec_nb_mailboxes(mailboxes): from modoboa.extensions.admin.models import Mailbox @@ -112,40 +97,66 @@ def dec_nb_mailboxes(mailboxes): mailboxes = [mailboxes] for mailbox in mailboxes: owner = get_object_owner(mailbox) - dec_limit(owner, 'mailboxes_limit') + dec_limit_usage(owner, 'mailboxes_limit') @events.observe('MailboxAliasCreated') def inc_nb_mbaliases(user, mailboxalias): - inc_limit(user, 'mailbox_aliases_limit') + inc_limit_usage(user, 'mailbox_aliases_limit') @events.observe('MailboxAliasDeleted') -def dec_nb_mbaliases(mailboxalias): - owner = get_object_owner(mailboxalias) - dec_limit(owner, 'mailbox_aliases_limit') +def dec_nb_mbaliases(mailboxaliases): + from modoboa.extensions.admin.models import Alias + + if isinstance(mailboxaliases, Alias): + mailboxaliases = [mailboxaliases] + for alias in mailboxaliases: + owner = get_object_owner(alias) + dec_limit_usage(owner, 'mailbox_aliases_limit') @events.observe('CanCreate') -def can_create_new_object(user, objtype): - check_limit(user, '%s_limit' % objtype) +def can_create_new_object(user, objtype, count=1): + check_limit(user, '%s_limit' % objtype, count) @events.observe("AccountCreated") def create_pool(user): owner = get_object_owner(user) - if not owner.is_superuser and \ - not owner.belongs_to_group("Resellers"): + if not owner.group in ['SuperAdmins', 'Resellers']: return - if user.belongs_to_group("DomainAdmins"): + if user.group == 'DomainAdmins': check_limit(owner, 'domain_admins_limit') - inc_limit(owner, 'domain_admins_limit') + inc_limit_usage(owner, 'domain_admins_limit') - if user.group in ["DomainAdmins", "Resellers"]: - p = LimitsPool(user=user) - p.save() - p.create_limits() + if user.group in ['DomainAdmins', 'Resellers']: + p, created = LimitsPool.objects.get_or_create(user=user) + p.create_limits(owner) + + +@events.observe("UserCanSetRole") +def user_can_set_role(user, role, account=None): + """Check if the user can still set this role. + + The only interesting case concerns resellers defining new domain + administrators. We want to check if they are allowed to do this + operation before any modification is made to :keyword:`account`. + + :param ``User`` user: connected user + :param ``User`` account: account modified (None on creation) + :param str newrole: role to check + """ + if role == 'DomainAdmins': + lname = 'domain_admins_limit' + else: + return [True] + if user.is_superuser or not user.limitspool.will_be_reached(lname): + return [True] + if account is not None and account.group == role: + return [True] + return [False] @events.observe("AccountModified") @@ -164,19 +175,19 @@ def on_account_modified(old, new): except LimitsPool.DoesNotExist: p = LimitsPool(user=new) p.save() - p.create_limits() + p.create_limits(owner) if not new.group in ["DomainAdmins", "Resellers"]: move_pool_resource(owner, new) if old.oldgroup == "DomainAdmins": if new.group != "DomainAdmins": - dec_limit(owner, 'domain_admins_limit') + dec_limit_usage(owner, 'domain_admins_limit') return if new.group == "DomainAdmins": check_limit(owner, 'domain_admins_limit') - inc_limit(owner, 'domain_admins_limit') + inc_limit_usage(owner, 'domain_admins_limit') @events.observe("AccountDeleted") @@ -188,4 +199,21 @@ def on_account_deleted(account, byuser, **kwargs): move_pool_resource(owner, account) if account.group == "DomainAdmins": - dec_limit(owner, 'domain_admins_limit') + dec_limit_usage(owner, 'domain_admins_limit') + + +@events.observe('DomainOwnershipRemoved') +def domain_ownership_removed(reseller, domain): + """DomainOwnershipRemoved listener. + + The access :keyword:`reseller` had to :keyword:`domain` has been + removed by a super adminstrator. We must decrement all limit + usages. + + :param ``User`` reseller: reseller that created :keyword:`domain` + :param ``Domain`` domain: domain + """ + dec_nb_domains(domain, reseller) + for dadmin in domain.admins: + if reseller.is_owner(dadmin): + dec_limit_usage(reseller, 'domain_admins_limit') diff --git a/modoboa/extensions/limits/forms.py b/modoboa/extensions/limits/forms.py index 8a5ac4fcf..a763284e2 100644 --- a/modoboa/extensions/limits/forms.py +++ b/modoboa/extensions/limits/forms.py @@ -1,44 +1,26 @@ # coding: utf-8 - from django import forms from django.utils.translation import ugettext as _, ugettext_lazy from modoboa.lib import parameters -from .models import limits_tpl +from .models import LimitTemplates from .lib import BadLimitValue, UnsufficientResource class ResourcePoolForm(forms.Form): - domain_admins_limit = forms.IntegerField( - label=ugettext_lazy("Domain admins"), - help_text=ugettext_lazy("Maximum number of domain administrators this user can create") - ) - domains_limit = forms.IntegerField( - label=ugettext_lazy("Domains"), - help_text=ugettext_lazy("Maximum number of domains this user can create") - ) - domain_aliases_limit = forms.IntegerField( - label=ugettext_lazy("Domain aliases"), - help_text=ugettext_lazy("Maximum number of domain aliases this user can create") - ) - mailboxes_limit = forms.IntegerField( - label=ugettext_lazy("Mailboxes"), - help_text=ugettext_lazy("Maximum number of mailboxes this user can create") - ) - mailbox_aliases_limit = forms.IntegerField( - label=ugettext_lazy("Mailbox aliases"), - help_text=ugettext_lazy("Maximum number of mailbox aliases this user can create") - ) def __init__(self, *args, **kwargs): + self.account = None if "instance" in kwargs: self.account = kwargs["instance"] del kwargs["instance"] super(ResourcePoolForm, self).__init__(*args, **kwargs) + for tpl in LimitTemplates().templates: + if len(tpl) > 3 and self.account is not None and self.account.group != tpl[3]: + continue + self.fields[tpl[0]] = forms.IntegerField( + label=tpl[1], help_text=tpl[2] + ) if hasattr(self, "account"): - if self.account.group != "Resellers": - del self.fields["domain_admins_limit"] - del self.fields["domains_limit"] - del self.fields["domain_aliases_limit"] self.load_from_user(self.account) def check_limit_value(self, lname): @@ -46,29 +28,30 @@ class ResourcePoolForm(forms.Form): raise forms.ValidationError(_("Invalid limit")) return self.cleaned_data[lname] - def clean_domains_limit(self): - return self.check_limit_value("domains_limit") - - def clean_domain_aliases_limit(self): - return self.check_limit_value("domain_aliases_limit") - - def clean_mailboxes_limit(self): - return self.check_limit_value("mailboxes_limit") - - def clean_mailbox_aliases_limit(self): - return self.check_limit_value("mailbox_aliases_limit") + def clean(self): + cleaned_data = super(ResourcePoolForm, self).clean() + for lname in self.fields.keys(): + if not lname in self._errors and cleaned_data[lname] < -1: + self._errors[lname] = self.error_class([_('Invalid limit')]) + del cleaned_data[lname] + return cleaned_data def load_from_user(self, user): - for l in limits_tpl: - if not l[0] in self.fields: - continue - self.fields[l[0]].initial = user.limitspool.getmaxvalue(l[0]) + for lname in self.fields.keys(): + self.fields[lname].initial = user.limitspool.getmaxvalue(lname) # The following lines will become useless in a near # future. - if self.fields[l[0]].initial == -2: - self.fields[l[0]].initial = parameters.get_admin("DEFLT_%s" % l[0].upper()) + if self.fields[lname].initial == -2: + self.fields[lname].initial = parameters.get_admin("DEFLT_%s" % lname.upper()) def allocate_from_pool(self, limit, pool): + """Allocate resource using an existing pool. + + When a reseller creates a domain administrator, he generally + assigns him resource to create new objetcs. As a reseller may + also be limited, the resource he gives is taken from its own + pool. + """ ol = pool.get_limit(limit.name) if ol.maxvalue == -2: raise BadLimitValue(_("Your resources are not initialized yet")) @@ -92,7 +75,7 @@ class ResourcePoolForm(forms.Form): from modoboa.lib.permissions import get_object_owner owner = get_object_owner(self.account) - for ltpl in limits_tpl: + for ltpl in LimitTemplates().templates: if not ltpl[0] in self.cleaned_data: continue l = self.account.limitspool.limit_set.get(name=ltpl[0]) diff --git a/modoboa/extensions/limits/general_callbacks.py b/modoboa/extensions/limits/general_callbacks.py new file mode 100644 index 000000000..0d41c2449 --- /dev/null +++ b/modoboa/extensions/limits/general_callbacks.py @@ -0,0 +1,97 @@ +from django.utils.translation import ugettext as _ +from modoboa.lib import events +from modoboa.extensions.limits.models import LimitTemplates +from modoboa.extensions.limits.forms import ResourcePoolForm + + +@events.observe("GetExtraRoles") +def get_extra_roles(user): + if user.is_superuser: + return [("Resellers", _("Reseller")), ] + return [] + + +@events.observe("ExtraAdminContent") +def display_pool_usage(user, target, currentpage): + from django.template.loader import render_to_string + + if target != "leftcol" or user.is_superuser: + return [] + if currentpage == "identities": + names = ["mailboxes_limit", "mailbox_aliases_limit"] + if user.has_perm("admin.add_domain"): + names += ["domain_admins_limit"] + else: + names = [ + tpl[0] for tpl in LimitTemplates().templates + if tpl[0] not in ["domain_admins_limit", "mailboxes_limit", + "mailbox_aliases_limit"] + and (len(tpl) == 3 or tpl[3] == user.group) + ] + + limits = user.limitspool.limit_set.filter(name__in=names, maxvalue__gt=0) + if len(limits) == 0: + return [] + return [render_to_string("limits/poolusage.html", dict(limits=limits))] + + +@events.observe("ExtraAccountForm") +def extra_account_form(user, account=None): + if not user.group in ["SuperAdmins", "Resellers"]: + return [] + if account is not None and \ + not account.group in ["Resellers", "DomainAdmins"]: + return [] + + return [ + dict( + id="resources", title=_("Resources"), cls=ResourcePoolForm + ) + ] + + +@events.observe("CheckExtraAccountForm") +def check_form_access(account, form): + if form["id"] != "resources": + return [True] + if not account.belongs_to_group("Resellers") and \ + not account.belongs_to_group("DomainAdmins"): + return [False] + return [True] + + +@events.observe("FillAccountInstances") +def fill_account_instances(user, account, instances): + if not user.is_superuser and not user.belongs_to_group("Resellers"): + return + if not account.belongs_to_group("Resellers") and \ + not account.belongs_to_group("DomainAdmins"): + return + instances["resources"] = account + + +@events.observe("GetStaticContent") +def get_static_content(caller, user): + if caller not in ['domains', 'identities']: + return [] + if user.group == "SimpleUsers": + return [] + return [""" + +"""] diff --git a/modoboa/extensions/limits/lib.py b/modoboa/extensions/limits/lib.py index 5a395b86c..c190b26d7 100644 --- a/modoboa/extensions/limits/lib.py +++ b/modoboa/extensions/limits/lib.py @@ -6,9 +6,12 @@ """ from django.utils.translation import ugettext as _ from modoboa.lib.exceptions import ModoboaException +from modoboa.extensions.limits.models import LimitsPool class LimitReached(ModoboaException): + http_code = 403 + def __init__(self, limit): self.limit = limit @@ -17,6 +20,8 @@ class LimitReached(ModoboaException): class UnsufficientResource(ModoboaException): + http_code = 424 + def __init__(self, limit): self.limit = limit @@ -25,4 +30,23 @@ class UnsufficientResource(ModoboaException): class BadLimitValue(ModoboaException): - pass + http_code = 400 + + +def inc_limit_usage(user, lname): + """Increase a given limit usage. + + """ + try: + user.limitspool.inc_curvalue(lname) + except LimitsPool.DoesNotExist: + pass + + +def dec_limit_usage(user, lname): + if user is None: + return + try: + user.limitspool.dec_curvalue(lname) + except LimitsPool.DoesNotExist: + pass diff --git a/modoboa/extensions/limits/models.py b/modoboa/extensions/limits/models.py index 80f521289..21f4e7b32 100644 --- a/modoboa/extensions/limits/models.py +++ b/modoboa/extensions/limits/models.py @@ -2,27 +2,56 @@ from django.db import models from django.utils.translation import ugettext as _, ugettext_lazy from modoboa.lib import parameters +from modoboa.lib.singleton import Singleton from django.conf import settings +from modoboa.lib import events -limits_tpl = [ - ("domain_admins_limit", ugettext_lazy("Domain admins")), - ("domains_limit", ugettext_lazy("Domains")), - ("domain_aliases_limit", ugettext_lazy("Domain aliases")), - ("mailboxes_limit", ugettext_lazy("Mailboxes")), - ("mailbox_aliases_limit", ugettext_lazy("Mailbox aliases")) -] + +class LimitTemplates(Singleton): + + def __init__(self): + self.__templates = [ + ("domain_admins_limit", ugettext_lazy("Domain admins"), + ugettext_lazy("Maximum number of domain administrators this user can create"), + 'Resellers'), + ("domains_limit", ugettext_lazy("Domains"), + ugettext_lazy("Maximum number of domains this user can create"), + 'Resellers'), + ("domain_aliases_limit", ugettext_lazy("Domain aliases"), + ugettext_lazy("Maximum number of domain aliases this user can create"), + 'Resellers'), + ("mailboxes_limit", ugettext_lazy("Mailboxes"), + ugettext_lazy("Maximum number of mailboxes this user can create")), + ("mailbox_aliases_limit", ugettext_lazy("Mailbox aliases"), + ugettext_lazy("Maximum number of mailbox aliases this user can create")) + ] + + @property + def templates(self): + return self.__templates + events.raiseQueryEvent('GetExtraLimitTemplates') class LimitsPool(models.Model): user = models.OneToOneField(settings.AUTH_USER_MODEL) - def create_limits(self): - for ltpl in limits_tpl: - l = Limit() - l.name = ltpl[0] - l.pool = self - l.maxvalue = int(parameters.get_admin("DEFLT_%s" % ltpl[0].upper())) - l.save() + def create_limits(self, creator): + """Create limits for this pool. + + All limits defined into ``LimitTemplates`` will be created. If + the creator is a super administrator, the default maximum values + will be used. + + :param ``User`` creator: user creating this pool + """ + for ltpl in LimitTemplates().templates: + try: + Limit.objects.get(name=ltpl[0], pool=self) + except Limit.DoesNotExist: + maxvalue = int(parameters.get_admin("DEFLT_%s" % ltpl[0].upper())) \ + if creator.is_superuser else 0 + Limit.objects.create( + name=ltpl[0], pool=self, maxvalue=maxvalue + ) def getcurvalue(self, lname): l = self.limit_set.get(name=lname) @@ -32,6 +61,11 @@ class LimitsPool(models.Model): l = self.limit_set.get(name=lname) return l.maxvalue + def set_maxvalue(self, lname, value): + l = self.limit_set.get(name=lname) + l.maxvalue = value + l.save() + def inc_curvalue(self, lname, nb=1): l = self.limit_set.get(name=lname) l.curvalue += nb @@ -92,7 +126,7 @@ class Limit(models.Model): @property def label(self): - for l in limits_tpl: + for l in LimitTemplates().templates: if l[0] == self.name: return l[1] return "" diff --git a/modoboa/extensions/limits/tests.py b/modoboa/extensions/limits/tests.py index 410b9f30c..cf529f8ae 100644 --- a/modoboa/extensions/limits/tests.py +++ b/modoboa/extensions/limits/tests.py @@ -1,22 +1,22 @@ from django.core.urlresolvers import reverse from modoboa.lib.tests import ExtTestCase -from modoboa.core.factories import UserFactory -from modoboa.extensions.admin.factories import DomainFactory, MailboxFactory +from modoboa.lib import parameters from modoboa.core.models import User +from modoboa.core.factories import UserFactory +from modoboa.extensions.admin.factories import ( + DomainFactory, MailboxFactory, populate_database +) +from modoboa.extensions.admin.models import Alias, Domain, DomainAlias +from modoboa.extensions.limits.models import LimitTemplates class PermissionsTestCase(ExtTestCase): fixtures = ["initial_users.json"] - name = "limits" def setUp(self): super(PermissionsTestCase, self).setUp() - dom = DomainFactory(name='test.com') - self.admin = UserFactory( - username='admin@test.com', groups=('DomainAdmins',) - ) - MailboxFactory(address='admin', domain=dom, user=self.admin) - dom.add_admin(self.admin) + self.activate_extensions('limits') + populate_database() def test_domainadmin_deletes_reseller(self): """Check if a domain admin can delete a reseller. @@ -26,13 +26,378 @@ class PermissionsTestCase(ExtTestCase): values = dict( username="reseller@test.com", first_name="Reseller", last_name="", password1="toto", password2="toto", role="Resellers", - is_active=True, email="reseller@test.com", stepid=2 + is_active=True, email="reseller@test.com", stepid='step2' ) - self.check_ajax_post(reverse("modoboa.extensions.admin.views.identity.newaccount"), values) + self.ajax_post(reverse("modoboa.extensions.admin.views.identity.newaccount"), values) account = User.objects.get(username="reseller@test.com") self.clt.logout() self.clt.login(username="admin@test.com", password="toto") - self.check_ajax_get( + resp = self.ajax_post( reverse("modoboa.extensions.admin.views.identity.delaccount", args=[account.id]), - {}, status="ko", respmsg="Permission denied" + {}, status=403 ) + self.assertEqual(resp, "Permission denied") + + +class ResourceTestCase(ExtTestCase): + fixtures = ["initial_users.json"] + + def setUp(self): + """Custom setUp method. + + The 'limits' is manually loaded to ensure extra parameters + provided by 'postfix_relay_domains' are properly received. + """ + super(ResourceTestCase, self).setUp() + self.activate_extensions('limits') + for tpl in LimitTemplates().templates: + parameters.save_admin('DEFLT_%s' % tpl[0].upper(), 2, app='limits') + populate_database() + + def _create_account(self, username, role='SimpleUsers', status=200): + values = dict( + username=username, first_name="Tester", last_name="Toto", + password1="toto", password2="toto", role=role, + quota_act=True, + is_active=True, email=username, stepid='step2', + ) + return self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.newaccount"), + values, status + ) + + def _create_alias(self, email, rcpt='user@test.com', status=200): + values = dict( + email=email, recipients=rcpt, enabled=True + ) + return self.ajax_post( + reverse("modoboa.extensions.admin.views.alias.newalias"), + values, status + ) + + def _create_domain(self, name, status=200, withtpl=False): + values = { + "name": name, "quota": 100, "create_dom_admin": "no", + "create_aliases": "no", "stepid": 'step2' + } + if withtpl: + values['create_dom_admin'] = 'yes' + values['dom_admin_username'] = 'admin' + values['create_aliases'] = 'yes' + return self.ajax_post( + reverse("modoboa.extensions.admin.views.domain.newdomain"), + values, status + ) + + def _domain_alias_operation(self, optype, domain, name, status=200): + dom = Domain.objects.get(name=domain) + values = { + 'name': dom.name, 'quota': dom.quota, 'enabled': dom.enabled, + } + aliases = [alias.name for alias in dom.domainalias_set.all()] + if optype == 'add': + aliases.append(name) + else: + aliases.remove(name) + for cpt, alias in enumerate(aliases): + fname = 'aliases' if not cpt else 'aliases_%d' % cpt + values[fname] = alias + self.ajax_post( + reverse("modoboa.extensions.admin.views.domain.editdomain", + args=[dom.id]), + values, status + ) + + def _check_limit(self, name, curvalue, maxvalue): + l = self.user.limitspool.get_limit('%s_limit' % name) + self.assertEqual(l.curvalue, curvalue) + self.assertEqual(l.maxvalue, maxvalue) + + +class DomainAdminTestCase(ResourceTestCase): + + def setUp(self): + super(DomainAdminTestCase, self).setUp() + self.user = User.objects.get(username='admin@test.com') + self.user.limitspool.set_maxvalue('mailboxes_limit', 2) + self.user.limitspool.set_maxvalue('mailbox_aliases_limit', 2) + self.clt.logout() + self.clt.login(username='admin@test.com', password='toto') + + def test_mailboxes_limit(self): + self._create_account('tester1@test.com') + self._check_limit('mailboxes', 1, 2) + self._create_account('tester2@test.com') + self._check_limit('mailboxes', 2, 2) + resp = self._create_account('tester3@test.com', status=403) + self._check_limit('mailboxes', 2, 2) + self.ajax_post( + reverse('modoboa.extensions.admin.views.identity.delaccount', + args=[User.objects.get(username='tester2@test.com').id]), + {} + ) + self._check_limit('mailboxes', 1, 2) + + def test_aliases_limit(self): + self._create_alias('alias1@test.com') + self._check_limit('mailbox_aliases', 1, 2) + self._create_alias('alias2@test.com') + self._check_limit('mailbox_aliases', 2, 2) + resp = self._create_alias('alias3@test.com', status=403) + self._check_limit('mailbox_aliases', 2, 2) + self.ajax_post( + reverse('modoboa.extensions.admin.views.alias.delalias') + '?selection=%d' \ + % Alias.objects.get(address='alias2', domain__name='test.com').id, + {} + ) + self._check_limit('mailbox_aliases', 1, 2) + + def test_aliases_limit_through_account_form(self): + user = User.objects.get(username='user@test.com') + values = dict( + username=user.username, role=user.group, + is_active=user.is_active, email=user.email, quota_act=True, + aliases="alias1@test.com", aliases_1="alias2@test.com" + ) + self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.editaccount", + args=[user.id]), + values + ) + Alias.objects.get(address='alias1', domain__name='test.com') + self._check_limit('mailbox_aliases', 2, 2) + + +class ResellerTestCase(ResourceTestCase): + + def setUp(self): + super(ResellerTestCase, self).setUp() + self.user = UserFactory.create( + username='reseller', groups=('Resellers',) + ) + self.clt.logout() + self.clt.login(username='reseller', password='toto') + + def test_domains_limit(self): + self._create_domain('domain1.tld') + self._check_limit('domains', 1, 2) + self._create_domain('domain2.tld') + self._check_limit('domains', 2, 2) + resp = self._create_domain('domain3.tld', 403) + self._check_limit('domains', 2, 2) + self.ajax_post( + reverse('modoboa.extensions.admin.views.domain.deldomain', + args=[Domain.objects.get(name='domain2.tld').id]), + {} + ) + self._check_limit('domains', 1, 2) + + def test_domain_aliases_limit(self): + self._create_domain('pouet.com') + self._domain_alias_operation('add', 'pouet.com', 'domain_alias1.tld') + self._check_limit('domain_aliases', 1, 2) + self._domain_alias_operation('add', 'pouet.com', 'domain_alias2.tld') + self._check_limit('domain_aliases', 2, 2) + resp = self._domain_alias_operation( + 'add', 'pouet.com', 'domain_alias3.tld', 403 + ) + self._check_limit('domain_aliases', 2, 2) + self._domain_alias_operation('delete', 'pouet.com', 'domain_alias2.tld') + self._check_limit('domain_aliases', 1, 2) + + def test_domain_admins_limit(self): + self._create_domain('domain.tld') + self._create_account('admin1@domain.tld', role='DomainAdmins') + self._check_limit('domain_admins', 1, 2) + self._create_account('admin2@domain.tld', role='DomainAdmins') + self._check_limit('domain_admins', 2, 2) + resp = self._create_account('admin3@domain.tld', role='DomainAdmins', status=400) + self.assertEqual( + resp['form_errors']['role'][0], + 'Select a valid choice. DomainAdmins is not one of the available choices.' + ) + self._check_limit('domain_admins', 2, 2) + + self.user.limitspool.set_maxvalue('mailboxes_limit', 3) + self._create_account('user1@domain.tld') + user = User.objects.get(username='user1@domain.tld') + values = { + 'username': user.username, 'role': 'DomainAdmins', 'quota_act': True, + 'is_active': user.is_active, 'email': user.email + } + resp = self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.editaccount", + args=[user.id]), + values, status=400 + ) + self.assertEqual( + resp['form_errors']['role'][0], + 'Select a valid choice. DomainAdmins is not one of the available choices.' + ) + self._check_limit('domain_admins', 2, 2) + + def test_domain_admin_resource_are_empty(self): + self._create_domain('domain.tld') + self._create_account('admin1@domain.tld', role='DomainAdmins') + domadmin = User.objects.get(username='admin1@domain.tld') + for l in ['mailboxes', 'mailbox_aliases']: + self.assertEqual( + domadmin.limitspool.get_limit('%s_limit' % l).maxvalue, 0 + ) + + def test_domain_admins_limit_from_domain_tpl(self): + self.user.limitspool.set_maxvalue('domains_limit', 3) + self._create_domain('domain1.tld', withtpl=True) + self._create_domain('domain2.tld', withtpl=True) + self._check_limit('domain_admins', 2, 2) + self._check_limit('domains', 2, 3) + resp = self._create_domain('domain3.tld', status=200, withtpl=True) + self._check_limit('domain_admins', 2, 2) + self._check_limit('domains', 3, 3) + + def test_reseller_deletes_domain(self): + """Check if all resources are restored after the deletion. + """ + self._create_domain('domain.tld', withtpl=True) + dom = Domain.objects.get(name="domain.tld") + self.ajax_post( + reverse("modoboa.extensions.admin.views.domain.deldomain", + args=[dom.id]), + {} + ) + self._check_limit('domains', 0, 2) + self._check_limit('domain_admins', 1, 2) + self._check_limit('mailboxes', 0, 2) + self._check_limit('mailbox_aliases', 0, 2) + + def test_sadmin_removes_ownership(self): + self._create_domain('domain.tld', withtpl=True) + dom = Domain.objects.get(name="domain.tld") + self.clt.logout() + self.clt.login(username='admin', password='password') + self.ajax_get( + reverse( + 'modoboa.extensions.admin.views.identity.remove_permission' + ) + '?domid=%d&daid=%d' % (dom.id, self.user.id) + , {} + ) + self._check_limit('domains', 0, 2) + self._check_limit('domain_admins', 0, 2) + self._check_limit('mailboxes', 0, 2) + self._check_limit('mailbox_aliases', 0, 2) + + def test_allocate_from_pool(self): + self._create_domain('domain.tld') + self._create_account('admin1@domain.tld', role='DomainAdmins') + user = User.objects.get(username='admin1@domain.tld') + + # Give 1 mailbox and 2 aliases to the admin -> should work + values = { + 'username': user.username, 'role': user.group, 'quota_act': True, + 'is_active': user.is_active, 'email': user.email, + 'mailboxes_limit': 1, 'mailbox_aliases_limit': 2 + } + self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.editaccount", + args=[user.id]), + values + ) + self._check_limit('mailboxes', 1, 1) + self._check_limit('mailbox_aliases', 0, 0) + + # Delete the admin -> resources should go back to the + # reseller's pool + self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.delaccount", + args=[user.id]), + {} + ) + self._check_limit('mailboxes', 0, 2) + self._check_limit('mailbox_aliases', 0, 2) + + def test_restore_resources(self): + self._create_domain('domain.tld') + dom = Domain.objects.get(name='domain.tld') + self._create_account('admin1@domain.tld', role='DomainAdmins') + user = User.objects.get(username='admin1@domain.tld') + values = { + 'username': user.username, 'role': user.group, 'quota_act': True, + 'is_active': user.is_active, 'email': user.email, + 'mailboxes_limit': 1, 'mailbox_aliases_limit': 2 + } + self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.editaccount", + args=[user.id]), + values + ) + dom.add_admin(user) + self.clt.logout() + self.clt.login(username='admin1@domain.tld', password='toto') + self._create_account('user1@domain.tld') + self._create_alias('alias1@domain.tld', 'user1@domain.tld') + self._create_alias('alias2@domain.tld', 'user1@domain.tld') + self.clt.logout() + self.clt.login(username='reseller', password='toto') + # Delete the admin -> resources should go back to the + # reseller's pool + self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.delaccount", + args=[user.id]), + {} + ) + self._check_limit('mailboxes', 1, 2) + self._check_limit('mailbox_aliases', 2, 2) + + def test_change_role(self): + self._create_domain('domain.tld') + self._create_account('admin1@domain.tld', role='DomainAdmins') + user = User.objects.get(username='admin1@domain.tld') + + # Give 1 mailbox and 2 aliases to the admin -> should work + values = { + 'username': user.username, 'role': user.group, 'quota_act': True, + 'is_active': user.is_active, 'email': user.email, + 'mailboxes_limit': 1, 'mailbox_aliases_limit': 2 + } + self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.editaccount", + args=[user.id]), + values + ) + self._check_limit('mailboxes', 1, 1) + self._check_limit('mailbox_aliases', 0, 0) + + # Change admin role to SimpleUser -> resources should go back + # to the reseller. + values = { + 'username': user.username, 'role': 'SimpleUsers', 'quota_act': True, + 'is_active': user.is_active, 'email': user.email, + } + self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.editaccount", + args=[user.id]), + values + ) + self._check_limit('mailboxes', 1, 2) + self._check_limit('mailbox_aliases', 0, 2) + + + def test_allocate_too_much(self): + self._create_domain('domain.tld') + self._create_account('admin1@domain.tld', role='DomainAdmins') + user = User.objects.get(username='admin1@domain.tld') + + # Give 2 mailboxes and 3 aliases to the admin -> should fail. + values = { + 'username': user.username, 'role': user.group, 'quota_act': True, + 'is_active': user.is_active, 'email': user.email, + 'mailboxes_limit': 2, 'mailbox_aliases_limit': 3 + } + resp = self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.editaccount", + args=[user.id]), + values, 424 + ) + self.assertEqual(resp, 'Not enough resources') + self._check_limit('mailboxes', 1, 2) + self._check_limit('mailbox_aliases', 0, 2) diff --git a/modoboa/extensions/postfix_autoreply/__init__.py b/modoboa/extensions/postfix_autoreply/__init__.py index 7f09472f6..c28dd67e0 100644 --- a/modoboa/extensions/postfix_autoreply/__init__.py +++ b/modoboa/extensions/postfix_autoreply/__init__.py @@ -6,13 +6,20 @@ This module provides a way to integrate Modoboa auto-reply functionality into Postfix. """ +import sys from django.utils.translation import ugettext_lazy from modoboa.lib import events, parameters from modoboa.core.extensions import ModoExtension, exts_pool -from .models import Transport, Alias +from modoboa.extensions.postfix_autoreply.models import ( + Transport, Alias +) class PostfixAutoreply(ModoExtension): + """ + Auto-reply (vacation) functionality using Postfix. + + """ name = "postfix_autoreply" label = "Postfix autoreply" version = "1.0" @@ -25,7 +32,7 @@ class PostfixAutoreply(ModoExtension): try: Transport.objects.get(domain="autoreply.%s" % dom.name) except Transport.DoesNotExist: - onCreateDomain(None, dom) + onDomainCreated(None, dom) else: continue @@ -33,19 +40,17 @@ class PostfixAutoreply(ModoExtension): try: Alias.objects.get(full_address=mb.full_address) except Alias.DoesNotExist: - onCreateMailbox(None, mb) + onMailboxCreated(None, mb) def load(self): from modoboa.extensions.postfix_autoreply.app_settings import ParametersForm parameters.register(ParametersForm, ugettext_lazy("Automatic replies")) + from modoboa.extensions.postfix_autoreply import general_callbacks + if 'modoboa.extensions.postfix_autoreply.general_callbacks' in sys.modules: + reload(general_callbacks) def destroy(self): - events.unregister("CreateDomain", onCreateDomain) - events.unregister("DeleteDomain", onDeleteDomain) - events.unregister("CreateMailbox", onCreateMailbox) - events.unregister("DeleteMailbox", onDeleteMailbox) - events.unregister("ModifyMailbox", onModifyMailbox) - events.unregister("UserMenuDisplay", menu) + events.unregister_extension() parameters.unregister() exts_pool.register_extension(PostfixAutoreply) @@ -55,87 +60,3 @@ exts_pool.register_extension(PostfixAutoreply) def extra_routes(): return [(r'^user/autoreply/$', 'modoboa.extensions.postfix_autoreply.views.autoreply'), ] - - -@events.observe("ExtraUprefsJS") -def extra_js(user): - return ["""function autoreply_cb() { - $('#id_untildate').datepicker({format: 'yyyy-mm-dd', language: '%s'}); -} -""" % parameters.get_user(user, "LANG", app="core") - ] - - -@events.observe("UserMenuDisplay") -def menu(target, user): - if target != "uprefs_menu": - return [] - if not user.mailbox_set.count(): - return [] - return [ - {"name": "autoreply", - "class": "ajaxlink", - "url": "autoreply/", - "label": ugettext_lazy("Auto-reply message")} - ] - - -@events.observe("CreateDomain") -def onCreateDomain(user, domain): - transport = Transport() - transport.domain = "autoreply.%s" % domain.name - transport.method = "autoreply:" - transport.save() - - -@events.observe("DomainModified") -def onDomainModified(domain): - if domain.oldname == domain.name: - return - Transport.objects.filter(domain="autoreply.%s" % domain.oldname) \ - .update(domain="autoreply.%s" % domain.name) - for al in Alias.objects.filter(full_address__contains="@%s" % domain.oldname): - new_address = al.full_address.replace("@%s" % domain.oldname, "@%s" % domain.name) - al.full_address = new_address - al.autoreply_address = "%s@autoreply.%s" % (new_address, domain.name) - al.save() - - -@events.observe("DeleteDomain") -def onDeleteDomain(domain): - Transport.objects.filter(domain="autoreply.%s" % domain.name).delete() - - -@events.observe("CreateMailbox") -def onCreateMailbox(user, mailbox): - alias = Alias() - alias.full_address = mailbox.full_address - alias.autoreply_address = \ - "%s@autoreply.%s" % (mailbox.full_address, mailbox.domain.name) - alias.save() - - -@events.observe("DeleteMailbox") -def onDeleteMailbox(mailboxes): - from modoboa.extensions.admin.models import Mailbox - - if isinstance(mailboxes, Mailbox): - mailboxes = [mailboxes] - for mailbox in mailboxes: - try: - alias = Alias.objects.get(full_address=mailbox.full_address) - except Alias.DoesNotExist: - pass - else: - alias.delete() - - -@events.observe("ModifyMailbox") -def onModifyMailbox(mailbox, oldmailbox): - if oldmailbox.full_address == mailbox.full_address: - return - alias = Alias.objects.get(full_address=oldmailbox.full_address) - alias.full_address = mailbox.full_address - alias.autoreply_address = \ - "%s@autoreply.%s" % (mailbox.full_address, mailbox.domain.name) - alias.save() diff --git a/modoboa/extensions/postfix_autoreply/app_settings.py b/modoboa/extensions/postfix_autoreply/app_settings.py index 1aec411a4..464c226df 100644 --- a/modoboa/extensions/postfix_autoreply/app_settings.py +++ b/modoboa/extensions/postfix_autoreply/app_settings.py @@ -16,3 +16,39 @@ class ParametersForm(AdminParametersForm): help_text=ugettext_lazy("Timeout in seconds between two auto-replies to the same recipient") ) + default_subject = forms.CharField( + label=ugettext_lazy("Default subject"), + initial=ugettext_lazy("I'm off"), + help_text=ugettext_lazy( + "Default subject used when an auto-reply message is created automatically" + ) + ) + + default_content = forms.CharField( + label=ugettext_lazy("Default content"), + initial=ugettext_lazy( + """I'm currently off. I'll answer as soon as I come back. + +Best regards, +%(name)s +"""), + help_text=ugettext_lazy( + "Default content user when an auto-reply message is created " + "automatically. The '%(name)s' macro will be replaced by the " + "user's full name." + ), + widget=forms.widgets.Textarea + ) + + def clean_default_content(self): + """Check if the provided value is valid. + + Must be a valid format string which will be used with the % + operator. + """ + tpl = self.cleaned_data["default_content"] + try: + test = tpl % {"name": "Antoine Nguyen"} + except (KeyError, ValueError): + raise forms.ValidationError(ugettext_lazy("Invalid syntax")) + return tpl diff --git a/modoboa/extensions/postfix_autoreply/forms.py b/modoboa/extensions/postfix_autoreply/forms.py index baeaa94b9..211186eb1 100644 --- a/modoboa/extensions/postfix_autoreply/forms.py +++ b/modoboa/extensions/postfix_autoreply/forms.py @@ -2,14 +2,26 @@ import datetime from django import forms from django.utils.translation import ugettext as _, ugettext_lazy +from django.utils import timezone from .models import ARmessage class ARmessageForm(forms.ModelForm): - untildate = forms.DateField( + fromdate = forms.DateTimeField( + label=ugettext_lazy('From'), + required=False, + help_text=ugettext_lazy("Activate your auto reply from this date"), + widget=forms.TextInput( + attrs={'class': 'datefield', 'readonly': 'readonly'} + ) + ) + untildate = forms.DateTimeField( label=ugettext_lazy('Until'), required=False, - help_text=ugettext_lazy("Activate your auto reply until this date") + help_text=ugettext_lazy("Activate your auto reply until this date"), + widget=forms.TextInput( + attrs={'class': 'datefield', 'readonly': 'readonly'} + ) ) class Meta: @@ -18,10 +30,32 @@ class ARmessageForm(forms.ModelForm): def __init__(self, *args, **kwargs): super(ARmessageForm, self).__init__(*args, **kwargs) - self.fields.keyOrder = ['subject', 'content', 'untildate', 'enabled'] + self.fields.keyOrder = ['subject', 'content', 'fromdate', 'untildate', 'enabled'] + if 'instance' in kwargs and kwargs['instance'] is not None: + self.fields['fromdate'].initial = kwargs['instance'].fromdate + self.fields['untildate'].initial = kwargs['instance'].untildate - def clean_untildate(self): + def clean(self): + """Custom fields validaton. + + We want to be sure that fromdate < untildate and that they are + both in the future. + """ + super(ARmessageForm, self).clean() + if self._errors: + raise forms.ValidationError(self._errors) + if self.cleaned_data["fromdate"] is not None: + if self.cleaned_data["fromdate"] < timezone.now(): + self._errrors["fromdate"] = self.error_class([_("This date is over")]) + del self.cleaned_data['fromdate'] + else: + self.cleaned_data['fromdate'] = timezone.now() if self.cleaned_data["untildate"] is not None: - if self.cleaned_data["untildate"] < datetime.date.today(): - raise forms.ValidationError(_("This date is over")) - return self.cleaned_data['untildate'] + if self.cleaned_data["untildate"] < timezone.now(): + self._errors["untildate"] = self.error_class([_("This date is over")]) + del self.cleaned_data['untildate'] + elif self.cleaned_data['untildate'] < self.cleaned_data['fromdate']: + self._errors["untildate"] = \ + self.error_class([_("Must be greater than start date")]) + del self.cleaned_data['untildate'] + return self.cleaned_data diff --git a/modoboa/extensions/postfix_autoreply/general_callbacks.py b/modoboa/extensions/postfix_autoreply/general_callbacks.py new file mode 100644 index 000000000..2cf11a2b9 --- /dev/null +++ b/modoboa/extensions/postfix_autoreply/general_callbacks.py @@ -0,0 +1,145 @@ +from django.utils import timezone +from django.utils.translation import ugettext_lazy +from modoboa.lib import events, parameters +from modoboa.extensions.postfix_autoreply.models import Transport, Alias +from .models import ARmessage + + +@events.observe("ExtraUprefsJS") +def extra_js(user): + return ["""function autoreply_cb() { + $('.datefield').datetimepicker({ + format: 'yyyy-mm-dd hh:ii:ss', + language: '%(lang)s', + autoclose: true, + todayHighlight: true, + todayBtn: 'linked' + }); +} +""" % {'lang': parameters.get_user(user, "LANG", app="core")} + ] + + +@events.observe("UserMenuDisplay") +def menu(target, user): + if target != "uprefs_menu": + return [] + if not user.mailbox_set.count(): + return [] + return [ + {"name": "autoreply", + "class": "ajaxlink", + "url": "autoreply/", + "label": ugettext_lazy("Auto-reply message")} + ] + + +@events.observe("DomainCreated") +def onDomainCreated(user, domain): + transport = Transport() + transport.domain = "autoreply.%s" % domain.name + transport.method = "autoreply:" + transport.save() + + +@events.observe("DomainModified") +def onDomainModified(domain): + if domain.oldname == domain.name: + return + Transport.objects.filter(domain="autoreply.%s" % domain.oldname) \ + .update(domain="autoreply.%s" % domain.name) + for al in Alias.objects.filter(full_address__contains="@%s" % domain.oldname): + new_address = al.full_address.replace("@%s" % domain.oldname, "@%s" % domain.name) + al.full_address = new_address + al.autoreply_address = "%s@autoreply.%s" % (new_address, domain.name) + al.save() + + +@events.observe("DomainDeleted") +def onDomainDeleted(domain): + Transport.objects.filter(domain="autoreply.%s" % domain.name).delete() + + +@events.observe("MailboxCreated") +def onMailboxCreated(user, mailbox): + alias = Alias() + alias.full_address = mailbox.full_address + alias.autoreply_address = \ + "%s@autoreply.%s" % (mailbox.full_address, mailbox.domain.name) + alias.save() + + +@events.observe("MailboxDeleted") +def onMailboxDeleted(mailboxes): + from modoboa.extensions.admin.models import Mailbox + + if isinstance(mailboxes, Mailbox): + mailboxes = [mailboxes] + for mailbox in mailboxes: + try: + alias = Alias.objects.get(full_address=mailbox.full_address) + except Alias.DoesNotExist: + pass + else: + alias.delete() + + +@events.observe("MailboxModified") +def onMailboxModified(mailbox): + if mailbox.full_address == mailbox.old_full_address: + return + alias = Alias.objects.get(full_address=mailbox.old_full_address) + alias.full_address = mailbox.full_address + alias.autoreply_address = \ + "%s@autoreply.%s" % (mailbox.full_address, mailbox.domain.name) + alias.save() + + +@events.observe("ExtraFormFields") +def extra_mailform_fields(form_name, mailbox=None): + """Define extra fields to include in mail forms. + + For now, only the auto-reply state can be modified. + + :param str form_name: form name (must be 'mailform') + :param Mailbox mailbox: mailbox + """ + from modoboa.lib.formutils import YesNoField + + if form_name != "mailform": + return [] + status = False + if mailbox is not None and mailbox.armessage_set.count(): + status = mailbox.armessage_set.all()[0].enabled + return [ + ('autoreply', YesNoField( + label=ugettext_lazy("Enable auto-reply"), + initial="yes" if status else "no", + help_text=ugettext_lazy("Enable or disable Postfix auto-reply") + )) + ] + + +@events.observe("SaveExtraFormFields") +def save_extra_mailform_fields(form_name, mailbox, values): + """Set the auto-reply status for a mailbox. + + If a corresponding auto-reply message exists, we update its + status. Otherwise, we create a message using default values. + + :param str form_name: form name (must be 'mailform') + :param Mailbox mailbox: mailbox + :param dict values: form values + """ + if form_name != 'mailform': + return + if mailbox.armessage_set.count(): + arm = mailbox.armessage_set.all()[0] + else: + arm = ARmessage(mbox=mailbox) + arm.subject = parameters.get_admin("DEFAULT_SUBJECT") + arm.content = parameters.get_admin("DEFAULT_CONTENT") \ + % {'name': mailbox.user.fullname} + arm.fromdate = timezone.now() + arm.enabled = True if values['autoreply'] == 'yes' else False + arm.save() diff --git a/modoboa/extensions/postfix_autoreply/management/commands/autoreply.py b/modoboa/extensions/postfix_autoreply/management/commands/autoreply.py index 0ef5d35b3..450fee4fe 100644 --- a/modoboa/extensions/postfix_autoreply/management/commands/autoreply.py +++ b/modoboa/extensions/postfix_autoreply/management/commands/autoreply.py @@ -12,8 +12,10 @@ from modoboa.extensions.postfix_autoreply.models import ARmessage, ARhistoric def send_autoreply(sender, mailbox, armessage): + if armessage.fromdate < timezone.now(): + return if armessage.untildate is not None \ - and armessage.untildate < datetime.date.today(): + and armessage.untildate < timezone.now(): armessage.enabled = False armessage.save() return diff --git a/modoboa/extensions/postfix_autoreply/migrations/0005_auto__add_field_armessage_fromdate.py b/modoboa/extensions/postfix_autoreply/migrations/0005_auto__add_field_armessage_fromdate.py new file mode 100644 index 000000000..0d1b2c0f9 --- /dev/null +++ b/modoboa/extensions/postfix_autoreply/migrations/0005_auto__add_field_armessage_fromdate.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models +from django.utils import timezone + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'ARmessage.fromdate' + db.add_column(u'postfix_autoreply_armessage', 'fromdate', + self.gf('django.db.models.fields.DateTimeField')(default=timezone.now), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'ARmessage.fromdate' + db.delete_column(u'postfix_autoreply_armessage', 'fromdate') + + + models = { + 'admin.domain': { + 'Meta': {'ordering': "['name']", 'object_name': 'Domain'}, + 'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.ObjectDates']"}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), + 'quota': ('django.db.models.fields.IntegerField', [], {}) + }, + 'admin.mailbox': { + 'Meta': {'object_name': 'Mailbox'}, + 'address': ('django.db.models.fields.CharField', [], {'max_length': '252'}), + 'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.ObjectDates']"}), + 'domain': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.Domain']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'quota': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'use_domain_quota': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['core.User']"}) + }, + 'admin.objectdates': { + 'Meta': {'object_name': 'ObjectDates'}, + 'creation': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_modification': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}) + }, + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'core.objectaccess': { + 'Meta': {'unique_together': "(('user', 'content_type', 'object_id'),)", 'object_name': 'ObjectAccess'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_owner': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['core.User']"}) + }, + u'core.user': { + 'Meta': {'ordering': "['username']", 'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '254', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_local': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '256'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '254'}) + }, + u'postfix_autoreply.alias': { + 'Meta': {'object_name': 'Alias'}, + 'autoreply_address': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'full_address': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + u'postfix_autoreply.arhistoric': { + 'Meta': {'unique_together': "(('armessage', 'sender'),)", 'object_name': 'ARhistoric'}, + 'armessage': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['postfix_autoreply.ARmessage']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_sent': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'sender': ('django.db.models.fields.CharField', [], {'max_length': '254'}) + }, + u'postfix_autoreply.armessage': { + 'Meta': {'object_name': 'ARmessage'}, + 'content': ('django.db.models.fields.TextField', [], {}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'fromdate': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mbox': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.Mailbox']"}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'untildate': ('django.db.models.fields.DateTimeField', [], {}) + }, + u'postfix_autoreply.transport': { + 'Meta': {'object_name': 'Transport'}, + 'domain': ('django.db.models.fields.CharField', [], {'max_length': '300'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'method': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + } + } + + complete_apps = ['postfix_autoreply'] diff --git a/modoboa/extensions/postfix_autoreply/migrations/0006_auto__chg_field_armessage_untildate.py b/modoboa/extensions/postfix_autoreply/migrations/0006_auto__chg_field_armessage_untildate.py new file mode 100644 index 000000000..9458b7891 --- /dev/null +++ b/modoboa/extensions/postfix_autoreply/migrations/0006_auto__chg_field_armessage_untildate.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Changing field 'ARmessage.untildate' + db.alter_column(u'postfix_autoreply_armessage', 'untildate', self.gf('django.db.models.fields.DateTimeField')(null=True)) + + def backwards(self, orm): + + # User chose to not deal with backwards NULL issues for 'ARmessage.untildate' + raise RuntimeError("Cannot reverse this migration. 'ARmessage.untildate' and its values cannot be restored.") + + models = { + 'admin.domain': { + 'Meta': {'ordering': "['name']", 'object_name': 'Domain'}, + 'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.ObjectDates']"}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), + 'quota': ('django.db.models.fields.IntegerField', [], {}) + }, + 'admin.mailbox': { + 'Meta': {'object_name': 'Mailbox'}, + 'address': ('django.db.models.fields.CharField', [], {'max_length': '252'}), + 'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.ObjectDates']"}), + 'domain': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.Domain']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'quota': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'use_domain_quota': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['core.User']"}) + }, + 'admin.objectdates': { + 'Meta': {'object_name': 'ObjectDates'}, + 'creation': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_modification': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}) + }, + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'core.objectaccess': { + 'Meta': {'unique_together': "(('user', 'content_type', 'object_id'),)", 'object_name': 'ObjectAccess'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_owner': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['core.User']"}) + }, + u'core.user': { + 'Meta': {'ordering': "['username']", 'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '254', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_local': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '256'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '254'}) + }, + u'postfix_autoreply.alias': { + 'Meta': {'object_name': 'Alias'}, + 'autoreply_address': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'full_address': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + u'postfix_autoreply.arhistoric': { + 'Meta': {'unique_together': "(('armessage', 'sender'),)", 'object_name': 'ARhistoric'}, + 'armessage': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['postfix_autoreply.ARmessage']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_sent': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'sender': ('django.db.models.fields.CharField', [], {'max_length': '254'}) + }, + u'postfix_autoreply.armessage': { + 'Meta': {'object_name': 'ARmessage'}, + 'content': ('django.db.models.fields.TextField', [], {}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'fromdate': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mbox': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.Mailbox']"}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'untildate': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}) + }, + u'postfix_autoreply.transport': { + 'Meta': {'object_name': 'Transport'}, + 'domain': ('django.db.models.fields.CharField', [], {'max_length': '300'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'method': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + } + } + + complete_apps = ['postfix_autoreply'] \ No newline at end of file diff --git a/modoboa/extensions/postfix_autoreply/models.py b/modoboa/extensions/postfix_autoreply/models.py index 194411c76..ad8c1e03a 100644 --- a/modoboa/extensions/postfix_autoreply/models.py +++ b/modoboa/extensions/postfix_autoreply/models.py @@ -1,5 +1,6 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone from modoboa.extensions.admin.models import Mailbox @@ -27,7 +28,8 @@ class ARmessage(models.Model): _('enabled'), help_text=_("Activate/Deactivate your auto reply") ) - untildate = models.DateTimeField() + fromdate = models.DateTimeField(default=timezone.now) + untildate = models.DateTimeField(null=True, blank=True) class ARhistoric(models.Model): diff --git a/modoboa/extensions/postfix_autoreply/tests.py b/modoboa/extensions/postfix_autoreply/tests.py new file mode 100644 index 000000000..fe73d2ddd --- /dev/null +++ b/modoboa/extensions/postfix_autoreply/tests.py @@ -0,0 +1,129 @@ +from django.core.urlresolvers import reverse +from modoboa.core.models import User +from modoboa.lib.tests import ExtTestCase +from modoboa.extensions.admin import factories +from modoboa.extensions.admin.models import ( + Domain +) +from modoboa.extensions.postfix_autoreply.models import ( + Transport, Alias, ARmessage +) + + +class EventsTestCase(ExtTestCase): + fixtures = ['initial_users.json'] + + def setUp(self): + super(EventsTestCase, self).setUp() + self.activate_extensions('postfix_autoreply') + factories.populate_database() + + def test_domain_created_event(self): + values = { + "name": "domain.tld", "quota": 100, "create_dom_admin": "no", + "stepid": 'step2' + } + self.ajax_post( + reverse("modoboa.extensions.admin.views.domain.newdomain"), + values + ) + trans = Transport.objects.get(domain='autoreply.domain.tld') + + def test_domain_deleted_event(self): + dom = Domain.objects.get(name="test.com") + trans = Transport.objects.get(domain='autoreply.test.com') + self.ajax_post( + reverse("modoboa.extensions.admin.views.domain.deldomain", + args=[dom.id]), + {} + ) + with self.assertRaises(Transport.DoesNotExist): + Transport.objects.get(domain='autoreply.test.com') + + def test_domain_modified_event(self): + values = { + "name": "test.fr", "quota": 100, "enabled": True + } + dom = Domain.objects.get(name="test.com") + self.ajax_post( + reverse("modoboa.extensions.admin.views.domain.editdomain", + args=[dom.id]), + values + ) + trans = Transport.objects.get(domain='autoreply.test.fr') + self.assertEqual( + Alias.objects.filter(full_address__contains='@test.fr').count(), 2 + ) + for al in Alias.objects.filter(full_address__contains='@test.fr'): + self.assertIn('autoreply.test.fr', al.autoreply_address) + + def test_mailbox_created_event(self): + values = { + 'username': "tester@test.com", 'first_name': 'Tester', 'last_name': 'Toto', + 'password1': 'toto', 'password2': 'toto', 'role': 'SimpleUsers', + 'quota_act': True, 'is_active': True, 'email': 'tester@test.com', + 'stepid': 'step2', 'autoreply': 'no' + } + self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.newaccount"), + values + ) + al = Alias.objects.get(full_address='tester@test.com') + self.assertEqual(al.autoreply_address, 'tester@test.com@autoreply.test.com') + arm = ARmessage.objects.get(mbox__address='tester') + + def test_mailbox_deleted_event(self): + account = User.objects.get(username="user@test.com") + self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.delaccount", + args=[account.id]), + {} + ) + with self.assertRaises(Alias.DoesNotExist): + Alias.objects.get(full_address='user@test.com') + with self.assertRaises(ARmessage.DoesNotExist): + ARmessage.objects.get(mbox__address='user', mbox__domain__name='test.com') + + + def test_modify_mailbox_event(self): + values = { + 'username': "leon@test.com", 'first_name': 'Tester', 'last_name': 'Toto', + 'role': 'SimpleUsers', 'quota_act': True, 'is_active': True, + 'email': 'leon@test.com', 'autoreply': 'no' + } + account = User.objects.get(username="user@test.com") + self.ajax_post( + reverse("modoboa.extensions.admin.views.identity.editaccount", + args=[account.id]), + values + ) + with self.assertRaises(Alias.DoesNotExist): + Alias.objects.get(full_address='user@test.com') + al = Alias.objects.get(full_address='leon@test.com') + + +class FormTestCase(ExtTestCase): + fixtures = ['initial_users.json'] + + def setUp(self): + super(FormTestCase, self).setUp() + self.activate_extensions('postfix_autoreply') + factories.populate_database() + + # def test_set_autoreply(self): + # self.clt.get( + # reverse('modoboa.core.views.user.index') + # ) + # values = { + # 'subject': 'test', 'content': "I'm off", "enabled": True + # } + # self.ajax_post( + # reverse('modoboa.extensions.postfix_autoreply.views.autoreply'), + # values + # ) + # account = User.objects.get(username="user@test.com") + # arm = ARmessage.objects.get(mbox=account.mailbox_set.all()[0]) + # self.assertEqual(arm.subject, 'test') + # self.assertTrue(arm.enabled) + # self.assertFalse(arm.untildate) + # self.assertTrue(arm.fromdate) diff --git a/modoboa/extensions/postfix_autoreply/views.py b/modoboa/extensions/postfix_autoreply/views.py index 2766d533e..0351a8c18 100644 --- a/modoboa/extensions/postfix_autoreply/views.py +++ b/modoboa/extensions/postfix_autoreply/views.py @@ -3,7 +3,7 @@ from datetime import date from django.contrib.auth.decorators import login_required from django.utils.translation import ugettext as _ from modoboa.lib.webutils import ( - ajax_simple_response, _render_to_string + render_to_json_response, _render_to_string ) from modoboa.extensions.admin.lib import needs_mailbox from modoboa.extensions.admin.models import Mailbox @@ -26,27 +26,20 @@ def autoreply(request, tplname="postfix_autoreply/autoreply.html"): form = ARmessageForm(request.POST) if form.is_valid(): arm = form.save(commit=False) + arm.fromdate = form.cleaned_data["fromdate"] arm.untildate = form.cleaned_data["untildate"] arm.mbox = mb arm.save() - return ajax_simple_response(dict( - status="ok", - respmsg=_("Auto reply message updated successfully.") - )) + return render_to_json_response( + _("Auto reply message updated successfully.") + ) - return ajax_simple_response({ - "status": "ko", - "errors": form.errors, - "onload_cb": "autoreply_cb" - }) + return render_to_json_response( + {"form_errors": form.errors}, status=400 + ) form = ARmessageForm(instance=arm) - if arm is not None: - form.fields['untildate'].initial = arm.untildate - else: - form.fields['untildate'].initial = date.today() - return ajax_simple_response({ - "status": "ok", + return render_to_json_response({ "content": _render_to_string(request, tplname, {"form": form}), "onload_cb": "autoreply_cb" }) diff --git a/modoboa/extensions/postfix_relay_domains/__init__.py b/modoboa/extensions/postfix_relay_domains/__init__.py new file mode 100644 index 000000000..d3451781e --- /dev/null +++ b/modoboa/extensions/postfix_relay_domains/__init__.py @@ -0,0 +1,108 @@ +# coding: utf-8 +import sys +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import ugettext_lazy +from modoboa.lib import events, parameters +from modoboa.core.extensions import ModoExtension, exts_pool +from .models import RelayDomain, RelayDomainAlias, Service + +extension_events = [ + "RelayDomainCreated", + "RelayDomainDeleted", + "RelayDomainModified", + "RelayDomainAliasCreated", + "RelayDomainAliasDeleted", + "ExtraRelayDomainForm", + "FillRelayDomainInstances" +] + + +def init_limits_dependant_features(): + """Populate limits tables. + + Update the pool of every defined user (to add new limits) and + update the *Resellers* group with new permissions. + """ + from modoboa.core.models import User + from modoboa.extensions.limits import controls + + for u in User.objects.all(): + controls.create_pool(u) + grp = Group.objects.get(name='Resellers') + for model in [RelayDomain, RelayDomainAlias, Service]: + ct = ContentType.objects.get_for_model(model) + name = model.__name__.lower() + for action in ['add', 'change', 'delete']: + grp.permissions.add( + Permission.objects.get( + content_type=ct, codename='%s_%s' % (action, name) + ) + ) + grp.save() + + +def init_amavis_dependant_features(): + """Populate amavis database. + + We create records for *users* and *policy* tables for each defined + relay domain or relay domain alias. + """ + from modoboa.extensions.amavis.models import Users + from modoboa.extensions.amavis.lib import ( + create_user_and_policy, create_user_and_use_policy + ) + + for rdom in RelayDomain.objects.all(): + try: + Users.objects.get(email="@%s" % rdom.name) + except Users.DoesNotExist: + create_user_and_policy(rdom.name) + for rdomalias in rdom.relaydomainalias_set.all(): + try: + Users.objects.get(email='@%s' % rdomalias.name) + except Users.DoesNotExist: + create_user_and_use_policy(rdomalias.name, rdom.name) + + +class PostfixRelayDomains(ModoExtension): + name = "postfix_relay_domains" + label = "Postfix relay domains" + version = "1.0" + description = ugettext_lazy("Relay domains support for Postfix") + + def init(self): + """Initialisation method. + + Only run once, when the extension is enabled. Populates the + service table with default entries. + """ + for service_name in ['relay', 'smtp']: + Service.objects.get_or_create(name=service_name) + if exts_pool.is_extension_enabled('limits'): + init_limits_dependant_features() + + def load(self): + from .app_settings import AdminParametersForm + + parameters.register( + AdminParametersForm, ugettext_lazy("Relay domains") + ) + events.declare(extension_events) + from modoboa.extensions.postfix_relay_domains import general_callbacks + if 'modoboa.extensions.postfix_relay_domains.general_callbacks' in sys.modules: + reload(general_callbacks) + if exts_pool.is_extension_enabled('limits'): + import limits_callbacks + if 'modoboa.extensions.postfix_relay_domains.limits_callbacks' in sys.modules: + reload(limits_callbacks) + if exts_pool.is_extension_enabled('amavis'): + import amavis_callbacks + if 'modoboa.extensions.postfix_relay_domains.amavis_callbacks' in sys.modules: + reload(amavis_callbacks) + + def destroy(self): + events.unregister_extension() + parameters.unregister() + +exts_pool.register_extension(PostfixRelayDomains) diff --git a/modoboa/extensions/postfix_relay_domains/amavis_callbacks.py b/modoboa/extensions/postfix_relay_domains/amavis_callbacks.py new file mode 100644 index 000000000..c43052af3 --- /dev/null +++ b/modoboa/extensions/postfix_relay_domains/amavis_callbacks.py @@ -0,0 +1,30 @@ +from modoboa.lib import events +from modoboa.extensions.amavis.lib import ( + create_user_and_policy, update_user_and_policy, delete_user_and_policy, + create_user_and_use_policy, delete_user +) + + +@events.observe("RelayDomainCreated") +def on_relay_domain_created(user, rdomain): + create_user_and_policy(rdomain.name) + + +@events.observe("RelayDomainModified") +def on_relay_domain_modified(rdomain): + update_user_and_policy(rdomain.oldname, rdomain.name) + + +@events.observe("RelayDomainDeleted") +def on_relay_domain_deleted(rdomain): + delete_user_and_policy(rdomain.name) + + +@events.observe("RelayDomainAliasCreated") +def on_relay_domain_alias_created(user, rdomainalias): + create_user_and_use_policy(rdomainalias.name, rdomainalias.target.name) + + +@events.observe("RelayDomainAliasDeleted") +def on_relay_domain_alias_deleted(rdomainalias): + delete_user(rdomainalias.name) diff --git a/modoboa/extensions/postfix_relay_domains/app_settings.py b/modoboa/extensions/postfix_relay_domains/app_settings.py new file mode 100644 index 000000000..49d17ae0c --- /dev/null +++ b/modoboa/extensions/postfix_relay_domains/app_settings.py @@ -0,0 +1,13 @@ +from django import forms +from django.utils.translation import ugettext_lazy +from modoboa.lib import parameters + + +class AdminParametersForm(parameters.AdminParametersForm): + app = "postfix_relay_domains" + + master_cf_path = forms.CharField( + label=ugettext_lazy("Postfix's master.cf path"), + initial="/etc/postfix/master.cf", + help_text=ugettext_lazy('Path to the master.cf configuration file') + ) diff --git a/modoboa/extensions/postfix_relay_domains/factories.py b/modoboa/extensions/postfix_relay_domains/factories.py new file mode 100644 index 000000000..c8bc6e1c5 --- /dev/null +++ b/modoboa/extensions/postfix_relay_domains/factories.py @@ -0,0 +1,23 @@ +import factory +from modoboa.core.factories import PermissionFactory +from . import models + + +class ServiceFactory(factory.DjangoModelFactory): + FACTORY_FOR = models.Service + FACTORY_DJANGO_GET_OR_CREATE = ('name', ) + + name = 'dummy' + + +class RelayDomainFactory(PermissionFactory): + FACTORY_FOR = models.RelayDomain + + target_host = 'external.host.tld' + service = factory.SubFactory(ServiceFactory) + + +class RelayDomainAliasFactory(PermissionFactory): + FACTORY_FOR = models.RelayDomainAlias + + enabled = True diff --git a/modoboa/extensions/postfix_relay_domains/forms.py b/modoboa/extensions/postfix_relay_domains/forms.py new file mode 100644 index 000000000..08cff2059 --- /dev/null +++ b/modoboa/extensions/postfix_relay_domains/forms.py @@ -0,0 +1,157 @@ +from django import forms +from django.http import QueryDict +from django.utils.translation import ugettext as _, ugettext_lazy +from modoboa.lib import events +from modoboa.lib.formutils import DynamicForm, DomainNameField, TabForms +from modoboa.extensions.admin.models import Domain, DomainAlias +from .models import RelayDomain, RelayDomainAlias + + +class RelayDomainFormGeneral(forms.ModelForm, DynamicForm): + aliases = DomainNameField( + label=ugettext_lazy("Alias(es)"), + required=False, + help_text=ugettext_lazy( + "Alias(es) of this relay domain. Indicate only one name per input" + ", press ENTER to add a new input." + ) + ) + + class Meta: + model = RelayDomain + exclude = ['dates'] + widgets = { + 'service': forms.Select(attrs={'class': 'span2'}) + } + + def __init__(self, *args, **kwargs): + super(RelayDomainFormGeneral, self).__init__(*args, **kwargs) + if args and isinstance(args[0], QueryDict): + self._load_from_qdict(args[0], "aliases", DomainNameField) + elif 'instance' in kwargs: + rd = kwargs['instance'] + for pos, rdalias in enumerate(rd.relaydomainalias_set.all()): + name = "aliases_%d" % (pos + 1) + self._create_field(forms.CharField, name, rdalias.name, 3) + + def clean(self): + """Custom fields validaton. + + We want to prevent duplicate names between domains, relay + domains, domain aliases and relay domain aliases. + + The validation way is not very smart... + """ + super(RelayDomainFormGeneral, self).clean() + if self._errors: + raise forms.ValidationError(self._errors) + cleaned_data = self.cleaned_data + for dtype, label in [(Domain, _('domain')), + (DomainAlias, _('domain alias')), + (RelayDomainAlias, _('relay domain alias'))]: + try: + dtype.objects.get(name=cleaned_data['name']) + except dtype.DoesNotExist: + pass + else: + self._errors["name"] = self.error_class( + [_("A %s with this name already exists" % label)] + ) + del cleaned_data["name"] + break + + for k in cleaned_data.keys(): + if not k.startswith("aliases"): + continue + if not cleaned_data[k]: + del cleaned_data[k] + continue + for dtype, name in [(RelayDomain, _('relay domain')), + (DomainAlias, _('domain alias')), + (Domain, _('domain'))]: + try: + dtype.objects.get(name=cleaned_data[k]) + except dtype.DoesNotExist: + pass + else: + self._errors[k] = self.error_class( + [_("A %s with this name already exists" % name)] + ) + del cleaned_data[k] + break + + return cleaned_data + + def save(self, user, commit=True, rdomalias_post_create=False): + """Custom save method. + + As relay domain aliases are defined using the same form as + relay domains, we need to save them manually. + + :param ``User`` user: connected user + """ + rd = super(RelayDomainFormGeneral, self).save(commit=False) + if commit: + rd.save() + aliases = [] + for k, v in self.cleaned_data.iteritems(): + if not k.startswith("aliases"): + continue + if v in ["", None]: + continue + aliases.append(v) + for rdalias in rd.relaydomainalias_set.all(): + if not rdalias.name in aliases: + rdalias.delete() + else: + aliases.remove(rdalias.name) + if aliases: + events.raiseEvent( + "CanCreate", user, "relay_domain_aliases", len(aliases) + ) + for alias in aliases: + try: + rd.relaydomainalias_set.get(name=alias) + except RelayDomainAlias.DoesNotExist: + pass + else: + continue + al = RelayDomainAlias( + name=alias, target=rd, enabled=rd.enabled + ) + al.save(creator=user) \ + if rdomalias_post_create else al.save() + return rd + + +class RelayDomainForm(TabForms): + """Specific edition form for relay domains. + + We use a *tabs* compatible form because extensions can add their + own tab. (ex: amavis) + """ + def __init__(self, user, *args, **kwargs): + self.user = user + self.forms = [] + if user.has_perm("postfix_relay_domains.change_relaydomain"): + self.forms.append({ + 'id': 'general', 'title': _("General"), + 'formtpl': 'postfix_relay_domains/relaydomain_form.html', + 'cls': RelayDomainFormGeneral, 'mandatory': True + }) + + cbargs = [user] + if "instances" in kwargs: + cbargs += [kwargs["instances"]["general"]] + self.forms += events.raiseQueryEvent("ExtraRelayDomainForm", *cbargs) + super(RelayDomainForm, self).__init__(*args, **kwargs) + + def save(self, user): + """Custom save method + + As forms interact with each other, it is easier to make custom + code to save them. + """ + self.forms[0]['instance'].save(user, rdomalias_post_create=True) + for f in self.forms[1:]: + f["instance"].save(user) diff --git a/modoboa/extensions/postfix_relay_domains/general_callbacks.py b/modoboa/extensions/postfix_relay_domains/general_callbacks.py new file mode 100644 index 000000000..34e0b7630 --- /dev/null +++ b/modoboa/extensions/postfix_relay_domains/general_callbacks.py @@ -0,0 +1,143 @@ +from django.core.urlresolvers import reverse +from django.template import Template, Context +from django.conf import settings +from django.utils.translation import ugettext_lazy +from django.db.models import Q +from modoboa.lib import events +from .models import RelayDomain, RelayDomainAlias + + +@events.observe('GetStaticContent') +def static_content(caller, user): + if caller != 'domains': + return [] + + t = Template(""" + +""") + return [t.render(Context({'STATIC_URL': settings.STATIC_URL}))] + + +@events.observe('ExtraDomainFilters') +def extra_domain_filters(): + return ['srvfilter'] + + +@events.observe('ExtraDomainMenuEntries') +def extra_domain_menu_entries(user): + return [ + {"name": "newrelaydomain", + "label": ugettext_lazy("Add relay domain"), + "img": "icon-plus", + "modal": True, + "modalcb": "rdomain.domainform_cb", + "url": reverse( + "modoboa.extensions.postfix_relay_domains.views.create" + )}, + ] + + +@events.observe('ExtraDomainEntries') +def extra_domain_entries(user, domfilter, searchquery, **extrafilters): + if domfilter is not None and domfilter and domfilter != 'relaydomain': + return [] + relay_domains = RelayDomain.objects.get_for_admin(user) + if searchquery is not None: + q = Q(name__contains=searchquery) + q |= Q(relaydomainalias__name__contains=searchquery) + relay_domains = relay_domains.filter(q).distinct() + if 'srvfilter' in extrafilters and extrafilters['srvfilter']: + relay_domains = relay_domains.filter( + Q(service__name=extrafilters['srvfilter']) + ) + return relay_domains + + +@events.observe('GetDomainModifyLink') +def rdomain_modify_link(domain): + if not isinstance(domain, RelayDomain): + return {} + return { + 'url': reverse( + 'modoboa.extensions.postfix_relay_domains.views.edit', + args=[domain.id] + ), + 'modalcb': 'rdomain.editdomain_form_cb' + } + + +@events.observe('GetDomainActions') +def rdomain_actions(user, domain): + if not isinstance(domain, RelayDomain): + return [] + if not user.has_perm("postfix_relay_domains.delete_relaydomain"): + return [] + return [{ + "name": "delrelaydomain", + "url": reverse("modoboa.extensions.postfix_relay_domains.views.delete", + args=[domain.id]), + "title": ugettext_lazy("Delete %s?" % domain.name), + "img": "icon-trash" + }] + + +@events.observe('CheckDomainName') +def check_domain_name(): + return [ + (RelayDomain, ugettext_lazy('relay domain')), + (RelayDomainAlias, ugettext_lazy('relay domain alias')) + ] + + +@events.observe('GetExtraLimitTemplates') +def extra_limit_templates(): + return [ + ('relay_domains_limit', ugettext_lazy('Relay domains'), + ugettext_lazy('Maximum number of relay domains this user can create'), + 'Resellers'), + ("relay_domain_aliases_limit", ugettext_lazy("Relay domain aliases"), + ugettext_lazy('Maximum number of relay domain aliases this user can create'), + 'Resellers'), + ] + + +@events.observe('ExtraDomainImportHelp') +def extra_domain_import_help(): + return [ugettext_lazy(""" +
  • relaydomain; name; target host; service; enabled; verify recipients
  • +
  • relaydomainalias; name; target; enabled
  • +""")] + + +@events.observe('ImportObject') +def get_import_func(objtype): + from .lib import import_relaydomain, import_relaydomainalias + + if objtype == 'relaydomain': + return [import_relaydomain] + if objtype == 'relaydomainalias': + return [import_relaydomainalias] + return [] + + +@events.observe('ExtEnabled') +def extension_enabled(extension): + """ExtEnabled event listener. + + Usefull when *limits* or *amavis* extensions are activated after + *postfix_relay_domains*. + + :param extension: enabled extension + """ + from modoboa.extensions.postfix_relay_domains import ( + init_limits_dependant_features, init_amavis_dependant_features + ) + if extension.name == 'limits': + init_limits_dependant_features() + if extension.name == 'amavis': + init_amavis_dependant_features() diff --git a/modoboa/extensions/postfix_relay_domains/lib.py b/modoboa/extensions/postfix_relay_domains/lib.py new file mode 100644 index 000000000..a5c4764ec --- /dev/null +++ b/modoboa/extensions/postfix_relay_domains/lib.py @@ -0,0 +1,14 @@ +from django.db import transaction +from .models import RelayDomain, RelayDomainAlias + + +@transaction.commit_on_success +def import_relaydomain(user, row, formopts): + """Specific code for relay domains import""" + RelayDomain().from_csv(user, row) + + +@transaction.commit_on_success +def import_relaydomainalias(user, row, formopts): + """Specific code for relay domain aliases import""" + RelayDomainAlias().from_csv(user, row) diff --git a/modoboa/extensions/postfix_relay_domains/limits_callbacks.py b/modoboa/extensions/postfix_relay_domains/limits_callbacks.py new file mode 100644 index 000000000..5d50ea28a --- /dev/null +++ b/modoboa/extensions/postfix_relay_domains/limits_callbacks.py @@ -0,0 +1,58 @@ +from django.utils.translation import ugettext_lazy +from modoboa.lib import events +from modoboa.lib.permissions import get_object_owner +from modoboa.extensions.limits.lib import inc_limit_usage, dec_limit_usage +from .models import RelayDomainAlias + + +@events.observe('GetExtraParameters') +def extra_parameters(app, level): + from django import forms + + if app != 'limits' or level != 'A': + return {} + return { + 'deflt_relay_domains_limit': forms.IntegerField( + label=ugettext_lazy("Relay domains"), + initial=0, + help_text=ugettext_lazy( + "Maximum number of allowed relay domains for a new administrator" + ), + widget=forms.widgets.TextInput(attrs={"class": "span1"}) + ), + 'deflt_relay_domain_aliases_limit': forms.IntegerField( + label=ugettext_lazy("Relay domain aliases"), + initial=0, + help_text=ugettext_lazy( + "Maximum number of allowed relay domain aliases for a new administrator" + ), + widget=forms.widgets.TextInput(attrs={"class": "span1"}) + ) + } + + +@events.observe('RelayDomainCreated') +def inc_relaydomains_count(user, rdomain): + inc_limit_usage(user, 'relay_domains_limit') + + +@events.observe('RelayDomainDeleted') +def dec_relaydomains_count(rdomain): + owner = get_object_owner(rdomain) + dec_limit_usage(owner, 'relay_domains_limit') + for rdomalias in rdomain.relaydomainalias_set.all(): + dec_rdomaliases_count(rdomalias) + + +@events.observe('RelayDomainAliasCreated') +def inc_rdomaliases_count(user, rdomalias): + inc_limit_usage(user, 'relay_domain_aliases_limit') + + +@events.observe('RelayDomainAliasDeleted') +def dec_rdomaliases_count(rdomainaliases): + if isinstance(rdomainaliases, RelayDomainAlias): + rdomainaliases = [rdomainaliases] + for rdomainalias in rdomainaliases: + owner = get_object_owner(rdomainalias) + dec_limit_usage(owner, 'relay_domain_aliases_limit') diff --git a/modoboa/extensions/postfix_relay_domains/models.py b/modoboa/extensions/postfix_relay_domains/models.py new file mode 100644 index 000000000..088874c2e --- /dev/null +++ b/modoboa/extensions/postfix_relay_domains/models.py @@ -0,0 +1,199 @@ +import reversion +from django.db import models +from django.db.models.manager import Manager +from django.contrib.contenttypes import generic +from django.utils.translation import ugettext as _, ugettext_lazy +from modoboa.core.models import ObjectAccess +from modoboa.lib import parameters +from modoboa.lib.exceptions import BadRequest, NotFound +from modoboa.extensions.admin.models import AdminObject + + +class RelayDomainManager(Manager): + + def get_for_admin(self, admin): + """Return the relay domains belonging to this admin. + + The result is a ``QuerySet`` object, so this function can be used + to fill ``ModelChoiceField`` objects. + """ + if admin.is_superuser: + return self.get_query_set() + return self.get_query_set().filter(owners__user=admin) + + +class ServiceManager(Manager): + + def load_from_master_cf(self): + """Load services from the master.cf file. + + Parse the configuration file to update the service table. New + entries are saved and outdated ones (ie. present in the + database but not in the file) are removed. + """ + with open(parameters.get_admin('MASTER_CF_PATH')) as fp: + content = fp.read() + services = [] + for line in content.split('\n'): + if not line or line.startswith('#'): + continue + parts = line.strip().split() + if len(parts) != 8: + continue + if parts[7] != 'smtp': + continue + srv, created = self.get_or_create(name=parts[0]) + services.append(parts[0]) + to_delete = [] + for service in self.all(): + if not service.name in services: + to_delete.append(service.name) + Service.objects.filter(name__in=to_delete).delete() + + +class Service(models.Model): + """Postfix service. + """ + name = models.CharField( + ugettext_lazy('name'), max_length=100, unique=True, + help_text=ugettext_lazy('The service name') + ) + + objects = ServiceManager() + + def __str__(self): + return self.name + + +class RelayDomain(AdminObject): + """Relay domain. + + A relay domain differs from a usual domaine because its final + destination is not reached yet. It must be accepted by the MTA but + it will then be transfered to another one. + """ + name = models.CharField( + ugettext_lazy('name'), max_length=100, unique=True, + help_text=ugettext_lazy('The domain name') + ) + target_host = models.CharField( + ugettext_lazy('target host'), max_length=255, + help_text=ugettext_lazy('Remote destination of this domain') + ) + service = models.ForeignKey(Service, default='relay') + enabled = models.BooleanField( + ugettext_lazy('enabled'), + help_text=ugettext_lazy('Check to activate this domain') + ) + verify_recipients = models.BooleanField( + ugettext_lazy('verify recipients'), + help_text=ugettext_lazy('Check for valid recipients') + ) + + owners = generic.GenericRelation(ObjectAccess) + + objects = RelayDomainManager() + + class Meta: + ordering = ['name'] + + @property + def tags(self): + return [ + {"name": "relaydomain", "label": _("Relay Domain"), "type": "dom"}, + {"name": self.service.name, "label": "%s:" % self.service.name, + "type": "srv", "color": "info"} + ] + + @property + def aliases(self): + return self.relaydomainalias_set + + def __str__(self): + return self.name + + def to_csv(self, csvwriter): + """Export this relay domain to CSV. + + :param csvwriter: + """ + csvwriter.writerow( + ["relaydomain", self.name, self.target_host, + self.service.name, self.enabled, self.verify_recipients] + ) + for rdalias in self.relaydomainalias_set.all(): + rdalias.to_csv(csvwriter) + + def from_csv(self, user, row): + """Import a relay domain from CSV. + + :param user: user importing the relay domain + :param str row: relay domain definition + """ + if len(row) != 6: + raise BadRequest(_("Invalid line")) + self.name = row[1].strip() + self.target_host = row[2].strip() + self.service, created = Service.objects.get_or_create(name=row[3].strip()) + self.enabled = (row[4].strip() == 'True') + self.verify_recipients = (row[5].strip() == 'True') + self.save(creator=user) + + def post_create(self, creator): + """Post creation actions. + + :param ``User`` creator: user whos created this relay domain + """ + super(RelayDomain, self).post_create(creator) + for rdomalias in self.relaydomainalias_set.all(): + rdomalias.post_create(creator) + +reversion.register(RelayDomain) + + +class RelayDomainAlias(AdminObject): + """Relay domain alias. + + """ + name = models.CharField( + ugettext_lazy("name"), max_length=100, unique=True, + help_text=ugettext_lazy("The alias name") + ) + target = models.ForeignKey( + RelayDomain, verbose_name=ugettext_lazy('target'), + help_text=ugettext_lazy("The relay domain this alias points to") + ) + enabled = models.BooleanField( + ugettext_lazy('enabled'), + help_text=ugettext_lazy("Check to activate this alias") + ) + + def __str__(self): + return self.name + + def to_csv(self, csvwriter): + """Export this relay domain alias to CSV. + + :param csvwriter: + """ + csvwriter.writerow( + ['relaydomainalias', self.name, self.target.name, self.enabled] + ) + + def from_csv(self, user, row): + """Import a relay domain alias from CSV. + + :param user: user importing the relay domain alias + :param str row: relay domain alias definition + """ + if len(row) != 4: + raise BadRequest(_("Invalid line")) + self.name = row[1].strip() + try: + self.target = RelayDomain.objects.get(name=row[2].strip()) + except RelayDomain.DoesNotExist: + raise NotFound(_("Relay domain %s does not exist" % row[2].strip())) + self.enabled = (row[3].strip() == 'True') + self.save(creator=user) + +reversion.register(RelayDomainAlias) diff --git a/modoboa/extensions/postfix_relay_domains/static/postfix_relay_domains/js/relay_domains.js b/modoboa/extensions/postfix_relay_domains/static/postfix_relay_domains/js/relay_domains.js new file mode 100644 index 000000000..e1ab3e40d --- /dev/null +++ b/modoboa/extensions/postfix_relay_domains/static/postfix_relay_domains/js/relay_domains.js @@ -0,0 +1,92 @@ +/** + * @module relay_domains + * @desc Utility class to manage relay domains. + */ + +var RelayDomains = function(options) { + this.initialize(options); +}; + +RelayDomains.prototype = { + constructor: RelayDomains, + + defaults: { + }, + + initialize: function(options) { + this.options = $.extend({}, this.defaults, options); + admin.register_tag_handler("srv", this.srv_tag_handler); + $(document).bind('domain_listing_refresh', this.listing_refresh_cb); + }, + + listing_refresh_cb: function() { + $("a[name=delrelaydomain]").confirm({ + question: function() { return this.$element.attr('title'); }, + method: "DELETE", + success_cb: $.proxy(admin.reload_listing, admin) + }); + }, + + launch_scan: function(e) { + var $link = get_target(e); + + e.preventDefault(); + $.ajax({ + type: "POST", + url: $link.attr("href"), + global: false + }).done(function(data) { + var $widget = $('#id_service'); + + $widget.empty(); + $.each(data, function(label, id) { + $widget.append($('