mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-18 05:51:26 +02:00
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
This commit is contained in:
+40
-1
@@ -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)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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"]]}}]
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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'))
|
||||
@@ -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"))
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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))
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "layout_simple.html" %}
|
||||
{% load i18n %}
|
||||
{% block html_head %}
|
||||
<script src="{{ MEDIA_URL }}js/form.js" type="text/javascript"></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="title"><h1>{% trans "Limits pool for" %} {{ reseller.username }}</h1></div>
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
<form action="" method="POST" id="limits_pool_form" name="limits_pool_form">
|
||||
{% include "common/genericform.html" %}
|
||||
<div class="spacer"></div>
|
||||
<div class="lastrow" align="center">
|
||||
<input type="submit" name="submit" value="{% trans 'Update' %}" />
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "layout_simple.html" %}
|
||||
{% load i18n %}
|
||||
{% block html_head %}
|
||||
<script src="{{ MEDIA_URL }}js/form.js" type="text/javascript"></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="title"><h1>{% trans "New reseller" %}</h1></div>
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
<form action="" method="POST" id="reseller_form" name="reseller_form">
|
||||
{% include "common/genericform.html" %}
|
||||
<div class="spacer"></div>
|
||||
<div class="lastrow" align="center">
|
||||
<input type="submit" name="submit" value="{{ submit_label }}" />
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends "nlayout.html" %}
|
||||
{% load i18n limits_tags libextras %}
|
||||
{% block html_head %}
|
||||
<script src="{{ MEDIA_URL }}limits/js/resellers.js" type="text/javascript"></script>
|
||||
<script type="text/javascript">
|
||||
window.addEvent("domready", function() {
|
||||
new Resellers();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% block middletitle %}{% trans "Available resellers" %}{% endblock %}
|
||||
{% block menu %}{% resellers_menu user %} {% endblock %}
|
||||
{% block content %}
|
||||
{% if messages %}
|
||||
{% display_messages messages %}
|
||||
{% endif %}
|
||||
<div id="resellers" name="resellers">
|
||||
{{ table|safe }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block footer %}
|
||||
<div id="navbar">
|
||||
{% if paginator.count %}
|
||||
<label>{% trans "Items" %} {{ page.start_index }} {% trans "to" %} {{ page.end_index }} {% trans "of" %} {{ paginator.count }}</label>
|
||||
{% endif %}
|
||||
{% if page.has_previous %}
|
||||
<a href="?page=1">
|
||||
<img src="{{ MEDIA_URL }}pics/first.png" border="0" title="{% trans "First" %}" />
|
||||
</a>
|
||||
<a href="?page={{ page.previous_page_number }}">
|
||||
<img src="{{ MEDIA_URL }}pics/back.png" border="0" title="{% trans "Previous" %}" />
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if page.has_next %}
|
||||
<a href="?page={{ page.next_page_number }}">
|
||||
<img src="{{ MEDIA_URL }}pics/next.png" border="0" title="{% trans "Next" %}" />
|
||||
</a>
|
||||
<a href="?page={{ page.last_page }}">
|
||||
<img src="{{ MEDIA_URL }}pics/last.png" border="0" title="{% trans "Last" %}" />
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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)
|
||||
@@ -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<resid>\d+)/$', 'edit_reseller'),
|
||||
(r'reseller/delete/$', 'delete_resellers'),
|
||||
(r'pool/edit/(?P<resid>\d+)/$', 'edit_limits_pool'),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -15,6 +15,7 @@ div.row label {
|
||||
float: left;
|
||||
width: 120px;
|
||||
padding: 3px 0;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
input {
|
||||
|
||||
+5
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 734 B |
@@ -23,7 +23,7 @@
|
||||
<a href="?page={{ objects.next_page_number }}">
|
||||
<img src="{{ MEDIA_URL }}pics/next.png" border="0" title="{% trans "Next" %}" />
|
||||
</a>
|
||||
<a href="?page={{ page.last_page }}">
|
||||
<a href="?page={{ objects.last_page }}">
|
||||
<img src="{{ MEDIA_URL }}pics/last.png" border="0" title="{% trans "Last" %}" />
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user