Added support from domain SOA record massive editing and phplist password changing

This commit is contained in:
Marc Aymerich 2015-07-20 12:51:30 +00:00
parent 0d9058d266
commit 708758880d
21 changed files with 290 additions and 68 deletions

View File

@ -426,7 +426,4 @@ Case
# bill changelist: dates: (closed_on, created_on, updated_on)
# Add record modeladmin action: select domains + add records (formset) to selected domains
# Resource data inline show info: link to monitor data, and history chart: link to monitor data of each item

View File

@ -129,4 +129,4 @@ def disable(modeladmin, request, queryset):
num)
modeladmin.message_user(request, msg)
disable.url_name = 'disable'
disable.verbose_name = _("Disable")
disable.short_description = _("Disable")

View File

@ -17,7 +17,9 @@ class AdminFormMixin(object):
def as_admin(self):
prepopulated_fields = {}
fieldsets = [
(None, {'fields': list(self.fields.keys())})
(None, {
'fields': list(self.fields.keys())
}),
]
adminform = helpers.AdminForm(self, fieldsets, prepopulated_fields)
template = Template(
@ -25,7 +27,10 @@ class AdminFormMixin(object):
' {% include "admin/includes/fieldset.html" %}'
'{% endfor %}'
)
return template.render(Context({'adminform': adminform}))
context = Context({
'adminform': adminform
})
return template.render(context)
class AdminFormSet(BaseModelFormSet):
@ -43,7 +48,10 @@ class AdminFormSet(BaseModelFormSet):
template = Template(
'{% include "admin/edit_inline/tabular.html" %}'
)
return template.render(Context({'inline_admin_formset': inline_admin_formset}))
context = Context({
'inline_admin_formset': inline_admin_formset
})
return template.render(context)
def adminmodelformset_factory(modeladmin, form, formset=AdminFormSet, **kwargs):

View File

@ -98,11 +98,13 @@ class ChangeViewActionsMixin(object):
action = getattr(self, action)
view = action_to_view(action, self)
view.url_name = getattr(action, 'url_name', action.__name__)
verbose_name = getattr(action, 'verbose_name',
tool_description = getattr(action, 'tool_description', '')
if not tool_description:
tool_description = getattr(action, 'short_description',
view.url_name.capitalize().replace('_', ' '))
if hasattr(verbose_name, '__call__'):
verbose_name = verbose_name(obj)
view.verbose_name = verbose_name
if hasattr(tool_description, '__call__'):
tool_description = tool_description(obj)
view.tool_description = tool_description
view.css_class = getattr(action, 'css_class', 'historylink')
view.help_text = getattr(action, 'help_text', '')
views.append(view)

View File

@ -98,7 +98,7 @@ def change_url(obj):
@admin_field
def admin_link(*args, **kwargs):
instance = args[-1]
if kwargs['field'] in ['id', 'pk', '__str__']:
if kwargs['field'] in ('id', 'pk', '__str__'):
obj = instance
else:
try:
@ -120,7 +120,7 @@ def admin_link(*args, **kwargs):
extra = ''
if kwargs['popup']:
extra = 'onclick="return showAddAnotherPopup(this);"'
return '<a href="%s" %s>%s</a>' % (url, extra, display)
return mark_safe('<a href="%s" %s>%s</a>' % (url, extra, display))
@admin_field

View File

@ -25,7 +25,7 @@ def list_contacts(modeladmin, request, queryset):
url = reverse('admin:contacts_contact_changelist')
url += '?account__in=%s' % ','.join(map(str, ids))
return redirect(url)
list_contacts.verbose_name = _("List contacts")
list_contacts.short_description = _("List contacts")
def service_report(modeladmin, request, queryset):

View File

@ -31,7 +31,7 @@ def view_bill(modeladmin, request, queryset):
return
html = bill.html or bill.render()
return HttpResponse(html)
view_bill.verbose_name = _("View")
view_bill.tool_description = _("View")
view_bill.url_name = 'view'
@ -91,7 +91,7 @@ def close_bills(modeladmin, request, queryset, action='close_bills'):
'obj': get_object_from_url(modeladmin, request),
}
return render(request, 'admin/orchestra/generic_confirmation.html', context)
close_bills.verbose_name = _("Close")
close_bills.tool_description = _("Close")
close_bills.url_name = 'close'
@ -137,7 +137,7 @@ def download_bills(modeladmin, request, queryset):
response = HttpResponse(pdf, content_type='application/pdf')
response['Content-Disposition'] = 'attachment; filename="%s.pdf"' % bill.number
return response
download_bills.verbose_name = _("Download")
download_bills.tool_description = _("Download")
download_bills.url_name = 'download'
@ -149,7 +149,7 @@ def close_send_download_bills(modeladmin, request, queryset):
return
return download_bills(modeladmin, request, queryset)
return response
close_send_download_bills.verbose_name = _("C.S.D.")
close_send_download_bills.tool_description = _("C.S.D.")
close_send_download_bills.url_name = 'close-send-download'
close_send_download_bills.help_text = _("Close, send and download bills in one shot.")
@ -288,7 +288,7 @@ def amend_bills(modeladmin, request, queryset):
_('<a href="%(url)s">%(num)i amendment bills</a> have been generated.') % context,
num
)))
amend_bills.verbose_name = _("Amend")
amend_bills.tool_description = _("Amend")
amend_bills.url_name = 'amend'

View File

@ -1,16 +1,19 @@
import copy
from django.contrib import messages
from django.contrib.admin import helpers
from django.db.models import Q
from django.db.models.functions import Concat, Coalesce
from django.shortcuts import render
from django.utils.safestring import mark_safe
from django.utils.translation import ungettext, ugettext_lazy as _
from django.template.response import TemplateResponse
from orchestra.admin.forms import adminmodelformset_factory
from orchestra.admin.utils import get_object_from_url, change_url
from orchestra.admin.utils import get_object_from_url, change_url, admin_link
from orchestra.utils.python import AttrDict
from .forms import RecordForm, RecordEditFormSet
from .forms import RecordForm, RecordEditFormSet, SOAForm
from .models import Record
@ -23,15 +26,33 @@ def view_zone(modeladmin, request, queryset):
}
return TemplateResponse(request, 'admin/domains/domain/view_zone.html', context)
view_zone.url_name = 'view-zone'
view_zone.verbose_name = _("View zone")
view_zone.short_description = _("View zone")
def edit_records(modeladmin, request, queryset):
selected_ids = queryset.values_list('id', flat=True)
# Include subodmains
queryset = queryset.model.objects.filter(
Q(top__id__in=selected_ids) | Q(id__in=selected_ids)
).annotate(
structured_id=Coalesce('top__id', 'id'),
structured_name=Concat('top__name', 'name')
).order_by('-structured_id', 'structured_name')
formsets = []
for domain in queryset.prefetch_related('records'):
modeladmin_copy = copy.copy(modeladmin)
modeladmin_copy.model = Record
link = '<a href="%s">%s</a>' % (change_url(domain), domain.name)
prefix = '' if domain.is_top else '&nbsp;'*8
context = {
'url': change_url(domain),
'name': prefix+domain.name,
'title': '',
}
if domain.id not in selected_ids:
context['name'] += '*'
context['title'] = _("This subdomain was not explicitly selected "
"but has been automatically added to this list.")
link = '<a href="%(url)s" title="%(title)s">%(name)s</a>' % context
modeladmin_copy.verbose_name_plural = mark_safe(link)
RecordFormSet = adminmodelformset_factory(
modeladmin_copy, RecordForm, formset=RecordEditFormSet, extra=1, can_delete=True)
@ -73,7 +94,7 @@ def edit_records(modeladmin, request, queryset):
opts = modeladmin.model._meta
context = {
'title': _("Edit records"),
'action_name': 'Edit records',
'action_name': _("Edit records"),
'action_value': 'edit_records',
'display_objects': [],
'queryset': queryset,
@ -86,6 +107,46 @@ def edit_records(modeladmin, request, queryset):
return render(request, 'admin/domains/domain/edit_records.html', context)
def add_records(modeladmin, request, queryset):
# TODO
pass
def set_soa(modeladmin, request, queryset):
if queryset.filter(top__isnull=False).exists():
msg = _("Set SOA on subdomains is not possible.")
modeladmin.message_user(request, msg, messages.ERROR)
return
form = SOAForm()
if request.POST.get('post') == 'generic_confirmation':
form = SOAForm(request.POST)
if form.is_valid():
updates = {name: value for name, value in form.cleaned_data.items() if value}
change_message = _("SOA set %s") % str(updates)[1:-1]
for domain in queryset:
for name, value in updates.items():
if name.startswith('clear_'):
name = name.replace('clear_', '')
value = ''
setattr(domain, name, value)
modeladmin.log_change(request, domain, change_message)
domain.save()
num = len(queryset)
msg = ungettext(
_("SOA record for one domain has been updated."),
_("SOA record for %s domains has been updated.") % num,
num
)
modeladmin.message_user(request, msg)
return
opts = modeladmin.model._meta
context = {
'title': _("Set SOA for selected domains"),
'content_message': '',
'action_name': _("Set SOA"),
'action_value': 'set_soa',
'display_objects': [admin_link('__str__')(domain) for domain in queryset],
'queryset': queryset,
'opts': opts,
'app_label': opts.app_label,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
'form': form,
'obj': get_object_from_url(modeladmin, request),
}
return render(request, 'admin/orchestra/generic_confirmation.html', context)
set_soa.short_description = _("Set SOA for selected domains")

View File

@ -8,7 +8,7 @@ from orchestra.admin.utils import admin_link, change_url
from orchestra.contrib.accounts.admin import AccountAdminMixin
from orchestra.utils import apps
from .actions import view_zone, edit_records
from .actions import view_zone, edit_records, set_soa
from .filters import TopDomainListFilter
from .forms import RecordForm, RecordInlineFormSet, BatchDomainCreationAdminForm
from .models import Domain, Record
@ -51,13 +51,16 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
)
add_fields = ('name', 'account')
fields = ('name', 'account_link')
inlines = [RecordInline, DomainInline]
list_filter = [TopDomainListFilter]
readonly_fields = ('account_link', 'top_link',)
inlines = (RecordInline, DomainInline)
list_filter = (TopDomainListFilter,)
change_readonly_fields = ('name', 'serial')
search_fields = ('name', 'account__username')
add_form = BatchDomainCreationAdminForm
actions = (edit_records,)
change_view_actions = [view_zone]
actions = (edit_records, set_soa)
change_view_actions = (view_zone, edit_records)
top_link = admin_link('top')
def structured_name(self, domain):
if domain.is_top:
@ -90,15 +93,26 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
def get_fieldsets(self, request, obj=None):
""" Add SOA fields when domain is top """
fieldsets = super(DomainAdmin, self).get_fieldsets(request, obj)
if obj and obj.is_top:
if obj:
if obj.is_top:
fieldsets += (
(_("SOA"), {
'classes': ('collapse',),
'fields': ('serial', 'refresh', 'retry', 'expire', 'min_ttl'),
}),
)
else:
existing = fieldsets[0][1]['fields']
if 'top_link' not in existing:
fieldsets[0][1]['fields'].insert(2, 'top_link')
return fieldsets
def get_inline_instances(self, request, obj=None):
inlines = super(DomainAdmin, self).get_inline_instances(request, obj)
if not obj or not obj.is_top:
return [inline for inline in inlines if type(inline) != DomainInline]
return inlines
def get_queryset(self, request):
""" Order by structured name and imporve performance """
qs = super(DomainAdmin, self).get_queryset(request)
@ -121,12 +135,6 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
domain = Domain.objects.create(name=name, account_id=obj.account_id)
self.extra_domains.append(domain)
def save_formset(self, request, form, formset, change):
"""
Given an inline formset save it to the database.
"""
formset.save()
def save_related(self, request, form, formsets, change):
""" batch domain creation support """
super(DomainAdmin, self).save_related(request, form, formsets, change)
@ -142,7 +150,7 @@ class DomainAdmin(AccountAdminMixin, ExtendedModelAdmin):
record.pk = None
formset.instance = domain
form.instance = domain
self.save_formset(request, form, formset, change=change)
self.save_formset(request, form, formset, change)
admin.site.register(Domain, DomainAdmin)

View File

@ -174,7 +174,8 @@ class Bind9MasterDomainBackend(ServiceController):
class Bind9SlaveDomainBackend(Bind9MasterDomainBackend):
"""
Generate the configuartion for slave servers
It auto-discover the master server based on your routing configuration or you can use DOMAINS_MASTERS to explicitly configure the master.
It auto-discover the master server based on your routing configuration or you can use
DOMAINS_MASTERS to explicitly configure the master.
"""
CONF_PATH = settings.DOMAINS_SLAVES_PATH

View File

@ -1,8 +1,9 @@
from django import forms
from django.core.exceptions import ValidationError
from django.utils.text import capfirst
from django.utils.translation import ugettext_lazy as _
from orchestra.admin.forms import AdminFormSet
from orchestra.admin.forms import AdminFormSet, AdminFormMixin
from . import validators
from .helpers import domain_for_validation
@ -97,3 +98,30 @@ class RecordEditFormSet(ValidateZoneMixin, AdminFormSet):
class RecordInlineFormSet(ValidateZoneMixin, forms.models.BaseInlineFormSet):
pass
class SOAForm(AdminFormMixin, forms.Form):
refresh = forms.CharField()
clear_refresh = forms.BooleanField(label=_("Clear refresh"), required=False,
help_text=_("Remove custom refresh value for all selected domains."))
retry = forms.CharField()
clear_retry = forms.BooleanField(label=_("Clear retry"), required=False,
help_text=_("Remove custom retry value for all selected domains."))
expire = forms.CharField()
clear_expire = forms.BooleanField(label=_("Clear expire"), required=False,
help_text=_("Remove custom expire value for all selected domains."))
min_ttl = forms.CharField()
clear_min_ttl = forms.BooleanField(label=_("Clear min TTL"), required=False,
help_text=_("Remove custom min TTL value for all selected domains."))
def __init__(self, *args, **kwargs):
super(SOAForm, self).__init__(*args, **kwargs)
for name in self.fields:
if not name.startswith('clear_'):
field = Domain._meta.get_field_by_name(name)[0]
self.fields[name] = forms.CharField(
label=capfirst(field.verbose_name),
help_text=field.help_text,
validators=field.validators,
required=False,
)

View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import orchestra.contrib.domains.utils
class Migration(migrations.Migration):
dependencies = [
('domains', '0002_auto_20150715_1017'),
]
operations = [
migrations.RemoveField(
model_name='domain',
name='expire',
),
migrations.RemoveField(
model_name='domain',
name='min_ttl',
),
migrations.RemoveField(
model_name='domain',
name='refresh',
),
migrations.RemoveField(
model_name='domain',
name='retry',
),
migrations.AlterField(
model_name='domain',
name='serial',
field=models.IntegerField(editable=False, verbose_name='serial', default=orchestra.contrib.domains.utils.generate_zone_serial, help_text='A revision number that changes whenever this domain is updated.'),
),
]

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import orchestra.contrib.domains.validators
class Migration(migrations.Migration):
dependencies = [
('domains', '0003_auto_20150720_1121'),
]
operations = [
migrations.AddField(
model_name='domain',
name='expire',
field=models.CharField(validators=[orchestra.contrib.domains.validators.validate_zone_interval], blank=True, help_text='The time that a secondary server will keep trying to complete a zone transfer. If this time expires prior to a successful zone transfer, the secondary server will expire its zone file. This means the secondary will stop answering queries. The default value is <tt>4w</tt>.', verbose_name='expire', max_length=16),
),
migrations.AddField(
model_name='domain',
name='min_ttl',
field=models.CharField(validators=[orchestra.contrib.domains.validators.validate_zone_interval], blank=True, help_text='The minimum time-to-live value applies to all resource records in the zone file. This value is supplied in query responses to inform other servers how long they should keep the data in cache. The default value is <tt>1h</tt>.', verbose_name='min TTL', max_length=16),
),
migrations.AddField(
model_name='domain',
name='refresh',
field=models.CharField(validators=[orchestra.contrib.domains.validators.validate_zone_interval], blank=True, help_text="The time a secondary DNS server waits before querying the primary DNS server's SOA record to check for changes. When the refresh time expires, the secondary DNS server requests a copy of the current SOA record from the primary. The primary DNS server complies with this request. The secondary DNS server compares the serial number of the primary DNS server's current SOA record and the serial number in it's own SOA record. If they are different, the secondary DNS server will request a zone transfer from the primary DNS server. The default value is <tt>1d</tt>.", verbose_name='refresh', max_length=16),
),
migrations.AddField(
model_name='domain',
name='retry',
field=models.CharField(validators=[orchestra.contrib.domains.validators.validate_zone_interval], blank=True, help_text='The time a secondary server waits before retrying a failed zone transfer. Normally, the retry time is less than the refresh time. The default value is <tt>2h</tt>.', verbose_name='retry', max_length=16),
),
]

View File

@ -21,7 +21,7 @@ class Domain(models.Model):
editable=False)
serial = models.IntegerField(_("serial"), default=utils.generate_zone_serial, editable=False,
help_text=_("A revision number that changes whenever this domain is updated."))
refresh = models.IntegerField(_("refresh"), null=True, blank=True,
refresh = models.CharField(_("refresh"), max_length=16, blank=True,
validators=[validators.validate_zone_interval],
help_text=_("The time a secondary DNS server waits before querying the primary DNS "
"server's SOA record to check for changes. When the refresh time expires, "
@ -32,19 +32,19 @@ class Domain(models.Model):
"If they are different, the secondary DNS server will request a zone "
"transfer from the primary DNS server. "
"The default value is <tt>%s</tt>.") % settings.DOMAINS_DEFAULT_REFRESH)
retry = models.IntegerField(_("retry"), null=True, blank=True,
retry = models.CharField(_("retry"), max_length=16, blank=True,
validators=[validators.validate_zone_interval],
help_text=_("The time a secondary server waits before retrying a failed zone transfer. "
"Normally, the retry time is less than the refresh time. "
"The default value is <tt>%s</tt>.") % settings.DOMAINS_DEFAULT_RETRY)
expire = models.IntegerField(_("expire"), null=True, blank=True,
expire = models.CharField(_("expire"), max_length=16, blank=True,
validators=[validators.validate_zone_interval],
help_text=_("The time that a secondary server will keep trying to complete a zone "
"transfer. If this time expires prior to a successful zone transfer, "
"the secondary server will expire its zone file. This means the secondary "
"will stop answering queries. "
"The default value is <tt>%s</tt>.") % settings.DOMAINS_DEFAULT_EXPIRE)
min_ttl = models.IntegerField(_("min TTL"), null=True, blank=True,
min_ttl = models.CharField(_("min TTL"), max_length=16, blank=True,
validators=[validators.validate_zone_interval],
help_text=_("The minimum time-to-live value applies to all resource records in the "
"zone file. This value is supplied in query responses to inform other "

View File

@ -68,7 +68,7 @@ def mark_as_executed(modeladmin, request, queryset):
num)
modeladmin.message_user(request, msg)
mark_as_executed.url_name = 'execute'
mark_as_executed.verbose_name = _("Mark as executed")
mark_as_executed.short_description = _("Mark as executed")
@transaction.atomic
@ -84,7 +84,7 @@ def mark_as_secured(modeladmin, request, queryset):
num)
modeladmin.message_user(request, msg)
mark_as_secured.url_name = 'secure'
mark_as_secured.verbose_name = _("Mark as secured")
mark_as_secured.short_description = _("Mark as secured")
@transaction.atomic
@ -100,7 +100,7 @@ def mark_as_rejected(modeladmin, request, queryset):
num)
modeladmin.message_user(request, msg)
mark_as_rejected.url_name = 'reject'
mark_as_rejected.verbose_name = _("Mark as rejected")
mark_as_rejected.short_description = _("Mark as rejected")
def _format_display_objects(modeladmin, request, queryset, related):
@ -139,7 +139,7 @@ def mark_process_as_executed(modeladmin, request, queryset):
num)
modeladmin.message_user(request, msg)
mark_process_as_executed.url_name = 'executed'
mark_process_as_executed.verbose_name = _("Mark as executed")
mark_process_as_executed.short_description = _("Mark as executed")
@transaction.atomic
@ -155,7 +155,7 @@ def abort(modeladmin, request, queryset):
num)
modeladmin.message_user(request, msg)
abort.url_name = 'abort'
abort.verbose_name = _("Abort")
abort.short_description = _("Abort")
@transaction.atomic
@ -171,7 +171,7 @@ def commit(modeladmin, request, queryset):
num)
modeladmin.message_user(request, msg)
commit.url_name = 'commit'
commit.verbose_name = _("Commit")
commit.short_description = _("Commit")
def delete_selected(modeladmin, request, queryset):

View File

@ -1,9 +1,12 @@
import hashlib
import re
import textwrap
import requests
from django.utils.translation import ugettext_lazy as _
from orchestra.contrib.orchestration import ServiceController
from orchestra.utils.sys import sshrun
from .. import settings
@ -45,7 +48,28 @@ class PhpListSaaSBackend(ServiceController):
if response.status_code != 200:
raise RuntimeError("Bad status code %i" % response.status_code)
else:
raise NotImplementedError("Change password not implemented")
md5_password = hashlib.md5()
md5_password.update(saas.password.encode('ascii'))
context = {
'name': saas.name,
'site_name': saas.name,
'db_user': settings.SAAS_PHPLIST_DB_USER,
'db_pass': settings.SAAS_PHPLIST_DB_PASS,
'db_name': settings.SAAS_PHPLIST_DB_NAME,
'db_host': settings.SAAS_PHPLIST_DB_HOST,
'digest': md5_password.hexdigest(),
}
context['db_name'] = context['db_name'] % context
cmd = textwrap.dedent("""\
mysql \\
--host=%(db_host)s \\
--user=%(db_user)s \\
--password=%(db_pass)s \\
--execute='UPDATE phplist_admin SET password="%(digest)s" where ID=1; \\
UPDATE phplist_user_user SET password="%(digest)s" where ID=1;' \\
%(db_name)s""") % context
print(cmd)
sshrun(server.get_address(), cmd)
def save(self, saas):
if hasattr(saas, 'password'):

View File

@ -54,7 +54,7 @@ class PHPListService(SoftwareService):
return db_name[:65]
def get_db_user(self):
return settings.SAAS_PHPLIST_DB_NAME
return settings.SAAS_PHPLIST_DB_USER
def get_account(self):
return self.instance.account.get_main()

View File

@ -1,3 +1,5 @@
from django.utils.translation import ugettext_lazy as _
from orchestra.contrib.settings import Setting
from orchestra.settings import ORCHESTRA_BASE_DOMAIN
@ -76,10 +78,30 @@ SAAS_DRUPAL_SITES_PATH = Setting('WEBSITES_DRUPAL_SITES_PATH',
)
SAAS_PHPLIST_DB_NAME = Setting('SAAS_PHPLIST_DB_NAME',
SAAS_PHPLIST_DB_USER = Setting('SAAS_PHPLIST_DB_USER',
'phplist_mu',
help_text=_("Needed for password changing support."),
)
SAAS_PHPLIST_DB_PASS = Setting('SAAS_PHPLIST_DB_PASS',
'secret',
help_text=_("Needed for password changing support."),
)
SAAS_PHPLIST_DB_NAME = Setting('SAAS_PHPLIST_DB_NAME',
'phplist_mu_%(site_name)s',
help_text=_("Needed for password changing support."),
)
SAAS_PHPLIST_DB_HOST = Setting('SAAS_PHPLIST_DB_HOST',
'loclahost',
help_text=_("Needed for password changing support."),
)
SAAS_PHPLIST_BASE_DOMAIN = Setting('SAAS_PHPLIST_BASE_DOMAIN',
'lists.{}'.format(ORCHESTRA_BASE_DOMAIN),
help_text="Uses <tt>ORCHESTRA_BASE_DOMAIN</tt> by default.",

View File

@ -47,7 +47,7 @@ def update_orders(modeladmin, request, queryset, extra_context=None):
}
return render(request, 'admin/services/service/update_orders.html', context)
update_orders.url_name = 'update-orders'
update_orders.verbose_name = _("Update orders")
update_orders.short_description = _("Update orders")
def view_help(modeladmin, request, queryset):
@ -62,7 +62,7 @@ def view_help(modeladmin, request, queryset):
}
return TemplateResponse(request, 'admin/services/service/help.html', context)
view_help.url_name = 'help'
view_help.verbose_name = _("Help")
view_help.tool_description = _("Help")
def clone(modeladmin, request, queryset):

View File

@ -67,7 +67,7 @@ def set_permission(modeladmin, request, queryset):
return TemplateResponse(request, 'admin/systemusers/systemuser/set_permission.html',
context, current_app=modeladmin.admin_site.name)
set_permission.url_name = 'set-permission'
set_permission.verbose_name = _("Set permission")
set_permission.tool_description = _("Set permission")
def delete_selected(modeladmin, request, queryset):

View File

@ -4,7 +4,7 @@
{% block object-tools-items %}
{% for item in object_tools_items %}
<li><a href="{{ item.url_name }}/" class="{{ item.css_class }}" title="{{ item.help_text }}">{{ item.verbose_name }}</a></li>
<li><a href="{{ item.url_name }}/" class="{{ item.css_class }}" title="{{ item.help_text }}">{{ item.tool_description }}</a></li>
{% endfor %}
{{ block.super }}
{% endblock %}