Merge pull request #969 from modoboa/refactor/replace_events

Refactor/replace events
This commit is contained in:
Antoine Nguyen
2016-12-14 14:13:00 +01:00
committed by GitHub
57 changed files with 1076 additions and 2102 deletions
-1
View File
@@ -21,4 +21,3 @@ Table of contents
contributing/getting_started
contributing/plugin_api
contributing/events
-907
View File
@@ -1,907 +0,0 @@
.. _events:
################
Available events
################
************
Introduction
************
Modoboa provides a simple API to interact with events. It understands
two kinds of events:
* Those returning a value
* Those returning nothing
Listening to a specific event is achieved as follows::
from modoboa.lib import events
def callback(*args):
pass
events.register('event', callback)
You can also use the custom decorator ``events.observe``::
@events.observe('event')
def callback(*args):
pass
``event`` is the event's name you want to listen to, ``callback`` is
the function that will be called each time this event is raised. Each
event impose to callbacks a specific prototype to respect. See below
for a detailled list.
To stop listening to as specific event, you must use the
``unregister`` function::
events.unregister('event', callback)
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
==============
Raised when a new account is created.
*Callback prototype*::
def callback(account): pass
* ``account`` is the newly created account (``User`` instance)
AccountDeleted
==============
Raised when an existing account is deleted.
*Callback prototype*::
def callback(account, byuser, **options): pass
* ``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
===============
Raised when an existing account is modified.
*Callback prototype*::
def callback(oldaccount, newaccount): pass
* ``oldaccount`` is the account before it is modified
* ``newaccount`` is the account after the modification
AdminMenuDisplay
================
Raised when an admin menu is about to be displayed.
*Callback prototype*::
def callback(target, user): pass
The ``target`` argument indicates which menu is being
displayed. Possible values are:
* ``admin_menu_box`` : corresponds to the menu bar available inside administration pages
* ``top_menu`` : corresponds to the top black bar
See :ref:`usermenudisplay` for a description of what callbacks that
listen to this event must return.
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.
.. _event_checkextraaccountform:
CheckExtraAccountForm
=====================
When an account is being modified, this event lets extensions check if
this account is concerned by a specific form.
*Callback prototype*::
def callback(account, form): pass
* ``account`` is the ``User`` instance beeing modified
* ``form`` is a dictionnary (same content as for ``ExtraAccountForm``)
Callbacks listening to this event must return a list containing one
Boolean.
DomainAliasCreated
==================
Raised when a new domain alias is created.
*Callback prototype*::
def callback(user, domain_alias): pass
* ``user`` is the new domain alias owner (``User`` instance)
* ``domain_alias`` is the new domain alias (``DomainAlias`` instance)
DomainAliasDeleted
==================
Raised when an existing domain alias is about to be deleted.
*Callback prototype*::
def callback(domain_alias): pass
* ``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
==============
Raised when a domain has been modified.
*Callback prototype*::
def callback(domain): pass
* ``domain`` is the modified ``Domain`` instance, it contains an extra
``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
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
.. _event_extraaccountform:
ExtraAccountForm
================
Let extensions add new forms to the account edition form (the one with
tabs).
*Callback prototype*::
def callback(user, account): pass
* ``user`` is a ``User`` instance corresponding to the currently
logged in user
* ``account`` is the account beeing modified (``User`` instance)
Callbacks listening to the event must return a list of dictionnaries,
each one must contain at least three keys::
{"id" : "<the form's id>",
"title" : "<the title used to present the form>",
"cls" : TheFormClassName}
ExtraAdminContent
=================
Let extensions add extra content into the admin panel.
*Callback prototype*::
def callback(user, target, currentpage): pass
* ``user`` is a ``User`` instance corresponding to the currently
logged in user
* ``target`` is a string indicating the place where the content will
be displayed. Possible values are : ``leftcol``
* ``currentpage`` is a string indicating which page (or section) is
displayed. Possible values are : ``domains``, ``identities``
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).
.. _event_extradomainform:
ExtraDomainForm
===============
Let extensions add new forms to the domain edition form (the one with
tabs).
*Callback prototype*::
def callback(user, domain): pass
* ``user`` is a ``User`` instance corresponding to the currently
logged in user
* ``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" : "<the form's id>",
"title" : "<the title used to present the form>",
"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": "<menu name>",
"label": "<menu label>",
"url": "<menu 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', <Django form field instance>)
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::
{"id" : "<the form's id>",
"title" : "<the title used to present the form>",
"cls" : TheFormClassName}
.. _event_fillaccountinstances:
FillAccountInstances
====================
When an account is beeing modified, this event is raised to fill extra
forms.
*Callback prototype*::
def callback(user, account, instances): pass
* ``user`` is a ``User`` instance corresponding to the currently
logged in user
* ``account`` is the ``User`` instance beeing modified
* ``instances`` is a dictionnary where the callback will add
information needed to fill a specific form
.. _event_filldomaininstances:
FillDomainInstances
===================
When a domain is beeing modified, this event is raised to fill extra
forms.
*Callback prototype*::
def callback(user, domain, instances): pass
* ``user`` is a ``User`` instance corresponding to the currently
logged in user
* ``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
GetAnnouncement
===============
Some places in the interface let plugins add their own announcement
(ie. message).
*Callback prototype*::
def callback(target): pass
* ``target`` is a string indicating the place where the announcement
will appear:
* ``loginpage`` : corresponds to the login page
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": "<action name>",
"url": "<action url>",
"title": "<action title>",
"img": "<action icon>"}
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': '<modification 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::
[('<limit_name>', '<limit label>', '<limit help text>')]
.. _getextrarolepermissions:
GetExtraRolePermissions
=======================
Let extensions define new permissions for a given role.
*Callback prototype*::
def callback(rolename): pass
* ``rolename`` is the role's name (str)
Callbacks listening to this event must return a list of list. The
second list level must contain exactly 3 strings: the application
name, the model name and the permission name. Example::
[
["core", "user", "add_user"],
["core", "user", "change_user"],
["core", "user", "delete_user"],
]
.. _getextraroles:
GetExtraRoles
=============
Let extensions define new administrative roles (will be used to create
or modify an account).
*Callback prototype*::
def callback(user, account): pass
* ``user`` is a ``User`` instance corresponding to the currently
logged in user
* ``account`` is the account being modified (None on creation)
Callbacks listening to this event must return a list of 2uple (two
strings) which respect the following format: ``(value, label)``.
GetStaticContent
================
Let extensions add static content (ie. CSS or javascript) to default
pages. It is pretty useful for functionalities that don't need a
template but need javascript stuff.
*Callback prototype*::
def callback(caller, st_type, user): pass
* ``caller`` is name of the application (or the location) responsible
for the call
* ``st_type`` is the expected static content type (``css`` or ``js``)
* ``user`` is a ``User`` instance corresponding to the currently
logged in user
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
InitialDataLoaded
=================
Raised a initial data has been loaded for a given extension.
*Callback prototype*::
def callback(extname); pass
``extname`` is the extension name (str)
MailboxAliasCreated
===================
Raised when a new mailbox alias is created.
*Callback prototype*::
def callback(user, mailbox_alias): pass
* ``user`` is the new domain alias owner (``User`` instance)
* ``mailbox_alias`` is the new mailbox alias (``Alias`` instance)
MailboxAliasDeleted
===================
Raised when an existing mailbox alias is about to be deleted.
*Callback prototype*::
def callback(mailbox_alias): pass
* ``mailbox_alias`` is an ``Alias`` instance
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.
*Callback prototype*::
def callback(mailbox): pass
* ``mailbox`` is the ``Mailbox`` modified instance. It contains a
``old_full_address`` extra field to check if the address was
modified.
PasswordChange
==============
Raised just before a *password change* action.
*Callback prototype*::
def callback(user): pass
* ``user`` is a ``User`` instance
Callbacks listening to this event must return a list containing either
``True`` or ``False``. If at least one ``True`` is returned, the
*password change* will be cancelled (ie. changing the password for
this user is disabled).
TopNotifications
================
Lets extensions subscribe to the global notification service (located
inside the top bar).
*Callback prototype*::
def callback(user, include_all): pass
* ``request`` is a ``Request`` instance
* ``include_all`` is a boolean indicating if empty notifications must
be included into the result or not
Callbacks listening to this event must return a list of dictionary,
each dictionary containing at least the following entries::
{"id": "<notification entry ID>",
"url": "<associated URL>",
"text": "<text to display>"}
If your notification needs a counter, you can specify it by adding the
two following entries in the dictionary:
{"counter": <associated counter>,
"level": "<info|success|warning|error>"}
UserLogin
=========
Raised when a user logs in.
*Callback prototype*::
def callback(request, username, password): pass
UserLogout
==========
Raised when a user logs out.
*Callback prototype*::
def callback(request): pass
.. _usermenudisplay:
UserMenuDisplay
===============
Raised when a user menu is about to be displayed.
*Callback prototype*::
def callback(target, user): pass
The ``target`` argument indicates which menu is being
displayed. Possible values are:
* ``options_menu``: corresponds to the top-right user menu
* ``uprefs_menu``: corresponds to the menu bar available inside the
*User preferences* page
* ``top_menu``: corresponds to the top black bar
All the callbacks that listen to this event must return a list of
dictionnaries (corresponding to menu entries). Each dictionnary must
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(user, role, account): pass
* ``user`` is the ``User`` instance corresponding to the currently
logged in user
* ``role`` is the role ``user`` tries to set
* ``account`` is the account being modified (None on creation)
Must return a list containing ``True`` or ``False`` to indicate if
this user can is allowed to set ``role``.
+66 -49
View File
@@ -19,12 +19,11 @@ of code that integrates them into Modoboa. The
the plugin:
* Admin and user parameters
* Observed events
* Custom menu entries
The communication between both applications is provided by
:ref:`events`. Modoboa offers some kind of hooks to let plugins add
custom actions.
The communication between both applications is provided by `Django
signals
<https://docs.djangoproject.com/en/1.9/topics/signals/>`_.
The following subsections describe the plugin architecture and explain
how you can create your own.
@@ -81,7 +80,7 @@ Parameters
A plugin can declare its own parameters. There are two levels available:
* 'Administration' parameters : used to configure the plugin, editable
* 'Global' parameters : used to configure the plugin, editable
inside the *Admin > Settings > Parameters* page
* 'User' parameters : per-user parameters (or preferences), editable
inside the *Options > Preferences* page
@@ -89,100 +88,118 @@ A plugin can declare its own parameters. There are two levels available:
Playing with parameters
=======================
To declare a new administration parameter, use the following function::
Parameters are defined using `Django forms
<https://docs.djangoproject.com/en/1.9/topics/forms/>`_ and Modoboa
adds two special forms you can inherit depending on the level of
parameter(s) you want to add:
from modoboa.lib import parameters
* ``modoboa.parameters.forms.AdminParametersForm``: for general parameters
parameters.register_admin(name, **kwargs)
* ``modoboa.parameters.forms.UserParametersForm``: for user parameters
To declare a new user parameter, use the following function::
To register new parameters, add the following line into the ``load``
method of your plugin class::
parameter.register_user(name, **kwargs)
from modoboa.parameters import tools as param_tools
param_tools.registry.add(
LEVEL, YourForm, ugettext_lazy("Title"))
Both functions accept extra arguments listed here:
Replace ``LEVEL`` by ``"global"`` or ``"user"``.
* ``type`` : parameter's type, possible values are : ``int``, ``string``, ``list``, ``list_yesno``,
* ``deflt`` : default value,
* ``help`` : help text,
* ``values`` : list of possible values if ``type`` is ``list``.
***************************
Custom administrative roles
***************************
***********************
Custom role permissions
***********************
Modoboa uses Django's internal permission system. Administrative roles
are nothing more than groups (``Group`` instances).
If an extension needs to add new roles, the following steps are required:
An extension can add new permissions to a group by listening to the
``extra_role_permissions`` signal. Here is an example:
#. Listen to the :ref:`getextraroles` event that will return
the group's name
.. sourcecode:: python
#. Listen to the :ref:`getextrarolepermissions` event that will return
the new group's permissions
from django.dispatch import receiver
from modoboa.core import signals as core_signals
The group will automatically be created the next time you run the
``load_initial_data`` command.
PERMISSIONS = {
"Resellers": [
("relaydomains", "relaydomain", "add_relaydomain"),
("relaydomains", "relaydomain", "change_relaydomain"),
("relaydomains", "relaydomain", "delete_relaydomain"),
("relaydomains", "service", "add_service"),
("relaydomains", "service", "change_service"),
("relaydomains", "service", "delete_service")
]
}
@receiver(core_signals.extra_role_permissions)
def extra_role_permissions(sender, role, **kwargs):
"""Add permissions to the Resellers group."""
return constants.PERMISSIONS.get(role, [])
*********************
Extending admin forms
*********************
the forms used to edit objects (account, domain, etc.) through the admin
panel are composed of tabs. You can extend those forms (ie. add new
tabs) in a pretty easy way by defining events.
The forms used to edit objects (account, domain, etc.) through the admin
panel are composed of tabs. You can extend them (ie. add new
tabs) in a pretty easy way thanks to custom signals.
Account
=======
To add a new tab to the account edition form, define new listeners
(handlers) for the following events:
(handlers) for the following signals:
* :ref:`event_extraaccountform`
* ``modoboa.admin.signals.extra_account_forms``
* :ref:`event_fillaccountinstances`
* ``modoboa.admin.signals.get_account_form_instances``
* :ref:`event_checkextraaccountform` (optional)
* ``modoboa.admin.signals.check_extra_account_form`` (optional)
Example:
.. sourcecode:: python
from modoboa.lib import events
from django.dispatch import receiver
from modoboa.admin import signals as admin_signals
@events.observe("ExtraAccountForm")
def extra_account_form(user, account=None):
@receiver(admin_signals.extra_account_forms)
def extra_account_form(sender, user, account, **kwargs):
return [
{"id": "tabid", "title": "Title", "cls": MyFormClass}
]
@events.observe("FillAccountInstances")
def fill_my_tab(user, account, instances):
instances["id"] = my_instance
@receiver(admin_signals.get_account_form_instances)
def fill_my_tab(sender, user, account, **kwargs):
return {"id": my_instance}
Domain
======
To add a new tab to the domain edition form, define new listeners
(handlers) for the following events:
(handlers) for the following signals:
* :ref:`event_extradomainform`
* ``modoboa.admin.signals.extra_domain_forms``
* :ref:`event_filldomaininstances`
* ``modoboa.admin.signals.get_domain_form_instances``
Example:
.. sourcecode:: python
from modoboa.lib import events
from django.dispatch import receiver
from modoboa.admin import signals as admin_signals
@events.observe("ExtraDomainForm")
def extra_domain_form(user, domain):
@receiver(admin_signals.extra_domain_forms)
def extra_account_form(sender, user, domain, **kwargs):
return [
{"id": "tabid", "title": "Title", "cls": MyFormClass}
]
@events.observe("FillDomainInstances")
def fill_my_tab(user, domain, instances):
instances["id"] = my_instance
@receiver(admin_signals.get_domain_form_instances)
def fill_my_tab(sender, user, domain, **kwargs):
return {"id": my_instance}
-4
View File
@@ -75,10 +75,6 @@ class AccountViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(mailbox__domain__name=domain)
return queryset
def perform_destroy(self, instance):
"""Add custom args to delete call."""
instance.delete(self.request.user)
@detail_route(methods=["put"])
def password(self, request, pk=None):
"""Change account password."""
+1 -40
View File
@@ -1,4 +1,4 @@
"""Modoboa admin settings and events."""
"""Modoboa admin settings."""
import os
@@ -10,43 +10,6 @@ from modoboa.lib.form_utils import YesNoField, SeparatorField
from modoboa.lib.sysutils import exec_cmd
from modoboa.parameters import forms as param_forms
from ..lib import events
ADMIN_EVENTS = [
"DomainCreated",
"DomainModified",
"DomainDeleted",
"DomainOwnershipRemoved",
"GetTagsForDomain",
"ExtraDomainMenuEntries",
"ExtraDomainFilters",
"ExtraDomainQsetFilters",
"ExtraDomainTypes",
"ExtraDomainWizardSteps",
"GetDomainModifyLink",
"CheckDomainName",
"DomainAliasCreated",
"DomainAliasDeleted",
"MailboxCreated",
"MailboxDeleted",
"MailboxModified",
"MailboxAliasCreated",
"MailboxAliasDeleted",
"ExtraDomainForm",
"FillDomainInstances",
"ExtraAccountForm",
"CheckExtraAccountForm",
"FillAccountInstances",
"ExtraDomainImportHelp",
"ImportObject"
]
class AdminParametersForm(param_forms.AdminParametersForm):
app = "admin"
@@ -178,5 +141,3 @@ def load_admin_settings():
param_tools.registry.add(
"global", AdminParametersForm, ugettext_lazy("Administration"))
events.declare(ADMIN_EVENTS)
from . import callbacks
-208
View File
@@ -1,208 +0,0 @@
"""Event callbacks for modoboa admin."""
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _, ugettext_lazy
from modoboa.core import signals as core_signals
from modoboa.lib import events
from modoboa.lib.email_utils import split_mailbox
from modoboa.lib.exceptions import PermDeniedException, BadRequest, Conflict
from modoboa.parameters import tools as param_tools
from .models import (
Domain, DomainAlias, Mailbox, Alias
)
@events.observe("AdminMenuDisplay")
def admin_menu(target, user):
if target != "top_menu":
return []
entries = []
if user.has_perm("admin.view_domains"):
entries += [
{"name": "domains",
"url": reverse("admin:domain_list"),
"label": _("Domains")}
]
conditions = (
user.has_perm("core.add_user"),
user.has_perm("admin.add_alias")
)
if any(conditions):
entries += [
{"name": "identities",
"url": reverse("admin:identity_list"),
"label": _("Identities")},
]
return entries
@events.observe("UserMenuDisplay")
def user_menu(target, user):
if target != "uprefs_menu":
return []
if not hasattr(user, "mailbox"):
return []
return [
{"name": "forward",
"class": "ajaxnav",
"url": "forward/",
"label": ugettext_lazy("Forward")}
]
@events.observe("RoleChanged")
def grant_access_to_all_objects(user, role):
from django.contrib.contenttypes.models import ContentType
from modoboa.lib.permissions import grant_access_to_objects
from modoboa.core.models import User
if role != "SuperAdmins":
return
grant_access_to_objects(
user, User.objects.all(),
ContentType.objects.get_for_model(User)
)
grant_access_to_objects(
user, Domain.objects.all(),
ContentType.objects.get_for_model(Domain)
)
grant_access_to_objects(
user, DomainAlias.objects.all(),
ContentType.objects.get_for_model(DomainAlias)
)
grant_access_to_objects(
user, Mailbox.objects.all(),
ContentType.objects.get_for_model(Mailbox)
)
grant_access_to_objects(
user, Alias.objects.all(),
ContentType.objects.get_for_model(Alias)
)
@events.observe("AccountExported")
def export_admin_domains(admin):
result = [admin.mailbox.quota] \
if hasattr(admin, "mailbox") else [""]
if admin.role != "DomainAdmins":
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:
mailbox, domname = split_mailbox(account.email)
try:
domain = Domain.objects.get(name=domname)
except Domain.DoesNotExist:
raise BadRequest(
_("Account import failed (%s): domain does not exist"
% account.username)
)
if not user.can_access(domain):
raise PermDeniedException
core_signals.can_create_object.send(
sender="import", context=user, object_type="mailboxes")
core_signals.can_create_object.send(
sender="import", context=domain, object_type="mailboxes")
account.save()
if Mailbox.objects.filter(address=mailbox, domain=domain).exists():
raise Conflict(
_("Mailbox {} already exists").format(account.email))
if len(row) == 1:
quota = None
else:
try:
quota = int(row[1].strip())
except ValueError:
raise BadRequest(
_("Account import failed (%s): wrong quota value"
% account.username)
)
use_domain_quota = True if not quota else False
mb = Mailbox(address=mailbox, domain=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.role == "DomainAdmins":
for domname in row[2:]:
try:
dom = Domain.objects.get(name=domname.strip())
except Domain.DoesNotExist:
continue
dom.add_admin(account)
@events.observe("AccountAutoCreated")
def account_auto_created(user):
from modoboa.core.models import User
from modoboa.lib.permissions import grant_access_to_object
from .lib import check_if_domain_exists
if not param_tools.get_global_parameter("auto_create_domain_and_mailbox"):
return
localpart, domname = split_mailbox(user.username)
if user.role != 'SimpleUsers' and domname is None:
return
sadmins = User.objects.filter(is_superuser=True)
try:
domain = Domain.objects.get(name=domname)
except Domain.DoesNotExist:
label = check_if_domain_exists(
domname, [(DomainAlias, _('domain alias'))])
if label is not None:
return
domain = Domain(name=domname, enabled=True, quota=0)
domain.save(creator=sadmins[0])
for su in sadmins[1:]:
grant_access_to_object(su, domain)
try:
mb = Mailbox.objects.get(domain=domain, address=localpart)
except Mailbox.DoesNotExist:
mb = Mailbox(
address=localpart, domain=domain, user=user, use_domain_quota=True
)
mb.set_quota(override_rules=True)
mb.save(creator=sadmins[0])
for su in sadmins[1:]:
grant_access_to_object(su, mb)
@events.observe("UserLogin")
def user_logged_in(request, username, password):
from modoboa.lib.cryptutils import encrypt
if hasattr(request.user, "mailbox"):
request.session["password"] = encrypt(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 hasattr(account, "mailbox"):
return
mb = account.mailbox
if not byuser.can_access(mb):
raise PermDeniedException
keep_mb_dir = kwargs.get("keep_mb_dir", True)
mb.delete(keepdir=keep_mb_dir)
+11 -19
View File
@@ -11,7 +11,6 @@ from passwords.fields import PasswordField
from modoboa.core import signals as core_signals
from modoboa.core.models import User
from modoboa.lib import events
from modoboa.lib.email_utils import split_mailbox
from modoboa.lib import exceptions as lib_exceptions
from modoboa.lib import fields as lib_fields
@@ -24,6 +23,7 @@ from modoboa.lib.web_utils import render_to_json_response
from modoboa.parameters import tools as param_tools
from .. import models
from .. import signals
class AccountFormGeneral(forms.ModelForm):
@@ -228,11 +228,6 @@ class AccountFormMail(forms.Form, DynamicForm):
self.field_widths = {
"quota": 3
}
self.extra_fields = []
result = events.raiseQueryEvent('ExtraFormFields', 'mailform', self.mb)
for fname, field in result:
self.fields[fname] = field
self.extra_fields.append(fname)
if self.mb is not None:
self.fields["email"].required = True
qset = self.mb.aliasrecipient_set.filter(alias__internal=False)
@@ -380,9 +375,6 @@ class AccountFormMail(forms.Form, DynamicForm):
self.cleaned_data["use_domain_quota"] = (
self.cleaned_data["quota_act"])
self.mb.update_from_dict(user, self.cleaned_data)
events.raiseEvent(
'SaveExtraFormFields', 'mailform', self.mb, self.cleaned_data
)
account.email = self.cleaned_data["email"]
account.save()
@@ -456,11 +448,13 @@ class AccountForm(TabForms):
"formtpl": "admin/permsform.html",
"cls": AccountPermissionsForm}
]
cbargs = [self.user]
cbargs = {"user": self.user}
if "instances" in kwargs:
cbargs += [kwargs["instances"]["general"]]
self.forms += events.raiseQueryEvent("ExtraAccountForm", *cbargs)
cbargs["account"] = kwargs["instances"]["general"]
results = signals.extra_account_forms.send(
sender=self.__class__, **cbargs)
self.forms += reduce(
lambda a, b: a + b, [result[1] for result in results])
super(AccountForm, self).__init__(request, *args, **kwargs)
def extra_context(self, context):
@@ -489,9 +483,10 @@ class AccountForm(TabForms):
return False
return True
extra_forms = events.raiseQueryEvent(
"CheckExtraAccountForm", self.account, form)
if False in extra_forms:
results = signals.check_extra_account_form.send(
sender=self.__class__, account=self.account, form=form)
results = [result[1] for result in results]
if False in results:
return False
return True
@@ -509,9 +504,6 @@ class AccountForm(TabForms):
As forms interact with each other, it is simpler to make
custom code to save them.
"""
events.raiseEvent(
"AccountModified", self.instances["general"], self.account
)
self.forms[1]["instance"].save(self.user, self.account)
if len(self.forms) <= 2:
return
+22 -16
View File
@@ -7,7 +7,6 @@ from django.utils.translation import ugettext as _, ugettext_lazy
from modoboa.core import signals as core_signals
from modoboa.core.models import User
from modoboa.lib import events
from modoboa.lib.exceptions import Conflict
from modoboa.lib.fields import DomainNameField
from modoboa.lib.form_utils import (
@@ -16,11 +15,13 @@ from modoboa.lib.form_utils import (
from modoboa.lib.web_utils import render_to_json_response
from modoboa.parameters import tools as param_tools
from .. import signals
from ..lib import check_if_domain_exists
from ..models import (
Domain, DomainAlias, Mailbox, Alias
)
DOMAIN_TYPES = [
("domain", _("Domain")),
]
@@ -60,9 +61,12 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm):
self.oldname = kwargs["instance"].name
super(DomainFormGeneral, self).__init__(*args, **kwargs)
self.fields["type"].choices = (
DOMAIN_TYPES + events.raiseQueryEvent("ExtraDomainTypes"))
extra_domain_types = reduce(
lambda a, b: a + b,
[result[1] for result in signals.extra_domain_types.send(
sender=self.__class__)]
)
self.fields["type"].choices = DOMAIN_TYPES + extra_domain_types
self.field_widths = {
"quota": 3
}
@@ -87,8 +91,7 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm):
"""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).
aliases.
The validation way is not very smart...
"""
@@ -193,8 +196,9 @@ class DomainFormOptions(forms.Form):
def __init__(self, user, *args, **kwargs):
super(DomainFormOptions, self).__init__(*args, **kwargs)
result = events.raiseQueryEvent("UserCanSetRole", user, "DomainAdmins")
if False in result:
results = core_signals.user_can_set_role.send(
sender=self.__class__, user=user, role="DomainAdmins")
if False in [result[1] for result in results]:
self.fields = {}
return
@@ -282,11 +286,14 @@ class DomainForm(TabForms):
"mandatory": True
})
cbargs = [self.user]
cbargs = {"user": self.user}
if "instances" in kwargs:
self.domain = kwargs["instances"]["general"]
cbargs += [self.domain]
self.forms += events.raiseQueryEvent("ExtraDomainForm", *cbargs)
cbargs["domain"] = self.domain
results = signals.extra_domain_forms.send(
sender=self.__class__, **cbargs)
self.forms += reduce(
lambda a, b: a + b, [result[1] for result in results])
if not self.forms:
self.active_id = "admins"
super(DomainForm, self).__init__(request, *args, **kwargs)
@@ -329,8 +336,6 @@ class DomainForm(TabForms):
f["instance"].save(self.request.user)
def done(self):
if "general" in self.instances:
events.raiseEvent("DomainModified", self.instances["general"])
return render_to_json_response(_("Domain modified"))
@@ -346,9 +351,10 @@ class DomainWizard(WizardForm):
"admin/domain_general_form.html"
)
)
steps = events.raiseQueryEvent("ExtraDomainWizardSteps")
for step in steps:
self.add_step(step)
results = signals.extra_domain_wizard_steps.send(sender=self.__class__)
for result in results:
for step in result[1]:
self.add_step(step)
self.add_step(
WizardStep(
"options", DomainFormOptions, _("Options"),
+186 -4
View File
@@ -1,10 +1,22 @@
"""Django signal handlers for admin."""
from django.core.urlresolvers import reverse
from django.db.models import signals
from django.dispatch import receiver
from django.utils.translation import ugettext as _
from django.contrib.contenttypes.models import ContentType
from modoboa.core import models as core_models
from modoboa.core import signals as core_signals
from modoboa.lib.cryptutils import encrypt
from modoboa.lib.email_utils import split_mailbox
from modoboa.lib import exceptions
from modoboa.lib import permissions
from modoboa.lib import signals as lib_signals
from modoboa.parameters import tools as param_tools
from . import lib
from . import models
from . import postfix_maps
@@ -74,7 +86,7 @@ def manage_alias_for_mailbox(sender, instance, **kwargs):
@receiver(signals.pre_delete, sender=models.Mailbox)
def mailbox_deleted_handler(sender, **kwargs):
"""``Mailbox`` pre_delete signal receiver
"""``Mailbox`` pre_delete signal receiver.
In order to properly handle deletions (ie. we don't want to leave
orphan records into the db), we define this custom receiver.
@@ -82,17 +94,24 @@ def mailbox_deleted_handler(sender, **kwargs):
It manually removes the mailbox from the aliases it is linked to
and then remove all empty aliases.
"""
from modoboa.lib import events
from modoboa.lib.permissions import ungrant_access_to_object
mb = kwargs['instance']
events.raiseEvent("MailboxDeleted", mb)
mb = kwargs["instance"]
ungrant_access_to_object(mb)
for ralias in mb.aliasrecipient_set.select_related("alias"):
alias = ralias.alias
ralias.delete()
if not alias.aliasrecipient_set.exists():
alias.delete()
models.Quota.objects.filter(username=mb.full_address).delete()
request = lib_signals.get_request()
if not request.localconfig.parameters.get_value(
"handle_mailboxes", raise_exception=False):
return
keepdir = request.POST.get("keepdir", "false") == "true"
if keepdir:
return
mb.delete_dir()
@receiver(signals.post_delete, sender=models.Mailbox)
@@ -111,3 +130,166 @@ def register_postfix_maps(sender, **kwargs):
postfix_maps.SenderLoginAliasMap, postfix_maps.SenderLoginMailboxMap,
postfix_maps.SenderLoginMailboxExtraMap
]
@receiver(core_signals.account_auto_created)
def account_auto_created(sender, user, **kwargs):
"""New account has been auto-created, build the rest."""
if not param_tools.get_global_parameter("auto_create_domain_and_mailbox"):
return
localpart, domname = split_mailbox(user.username)
if user.role != 'SimpleUsers' and domname is None:
return
sadmins = core_models.User.objects.filter(is_superuser=True)
try:
domain = models.Domain.objects.get(name=domname)
except models.Domain.DoesNotExist:
label = lib.check_if_domain_exists(
domname, [(models.DomainAlias, _("domain alias"))])
if label is not None:
return
domain = models.Domain(name=domname, enabled=True, quota=0)
domain.save(creator=sadmins[0])
for su in sadmins[1:]:
permissions.grant_access_to_object(su, domain)
qset = models.Mailbox.objects.filter(domain=domain, address=localpart)
if not qset.exists():
mb = models.Mailbox(
address=localpart, domain=domain, user=user, use_domain_quota=True
)
mb.set_quota(override_rules=True)
mb.save(creator=sadmins[0])
for su in sadmins[1:]:
permissions.grant_access_to_object(su, mb)
@receiver(core_signals.account_exported)
def export_admin_domains(sender, user, **kwargs):
"""Export administered domains too."""
result = [user.mailbox.quota] if hasattr(user, "mailbox") else [""]
if user.role != "DomainAdmins":
return result
return result + [
dom.name for dom in models.Domain.objects.get_for_admin(user)]
@receiver(core_signals.account_imported)
def import_account_mailbox(sender, user, account, row, **kwargs):
"""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:
mailbox, domname = split_mailbox(account.email)
domain = models.Domain.objects.filter(name=domname).first()
if not domain:
raise exceptions.BadRequest(
_("Account import failed (%s): domain does not exist")
% account.username
)
if not user.can_access(domain):
raise exceptions.PermDeniedException
core_signals.can_create_object.send(
sender="import", context=user, object_type="mailboxes")
core_signals.can_create_object.send(
sender="import", context=domain, object_type="mailboxes")
account.save()
qset = models.Mailbox.objects.filter(address=mailbox, domain=domain)
if qset.exists():
raise exceptions.Conflict(
_("Mailbox {} already exists").format(account.email))
if len(row) == 1:
quota = None
else:
try:
quota = int(row[1].strip())
except ValueError:
raise exceptions.BadRequest(
_("Account import failed (%s): wrong quota value")
% account.username
)
use_domain_quota = True if not quota else False
mb = models.Mailbox(
address=mailbox, domain=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.role == "DomainAdmins":
for domname in row[2:]:
try:
dom = models.Domain.objects.get(name=domname.strip())
except models.Domain.DoesNotExist:
continue
dom.add_admin(account)
@receiver(core_signals.extra_admin_menu_entries)
def admin_menu(sender, location, user, **kwargs):
"""Add extra menu entries for admins."""
if location != "top_menu":
return []
entries = []
if user.has_perm("admin.view_domains"):
entries += [
{"name": "domains",
"url": reverse("admin:domain_list"),
"label": _("Domains")}
]
conditions = (
user.has_perm("core.add_user"),
user.has_perm("admin.add_alias")
)
if any(conditions):
entries += [
{"name": "identities",
"url": reverse("admin:identity_list"),
"label": _("Identities")},
]
return entries
@receiver(core_signals.extra_user_menu_entries)
def user_menu(sender, location, user, **kwargs):
"""Add extra menu entries for users."""
if location != "uprefs_menu":
return []
if not hasattr(user, "mailbox"):
return []
return [
{"name": "forward",
"class": "ajaxnav",
"url": "forward/",
"label": _("Forward")}
]
@receiver(core_signals.user_login)
def user_logged_in(sender, username, password, **kwargs):
"""Store user password in session."""
request = lib_signals.get_request()
if hasattr(request.user, "mailbox"):
request.session["password"] = encrypt(password)
@receiver(core_signals.account_role_changed)
def grant_access_to_all_objects(sender, account, role, **kwargs):
"""Grant all permissions if new role is SuperAdmin."""
if role != "SuperAdmins":
return
perm_models = [
core_models.User, models.Domain, models.DomainAlias, models.Mailbox,
models.Alias]
for model in perm_models:
permissions.grant_access_to_objects(
account, model.objects.all(),
ContentType.objects.get_for_model(model)
)
+9 -9
View File
@@ -11,11 +11,12 @@ from django.utils.translation import ugettext as _
from modoboa.core.models import User
from modoboa.core import signals as core_signals
from modoboa.lib import events
from modoboa.lib.exceptions import PermDeniedException
from .models import Domain, DomainAlias, Alias
from . import signals
def needs_mailbox():
"""Check if the current user owns at least one mailbox
@@ -92,24 +93,23 @@ def get_domains(user, domfilter=None, searchquery=None, **extrafilters):
q = Q(name__contains=searchquery)
q |= Q(domainalias__name__contains=searchquery)
domains = domains.filter(q).distinct()
qset_filters = events.raiseDictEvent(
"ExtraDomainQsetFilters", domfilter, extrafilters
)
if qset_filters:
results = signals.extra_domain_qset_filters.send(
sender="get_domains", domfilter=domfilter, extrafilters=extrafilters)
if results:
qset_filters = {}
for result in results:
qset_filters.update(result[1])
domains = domains.filter(**qset_filters)
return domains
def check_if_domain_exists(name, extra_checks=None):
def check_if_domain_exists(name, dtypes):
"""Check if a domain already exists.
We not only look for domains, we also look for every object that
could conflict with a domain (domain alias, etc.)
"""
dtypes = events.raiseQueryEvent('CheckDomainName')
if extra_checks is not None:
dtypes = extra_checks + dtypes
for dtype, label in dtypes:
if dtype.objects.filter(name=name).exists():
return label
@@ -9,10 +9,10 @@ from django.core.management.base import BaseCommand, CommandError
from modoboa.core import models as core_models
from modoboa.core.extensions import exts_pool
from modoboa.lib import events
from modoboa.lib.exceptions import Conflict
from .... import lib
from .... import signals
class ImportCommand(BaseCommand):
@@ -59,11 +59,11 @@ class ImportCommand(BaseCommand):
try:
fct = getattr(lib, "import_%s" % row[0].strip())
except AttributeError:
fct = events.raiseQueryEvent(
"ImportObject", row[0].strip())
fct = signals.import_object.send(
sender=self.__class__, objtype=row[0].strip())
if not fct:
continue
fct = fct[0]
fct = fct[0][1]
try:
fct(superadmin, row, options)
except Conflict:
-3
View File
@@ -3,7 +3,6 @@
from django.db import models
from django.utils import timezone
from modoboa.lib import events
from modoboa.lib.permissions import (
grant_access_to_object, ungrant_access_to_object
)
@@ -32,7 +31,6 @@ class AdminObject(models.Model):
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):
creator = kwargs.pop("creator", None)
@@ -41,6 +39,5 @@ class AdminObject(models.Model):
self.post_create(creator)
def delete(self):
events.raiseEvent("%sDeleted" % self.objectname, self)
ungrant_access_to_object(self)
super(AdminObject, self).delete()
+5 -9
View File
@@ -15,10 +15,10 @@ from reversion import revisions as reversion
from modoboa.core import signals as core_signals
from modoboa.core.models import User, ObjectAccess
from modoboa.lib import events
from modoboa.lib.exceptions import BadRequest, Conflict
from modoboa.parameters import tools as param_tools
from .. import signals
from .base import AdminObject
@@ -89,7 +89,9 @@ class Domain(AdminObject):
def tags(self):
if self.type == "domain":
return [{"name": "domain", "label": _("Domain"), "type": "dom"}]
return events.raiseQueryEvent("GetTagsForDomain", self)
results = signals.get_domain_tags.send(
sender=self.__class__, domain=self)
return reduce(lambda a, b: a + b, [result[1] for result in results])
@property
def admins(self):
@@ -153,11 +155,8 @@ class Domain(AdminObject):
:param User account: administrator to remove
"""
from modoboa.lib.permissions import \
ungrant_access_to_object, get_object_owner
from modoboa.lib.permissions import ungrant_access_to_object
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"):
@@ -181,17 +180,14 @@ class Domain(AdminObject):
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.alias_set.count():
events.raiseEvent("MailboxAliasDeleted", self.alias_set.all())
ungrant_access_to_objects(self.alias_set.all())
if param_tools.get_global_parameter("auto_account_removal"):
for account in User.objects.filter(mailbox__domain=self):
account.delete(fromuser, keepdir)
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()
-18
View File
@@ -13,7 +13,6 @@ from django.utils.translation import ugettext as _, ugettext_lazy
from reversion import revisions as reversion
from modoboa.core.models import User
from modoboa.lib import events
from modoboa.lib import exceptions as lib_exceptions
from modoboa.lib.email_utils import split_mailbox
from modoboa.lib.sysutils import exec_cmd
@@ -75,7 +74,6 @@ class MailboxManager(Manager):
@python_2_unicode_compatible
class Mailbox(AdminObject):
"""User mailbox."""
address = models.CharField(
@@ -306,7 +304,6 @@ class Mailbox(AdminObject):
if newaddress:
self.rename(local_part, domain)
self.save()
events.raiseEvent("MailboxModified", self)
def save(self, *args, **kwargs):
"""Custom save.
@@ -326,21 +323,6 @@ class Mailbox(AdminObject):
username=self.full_address)
super(Mailbox, self).save(*args, **kwargs)
def delete(self, keepdir=False):
"""Custom delete method
We try to delete the associated quota in the same time (it may
has already been removed if we're deleting a domain).
:param bool keepdir: delete the mailbox home dir on the
filesystem or not
"""
Quota.objects.filter(username=self.full_address).delete()
if not keepdir:
self.delete_dir()
super(Mailbox, self).delete()
reversion.register(Mailbox)
+21 -2
View File
@@ -2,10 +2,29 @@
import django.dispatch
use_external_recipients = django.dispatch.Signal(providing_args=["recipients"])
check_extra_account_form = django.dispatch.Signal(
providing_args=["account", "form"])
extra_account_dashboard_widgets = django.dispatch.Signal(
providing_args=["user", "account"])
extra_account_forms = django.dispatch.Signal(
providing_args=["user", "account"])
extra_admin_content = django.dispatch.Signal(
providing_args=["user", "location", "currentpage"])
extra_domain_actions = django.dispatch.Signal(
providing_args=["user", "domain"])
extra_domain_dashboard_widgets = django.dispatch.Signal(
providing_args=["user", "domain"])
extra_account_dashboard_widgets = django.dispatch.Signal(
extra_domain_filters = django.dispatch.Signal()
extra_domain_forms = django.dispatch.Signal(providing_args=["user", "domain"])
extra_domain_menu_entries = django.dispatch.Signal(providing_args=["user"])
extra_domain_qset_filters = django.dispatch.Signal(
providing_args=["domfilter", "extrafilters"])
extra_domain_types = django.dispatch.Signal()
extra_domain_wizard_steps = django.dispatch.Signal()
get_account_form_instances = django.dispatch.Signal(
providing_args=["user", "account"])
get_domain_form_instances = django.dispatch.Signal(
providing_args=["user", "domain"])
get_domain_tags = django.dispatch.Signal(providing_args=["domain"])
import_object = django.dispatch.Signal(providing_args=["objtype"])
use_external_recipients = django.dispatch.Signal(providing_args=["recipients"])
@@ -0,0 +1,16 @@
{% extends "admin/importform.html" %}
{% load i18n %}
{% block helptext %}
{% blocktrans trimmed %}
Provide a CSV file where lines respect one of the following formats:
<ul>
<li><em>domain; name; quota; enabled</em></li>
<li><em>domainalias; name; targeted domain; enabled</em></li>
<li><em>relaydomain; name; target host; service; enabled; verify recipients</em></li>
</ul>
<p>The first element of each line is mandatory and must be equal to one of the previous values.</p>
<p>You can use a different character as separator.</p>
{% endblocktrans %}
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "admin/importform.html" %}
{% load i18n %}
{% block helptext %}
{% blocktrans trimmed %}
Provide a CSV file where lines respect one of the following formats:
<ul>
<li><em>account; loginname; password; first name; last name; enabled; group; address; quota; [, domain, ...]</em></li>
<li><em>alias; address; enabled; recipient; recipient; ...</em></li>
</ul>
<p>The first element of each line is mandatory and must be equal to one of the previous values.</p>
<p>You can use a different character as separator.</p>
{% endblocktrans %}
{% endblock %}
@@ -4,7 +4,7 @@
{% block formbody %}
<div class="alert alert-info">
<h4>{% trans "Help" %}</h4>
{{ helptext|safe }}
{% block helptext %}{% endblock %}
</div>
{% render_form form %}
<iframe id="import_target" name="import_target" src="#"
@@ -2,6 +2,5 @@
{% render_field form.email %}
{% include "common/generic_field.html" with field=form.quota help_display_mode="tooltip" label_width="col-sm-4" appended_text=_("MB") activator=True activator_value=form.quota_act.value deactivate_if_empty=False activator_label=_("Use domain default value") activator_size="col-sm-5" %}
{% render_extra_fields form %}
{% render_fields_group form "aliases" %}
{% render_fields_group form "senderaddress" %}
+17 -7
View File
@@ -6,7 +6,7 @@ from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext as _, ugettext_lazy
from modoboa.lib import events
from modoboa.core import signals as core_signals
from modoboa.lib.templatetags.lib_tags import render_link
from modoboa.lib.web_utils import render_actions
@@ -47,7 +47,10 @@ def domains_menu(selection, user, ajax_mode=True):
"url": reverse("admin:domain_statistics")}
]
if user.has_perm("admin.add_domain"):
entries += events.raiseQueryEvent("ExtraDomainMenuEntries", user)
extra_entries = signals.extra_domain_menu_entries.send(
sender="domains_menu", user=user)
for entry in extra_entries:
entries += entry[1]
entries += [
{"name": "import",
"label": _("Import"),
@@ -157,7 +160,11 @@ def identity_actions(user, ident):
name = ident.__class__.__name__
objid = ident.id
if name == "User":
actions = events.raiseQueryEvent("ExtraAccountActions", ident)
actions = []
result = core_signals.extra_account_actions.send(
sender="identity_actions", account=ident)
for action in result:
actions += action[1]
url = (
reverse("admin:account_change", args=[objid]) +
"?active_tab=default"
@@ -265,7 +272,10 @@ def gender(value, target):
@register.simple_tag
def get_extra_admin_content(user, target, currentpage):
res = events.raiseQueryEvent(
"ExtraAdminContent", user, target, currentpage
)
return mark_safe("".join(res))
results = signals.extra_admin_content.send(
sender="get_extra_admin_content",
user=user, location=target, currentpage=currentpage)
if not results:
return ""
results = reduce(lambda a, b: a + b, [result[1] for result in results])
return mark_safe("".join(results))
+8
View File
@@ -21,6 +21,10 @@ class ImportTestCase(ModoTestCase):
factories.populate_database()
def test_domains_import(self):
response = self.client.get(reverse("admin:domain_import"))
self.assertEqual(response.status_code, 200)
self.assertIn("Provide a CSV", response.content)
f = ContentFile(b"""domain; domain1.com; 100; True
domain; domain2.com; 200; False
domainalias; domalias1.com; domain1.com; True
@@ -56,6 +60,10 @@ domainalias;test.alias;test.com;True
self.assertIn('Object already exists: domainalias', resp.content)
def test_identities_import(self):
response = self.client.get(reverse("admin:identity_import"))
self.assertEqual(response.status_code, 200)
self.assertIn("Provide a CSV", response.content)
f = ContentFile(b"""
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
+10 -4
View File
@@ -16,7 +16,6 @@ from reversion import revisions as reversion
from modoboa.core.models import User
from modoboa.core import signals as core_signals
from modoboa.lib import events
from modoboa.lib.web_utils import (
_render_to_string, render_to_json_response
)
@@ -45,10 +44,14 @@ def index(request):
)
def _domains(request):
sort_order, sort_dir = get_sort_order(request.GET, "name")
extra_filters = reduce(
lambda a, b: a + b,
[result[1] for result in
signals.extra_domain_filters.send(sender="_domains")]
)
filters = dict(
(flt, request.GET.get(flt, None))
for flt in ['domfilter', 'searchquery'] +
events.raiseQueryEvent('ExtraDomainFilters')
for flt in ["domfilter", "searchquery"] + extra_filters
)
request.session['domains_filters'] = filters
domainlist = get_domains(request.user, **filters)
@@ -126,7 +129,10 @@ def editdomain(request, dom_id):
raise PermDeniedException
instances = dict(general=domain)
events.raiseEvent("FillDomainInstances", request.user, domain, instances)
results = signals.get_domain_form_instances.send(
sender="editdomain", user=request.user, domain=domain)
for result in results:
instances.update(result[1])
return DomainForm(request, instances=instances).process()
+5 -4
View File
@@ -13,7 +13,6 @@ from django.contrib.auth.decorators import (
from reversion import revisions as reversion
from modoboa.core.models import User
from modoboa.lib import events
from modoboa.lib.exceptions import (
PermDeniedException, BadRequest
)
@@ -181,15 +180,17 @@ def editaccount(request, pk):
mb = account.mailbox if hasattr(account, "mailbox") else None
instances = dict(general=account, mail=mb, perms=account)
events.raiseEvent("FillAccountInstances", request.user, account, instances)
results = signals.get_account_form_instances.send(
sender="editaccount", user=request.user, account=account)
for result in results:
instances.update(result[1])
return AccountForm(request, instances=instances).process()
@login_required
@permission_required("core.delete_user")
def delaccount(request, pk):
keepdir = True if request.POST.get("keepdir", "false") == "true" else False
User.objects.get(pk=pk).delete(request.user, keep_mb_dir=keepdir)
User.objects.get(pk=pk).delete()
return render_to_json_response(
ungettext("Account deleted", "Accounts deleted", 1)
)
+7 -30
View File
@@ -12,11 +12,11 @@ from django.utils.translation import ugettext as _
from reversion import revisions as reversion
from modoboa.lib import events
from modoboa.lib.exceptions import ModoboaException, Conflict
from ..forms import ImportIdentitiesForm, ImportDataForm
from .. import lib
from .. import signals
@reversion.create_revision()
@@ -48,12 +48,11 @@ def importdata(request, formclass=ImportDataForm):
try:
fct = getattr(lib, "import_%s" % row[0].strip())
except AttributeError:
fct = events.raiseQueryEvent(
'ImportObject', row[0].strip()
)
fct = signals.import_object.send(
sender="importdata", objtype=row[0].strip())
if not fct:
continue
fct = fct[0]
fct = fct[0][1]
with transaction.atomic():
try:
fct(request.user, row, form.cleaned_data)
@@ -83,17 +82,6 @@ def import_domains(request):
if request.method == "POST":
return importdata(request)
helptext = _("""Provide a CSV file where lines respect one of the following formats:
<ul>
<li><em>domain; name; quota; enabled</em></li>
<li><em>domainalias; name; targeted domain; enabled</em></li>
%s
</ul>
<p>The first element of each line is mandatory and must be equal to one of the previous values.</p>
<p>You can use a different character as separator.</p>
""" % ''.join([unicode(hlp) for hlp in
events.raiseQueryEvent('ExtraDomainImportHelp')]))
ctx = dict(
title=_("Import domains"),
action_label=_("Import"),
@@ -102,10 +90,9 @@ def import_domains(request):
formid="importform",
enctype="multipart/form-data",
target="import_target",
helptext=helptext,
form=ImportDataForm()
)
return render(request, "admin/importform.html", ctx)
return render(request, "admin/import_domains_form.html", ctx)
@login_required
@@ -117,15 +104,6 @@ def import_identities(request):
if request.method == "POST":
return importdata(request, ImportIdentitiesForm)
helptext = _("""Provide a CSV file where lines respect one of the following formats:
<ul>
<li><em>account; loginname; password; first name; last name; enabled; group; address; quota; [, domain, ...]</em></li>
<li><em>alias; address; enabled; recipient; recipient; ...</em></li>
</ul>
<p>The first element of each line is mandatory and must be equal to one of the previous values.</p>
<p>You can use a different character as separator.</p>
""")
ctx = dict(
title=_("Import identities"),
action_label=_("Import"),
@@ -134,7 +112,6 @@ def import_identities(request):
formid="importform",
enctype="multipart/form-data",
target="import_target",
form=ImportIdentitiesForm(),
helptext=helptext
form=ImportIdentitiesForm()
)
return render(request, "admin/importform.html", ctx)
return render(request, "admin/import_identities_form.html", ctx)
+1 -65
View File
@@ -1,67 +1,3 @@
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
from modoboa.lib import events
PERMISSIONS = {
"SimpleUsers": [],
"DomainAdmins": [
["core", "user", "add_user"],
["core", "user", "change_user"],
["core", "user", "delete_user"],
["admin", "domain", "view_domains"],
["admin", "domain", "view_domain"],
["admin", "mailbox", "add_mailbox"],
["admin", "mailbox", "change_mailbox"],
["admin", "mailbox", "delete_mailbox"],
["admin", "alias", "add_alias"],
["admin", "alias", "change_alias"],
["admin", "alias", "delete_alias"],
["admin", "mailbox", "view_mailboxes"],
["admin", "alias", "view_aliases"],
],
"Resellers": [
["core", "user", "add_user"],
["core", "user", "change_user"],
["core", "user", "delete_user"],
["admin", "domain", "view_domains"],
["admin", "domain", "view_domain"],
["admin", "mailbox", "add_mailbox"],
["admin", "mailbox", "change_mailbox"],
["admin", "mailbox", "delete_mailbox"],
["admin", "alias", "add_alias"],
["admin", "alias", "change_alias"],
["admin", "alias", "delete_alias"],
["admin", "mailbox", "view_mailboxes"],
["admin", "alias", "view_aliases"],
["admin", "domain", "view_domains"],
["admin", "domain", "add_domain"],
["admin", "domain", "change_domain"],
["admin", "domain", "delete_domain"],
["admin", "domainalias", "add_domainalias"],
["admin", "domainalias", "change_domainalias"],
["admin", "domainalias", "delete_domainalias"],
]
}
@events.observe("TopNotifications")
def check_for_new_versions(request, include_all):
"""Check if new versions are available."""
from modoboa.core.utils import check_for_updates
if not request.user.is_superuser:
return []
status, extensions = check_for_updates(request)
if not status:
return [{"id": "newversionavailable"}] if include_all else []
return [{
"id": "newversionavailable",
"url": reverse("core:index") + "#info/",
"text": _("One or more updates are available"),
"level": "info",
}]
"""App config for core."""
default_app_config = "modoboa.core.apps.CoreConfig"
-39
View File
@@ -4,42 +4,6 @@ from django.apps import AppConfig
from django.db.models import signals
from django.utils.translation import ugettext_lazy
BASE_EVENTS = [
"AccountCreated",
"AccountAutoCreated",
"AccountModified",
"AccountDeleted",
"AccountExported",
"AccountImported",
"PasswordUpdated",
"ExtraAccountActions",
"RoleChanged",
"GetExtraRoles",
"GetExtraRolePermissions",
"PasswordChange",
"UserCanSetRole",
"InitialDataLoaded",
"UserMenuDisplay",
"AdminMenuDisplay",
"GetStaticContent",
"UserLogin",
"UserLogout",
"GetAnnouncement",
"TopNotifications",
"ExtraAdminContent",
"ExtraUprefsRoutes",
"ExtraUprefsJS",
"ExtraFormFields",
"SaveExtraFormFields",
]
def load_core_settings():
"""Load core settings.
@@ -47,17 +11,14 @@ def load_core_settings():
This function must be manually called (see :file:`urls.py`) in
order to load base settings.
"""
from modoboa.lib import events
from modoboa.parameters import tools as param_tools
from .app_settings import GeneralParametersForm
param_tools.registry.add(
"global", GeneralParametersForm, ugettext_lazy("General"))
events.declare(BASE_EVENTS)
class CoreConfig(AppConfig):
"""App configuration."""
name = "modoboa.core"
+41
View File
@@ -42,3 +42,44 @@ LDAP_GROUP_TYPES = (
("posixgroup", "PosixGroup"),
("groupofnames", "GroupOfNames"),
)
PERMISSIONS = {
"SimpleUsers": [],
"DomainAdmins": [
["core", "user", "add_user"],
["core", "user", "change_user"],
["core", "user", "delete_user"],
["admin", "domain", "view_domains"],
["admin", "domain", "view_domain"],
["admin", "mailbox", "add_mailbox"],
["admin", "mailbox", "change_mailbox"],
["admin", "mailbox", "delete_mailbox"],
["admin", "alias", "add_alias"],
["admin", "alias", "change_alias"],
["admin", "alias", "delete_alias"],
["admin", "mailbox", "view_mailboxes"],
["admin", "alias", "view_aliases"],
],
"Resellers": [
["core", "user", "add_user"],
["core", "user", "change_user"],
["core", "user", "delete_user"],
["admin", "domain", "view_domains"],
["admin", "domain", "view_domain"],
["admin", "mailbox", "add_mailbox"],
["admin", "mailbox", "change_mailbox"],
["admin", "mailbox", "delete_mailbox"],
["admin", "alias", "add_alias"],
["admin", "alias", "change_alias"],
["admin", "alias", "delete_alias"],
["admin", "mailbox", "view_mailboxes"],
["admin", "alias", "view_aliases"],
["admin", "domain", "view_domains"],
["admin", "domain", "add_domain"],
["admin", "domain", "change_domain"],
["admin", "domain", "delete_domain"],
["admin", "domainalias", "add_domainalias"],
["admin", "domainalias", "change_domainalias"],
["admin", "domainalias", "delete_domainalias"],
]
}
+6 -3
View File
@@ -1,6 +1,6 @@
"""Custom context processors."""
from modoboa.lib import events
from . import signals
def top_notifications(request):
@@ -9,8 +9,11 @@ def top_notifications(request):
return {}
interval = request.localconfig.parameters.get_value(
"top_notifications_check_interval")
notifications = signals.get_top_notifications.send(
sender="top_notifications", include_all=False)
notifications = reduce(
lambda a, b: a + b, [notif[1] for notif in notifications])
return {
"notifications_check_interval": interval * 1000,
"top_notifications": events.raiseQueryEvent(
"TopNotifications", request, False)
"top_notifications": notifications
}
+56 -3
View File
@@ -2,7 +2,8 @@
import logging
from django.db.models.signals import post_delete
from django.core.urlresolvers import reverse
from django.db.models import signals
from django.dispatch import receiver
from django.utils.translation import ugettext as _
@@ -10,7 +11,13 @@ from django.contrib.sites import models as sites_models
from reversion import revisions as reversion
from modoboa.lib import exceptions
from modoboa.lib import permissions
from modoboa.lib.signals import get_request
from . import models
from . import signals as core_signals
from . import utils
@receiver(reversion.post_revision_commit)
@@ -44,14 +51,13 @@ def post_revision_commit(sender, **kwargs):
getattr(logger, level)(message)
@receiver(post_delete)
@receiver(signals.post_delete)
def log_object_removal(sender, instance, **kwargs):
"""Custom post-delete hook.
We want to know who was responsible for an object deletion.
"""
from reversion.models import Version
from modoboa.lib.signals import get_request
if not reversion.is_registered(sender):
return
@@ -75,3 +81,50 @@ def create_local_config(sender, **kwargs):
return
models.LocalConfig.objects.create(
site=sites_models.Site.objects.get_current())
@receiver(signals.pre_delete, sender=models.User)
def update_permissions(sender, instance, **kwargs):
"""Permissions cleanup."""
from_user = get_request().user
if from_user == instance:
raise exceptions.PermDeniedException(
_("You can't delete your own account")
)
if not from_user.can_access(instance):
raise exceptions.PermDeniedException
# We send an additional signal before permissions are removed
core_signals.account_deleted.send(
sender="update_permissions", user=instance)
owner = permissions.get_object_owner(instance)
if owner == instance:
# The default admin is being removed...
owner = from_user
# Change ownership of existing objects
for ooentry in instance.objectaccess_set.filter(is_owner=True):
if ooentry.content_object is not None:
permissions.grant_access_to_object(
owner, ooentry.content_object, True)
permissions.ungrant_access_to_object(
ooentry.content_object, instance)
# Remove existing permissions on this user
permissions.ungrant_access_to_object(instance)
@receiver(core_signals.get_top_notifications)
def check_for_new_versions(sender, include_all, **kwargs):
"""Check if new versions are available."""
request = get_request()
if not request.user.is_superuser:
return []
status, extensions = utils.check_for_updates(request)
if not status:
return [{"id": "newversionavailable"}] if include_all else []
return [{
"id": "newversionavailable",
"url": reverse("core:index") + "#info/",
"text": _("One or more updates are available"),
"level": "info",
}]
@@ -8,14 +8,14 @@
from django.contrib.auth.models import Group
from django.core.management.base import BaseCommand
from modoboa.core import PERMISSIONS
from modoboa.core.extensions import exts_pool
from modoboa.lib.cryptutils import random_key
from modoboa.lib import events
from modoboa.lib.permissions import add_permissions_to_group
import modoboa.relaydomains.models as relay_models
from ... import constants
from ... import extensions
from ... import models
from ... import signals
class Command(BaseCommand):
@@ -54,27 +54,26 @@ class Command(BaseCommand):
for service_name in ["relay", "smtp"]:
relay_models.Service.objects.get_or_create(name=service_name)
exts_pool.load_all()
extensions.exts_pool.load_all()
superadmin = models.User.objects.filter(is_superuser=True).first()
groups = PERMISSIONS.keys() + [
role[0] for role
in events.raiseQueryEvent("GetExtraRoles", superadmin, None)
]
groups = constants.PERMISSIONS.keys()
for groupname in groups:
group, created = Group.objects.get_or_create(name=groupname)
results = signals.extra_role_permissions.send(
sender=self.__class__, role=groupname)
permissions = (
PERMISSIONS.get(groupname, []) +
events.raiseQueryEvent("GetExtraRolePermissions", groupname)
constants.PERMISSIONS.get(groupname, []) +
reduce(lambda a, b: a + b, [result[1] for result in results])
)
if not permissions:
continue
add_permissions_to_group(group, permissions)
for extname in exts_pool.extensions.keys():
extension = exts_pool.get_extension(extname)
for extname in extensions.exts_pool.extensions.keys():
extension = extensions.exts_pool.get_extension(extname)
extension.load_initial_data()
events.raiseEvent("InitialDataLoaded", extname)
signals.initial_data_loaded.send(
sender=self.__class__, extname=extname)
if options["extra_fixtures"]:
from modoboa.admin import factories
+15 -45
View File
@@ -20,13 +20,13 @@ import jsonfield
from reversion import revisions as reversion
from modoboa.core.password_hashers import get_password_hasher
from modoboa.lib import events
from modoboa.lib.exceptions import (
PermDeniedException, InternalError, BadRequest, Conflict
)
from modoboa.parameters import tools as param_tools
from . import constants
from . import signals
try:
@@ -103,41 +103,6 @@ class User(PermissionsMixin):
super(User, self).__init__(*args, **kwargs)
self.parameters = param_tools.Manager("user", self._parameters)
def delete(self, fromuser, *args, **kwargs):
"""Custom delete method
To check permissions properly, we need to make a distinction
between 2 cases:
* If the user owns a mailbox, the check is made on that object
(useful for domain admins)
* Otherwise, the check is made on the user
"""
from modoboa.lib.permissions import \
get_object_owner, grant_access_to_object, ungrant_access_to_object
if fromuser == self:
raise PermDeniedException(
_("You can't delete your own account")
)
if not fromuser.can_access(self):
raise PermDeniedException
owner = get_object_owner(self)
if owner == self:
# The default admin is being removed...
owner = fromuser
for ooentry in self.objectaccess_set.filter(is_owner=True):
if ooentry.content_object is not None:
grant_access_to_object(owner, ooentry.content_object, True)
ungrant_access_to_object(ooentry.content_object, self)
events.raiseEvent("AccountDeleted", self, fromuser, **kwargs)
ungrant_access_to_object(self)
super(User, self).delete()
def _crypt_password(self, raw_value):
"""Crypt the local password using the appropriate scheme.
@@ -177,9 +142,9 @@ class User(PermissionsMixin):
LDAPAuthBackend().update_user_password(
self.username, curvalue, raw_value
)
events.raiseEvent(
"PasswordUpdated", self, raw_value, self.pk is None
)
signals.account_password_updated.send(
sender=self.__class__,
account=self, password=raw_value, created=self.pk is None)
def check_password(self, raw_value):
"""Compare raw_value to current password."""
@@ -329,7 +294,8 @@ class User(PermissionsMixin):
"""
if role is None or self.role == role:
return
events.raiseEvent("RoleChanged", self, role)
signals.account_role_changed.send(
sender=self.__class__, account=self, role=role)
self.groups.clear()
if role == "SuperAdmins":
self.is_superuser = True
@@ -363,7 +329,6 @@ class User(PermissionsMixin):
"""Grant permission on this user to creator."""
from modoboa.lib.permissions import grant_access_to_object
grant_access_to_object(creator, self, is_owner=True)
events.raiseEvent("AccountCreated", self)
def save(self, *args, **kwargs):
creator = kwargs.pop("creator", None)
@@ -378,7 +343,7 @@ class User(PermissionsMixin):
"account", loginname, password, first name, last name, enabled, role
Additional fields can be added using the *AccountImported* event.
Additional fields can be added using the *account_imported* signal.
:param user: a ``core.User`` instance
:param row: a list containing the expected information
@@ -431,7 +396,8 @@ class User(PermissionsMixin):
self.post_create(user)
if len(row) < 8:
return
events.raiseEvent("AccountImported", user, self, row[7:])
signals.account_imported.send(
sender=self.__class__, user=user, account=self, row=row[7:])
def to_csv(self, csvwriter):
"""Export this account.
@@ -450,7 +416,10 @@ class User(PermissionsMixin):
self.role,
self.email.encode("utf-8")
]
row += events.raiseQueryEvent("AccountExported", self)
results = signals.account_exported.send(
sender=self.__class__, user=self)
for result in results:
row += result[1]
csvwriter.writerow(row)
reversion.register(User)
@@ -473,7 +442,8 @@ def populate_callback(user, group="SimpleUsers"):
user.post_create(sadmins[0])
for su in sadmins[1:]:
grant_access_to_object(su, user)
events.raiseEvent("AccountAutoCreated", user)
signals.account_auto_created.send(
sender="populate_callback", user=user)
class ObjectAccess(models.Model):
+28
View File
@@ -2,6 +2,34 @@
import django.dispatch
account_auto_created = django.dispatch.Signal(providing_args=["user"])
account_exported = django.dispatch.Signal(providing_args=["account"])
account_imported = django.dispatch.Signal(
providing_args=["user", "account", "row"])
account_deleted = django.dispatch.Signal(providing_args=["user"])
account_role_changed = django.dispatch.Signal(
providing_args=["account", "role"])
account_password_updated = django.dispatch.Signal(
providing_args=["account", "password", "created"])
allow_password_change = django.dispatch.Signal(providing_args=["user"])
can_create_object = django.dispatch.Signal(
providing_args=["context", "object_type", "count"])
extra_account_actions = django.dispatch.Signal(providing_args=["account"])
extra_admin_menu_entries = django.dispatch.Signal(
providing_args=["location", "user"])
extra_role_permissions = django.dispatch.Signal(providing_args=["role"])
extra_static_content = django.dispatch.Signal(
providing_args=["caller", "st_type", "user"])
extra_uprefs_routes = django.dispatch.Signal()
extra_user_menu_entries = django.dispatch.Signal(
providing_args=["location", "user"])
get_announcements = django.dispatch.Signal(providing_args=["location"])
get_top_notifications = django.dispatch.Signal(
providing_args=["include_all"])
initial_data_loaded = django.dispatch.Signal(providing_args=["extname"])
register_postfix_maps = django.dispatch.Signal()
user_can_set_role = django.dispatch.Signal(
providing_args=["user", "role", "account"])
user_login = django.dispatch.Signal(
providing_args=["username", "password"])
user_logout = django.dispatch.Signal()
+1 -1
View File
@@ -12,7 +12,7 @@
<script type="text/javascript" src="{% static 'js/listing.js' %}"></script>
<script type="text/javascript" src="{% static 'js/twocols_nav.js' %}"></script>
<script type="text/javascript">
{{ extrajs|safe }}
{% extra_static_content "user_index" "js" user %}
$(document).ready(function() {
var userprefs = new TwocolsNav({
deflocation: "profile/",
+33 -21
View File
@@ -1,17 +1,20 @@
"""Custom tags for Core application."""
import os
import pkg_resources
import re
from django import template
from django.contrib.sessions.models import Session
from django.conf import settings
from django.core.urlresolvers import reverse
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext as _
from modoboa.lib import events
from django.contrib.sessions.models import Session
from .. import signals
register = template.Library()
@@ -19,8 +22,9 @@ register = template.Library()
@register.simple_tag
def core_menu(selection, user):
"""Build the top level menu."""
entries = (
events.raiseQueryEvent("AdminMenuDisplay", "top_menu", user))
entries = signals.extra_admin_menu_entries.send(
sender="core_menu", location="top_menu", user=user)
entries = reduce(lambda a, b: a + b, [entry[1] for entry in entries])
if user.is_superuser:
entries += [
{"name": "settings",
@@ -38,8 +42,10 @@ def core_menu(selection, user):
@register.simple_tag
def extensions_menu(selection, user):
menu = events.raiseQueryEvent("UserMenuDisplay", "top_menu", user)
return render_to_string('common/menulist.html', {
menu = signals.extra_user_menu_entries.send(
sender="core_menu", location="top_menu", user=user)
menu = reduce(lambda a, b: a + b, [entry[1] for entry in menu])
return render_to_string("common/menulist.html", {
"selection": selection, "entries": menu, "user": user
})
@@ -83,13 +89,16 @@ def user_menu(user, selection):
]}
]
entries[0]["menu"] += \
events.raiseQueryEvent("UserMenuDisplay", "options_menu", user) \
+ [{"name": "logout",
"url": reverse("core:logout"),
"label": _("Logout"),
"img": "fa fa-sign-out"}]
extra_entries = signals.extra_user_menu_entries.send(
sender="user_menu", location="options_menu", user=user)
extra_entries = reduce(
lambda a, b: a + b, [entry[1] for entry in extra_entries])
entries[0]["menu"] += (
extra_entries + [{"name": "logout",
"url": reverse("core:logout"),
"label": _("Logout"),
"img": "fa fa-sign-out"}]
)
return render_to_string("common/menulist.html", {
"selection": selection, "entries": entries, "user": user
})
@@ -114,9 +123,13 @@ def uprefs_menu(selection, user):
"url": "api/",
"label": _("API"),
})
entries += events.raiseQueryEvent("UserMenuDisplay", "uprefs_menu", user)
extra_entries = signals.extra_user_menu_entries.send(
sender="user_menu", location="uprefs_menu", user=user)
extra_entries = reduce(
lambda a, b: a + b, [entry[1] for entry in extra_entries])
entries += extra_entries
entries = sorted(entries, key=lambda e: e["label"])
return render_to_string('common/menu.html', {
return render_to_string("common/menu.html", {
"entries": entries,
"css": "nav nav-sidebar",
"selection": selection,
@@ -157,7 +170,6 @@ def visirule(field):
@register.simple_tag
def get_version():
import pkg_resources
return pkg_resources.get_distribution("modoboa").version
@@ -201,9 +213,6 @@ def connected_users(parser, token):
@register.simple_tag
def get_modoboa_logo():
import os
from django.conf import settings
try:
logo = settings.MODOBOA_CUSTOM_LOGO
except AttributeError:
@@ -215,9 +224,12 @@ def get_modoboa_logo():
@register.simple_tag
def load_optionalmenu(user):
menu = events.raiseQueryEvent("UserMenuDisplay", "top_menu_middle", user)
menu = signals.extra_user_menu_entries.send(
sender="user_menu", location="top_menu_middle", user=user)
menu = reduce(
lambda a, b: a + b, [entry[1] for entry in menu])
return template.loader.render_to_string(
'common/menulist.html',
"common/menulist.html",
{"entries": menu, "user": user}
)
+43
View File
@@ -1,5 +1,7 @@
"""Core views test cases."""
import json
from testfixtures import compare
from django import forms
@@ -11,6 +13,23 @@ from modoboa.parameters import tools as param_tools
from .. import factories
from .. import models
from .. import signals
def announcement(sender, location, **kwargs):
"""Simpler handler."""
return "This is an annoucement!"
class LoginTestCase(ModoTestCase):
"""Login page test cases."""
def test_announcements(self):
"""Check if announcements are displayed."""
signals.get_announcements.connect(announcement)
response = self.client.get(reverse("core:login"))
self.assertEqual(response.status_code, 200)
self.assertIn("This is an annoucement!", response.content)
class DashboardTestCase(ModoTestCase):
@@ -50,6 +69,13 @@ class DashboardTestCase(ModoTestCase):
self.assertEqual(response.status_code, 302)
self.assertTrue(response.url.endswith(reverse("core:dashboard")))
def test_top_notifications(self):
"""Check top notifications service."""
url = reverse("core:top_notifications_check")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(json.loads(response.content)), 1)
class SettingsTestCase(ModoTestCase):
"""Settings tests."""
@@ -131,6 +157,16 @@ class UserSettings(param_forms.UserParametersForm):
test = forms.CharField()
def extra_user_menu(sender, location, user, **kwargs):
"""Return extra menu entry."""
return [
{"name": "test_menu_entry",
"class": "ajaxnav",
"url": "toto/",
"label": "Test"}
]
class PreferencesTestCase(ModoTestCase):
"""Test user preferences."""
@@ -146,6 +182,13 @@ class PreferencesTestCase(ModoTestCase):
super(PreferencesTestCase, cls).tearDownClass()
del param_tools.registry._registry["user"]["core"]
def test_get_user_index(self):
"""Retrieve index page."""
signals.extra_user_menu_entries.connect(extra_user_menu)
url = reverse("core:user_index")
response = self.client.get(url)
self.assertContains(response, 'name="test_menu_entry"')
def test_get_preferences(self):
"""Test preferences display."""
url = reverse("core:user_preferences")
+10 -10
View File
@@ -1,6 +1,5 @@
"""
Views available to super administrators only.
"""
"""Views available to super administrators only."""
from django.contrib.auth.decorators import (
login_required, user_passes_test
)
@@ -10,13 +9,14 @@ from django.utils.translation import ugettext as _
from modoboa.core.models import Log
from modoboa.core.utils import check_for_updates
from modoboa.lib import events
from modoboa.lib.listing import get_sort_order, get_listing_page
from modoboa.lib.web_utils import (
_render_to_string, render_to_json_response
)
from modoboa.parameters import tools as param_tools
from .. import signals
@login_required
@user_passes_test(lambda u: u.is_superuser)
@@ -114,9 +114,9 @@ def logs_page(request, tplname="core/logs_page.html"):
@login_required
def check_top_notifications(request):
"""
AJAX service to check for new top notifications to display.
"""
return render_to_json_response(
events.raiseQueryEvent("TopNotifications", request, True)
)
"""AJAX service to check for new top notifications to display."""
notifications = signals.get_top_notifications.send(
sender="top_notifications", include_all=True)
notifications = reduce(
lambda a, b: a + b, [notif[1] for notif in notifications])
return render_to_json_response(notifications)
+14 -10
View File
@@ -3,18 +3,19 @@
import logging
from django.contrib.auth import authenticate, login, logout
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseRedirect
from django.utils import translation
from django.utils.translation import ugettext as _
from django.views.decorators.cache import never_cache
from django.contrib.auth import authenticate, login, logout
from modoboa.core.forms import LoginForm
from modoboa.lib import events
from modoboa.lib.web_utils import _render_to_string
from .base import find_nextlocation
from .. import signals
logger = logging.getLogger("modoboa.auth")
@@ -40,9 +41,10 @@ def dologin(request):
logger.info(
_("User '%s' successfully logged in") % user.username
)
events.raiseEvent("UserLogin", request,
form.cleaned_data["username"],
form.cleaned_data["password"])
signals.user_login.send(
sender="dologin",
username=form.cleaned_data["username"],
password=form.cleaned_data["password"])
return HttpResponseRedirect(find_nextlocation(request, user))
error = _(
"Your username and password didn't match. Please try again.")
@@ -59,20 +61,22 @@ def dologin(request):
nextlocation = request.GET.get("next", None)
httpcode = 200
announcements = signals.get_announcements.send(
sender="login", location="loginpage")
announcements = [announcement[1] for announcement in announcements]
return HttpResponse(_render_to_string(request, "registration/login.html", {
"form": form, "error": error, "next": nextlocation,
"annoucements": events.raiseQueryEvent("GetAnnouncement", "loginpage")
"annoucements": announcements
}), status=httpcode)
dologin = never_cache(dologin)
def dologout(request):
"""Logout the current user.
"""
"""Logout current user."""
if not request.user.is_anonymous():
events.raiseEvent("UserLogout", request)
signals.user_logout.send(sender="dologout")
logger = logging.getLogger("modoboa.auth")
logger.info(_("User '%s' logged out") % request.user.username)
logger.info(_("User {} logged out").format(request.user.username))
logout(request)
return HttpResponseRedirect(reverse("core:login"))
+6 -7
View File
@@ -8,7 +8,6 @@ from django.contrib.auth.decorators import login_required, user_passes_test
from rest_framework.authtoken.models import Token
from modoboa.lib import events
from modoboa.lib.cryptutils import encrypt
from modoboa.lib.web_utils import (
_render_to_string, render_to_json_response
@@ -16,22 +15,22 @@ from modoboa.lib.web_utils import (
from modoboa.parameters import tools as param_tools
from ..forms import ProfileForm, APIAccessForm
from .. import signals
@login_required
def index(request, tplname="core/user_index.html"):
extrajs = events.raiseQueryEvent("ExtraUprefsJS", request.user)
return render(request, tplname, {
"selection": "user",
"extrajs": "".join(extrajs)
})
"""Render user index page."""
return render(request, tplname, {"selection": "user"})
@login_required
def profile(request, tplname='core/user_profile.html'):
"""Profile detail/update view."""
update_password = True
if True in events.raiseQueryEvent("PasswordChange", request.user):
results = signals.allow_password_change.send(
sender="profile", user=request.user)
if True in [result[1] for result in results]:
update_password = False
if request.method == "POST":
-135
View File
@@ -1,135 +0,0 @@
# -*- coding: utf-8 -*-
"""
:mod:`EVENTS` --- simple EVENTS handling
----------------------------------------
This module provides a simple way of managing EVENTS between Modoboa
core application and additional components.
"""
EVENTS = []
CALLBACKS = {}
def declare(nevents):
"""Declare new events.
:param list nevents: a list of event names
"""
for evt in nevents:
if evt not in EVENTS:
EVENTS.append(evt)
def register(event, callback):
"""Register a plugin callback for a specific event
The event must be declared in this module.
:param event: the event to listen to
:param callback: a function to execute when the event is raised
"""
# if not event in EVENTS:
# print "Event %s not registered" % event
# return 0
if event not in CALLBACKS.keys():
CALLBACKS[event] = {}
fullname = "%s.%s" % (callback.__module__, callback.__name__)
if fullname not in CALLBACKS[event]:
CALLBACKS[event][fullname] = callback
return 1
class observe(object):
"""Event observing decorator.
Automatically register the decorated function to observe the given
event.
:param evtnames: a list of event names
"""
def __init__(self, *evtnames, **kwargs):
self.evtnames = evtnames
def __call__(self, f):
for evt in self.evtnames:
register(evt, f)
return f
def unregister(event, callback):
"""Unregister a callback for a specific event
:param event: the targeted event
:param callback: the callback to remove
"""
if event not in EVENTS:
return False
if event not in CALLBACKS:
return False
fullname = "%s.%s" % (callback.__module__, callback.__name__)
try:
del CALLBACKS[event][fullname]
except KeyError:
pass
def raiseEvent(event, *args, **kwargs):
"""Raise a specific event
Any additional keyword argument will be passed to registered
CALLBACKS.
:param event: the event to raise
"""
if event not in EVENTS or event not in CALLBACKS:
return 0
for callback in CALLBACKS[event].values():
callback(*args, **kwargs)
return 1
def raiseQueryEvent(event, *args, **kwargs):
"""Raise a specific event and wait for answers from CALLBACKS
Any additional keyword argument will be passed to registered
CALLBACKS. Callback answers are returned as a list.
:param event: the event to raise
"""
result = []
if event not in EVENTS or event not in CALLBACKS:
return result
for callback in CALLBACKS[event].values():
tmp = callback(*args, **kwargs)
if tmp is None:
# Callback is registered but associated extension is
# disabled.
continue
result += tmp
return result
def raiseDictEvent(event, *args):
"""Raise a specific event and return result as a dictionnary
Any additional keyword argument will be passed to registered
CALLBACKS. Callback answers must be dictionnaries.
:param event: the event to raise
:return: a dictionnary
"""
result = {}
if event not in EVENTS or event not in CALLBACKS:
return result
for callback in CALLBACKS[event].values():
tmp = callback(*args)
if tmp is None:
# Callback is registered but associated extension is
# disabled.
continue
for k, v in tmp.iteritems():
result[k] = v
return result
+9 -9
View File
@@ -5,31 +5,31 @@ from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from modoboa.core import constants as core_constants
from modoboa.core import signals as core_signals
from modoboa.core.models import ObjectAccess, User
from modoboa.lib import events
def get_account_roles(user, account=None):
"""Return the list of available account roles.
This function is used to create or modify an account. The returned
list can be extended by listening to the ``GetExtraRoles`` event.
This function is used to create or modify an account.
:param ``User`` user: connected user
:param ``User`` account: account beeing modified (None on creation)
:return: list of strings
"""
result = [core_constants.SIMPLEUSERS_ROLE]
filters = events.raiseQueryEvent(
"UserCanSetRole", user, "DomainAdmins", account
)
if user.has_perm("admin.add_domain") and \
(not filters or True in filters):
filters = core_signals.user_can_set_role.send(
sender="get_account_roles", user=user, role="DomainAdmins",
account=account)
condition = (
user.has_perm("admin.add_domain") and
(not filters or True in [flt[1] for flt in filters]))
if condition:
result += [core_constants.DOMAINADMINS_ROLE]
if user.is_superuser:
result += [
core_constants.RESELLERS_ROLE, core_constants.SUPERADMINS_ROLE]
result += events.raiseQueryEvent("GetExtraRoles", user, account)
return sorted(result, key=lambda role: role[1])
-11
View File
@@ -79,17 +79,6 @@ def render_fields_group(form, pattern):
))
@register.simple_tag
def render_extra_fields(form):
result = ''
for fname in form.extra_fields:
result += render_to_string("common/generic_field.html", {
'field': form[fname], 'help_display_mode': 'tooltip',
'label_width': 'col-sm-4'
})
return result
@register.simple_tag
def render_field_width(field):
"""."""
+12 -6
View File
@@ -8,7 +8,7 @@ from django.template import Template, Context
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext as _
from modoboa.lib import events
from modoboa.core import signals as core_signals
register = template.Library()
@@ -89,16 +89,22 @@ def extra_static_content(caller, st_type, user):
tpl = template.Template(
"{% for sc in static_content %}{{ sc|safe }}{% endfor %}"
)
static_content = core_signals.extra_static_content.send(
sender="extra_static_content",
caller=caller, st_type=st_type, user=user)
static_content = [result[1] for result in static_content]
return tpl.render(
template.Context({
'static_content': events.raiseQueryEvent(
"GetStaticContent", caller, st_type, user)
})
template.Context({"static_content": static_content})
)
@register.filter(name='localize_header_name')
def localize_header_name(headername):
""" Localizes the header names """
names = {"From": _("From"), "To": _("To"), "Date": _("Date"), "Subject": _("Subject")}
names = {
"From": _("From"),
"To": _("To"),
"Date": _("Date"),
"Subject": _("Subject")
}
return names.get(headername, headername)
-8
View File
@@ -3,21 +3,13 @@
from django.apps import AppConfig
from django.utils.translation import ugettext as _
EVENTS = [
"GetExtraLimitTemplates"
]
def load_limits_settings():
"""Load settings."""
from modoboa.lib import events
from modoboa.parameters import tools as param_tools
from .app_settings import ParametersForm
param_tools.registry.add("global", ParametersForm, _("Limits"))
events.declare(EVENTS)
from . import controls
from . import general_callbacks
class LimitsConfig(AppConfig):
-75
View File
@@ -1,75 +0,0 @@
# coding: utf-8
"""Event handlers that check if limits are reached."""
from modoboa.lib import events
from modoboa.lib.permissions import get_object_owner
from modoboa.parameters import tools as param_tools
from . import utils
def move_pool_resource(owner, user):
"""Move resource from one pool to another.
When an account doesn't need a pool anymore, we move the
associated resource to the pool of its owner.
"""
if not owner.is_superuser:
for name, ltpl in utils.get_user_limit_templates():
l = user.userobjectlimit_set.get(name=name)
if l.max_value < 0:
continue
ol = owner.userobjectlimit_set.get(name=name)
ol.max_value += l.max_value
ol.save()
@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
"""
condition = (
not param_tools.get_global_parameter("enable_admin_limits") or
role != "DomainAdmins")
if condition:
return [True]
lname = "domain_admins"
condition = (
user.is_superuser or
not user.userobjectlimit_set.get(name=lname).is_exceeded()
)
if condition:
return [True]
if account is not None and account.role == role:
return [True]
return [False]
@events.observe("AccountModified")
def on_account_modified(old, new):
"""Update limits when roles are updated"""
owner = get_object_owner(old)
if owner.role not in ["SuperAdmins", "Resellers"]:
# Domain admins can't change the role so nothing to check.
return
if new.role not in ["DomainAdmins", "Resellers"]:
move_pool_resource(owner, new)
@events.observe("AccountDeleted")
def on_account_deleted(account, byuser, **kwargs):
owner = get_object_owner(account)
if owner.role not in ["SuperAdmins", "Resellers"]:
return
move_pool_resource(owner, account)
-137
View File
@@ -1,137 +0,0 @@
"""General event callbacks."""
from django.template.loader import render_to_string
from django.utils.translation import ugettext as _
from modoboa.lib import events
from modoboa.parameters import tools as param_tools
from . import forms
from . import utils
@events.observe("ExtraAdminContent")
def display_pool_usage(user, target, currentpage):
condition = (
not param_tools.get_global_parameter("enable_admin_limits") or
target != "leftcol" or user.is_superuser)
if condition:
return []
if currentpage == "identities":
names = ["mailboxes", "mailbox_aliases"]
if user.has_perm("admin.add_domain"):
names += ["domain_admins"]
else:
exceptions = ["domain_admins", "mailboxes", "mailbox_aliases"]
names = [
name for name, tpl in utils.get_user_limit_templates()
if name not in exceptions and
("required_role" not in tpl or
tpl["required_role"] == user.role)
]
limits = user.userobjectlimit_set.filter(name__in=names, max_value__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 param_tools.get_global_parameter("enable_admin_limits"):
return []
if user.role not in ["SuperAdmins", "Resellers"]:
return []
condition = (
(account is not None and
account.role not in ["Resellers", "DomainAdmins"]) or
user == account
)
if condition:
return []
return [{
"id": "resources", "title": _("Resources"),
"cls": forms.ResourcePoolForm
}]
@events.observe("ExtraDomainForm")
def extra_domain_form(user, domain):
"""Include domain limits form."""
if not param_tools.get_global_parameter("enable_domain_limits"):
return []
if not user.has_perm("admin.change_domain"):
return []
return [{
"id": "resources", "title": _("Resources"),
"cls": forms.DomainLimitsForm
}]
@events.observe("FillDomainInstances")
def fill_domain_instances(user, domain, instances):
"""Set domain instance for resources form."""
if not param_tools.get_global_parameter("enable_domain_limits"):
return
if not user.has_perm("admin.change_domain"):
return
instances["resources"] = domain
@events.observe("CheckExtraAccountForm")
def check_form_access(account, form):
if form["id"] != "resources":
return [True]
if account.role not in ["Resellers", "DomainAdmins"]:
return [False]
return [True]
@events.observe("FillAccountInstances")
def fill_account_instances(user, account, instances):
condition = (
not param_tools.get_global_parameter("enable_admin_limits") or
(not user.is_superuser and user.role != "Resellers")
)
if condition:
return
if account.role not in ["Resellers", "DomainAdmins"]:
return
instances["resources"] = account
@events.observe("GetStaticContent")
def get_static_content(caller, st_type, user):
condition = (
not param_tools.get_global_parameter("enable_admin_limits") or
caller not in ["domains", "identities"] or
user.role in ["SuperAdmins", "SimpleUsers"]
)
if condition:
return []
if st_type == "css":
return ["""<style>
.resource {
padding: 10px 15px;
}
.resource .progress {
margin-bottom: 0px;
}
.resource .progress .bar {
color: #000000;
}
</style>
"""]
return ["""
<script type="text/javascript">
$(document).ready(function() {
$(".progress").tooltip();
});
</script>
"""]
+186
View File
@@ -2,16 +2,20 @@
from django.db.models import signals
from django.dispatch import receiver
from django.template.loader import render_to_string
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import ugettext as _
from modoboa.admin import models as admin_models
from modoboa.admin import signals as admin_signals
from modoboa.core import models as core_models
from modoboa.core import signals as core_signals
from modoboa.lib import signals as lib_signals
from modoboa.lib import permissions
from modoboa.parameters import tools as param_tools
from . import forms
from . import lib
from . import models
from . import utils
@@ -99,3 +103,185 @@ def display_admin_limits(sender, user, account, **kwargs):
account.userobjectlimit_set.select_related("content_type"))
}
}]
@receiver(admin_signals.extra_account_forms)
def extra_account_form(sender, user, account=None, **kwargs):
"""Add limits form."""
if not param_tools.get_global_parameter("enable_admin_limits"):
return []
if user.role not in ["SuperAdmins", "Resellers"]:
return []
condition = (
(account is not None and
account.role not in ["Resellers", "DomainAdmins"]) or
user == account
)
if condition:
return []
return [{
"id": "resources", "title": _("Resources"),
"cls": forms.ResourcePoolForm
}]
@receiver(admin_signals.check_extra_account_form)
def check_form_access(sender, account, form, **kwargs):
"""Check if form must be used for account."""
if form["id"] != "resources":
return True
if account.role not in ["Resellers", "DomainAdmins"]:
return False
return True
@receiver(admin_signals.get_account_form_instances)
def fill_account_instances(sender, user, account, **kwargs):
"""Set account instance for resources form."""
condition = (
not param_tools.get_global_parameter("enable_admin_limits") or
(not user.is_superuser and user.role != "Resellers") or
account.role not in ["Resellers", "DomainAdmins"]
)
if condition:
return {}
return {"resources": account}
@receiver(admin_signals.extra_domain_forms)
def extra_domain_form(sender, user, domain, **kwargs):
"""Include domain limits form."""
if not param_tools.get_global_parameter("enable_domain_limits"):
return []
if not user.has_perm("admin.change_domain"):
return []
return [{
"id": "resources", "title": _("Resources"),
"cls": forms.DomainLimitsForm
}]
@receiver(admin_signals.get_domain_form_instances)
def fill_domain_instances(sender, user, domain, **kwargs):
"""Set domain instance for resources form."""
condition = (
not param_tools.get_global_parameter("enable_domain_limits") or
not user.has_perm("admin.change_domain")
)
if condition:
return {}
return {"resources": domain}
@receiver(core_signals.account_deleted)
def move_resource(sender, user, **kwargs):
"""Move remaining resource to another user."""
owner = permissions.get_object_owner(user)
if owner.is_superuser or owner.role != "Resellers":
return
utils.move_pool_resource(owner, user)
@receiver(core_signals.user_can_set_role)
def user_can_set_role(sender, user, role, account=None, **kwargs):
"""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 str role: role to check
:param ``User`` account: account modified (None on creation)
"""
condition = (
not param_tools.get_global_parameter("enable_admin_limits") or
role != "DomainAdmins")
if condition:
return True
lname = "domain_admins"
condition = (
user.is_superuser or
not user.userobjectlimit_set.get(name=lname).is_exceeded()
)
if condition:
return True
if account is not None and account.role == role:
return True
return False
@receiver(core_signals.extra_static_content)
def get_static_content(sender, caller, st_type, user, **kwargs):
"""Add extra static content."""
condition = (
not param_tools.get_global_parameter("enable_admin_limits") or
caller not in ["domains", "identities"] or
user.role in ["SuperAdmins", "SimpleUsers"]
)
if condition:
return ""
if st_type == "css":
return """<style>
.resource {
padding: 10px 15px;
}
.resource .progress {
margin-bottom: 0px;
}
.resource .progress .bar {
color: #000000;
}
</style>
"""
return """
<script type="text/javascript">
$(document).ready(function() {
$(".progress").tooltip();
});
</script>
"""
@receiver(admin_signals.extra_admin_content)
def display_pool_usage(sender, user, location, currentpage, **kwargs):
"""Display current usage."""
condition = (
not param_tools.get_global_parameter("enable_admin_limits") or
location != "leftcol" or user.is_superuser)
if condition:
return []
if currentpage == "identities":
names = ["mailboxes", "mailbox_aliases"]
if user.has_perm("admin.add_domain"):
names += ["domain_admins"]
else:
exceptions = ["domain_admins", "mailboxes", "mailbox_aliases"]
names = [
name for name, tpl in utils.get_user_limit_templates()
if name not in exceptions and
("required_role" not in tpl or
tpl["required_role"] == user.role)
]
limits = user.userobjectlimit_set.filter(name__in=names, max_value__gt=0)
if len(limits) == 0:
return []
return [
render_to_string("limits/poolusage.html",
dict(limits=limits))
]
@receiver(core_signals.account_role_changed)
def move_pool_resource(sender, account, role, **kwargs):
"""Move remaining resource to owner if needed."""
owner = permissions.get_object_owner(account)
if not owner or owner.is_superuser or owner.role != "Resellers":
# Domain admins can't change the role so nothing to check.
return
if role not in ["DomainAdmins", "Resellers"]:
utils.move_pool_resource(owner, account)
+15 -1
View File
@@ -5,10 +5,24 @@ from . import constants
def get_user_limit_templates():
"""Return defined templates."""
# FIXME: change GetExtraLimitTemplates
return constants.DEFAULT_USER_LIMITS.items()
def get_domain_limit_templates():
"""Return defined templates."""
return constants.DEFAULT_DOMAIN_LIMITS.items()
def move_pool_resource(owner, user):
"""Move resource from one pool to another.
When an account doesn't need a pool anymore, we move the
associated resource to the pool of its owner.
"""
for name, ltpl in get_user_limit_templates():
l = user.userobjectlimit_set.get(name=name)
if l.max_value < 0:
continue
ol = owner.userobjectlimit_set.get(name=name)
ol.max_value += l.max_value
ol.save()
-1
View File
@@ -174,7 +174,6 @@ def get_global_parameter(name, app=None, **kwargs):
:param name: the parameter's name
:param app: the application owning the parameter
:return: the corresponding value
"""
if app is None:
app = guess_extension_name()
-13
View File
@@ -3,26 +3,13 @@
from django.apps import AppConfig
from django.utils.translation import ugettext as _
EVENTS = [
"RelayDomainCreated",
"RelayDomainDeleted",
"RelayDomainModified",
"RelayDomainAliasCreated",
"RelayDomainAliasDeleted",
"ExtraRelayDomainForm",
"FillRelayDomainInstances"
]
def load_relaydomains_settings():
"""Load application settings."""
from modoboa.parameters import tools as param_tools
from modoboa.lib import events
from .app_settings import AdminParametersForm
param_tools.registry.add("global", AdminParametersForm, _("Relay domains"))
events.declare(EVENTS)
from . import general_callbacks
class RelayDomainsConfig(AppConfig):
+12
View File
@@ -0,0 +1,12 @@
"""Relaydomains app constants."""
PERMISSIONS = {
"Resellers": [
("relaydomains", "relaydomain", "add_relaydomain"),
("relaydomains", "relaydomain", "change_relaydomain"),
("relaydomains", "relaydomain", "delete_relaydomain"),
("relaydomains", "service", "add_service"),
("relaydomains", "service", "change_service"),
("relaydomains", "service", "delete_service")
]
}
+3 -2
View File
@@ -17,7 +17,7 @@ class ServiceFactory(factory.DjangoModelFactory):
model = models.Service
django_get_or_create = ('name', )
name = 'dummy'
name = "dummy"
class RelayDomainFactory(PermissionFactory):
@@ -26,8 +26,9 @@ class RelayDomainFactory(PermissionFactory):
class Meta:
model = models.RelayDomain
django_get_or_create = ("domain", )
target_host = "external.host.tld"
domain = factory.SubFactory(DomainFactory)
target_host = 'external.host.tld'
verify_recipients = True
service = factory.SubFactory(ServiceFactory)
+3
View File
@@ -45,6 +45,9 @@ class RelayDomainFormGeneral(forms.ModelForm):
"""Custom save method."""
domain = kwargs.get("domain")
instance = super(RelayDomainFormGeneral, self).save(commit=False)
if instance.domain and instance.domain.type == "domain":
# Avoid new creation if domain type has been changed.
return None
if domain:
instance.domain = domain
instance.save()
-120
View File
@@ -1,120 +0,0 @@
"""General event callbacks."""
from django.conf import settings
from django.template import Template, Context
from django.utils.translation import ugettext_lazy, ugettext as _
from modoboa.lib import events
from .forms import RelayDomainWizardStep, RelayDomainFormGeneral
PERMISSIONS = {
"Resellers": [
("relaydomains", "relaydomain", "add_relaydomain"),
("relaydomains", "relaydomain", "change_relaydomain"),
("relaydomains", "relaydomain", "delete_relaydomain"),
("relaydomains", "service", "add_service"),
("relaydomains", "service", "change_service"),
("relaydomains", "service", "delete_service")
]
}
@events.observe("GetExtraRolePermissions")
def extra_permissions(rolename):
"""Return extra permissions for Resellers."""
return PERMISSIONS.get(rolename, [])
@events.observe('GetStaticContent')
def static_content(caller, st_type, user):
if caller != 'domains' or st_type != 'js':
return []
t = Template("""<script src="{{ STATIC_URL }}relaydomains/js/relay_domains.js" type="text/javascript"></script>
<script type="text/javascript">
var rdomain;
$(document).ready(function() {
rdomain = new RelayDomains({});
});
</script>
""")
return [t.render(Context({'STATIC_URL': settings.STATIC_URL}))]
@events.observe('ExtraDomainFilters')
def extra_domain_filters():
return ['srvfilter']
@events.observe("ExtraDomainTypes")
def extra_domain_types():
"""Declare the relay domain type."""
return [("relaydomain", _("Relay domain"))]
@events.observe("ExtraDomainWizardSteps")
def extra_wizard_step():
"""Return a step to configure the relay settings."""
return [RelayDomainWizardStep(
"relay", RelayDomainFormGeneral, _("Relay domain"),
"relaydomains/relaydomain_form.html"
)]
@events.observe("ExtraDomainForm")
def extra_domain_form(user, domain):
"""Return relay settings for domain edition."""
if not user.has_perm("relaydomains.change_relaydomain"):
return []
if domain.type != "relaydomain":
return []
return [{
"id": "relaydomain", "title": _("Relay settings"),
"cls": RelayDomainFormGeneral,
"formtpl": "relaydomains/relaydomain_form.html"
}]
@events.observe("FillDomainInstances")
def fill_domain_instances(user, domain, instances):
"""Fill the relaydomain form with the right instance."""
if not user.has_perm("relaydomains.change_relaydomain"):
return
if domain.type != "relaydomain":
return
instances["relaydomain"] = domain.relaydomain
@events.observe("ExtraDomainQsetFilters")
def extra_domain_entries(domfilter, extrafilters):
"""Return extra queryset filters."""
if domfilter is not None and domfilter and domfilter != 'relaydomain':
return {}
if "srvfilter" in extrafilters and extrafilters["srvfilter"]:
return {"relaydomain__service__name": extrafilters["srvfilter"]}
return {}
@events.observe("GetTagsForDomain")
def get_tags_for_domain(domain):
"""Return relay domain custom tags."""
if domain.type != "relaydomain":
return []
return domain.relaydomain.tags
@events.observe('ExtraDomainImportHelp')
def extra_domain_import_help():
return [ugettext_lazy("""
<li><em>relaydomain; name; target host; service; enabled; verify recipients</em></li>
""")]
@events.observe('ImportObject')
def get_import_func(objtype):
from .lib import import_relaydomain
if objtype == 'relaydomain':
return [import_relaydomain]
return []
+106 -3
View File
@@ -1,18 +1,24 @@
"""Django signal handlers for relaydomains."""
from django.conf import settings
from django.db.models import signals
from django.dispatch import receiver
from django.template import Template, Context
from django.utils.translation import ugettext as _
from modoboa.admin import models as admin_models
from modoboa.admin.signals import use_external_recipients
from modoboa.admin import signals as admin_signals
from modoboa.core import signals as core_signals
from modoboa.lib.email_utils import split_mailbox
from . import constants
from . import forms
from . import lib
from . import models
from . import postfix_maps
@receiver(use_external_recipients)
@receiver(admin_signals.use_external_recipients)
def check_relaydomain_alias(sender, **kwargs):
"""Allow the creation of an alias on a relaydomain."""
recipient = kwargs.get("recipients")
@@ -35,7 +41,7 @@ def clean_domain(sender, instance, **kwargs):
return
has_relaydom = hasattr(instance, "relaydomain")
if instance.type == "domain" and has_relaydom:
models.RelayDomain.objects.filter(domain=instance).delete()
instance.relaydomain.delete()
elif instance.type == "relaydomain" and not has_relaydom:
# Make sure to create a RelayDomain instance since we can't do it
# at form level...
@@ -52,3 +58,100 @@ def register_postfix_maps(sender, **kwargs):
postfix_maps.SplitedDomainsTransportMap,
postfix_maps.RelayRecipientVerification
]
@receiver(core_signals.extra_role_permissions)
def extra_role_permissions(sender, role, **kwargs):
"""Add permissions to the Resellers group."""
return constants.PERMISSIONS.get(role, [])
@receiver(core_signals.extra_static_content)
def static_content(sender, caller, st_type, user, **kwargs):
"""Add extra static content."""
if caller != "domains" or st_type != "js":
return ""
t = Template("""<script src="{{ STATIC_URL }}relaydomains/js/relay_domains.js" type="text/javascript"></script>
<script type="text/javascript">
var rdomain;
$(document).ready(function() {
rdomain = new RelayDomains({});
});
</script>
""")
return t.render(Context({"STATIC_URL": settings.STATIC_URL}))
@receiver(admin_signals.extra_domain_filters)
def extra_domain_filters(sender, **kwargs):
"""Return relaydomain filters."""
return ["srvfilter"]
@receiver(admin_signals.extra_domain_forms)
def extra_domain_form(sender, user, **kwargs):
"""Return relay settings for domain edition."""
if not user.has_perm("relaydomains.change_relaydomain"):
return []
domain = kwargs.get("domain")
if not domain or domain.type != "relaydomain":
return []
return [{
"id": "relaydomain", "title": _("Relay settings"),
"cls": forms.RelayDomainFormGeneral,
"formtpl": "relaydomains/relaydomain_form.html"
}]
@receiver(admin_signals.get_domain_form_instances)
def fill_domain_instances(sender, user, domain, **kwargs):
"""Fill the relaydomain form with the right instance."""
condition = (
not user.has_perm("relaydomains.change_relaydomain") or
domain.type != "relaydomain"
)
if condition:
return {}
return {"relaydomain": domain.relaydomain}
@receiver(admin_signals.extra_domain_qset_filters)
def extra_domain_entries(sender, domfilter, extrafilters, **kwargs):
"""Return extra queryset filters."""
if domfilter is not None and domfilter and domfilter != "relaydomain":
return {}
if "srvfilter" in extrafilters and extrafilters["srvfilter"]:
return {"relaydomain__service__name": extrafilters["srvfilter"]}
return {}
@receiver(admin_signals.extra_domain_types)
def extra_domain_types(sender, **kwargs):
"""Declare the relay domain type."""
return [("relaydomain", _("Relay domain"))]
@receiver(admin_signals.extra_domain_wizard_steps)
def extra_wizard_step(sender, **kwargs):
"""Return a step to configure the relay settings."""
return [forms.RelayDomainWizardStep(
"relay", forms.RelayDomainFormGeneral, _("Relay domain"),
"relaydomains/relaydomain_form.html"
)]
@receiver(admin_signals.get_domain_tags)
def get_tags_for_domain(sender, domain, **kwargs):
"""Return relay domain custom tags."""
if domain.type != "relaydomain":
return []
return domain.relaydomain.tags
@receiver(admin_signals.import_object)
def get_import_func(sender, objtype, **kwargs):
"""Return function used to import objtype."""
if objtype == "relaydomain":
return lib.import_relaydomain
return None
+1 -1
View File
@@ -113,7 +113,7 @@ class RelayDomain(admin_models.AdminObject):
if len(row) != 6:
raise BadRequest(_("Invalid line"))
self.domain = admin_models.Domain(
name=row[1].strip(), type="relaydomain",
name=row[1].strip(), type="relaydomain", quota=0,
enabled=(row[4].strip() in ["True", "1", "yes", "y"])
)
self.domain.save(creator=user)
+49 -17
View File
@@ -1,5 +1,8 @@
"""modoboa-admin-relaydomains unit tests."""
import json
from django.core.files.base import ContentFile
from django.core.urlresolvers import reverse
from django.test import TestCase
@@ -11,13 +14,13 @@ from modoboa.lib.test_utils import MapFilesTestCaseMixin
from modoboa.limits import utils as limits_utils
from .factories import RelayDomainFactory
from .models import RelayDomain, Service
from . import models
class Operations(object):
def _create_relay_domain(self, name, status=200):
srv, created = Service.objects.get_or_create(name='dummy')
srv, created = models.Service.objects.get_or_create(name='dummy')
values = {
"name": name, "create_dom_admin": False, "type": "relaydomain",
"target_host": "external.host.tld", "service": srv.id,
@@ -29,7 +32,7 @@ class Operations(object):
)
def _relay_domain_alias_operation(self, optype, rdomain, name, status=200):
rdom = RelayDomain.objects.get(domain__name=rdomain)
rdom = models.RelayDomain.objects.get(domain__name=rdomain)
values = {
"name": rdom.domain.name, "target_host": rdom.target_host,
"type": "relaydomain", "service": rdom.service.id
@@ -61,15 +64,26 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
"""Create test data."""
super(RelayDomainsTestCase, cls).setUpTestData()
admin_factories.populate_database()
cls.rdom = RelayDomainFactory(domain__name='relaydomain.tld')
cls.rdom = RelayDomainFactory(domain__name="relaydomain.tld")
cls.rdom.domain.type = "relaydomain"
cls.rdom.domain.save()
admin_factories.DomainAliasFactory(
name='relaydomainalias.tld', target=cls.rdom.domain)
name="relaydomainalias.tld", target=cls.rdom.domain)
admin_factories.MailboxFactory(
domain=cls.rdom.domain, address="local",
user__username="local@relaydomain.tld",
user__groups=("SimpleUsers", )
)
def test_domain_list_view(self):
"""Make sure relaydomain is listed."""
url = reverse("admin:_domain_list")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content)
self.assertIn("relaydomain.tld", content["rows"])
self.assertIn("Relay Domain", content["rows"])
def test_create_relaydomain(self):
"""Test the creation of a relay domain.
@@ -79,7 +93,7 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
FIXME: add a check for domain alias.
"""
self._create_relay_domain('relaydomain1.tld')
rdom = RelayDomain.objects.get(domain__name='relaydomain1.tld')
rdom = models.RelayDomain.objects.get(domain__name='relaydomain1.tld')
self.assertEqual(rdom.target_host, 'external.host.tld')
self.assertEqual(rdom.service.name, 'dummy')
self.assertEqual(rdom.domain.enabled, True)
@@ -126,16 +140,16 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
Rename 'relaydomain.tld' domain to 'relaydomain.org'
"""
values = {
'name': "relaydomain.org", "target_host": self.rdom.target_host,
"name": "relaydomain.org", "target_host": self.rdom.target_host,
"type": "relaydomain", "enabled": True,
"service": self.rdom.service.id
}
self.ajax_post(
reverse('admin:domain_change',
reverse("admin:domain_change",
args=[self.rdom.domain.id]),
values
)
RelayDomain.objects.get(domain__name='relaydomain.org')
models.RelayDomain.objects.get(domain__name='relaydomain.org')
def test_relaydomain_domain_switch(self):
"""Check domain <-> relaydomain transitions."""
@@ -144,12 +158,13 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
"name": "relaydomain.tld",
"type": "domain",
"enabled": True,
"target_host": self.rdom.target_host,
"service": self.rdom.service.pk
}
self.ajax_post(
reverse('admin:domain_change', args=[domain.pk]), values)
reverse("admin:domain_change", args=[domain.pk]), values)
self.assertFalse(
RelayDomain.objects.filter(
domain__name="relaydomain.tld").exists())
models.RelayDomain.objects.filter(domain=domain).exists())
self.assertEqual(
admin_models.Domain.objects.get(name="relaydomain.tld").type,
"domain")
@@ -164,7 +179,7 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
admin_models.Domain.objects.get(name="relaydomain.tld").type,
"relaydomain")
self.assertTrue(
RelayDomain.objects.filter(
models.RelayDomain.objects.filter(
domain__name="relaydomain.tld").exists())
def test_edit_relaydomainalias(self):
@@ -193,16 +208,16 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
args=[self.rdom.domain.id]),
{}
)
with self.assertRaises(RelayDomain.DoesNotExist):
RelayDomain.objects.get(domain__name='relaydomain.tld')
with self.assertRaises(models.RelayDomain.DoesNotExist):
models.RelayDomain.objects.get(domain__name='relaydomain.tld')
def test_delete_relaydomainalias(self):
"""Test the removal of a relay domain alias."""
self._relay_domain_alias_operation(
'del', self.rdom.domain.name, 'relaydomainalias.tld'
)
with self.assertRaises(RelayDomain.DoesNotExist):
RelayDomain.objects.get(domain__name='relaydomainalias.tld')
with self.assertRaises(models.RelayDomain.DoesNotExist):
models.RelayDomain.objects.get(domain__name='relaydomainalias.tld')
def test_alias_on_relaydomain(self):
"""Create an alias on a relay domain."""
@@ -226,6 +241,23 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
address="alias2@relaydomain.tld").exists())
class ImportTestCase(ModoTestCase):
"""Test import."""
def test_webui_import(self):
"""Check if import from webui works."""
f = ContentFile("relaydomain;relay.com;127.0.0.1;relay;True;True",
name="domains.csv")
self.client.post(
reverse("admin:domain_import"), {
"sourcefile": f
}
)
self.assertTrue(
admin_models.Domain.objects.filter(
name="relay.com", type="relaydomain").exists())
class LimitsTestCase(ModoTestCase, Operations):
@classmethod
+9 -5
View File
@@ -2,7 +2,7 @@ from django.conf import settings
from django.conf.urls import patterns, include, url
from modoboa.core.extensions import exts_pool
from modoboa.lib import events
from modoboa.core import signals as core_signals
urlpatterns = patterns(
@@ -22,10 +22,14 @@ urlpatterns += patterns(
*exts_pool.load_all()
)
urlpatterns += patterns(
'',
*events.raiseQueryEvent("ExtraUprefsRoutes")
)
extra_routes = core_signals.extra_uprefs_routes.send(sender="urls")
if extra_routes:
extra_routes = reduce(
lambda a, b: a + b, [route[1] for route in extra_routes])
urlpatterns += patterns(
'',
*extra_routes
)
# API urls
urlpatterns += patterns(