From 99888a861b31334d4b9f29f3ee18a33169e8a6e1 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 12 Jan 2012 16:49:09 +0100 Subject: [PATCH] wip: limits and new reseller profile We add an intermediate role : Reseller. It is somewhere between a super administrator and a domain administrator. A reseller can create objects (a limited or not amount) and then define permissions on those objects: * Create a domain admins, * Assign limits to those admins, * ... see #105 --- admin/lib.py | 41 +++++++- admin/views.py | 28 ------ extensions/limits/__init__.py | 0 extensions/limits/fixtures/initial_data.json | 1 + extensions/limits/forms.py | 50 ++++++++++ extensions/limits/main.py | 47 +++++++++ extensions/limits/models.py | 21 ++++ extensions/limits/static/js/resellers.js | 42 ++++++++ extensions/limits/tables.py | 23 +++++ extensions/limits/templates/limits/pool.html | 16 +++ .../limits/templates/limits/reseller.html | 16 +++ .../limits/templates/limits/resellers.html | 43 ++++++++ extensions/limits/templatetags/__init__.py | 0 extensions/limits/templatetags/limits_tags.py | 44 +++++++++ extensions/limits/urls.py | 10 ++ extensions/limits/views.py | 93 ++++++++++++++++++ static/css/box.css | 1 + static/js/utils.js | 6 +- static/pics/resellers.png | Bin 0 -> 734 bytes templates/admin/listing.html | 2 +- 20 files changed, 453 insertions(+), 31 deletions(-) create mode 100644 extensions/limits/__init__.py create mode 100644 extensions/limits/fixtures/initial_data.json create mode 100644 extensions/limits/forms.py create mode 100644 extensions/limits/main.py create mode 100644 extensions/limits/models.py create mode 100644 extensions/limits/static/js/resellers.js create mode 100644 extensions/limits/tables.py create mode 100644 extensions/limits/templates/limits/pool.html create mode 100644 extensions/limits/templates/limits/reseller.html create mode 100644 extensions/limits/templates/limits/resellers.html create mode 100644 extensions/limits/templatetags/__init__.py create mode 100644 extensions/limits/templatetags/limits_tags.py create mode 100644 extensions/limits/urls.py create mode 100644 extensions/limits/views.py create mode 100644 static/pics/resellers.png diff --git a/admin/lib.py b/admin/lib.py index 84d838dd6..c1cf1cc9d 100644 --- a/admin/lib.py +++ b/admin/lib.py @@ -2,8 +2,10 @@ from django.http import HttpResponse, HttpResponseRedirect from django.contrib.auth.models import User, Group from django.utils.translation import ugettext as _ +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from models import * -from modoboa.lib.webutils import _render_error +from modoboa.lib.webutils import _render_error, _render +from modoboa.admin.tables import * def is_not_localadmin(errortpl="error"): def dec(f): @@ -64,3 +66,40 @@ def good_domain(f): login_url = settings.LOGIN_URL return HttpResponseRedirect("%s?next=%s" % (login_url, path)) return dec + +def render_listing(request, objtype, tplname="admin/listing.html", + **kwargs): + """Common function to render a listing + + All listing pages available into the admin application use the + same layout, rendered by this function. + + :param request: a ``Request`` object + :param objtype: the object type's name (lowercase) + :param tplname: the template used to render the HTML + """ + tblclass = "%sTable" % objtype.capitalize() + if not globals().has_key(tblclass): + raise AdminError(_("Unknown object type")) + tblclass = globals()[tblclass] + + if request.GET.has_key("domid"): + kwargs["domid"] = request.GET["domid"] + else: + kwargs["domid"] = "" + kwargs["selection"] = objtype + paginator = Paginator(kwargs["objects"], + int(parameters.get_admin("ITEMS_PER_PAGE"))) + try: + page = request.GET.get("page", "1") + except ValueError: + page = 1 + try: + kwargs["objects"] = paginator.page(page) + except (EmptyPage, PageNotAnInteger): + kwargs["objects"] = paginator.page(paginator.num_pages) + kwargs["last_page"] = paginator.num_pages + kwargs["total"] = paginator.count + kwargs["table"] = tblclass(request, kwargs["objects"].object_list) + + return _render(request, tplname, kwargs) diff --git a/admin/views.py b/admin/views.py index 7c7b56472..f9e80b68d 100644 --- a/admin/views.py +++ b/admin/views.py @@ -9,7 +9,6 @@ from django.db.models import Q from django.contrib.auth.models import User, Group from django.contrib import messages from django.db import transaction, IntegrityError -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from lib import * from modoboa import admin, userprefs @@ -24,33 +23,6 @@ from modoboa.lib.emailutils import split_mailbox from modoboa.lib.models import Parameter import copy -def render_listing(request, objtype, tplname="admin/listing.html", - **kwargs): - tblclass = "%sTable" % objtype.capitalize() - if not globals().has_key(tblclass): - raise AdminError(_("Unknown object type")) - tblclass = globals()[tblclass] - if request.GET.has_key("domid"): - kwargs["domid"] = request.GET["domid"] - else: - kwargs["domid"] = "" - kwargs["selection"] = objtype - paginator = Paginator(kwargs["objects"], - int(parameters.get_admin("ITEMS_PER_PAGE"))) - try: - page = request.GET.get("page", "1") - except ValueError: - page = 1 - try: - kwargs["objects"] = paginator.page(page) - except (EmptyPage, PageNotAnInteger): - kwargs["objects"] = paginator.page(paginator.num_pages) - kwargs["last_page"] = paginator.num_pages - kwargs["total"] = paginator.count - kwargs["table"] = tblclass(request, kwargs["objects"].object_list) - - return _render(request, tplname, kwargs) - @login_required def domains(request): if not request.user.has_perm("admin.view_domains"): diff --git a/extensions/limits/__init__.py b/extensions/limits/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/limits/fixtures/initial_data.json b/extensions/limits/fixtures/initial_data.json new file mode 100644 index 000000000..6bd24036c --- /dev/null +++ b/extensions/limits/fixtures/initial_data.json @@ -0,0 +1 @@ +[{"pk": 2, "model": "auth.group", "fields": {"name": "Resellers", "permissions": [["add_domain", "admin", "domain"], ["change_domain", "admin", "domain"], ["delete_domain", "admin", "domain"], ["view_domains", "admin", "domain"], ["add_mailbox", "admin", "mailbox"], ["change_mailbox", "admin", "mailbox"], ["delete_mailbox", "admin", "mailbox"], ["add_alias", "admin", "alias"], ["change_alias", "admin", "alias"], ["delete_alias", "admin", "alias"], ["view_domain", "admin", "domain"], ["view_mailboxes", "admin", "mailbox"], ["view_aliases", "admin", "alias"], ["view_domaliases", "admin", "domainalias"], ["add_domainalias", "admin", "domainalias"], ["change_domainalias", "admin", "domainalias"], ["delete_domainalias", "admin", "domainalias"]]}}] diff --git a/extensions/limits/forms.py b/extensions/limits/forms.py new file mode 100644 index 000000000..efabfc74e --- /dev/null +++ b/extensions/limits/forms.py @@ -0,0 +1,50 @@ +# coding: utf-8 + +from django import forms +from django.utils.translation import ugettext_noop as _ +from django.contrib.auth.models import User, Group +from models import * + +class ResellerForm(forms.ModelForm): + class Meta: + model = User + fields = ("username", "first_name", "last_name", "email") + + def save(self, commit=True): + user = super(ResellerForm, self).save(commit=False) + if self.cleaned_data.has_key("password1"): + user.set_password(self.cleaned_data["password1"]) + if commit: + user.save() + grp = Group.objects.get(name="Resellers") + if not grp in user.groups.all(): + user.groups.add(grp) + user.save() + try: + pool = LimitsPool.objects.get(user=user) + except LimitsPool.DoesNotExist: + pool = LimitsPool() + pool.user = user + pool.save() + return user + +class ResellerWithPasswordForm(ResellerForm): + password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput) + password2 = forms.CharField(label=_("Confirmation"), + widget=forms.PasswordInput, + help_text = _("Enter the same password as above, for verification.")) + + def clean_password2(self): + password1 = self.cleaned_data.get("password1", "") + password2 = self.cleaned_data["password2"] + if password1 != password2: + raise forms.ValidationError(_("The two password fields didn't match.")) + return password2 + +class LimitsPoolForm(forms.ModelForm): + class Meta: + model = LimitsPool + fields = ("maxdomains", "maxdomaliases", "maxmboxes", "maxmbaliases") + + + diff --git a/extensions/limits/main.py b/extensions/limits/main.py new file mode 100644 index 000000000..b55a77923 --- /dev/null +++ b/extensions/limits/main.py @@ -0,0 +1,47 @@ +# coding: utf-8 + +""" +The *limits* extension +---------------------- + + +""" +from django.utils.translation import ugettext_noop as _, ugettext +from django.core.urlresolvers import reverse +from django.conf.urls.defaults import include +from modoboa.lib import events, parameters +from modoboa.lib.webutils import static_url + +baseurl = "limits" + +def init(): + events.register("AdminMenuDisplay", menu) + +def destroy(): + events.unregister("AdminMenuDisplay", menu) + +def infos(): + return { + "name" : "Limits", + "version" : "1.0", + "description" : ugettext("Limits for objects creation"), + "url" : baseurl + } + +def menu(**kwargs): + import views + + if kwargs["target"] != "top_menu": + return [] + if not kwargs["user"].is_superuser: + return [] + return [ + {"name" : "resellers", + "label" : _("Resellers"), + "url" : reverse(views.resellers), + "img" : static_url("pics/resellers.png")} + ] + +def urls(prefix): + return (r'^%s%s/' % (prefix, baseurl), + include('modoboa.extensions.limits.urls')) diff --git a/extensions/limits/models.py b/extensions/limits/models.py new file mode 100644 index 000000000..d00ebcf0f --- /dev/null +++ b/extensions/limits/models.py @@ -0,0 +1,21 @@ +# coding: utf-8 + +from django.db import models +from django.contrib.auth.models import User +from django.utils.translation import ugettext as _, ugettext_noop + +class LimitsPool(models.Model): + user = models.OneToOneField(User) + + maxdomains = models.IntegerField(ugettext_noop("Max domains"), + default=0, + help_text=ugettext_noop("Maximum number of domains that can be created by this user")) + maxdomaliases = models.IntegerField(ugettext_noop("Max domain aliases"), + default=0, + help_text=ugettext_noop("Maximum number of domain aliases that can be created by this user")) + maxmboxes = models.IntegerField(ugettext_noop("Max mailboxes"), + default=0, + help_text=ugettext_noop("Maximum number of mailboxes that can be created by this user")) + maxmbaliases = models.IntegerField(ugettext_noop("Max mailbox aliases"), + default=0, + help_text=ugettext_noop("Maximum number of mailbox aliases that can be created by this user")) diff --git a/extensions/limits/static/js/resellers.js b/extensions/limits/static/js/resellers.js new file mode 100644 index 000000000..6f68e7f2a --- /dev/null +++ b/extensions/limits/static/js/resellers.js @@ -0,0 +1,42 @@ +var Resellers = new Class({ + initialize: function() { + this.resellers_table = new HtmlTable($("resellers_table"), { + selectable: true + }); + SqueezeBox.assign($("content").getElements('a[class=boxed]'), { + parse: 'rel' + }); + + $$("a[name=remove]").addEvent("click", this.remove_clickcb.bind(this)); + }, + + remove_clickcb: function(evt) { + evt.stop(); + if (this.resellers_table._selectedRows.length == 0) { + return; + } + + if (!confirm(gettext("Remove this selection?"))) { + return; + } + + var selection = ""; + + this.resellers_table._selectedRows.each(function(item) { + if (selection != "") { + selection += ","; + } + selection += item.get("id"); + }); + + new Request.JSON.mdb({ + url: evt.target.get("href"), + method: "GET", + onSuccess: function(res) { + if (res.status == "ok") { + window.location.reload(); + } + } + }).send("selection=" + selection); + } +}); \ No newline at end of file diff --git a/extensions/limits/tables.py b/extensions/limits/tables.py new file mode 100644 index 000000000..0ed3fd8b1 --- /dev/null +++ b/extensions/limits/tables.py @@ -0,0 +1,23 @@ +# coding: utf-8 + +from django.utils.translation import ugettext as _, ugettext_noop +from modoboa.lib import tables +from templatetags.limits_tags import reseller_actions + +class ResellersTable(tables.Table): + tableid = "resellers_table" + idkey = "id" + + fname = tables.Column("first_name", label=ugettext_noop("First name")) + lname = tables.Column("last_name", label=ugettext_noop("Last name")) + username = tables.Column("username", label=ugettext_noop("Username")) + created = tables.Column("date_joined", label=ugettext_noop("Created on")) + actions = tables.ActionColumn("actions", label=ugettext_noop("Actions"), + width="50px", + align="center", defvalue=reseller_actions) + + cols_order = ["username", "fname", "lname", "created", "actions"] + + def __init__(self, request, users): + super(ResellersTable, self).__init__(request) + self.populate(self._rows_from_model(users)) diff --git a/extensions/limits/templates/limits/pool.html b/extensions/limits/templates/limits/pool.html new file mode 100644 index 000000000..f43491e1c --- /dev/null +++ b/extensions/limits/templates/limits/pool.html @@ -0,0 +1,16 @@ +{% extends "layout_simple.html" %} +{% load i18n %} +{% block html_head %} + +{% endblock %} +{% block content %} +

{% trans "Limits pool for" %} {{ reseller.username }}

+{% if error %}
{{ error }}
{% endif %} +
+ {% include "common/genericform.html" %} +
+
+ +
+
+{% endblock %} diff --git a/extensions/limits/templates/limits/reseller.html b/extensions/limits/templates/limits/reseller.html new file mode 100644 index 000000000..2655cfb32 --- /dev/null +++ b/extensions/limits/templates/limits/reseller.html @@ -0,0 +1,16 @@ +{% extends "layout_simple.html" %} +{% load i18n %} +{% block html_head %} + +{% endblock %} +{% block content %} +

{% trans "New reseller" %}

+{% if error %}
{{ error }}
{% endif %} +
+ {% include "common/genericform.html" %} +
+
+ +
+
+{% endblock %} diff --git a/extensions/limits/templates/limits/resellers.html b/extensions/limits/templates/limits/resellers.html new file mode 100644 index 000000000..0bd6594b0 --- /dev/null +++ b/extensions/limits/templates/limits/resellers.html @@ -0,0 +1,43 @@ +{% extends "nlayout.html" %} +{% load i18n limits_tags libextras %} +{% block html_head %} + + +{% endblock %} +{% block middletitle %}{% trans "Available resellers" %}{% endblock %} +{% block menu %}{% resellers_menu user %} {% endblock %} +{% block content %} +{% if messages %} +{% display_messages messages %} +{% endif %} +
+ {{ table|safe }} +
+{% endblock %} +{% block footer %} + +{% endblock %} diff --git a/extensions/limits/templatetags/__init__.py b/extensions/limits/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/limits/templatetags/limits_tags.py b/extensions/limits/templatetags/limits_tags.py new file mode 100644 index 000000000..739eb115b --- /dev/null +++ b/extensions/limits/templatetags/limits_tags.py @@ -0,0 +1,44 @@ +# coding: utf-8 + +from django import template +from django.utils.translation import ugettext as _ +from django.template.loader import render_to_string +from django.core.urlresolvers import reverse +from modoboa.lib.webutils import static_url, render_actions +from modoboa.extensions import limits + +register = template.Library() + +@register.simple_tag +def resellers_menu(user): + entries = [{"name" : "new", + "label" : _("New"), + "img" : static_url("pics/add.png"), + "class" : "boxed", + "rel" : "{handler:'iframe',size:{x:350,y:300}}", + "url" : reverse(limits.views.new_reseller)}, + {"name" : "remove", + "label" : _("Remove"), + "img" : static_url("pics/remove.png"), + "url" : reverse(limits.views.delete_resellers) + }] + return render_to_string("common/menulist.html", + {"entries" : entries, "user" : user}) + +@register.simple_tag +def reseller_actions(user, resid): + actions = [ + {"name" : "editreseller", + "url" : reverse(limits.views.edit_reseller, args=[resid]), + "img" : static_url("pics/edit.png"), + "title" : _("Edit reseller"), + "class" : "boxed", + "rel" : "{handler:'iframe',size:{x:350,y:300}}"}, + {"name" : "editlimits", + "url" : reverse(limits.views.edit_limits_pool, args=[resid]), + "img" : static_url("pics/settings.png"), + "title" : _("Edit limits allocated to this reseller"), + "class" : "boxed", + "rel" : "{handler:'iframe',size:{x:330,y:280}}"}, + ] + return render_actions(actions) diff --git a/extensions/limits/urls.py b/extensions/limits/urls.py new file mode 100644 index 000000000..cadf99fed --- /dev/null +++ b/extensions/limits/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls.defaults import * + +urlpatterns = patterns( + 'modoboa.extensions.limits.views', + (r'^$', 'resellers'), + (r'reseller/new/$', 'new_reseller'), + (r'reseller/edit/(?P\d+)/$', 'edit_reseller'), + (r'reseller/delete/$', 'delete_resellers'), + (r'pool/edit/(?P\d+)/$', 'edit_limits_pool'), + ) diff --git a/extensions/limits/views.py b/extensions/limits/views.py new file mode 100644 index 000000000..d40a4dcc3 --- /dev/null +++ b/extensions/limits/views.py @@ -0,0 +1,93 @@ +# coding: utf-8 +from django.utils.translation import ugettext as _, ungettext +from django.db import IntegrityError +from django.contrib.auth.models import * +from django.contrib import messages +from django.contrib.auth.decorators \ + import login_required, user_passes_test +from django.core.urlresolvers import reverse +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from tables import * +from forms import * +from modoboa.lib import parameters +from modoboa.lib.webutils import _render, ajax_response + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def resellers(request, tplname="limits/resellers.html"): + users = User.objects.filter(groups__name="Resellers") + paginator = Paginator(users, + int(parameters.get_admin("ITEMS_PER_PAGE", app="admin"))) + page = int(request.GET.get("page", "1")) + try: + subset = paginator.page(page) + except (EmptyPage, PageNotAnInteger): + subset = paginator.page(paginator.num_pages) + + ctx = dict(table=ResellersTable(request, subset.object_list), + page=subset, + paginator=paginator) + return _render(request, tplname, ctx) + +def validate_reseller(request, form, action, tplname="limits/reseller.html"): + error = None + if form.is_valid(): + try: + form.save() + except IntegrityError: + error = _("User with this Username already exists") + else: + msg = _("Reseller created") if action == "create" \ + else _("Reseller updated") + messages.info(request, msg, fail_silently=True) + return ajax_response(request, url=reverse(resellers)) + submit_label = _("Create") if action == "create" else _("Update") + ctx = dict(form=form, error=error, submit_label=submit_label) + return ajax_response(request, status="ko", template=tplname, **ctx) + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def new_reseller(request, tplname="limits/reseller.html"): + if request.method == "POST": + form = ResellerWithPasswordForm(request.POST) + return validate_reseller(request, form, "create") + + form = ResellerWithPasswordForm() + ctx = dict(form=form, submit_label=_("Create")) + return _render(request, tplname, ctx) + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def edit_reseller(request, resid, tplname="limits/reseller.html"): + reseller = User.objects.get(pk=resid) + if request.method == "POST": + if request.POST.get("password1", "") != "" and \ + request.POST.get("password2", "") != "": + form = ResellerWithPasswordForm(request.POST, instance=reseller) + else: + form = ResellerForm(request.POST, instance=reseller) + return validate_reseller(request, form, action="update") + + form = ResellerWithPasswordForm(instance=reseller) + ctx = dict(form=form, submit_label=_("Update")) + return _render(request, tplname, ctx) + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def delete_resellers(request): + selection = map(lambda idx: int(idx), request.GET.get("selection", "").split(",")) + if not len(selection): + raise Exception("") + + User.objects.filter(id__in=selection).delete() + msg = ungettext("Reselled deleted", "Resellers deleted", len(selection)) + messages.info(request, msg, fail_silently=True) + return ajax_response(request) + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def edit_limits_pool(request, resid, tplname="limits/pool.html"): + pool = LimitsPool.objects.get(user=resid) + form = LimitsPoolForm(instance=pool) + ctx = dict(form=form, reseller=pool.user) + return _render(request, tplname, ctx) diff --git a/static/css/box.css b/static/css/box.css index 6cc8a0042..9c0d49370 100644 --- a/static/css/box.css +++ b/static/css/box.css @@ -15,6 +15,7 @@ div.row label { float: left; width: 120px; padding: 3px 0; + margin-right: 5px; } input { diff --git a/static/js/utils.js b/static/js/utils.js index 51787fb61..48b11aaaf 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -25,6 +25,10 @@ Request.JSON.mdb = new Class({ Extends: Request.JSON, options: { - onComplete: ajax_login_redirect + onComplete: ajax_login_redirect, + onFailure: function(xhr) { + $(document.body).setStyle("overflow", "auto"); + $(document.body).set("html", xhr.responseText); + } } }); \ No newline at end of file diff --git a/static/pics/resellers.png b/static/pics/resellers.png new file mode 100644 index 0000000000000000000000000000000000000000..dbf04e27c245be0572675f2ca3fd66d70b0f7f98 GIT binary patch literal 734 zcmV<40wMj0P)3nHoT7gFDVXKk zmYPdlnQtpCwV8>PN%UrlmMDSth7ly`)4em6E5i;voImIJKR*r}&eD2JaVSl=%gc)d z7dpau&PY1-GP4}c3S|!u$+*^6dJ%@Ir5G$DaKC8pd_{!Q2;=cq1e8a)-WjbF!&(}F zu95?=Xn5$z+Klc*_XP%kN3pBEGQHuS8=WIuFi=XMLl*)|i3EK)TVR#DVah<@+dv^^ z?<(=$M9njRzGXL36RzmF5RCSGFSHd1aWBOk)24%%eW1d0M<%9BVffT_3}d?8a}1#W zFF->0+OeJjf0$J~n6fwFIlUU+`&5|e%*5v>dQ3Htm@xQbvXKGG#6hw(=k~aig&k?Fn)O(-WmchQXMhO06sc|86j1<1Mx@JBR7tDi`*bT z%zBi1s#;y2)r za%P8-suqxWEeNMeAv)d!q0(&EeyJWo8D{LuYFZY+k$6iBS>t~c!>83h0o*Rv#?yPu QLjV8(07*qoM6N<$f?D@V)c^nh literal 0 HcmV?d00001 diff --git a/templates/admin/listing.html b/templates/admin/listing.html index eb1d00896..79dd8bf9d 100644 --- a/templates/admin/listing.html +++ b/templates/admin/listing.html @@ -23,7 +23,7 @@ - + {% endif %}