Bug 1041873: Add mass deletion tool.

This commit is contained in:
Kris Maglione 2014-07-16 02:39:49 -07:00
Родитель cd5b1aba9c
Коммит 70ab314f14
14 изменённых файлов: 622 добавлений и 11 удалений

Просмотреть файл

@ -488,19 +488,19 @@ class OBJECT_DELETED(_LOG):
class ADMIN_USER_EDITED(_LOG):
id = 103
format = _(u'User {user} edited, reason: {1}')
format = _(u'User {0} edited, reason: {1}')
admin_event = True
class ADMIN_USER_ANONYMIZED(_LOG):
id = 104
format = _(u'User {user} anonymized.')
format = _(u'User {0} anonymized.')
admin_event = True
class ADMIN_USER_RESTRICTED(_LOG):
id = 105
format = _(u'User {user} restricted.')
format = _(u'User {0} restricted.')
admin_event = True
@ -522,6 +522,12 @@ class THEME_REVIEW(_LOG):
format = _(u'{addon} reviewed.')
class ADMIN_MASS_DELETE(_LOG):
id = 109
format = _(u'{0} objects {1} mass deleted, reason: {2}')
admin_event = True
class GROUP_USER_ADDED(_LOG):
id = 120
action_class = 'access'

Просмотреть файл

@ -1,5 +1,6 @@
from datetime import datetime, timedelta
import logging
import re
from django.conf import settings
from django.core.cache import cache
@ -58,6 +59,14 @@ class Review(amo.models.ModelBase):
db_table = 'reviews'
ordering = ('-created',)
def __unicode__(self):
quote = lambda o: "'%s'" % re.sub(r"[\\']", lambda m: '\\' + m.group(0),
unicode(o))
return u'Review %d: <%s, %s, %s>' % (self.pk,
quote(self.addon),
quote(self.user),
quote(self.title))
def get_url_path(self):
if 'mkt.ratings' in settings.INSTALLED_APPS:
return '/app/%s/ratings/%s' % (self.addon.app_slug, self.id)

Просмотреть файл

@ -25,6 +25,8 @@ from compat.forms import CompatForm as BaseCompatForm
from files.models import File
from zadmin.models import SiteEvent, ValidationJob
from .helpers import MassDeleteHelper
LOGGER_NAME = 'z.zadmin'
log = commonware.log.getLogger(LOGGER_NAME)
@ -131,6 +133,38 @@ class NotifyForm(happyforms.Form):
return self.check_template(self.cleaned_data['subject'])
class MassDeleteForm(happyforms.Form):
urls = forms.CharField(label=_lazy(u'URLs to delete'),
widget=forms.Textarea, required=True)
reason = forms.CharField(label=_lazy(u'Reason for deletion'),
required=True)
class MassDeleteConfirmForm(happyforms.Form):
objects = forms.CharField(widget=forms.HiddenInput, required=True)
reason = forms.CharField(widget=forms.HiddenInput, required=True)
def clean_objects(self):
try:
data = json.loads(self.cleaned_data.get('objects'))
assert all(all(isinstance(id_, int) for id_ in ids)
for ids in data.values())
for m, ids in data.items():
model = MassDeleteHelper.MODEL_MAP[m]
objs = model.objects.in_bulk(ids)
assert len(objs) == len(ids)
data[m] = map(objs.get, ids)
except Exception, e:
raise forms.ValidationError(
u'Invalid objects JSON')
return data
class FeaturedCollectionForm(happyforms.ModelForm):
LOCALES = (('', u'(Default Locale)'),) + tuple(
(i, product_details.languages[i]['native'])

Просмотреть файл

@ -1,7 +1,19 @@
from collections import defaultdict
import json
import re
from jingo import register
from amo.urlresolvers import reverse
import amo
from amo.urlresolvers import resolve, reverse
from bandwagon.models import (Collection, CollectionAddon, CollectionUser,
CollectionVote, CollectionWatcher)
from addons.models import Addon, AddonUser
from abuse.models import AbuseReport
from files.models import File, FileUpload
from reviews.models import Review
from users.models import UserProfile
from versions.models import Version
@register.function
def admin_site_links():
@ -39,3 +51,178 @@ def admin_site_links():
('Site Status', reverse('amo.monitor')),
],
}
class MassDeleteHelper(object):
VIEW_MAP = {
'addons.': ('addon_id', Addon, 'slug'),
'editors.review': ('addon_id', Addon, 'slug'),
'zadmin.addon_manage': ('addon_id', Addon, 'slug'),
'addons.reviews.': ('review_id', Review, 'pk'),
'users.': ('user_id', UserProfile, 'username'),
'users.admin_edit': ('user_id', UserProfile, 'pk'),
'collections.': (('username', 'slug'),
Collection,
('author__username', 'slug')),
}
MODEL_MAP = {
'Addon': Addon,
'Collection': Collection,
'Review': Review,
'UserProfile': UserProfile,
}
DELETION_MAP = {
Addon: {
'RELATED_MODELS': (
(AbuseReport, 'addon_id'),
(AddonUser, 'addon_id'),
(CollectionAddon, 'addon_id'),
(Review, 'addon_id'),
(File, 'version__addon_id'),
(Version, 'addon_id'),
)
},
UserProfile: {
'RELATED_MODELS': (
(CollectionWatcher, 'collection__author_id'),
(CollectionVote, 'collection__author_id'),
(CollectionVote, 'user_id'),
(CollectionUser, 'user_id'),
(Collection, 'author_id'),
(FileUpload, 'user_id'),
(AbuseReport, 'reporter_id'),
(AddonUser, 'user_id'),
# Argh. Sometimes I hate Django's ORM.
# I want `.exclude(authors__id__not_in=ids)`, but even with
# `~Q(...)`, the NOT winds up outside of the generated
# subselect rather than in it.
(Addon, lambda objects, ids: (
objects.filter(authors__id__in=ids)
.extra(
where=['''
`addons`.`id` NOT IN
(SELECT `a2`.`id`
FROM `addons` AS `a2`
INNER JOIN `addons_users` AS `au2`
ON `au2`.`addon_id` = `a2`.`id`
WHERE `au2`.`user_id` NOT IN %s)
'''],
params=[ids]))),
),
}
}
URL_LOP_RE = re.compile(r'^https?://.*?/')
VIEW_LOP_RE = re.compile(r'[^.]+\.?$')
def __init__(self, objects=None, urls=None, reason=None):
self.reason = reason
if objects:
self.object_types = objects
if urls:
self.objects = map(self.url_to_object, urls)
self.unknown_urls = tuple(l
for i, l in enumerate(urls)
if not self.objects[i])
object_types = defaultdict(list)
for o in self.objects:
if o:
object_types[o.__class__.__name__].append(o)
self.object_types = dict(object_types.iteritems())
@property
def object_types_json(self):
return json.dumps(self.object_types,
default=lambda o: o.pk)
def url_to_object(self, url):
to_tuple = lambda v: v if isinstance(v, tuple) else (v,)
# Crudely lop off host and protocol part.
url = self.URL_LOP_RE.sub('/', url)
try:
r = resolve(url)
except:
return None
key = None
view_id = r.url_name
while not key and view_id:
key = self.VIEW_MAP.get(view_id)
args = key and map(r.kwargs.get, to_tuple(key[0]))
if not (key and all(args)):
key = None
view_id = self.VIEW_LOP_RE.sub('', view_id)
_, model, fields = key
try:
return model.objects.get(**dict(zip(to_tuple(fields),
args)))
except (ValueError, model.DoesNotExist):
return None
def delete_objects(self):
for model_name, objs in self.object_types.iteritems():
model = self.MODEL_MAP[model_name]
ids = [o.pk for o in objs]
amo.log(amo.LOG.ADMIN_MASS_DELETE, model_name,
json.dumps(map(unicode, objs)),
self.reason)
for model_, qs in self.get_related_objects(model, ids):
qs.delete()
qs = model.objects.filter(pk__in=ids)
qs.delete()
def count_related(self, obj):
model = obj.__class__
counts = defaultdict(lambda: 0)
for model, qs in self.get_related_objects(model, (obj.pk,)):
count = qs.count()
if count:
counts[model.__name__] += count
return sorted(counts.items())
def get_related_objects(self, model, ids, seen=()):
if model not in self.DELETION_MAP:
return
seen = set(seen)
for key in self.DELETION_MAP[model]['RELATED_MODELS']:
model, field = key
qs = self.get_objects(model, field, ids)
if model in self.DELETION_MAP and key not in seen:
seen.add(key)
pks = qs.values_list('pk', flat=True)
for model_, qs_ in self.get_related_objects(model, pks,
seen=seen):
yield model_, qs_
yield model, qs
def get_objects(self, model, field, ids):
if callable(field):
return field(model.objects, ids)
else:
return model.objects.filter(**{'%s__in' % field: ids})

Просмотреть файл

@ -0,0 +1,20 @@
{% extends "admin/base.html" %}
{% block title %}{{ page_title("Spammers! Pr0nmongers! Infringers! Down with them all!") }}{% endblock %}
{% block bodyclass %}proper-lists admin-mass-delete{% endblock %}
{% block content %}
<h1><acronym title="We do what we must, because we can">{{ _("Mass Deletion Completed") }}</acronym></h1>
<center>
<div>
<img src="{{ MEDIA_URL }}img/zamboni/admin/cheers.jpg"/>
</div>
<caption>
{{ _("We have done a great thing today.") }}
</caption>
</center>
{% endblock %}

Просмотреть файл

@ -0,0 +1,64 @@
{% extends "admin/base.html" %}
{% block title %}{{ page_title("Spammers! Pr0nmongers! Infringers! Down with them all!") }}{% endblock %}
{% block bodyclass %}proper-lists admin-mass-delete{% endblock %}
{% block content %}
<h1><acronym title="We do what we must, because we can">{{ _("Confirm Mass Deletion") }}</acronym></h1>
{% if unknown_urls %}
<h3>{{ _("Uknown URLs:") }}</h3>
<p>{{ _("The following URLs could not be mapped to objects:") }}</p>
<ul id="unknown-urls">
{% for url in unknown_urls %}
<li>{{ url }}</li>
{% endfor %}
</ul>
{% endif %}
<h3>{{ _("The following objects and dependent objects will be deleted:") }}</h3>
{% for type, objects in deletion_helper.object_types.iteritems() %}
<h4>{{ _('%s objects:')|format(type) }}</h4>
<ul id="delete-confirm-{{ type }}">
{% for object in objects %}
<li id="delete-confirm-{{ type }}-{{ object.pk }}"><a href="{{ object.get_url_path() }}">{{ object|string }}</a>
<ul>
{% for type_, count in deletion_helper.count_related(object) %}
<li id="delete-confirm-{{ type }}-{{ object.pk }}-{{ type_ }}">
{{ _('%(count)d %(type)s objects')|format(count=count, type=type_) }}
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
{% endfor %}
<center>
<h2>{{ _("Caution!") }}</h2>
<div>
<img src="{{ MEDIA_URL }}img/zamboni/admin/warning-you.jpg"/>
</div>
<caption>
{% trans %}
<p>
With great power comes great <em>don't screw around with this</em>.
This action is irreversible. Please do not take it lightly! Please
triple-check the above object list.
<p>
<p><em>I'm watching you.</em></p>
{% endtrans %}
</caption>
</center>
<form method="post" action="{{ url('zadmin.mass_delete.confirm') }}">
{{ csrf() }}
{{ confirm_form.as_p() }}
<button type="submit">Kill them with <em>napalm</em> fire!</button>
</form>
{% endblock %}

Просмотреть файл

@ -0,0 +1,17 @@
{% extends "admin/base.html" %}
{% block title %}{{ page_title("Spammers! Pr0nmongers! Infringers! Down with them all!") }}{% endblock %}
{% block bodyclass %}proper-lists admin-mass-delete{% endblock %}
{% block content %}
<h1><acronym title="We do what we must, because we can">{{ _("Mass Deletion") }}</acronym></h1>
<form method="post" action="">
{{ csrf() }}
{{ mass_delete_form.as_p() }}
<button type="submit">Kill them with fire!</button>
</form>
{% endblock %}

Просмотреть файл

@ -0,0 +1,144 @@
# -*- coding: utf-8 -*-
import itertools
from collections import defaultdict
from django.conf import settings
from mock import Mock, patch
from nose.tools import eq_
import amo.tests
from amo.helpers import absolutify
from amo.urlresolvers import resolve, reverse
from bandwagon.models import (Collection, CollectionAddon, CollectionUser,
CollectionVote, CollectionWatcher)
from addons.models import Addon, AddonUser
from abuse.models import AbuseReport
from files.models import File, FileUpload
from reviews.models import Review
from users.models import UserProfile
from versions.models import Version
from zadmin.helpers import MassDeleteHelper
from zadmin.helpers import MassDeleteHelper
class MassDeletionTest(amo.tests.TestCase):
fixtures = ['addons/featured',
'base/featured',
'base/collections',
'base/users',
'base/addon_3615',
'base/addon_3723_listed',
'reviews/test_models',
'bandwagon/featured_collections']
OBJECTS = (
{'model': UserProfile, 'pk': 999, 'related': {}},
{
'model': UserProfile,
'pk': 10482,
'related': {
Collection: [80, 56445, 56446, 56447],
}
},
{'model': Collection, 'pk': 80, 'related': {}},
{'model': Collection, 'pk': 56445, 'related': {}},
{
'model': Addon,
'pk': 4,
'related': {
Review: [1, 2],
File: [592],
Version: [5, 592],
}
},
{
'model': Addon,
'pk': 3615,
'related': {
AddonUser: [2818],
CollectionAddon: [207981],
File: [67442],
Version: [81551],
}
},
{
'model': Addon,
'pk': 3723,
'related': {
AddonUser: [2905],
Version: [89774],
}
},
{'model': Review, 'pk': 1, 'related': {}},
{'model': Review, 'pk': 2, 'related': {}},
)
def setUp(self):
self.objects = defaultdict(lambda: [])
for obj in self.OBJECTS:
model = obj['model']
self.objects[model.__name__].append(
model.objects.get(pk=obj['pk']))
self.objects = dict(self.objects)
self.urls = tuple(o.get_url_path()
for objs in self.objects.values()
for o in objs)
self.fake_urls = (
'https://addons.mozilla.org/en-US/firefox/addon/floorgl-plikzret/',
'/foo/bar/zuq/',
)
def assert_deleted(self):
for obj in self.OBJECTS:
assert not obj['model'].objects.filter(pk=obj['pk']).exists()
for model, pks in obj['related'].iteritems():
assert not model.objects.filter(pk__in=pks).exists()
class TestMassDeletion(MassDeletionTest):
def test_relative_vs_absolute_urls(self):
helper1 = MassDeleteHelper(urls=self.urls)
helper2 = MassDeleteHelper(urls=map(absolutify, self.urls))
eq_(helper1.object_types_json,
helper2.object_types_json)
def test_url_resolution(self):
helper1 = MassDeleteHelper(urls=self.urls + self.fake_urls)
helper2 = MassDeleteHelper(objects=self.objects)
eq_(helper1.unknown_urls, self.fake_urls)
eq_(helper1.object_types_json,
helper2.object_types_json)
eq_(helper1.object_types,
self.objects)
def test_related_objects(self):
helper = MassDeleteHelper(objects=self.objects)
for obj in self.OBJECTS:
model = obj['model']
related = dict((k, set(v))
for k, v in obj['related'].iteritems())
for model, objs in helper.get_related_objects(model, [obj['pk']]):
for o in objs:
assert model in related
assert o.pk in related[model]
related[model].remove(o.pk)
assert not any(related.values())
def test_deletion(self):
helper = MassDeleteHelper(objects=self.objects)
helper.delete_objects()
self.assert_deleted()

Просмотреть файл

@ -37,6 +37,8 @@ from zadmin.forms import DevMailerForm
from zadmin.models import EmailPreviewTopic, ValidationJob, ValidationResult
from zadmin.views import updated_versions, find_files
from .test_helpers import MassDeletionTest
no_op_validation = dict(errors=0, warnings=0, notices=0, messages=[],
compatibility_summary=dict(errors=0, warnings=0,
@ -1151,6 +1153,59 @@ class TestEmailPreview(amo.tests.TestCase):
'the subject', 'Hello Ivan Krsti\xc4\x87'])
class TestMassDeletion(MassDeletionTest):
def setUp(self):
super(TestMassDeletion, self).setUp()
assert self.client.login(username='admin@mozilla.com',
password='password')
def do_confirm(self):
r = self.client.post(reverse('zadmin.mass_delete'),
{'urls': '\n'.join(self.urls + self.fake_urls),
'reason': 'Because I can.'})
eq_(r.status_code, 200)
return pq(r.content)
def test_confirm(self):
doc = self.do_confirm()
eq_(doc('#unknown-urls li').text(),
' '.join(self.fake_urls))
print doc('ul[id^="delete-confirm"]').text()
def test_confirm_related_objects(self):
doc = self.do_confirm()
for obj in self.OBJECTS:
model = obj['model']
inst = model.objects.get(pk=obj['pk'])
base_id = '#delete-confirm-%s-%d' % (model.__name__, obj['pk'])
a = doc('%s > a' % base_id)
eq_(a.attr('href'), inst.get_url_path())
eq_(a.text(), unicode(inst))
for model_, ids in obj['related'].iteritems():
eq_(doc('%s-%s' % (base_id, model_.__name__)).text(),
'%d %s objects' % (len(ids), model_.__name__))
# Very important.
assert "I'm watching you." in doc('body').text()
def test_delete(self):
doc = self.do_confirm()
r = self.client.post(reverse('zadmin.mass_delete.confirm'),
{'objects': doc('#id_objects').val(),
'reason': doc('#id_reason').val()})
self.assert3xx(r, reverse('zadmin.mass_delete.confirm'))
self.assert_deleted()
class TestMonthlyPick(amo.tests.TestCase):
fixtures = ['base/addon_3615', 'base/apps', 'base/users']
@ -1911,6 +1966,9 @@ class TestPerms(amo.tests.TestCase):
eq_(self.client.get(reverse('zadmin.settings')).status_code, 200)
eq_(self.client.get(reverse('zadmin.flagged')).status_code, 200)
eq_(self.client.get(reverse('zadmin.langpacks')).status_code, 200)
eq_(self.client.get(reverse('zadmin.mass_delete')).status_code, 200)
eq_(self.client.get(reverse('zadmin.mass_delete.confirm')).status_code,
200)
eq_(self.client.get(reverse('zadmin.addon-search')).status_code, 200)
eq_(self.client.get(reverse('zadmin.monthly_pick')).status_code, 200)
eq_(self.client.get(reverse('zadmin.features')).status_code, 200)
@ -1930,6 +1988,9 @@ class TestPerms(amo.tests.TestCase):
eq_(self.client.get(reverse('zadmin.settings')).status_code, 200)
eq_(self.client.get(reverse('zadmin.flagged')).status_code, 200)
eq_(self.client.get(reverse('zadmin.langpacks')).status_code, 200)
eq_(self.client.get(reverse('zadmin.mass_delete')).status_code, 200)
eq_(self.client.get(reverse('zadmin.mass_delete.confirm')).status_code,
200)
eq_(self.client.get(reverse('zadmin.addon-search')).status_code, 200)
eq_(self.client.get(reverse('zadmin.monthly_pick')).status_code, 200)
eq_(self.client.get(reverse('zadmin.features')).status_code, 200)
@ -1949,6 +2010,9 @@ class TestPerms(amo.tests.TestCase):
eq_(self.client.get(reverse('zadmin.index')).status_code, 200)
eq_(self.client.get(reverse('zadmin.flagged')).status_code, 200)
eq_(self.client.get(reverse('zadmin.langpacks')).status_code, 200)
eq_(self.client.get(reverse('zadmin.mass_delete')).status_code, 403)
eq_(self.client.get(reverse('zadmin.mass_delete.confirm')).status_code,
403)
eq_(self.client.get(reverse('zadmin.addon-search')).status_code, 200)
eq_(self.client.get(reverse('zadmin.settings')).status_code, 403)
eq_(self.client.get(
@ -1966,6 +2030,9 @@ class TestPerms(amo.tests.TestCase):
eq_(self.client.get(reverse('zadmin.validation')).status_code, 200)
eq_(self.client.get(reverse('zadmin.flagged')).status_code, 403)
eq_(self.client.get(reverse('zadmin.langpacks')).status_code, 403)
eq_(self.client.get(reverse('zadmin.mass_delete')).status_code, 403)
eq_(self.client.get(reverse('zadmin.mass_delete.confirm')).status_code,
403)
eq_(self.client.get(reverse('zadmin.addon-search')).status_code, 403)
eq_(self.client.get(reverse('zadmin.settings')).status_code, 403)
eq_(self.client.get(
@ -1979,6 +2046,9 @@ class TestPerms(amo.tests.TestCase):
eq_(self.client.get(reverse('zadmin.settings')).status_code, 403)
eq_(self.client.get(reverse('zadmin.flagged')).status_code, 403)
eq_(self.client.get(reverse('zadmin.langpacks')).status_code, 403)
eq_(self.client.get(reverse('zadmin.mass_delete')).status_code, 403)
eq_(self.client.get(reverse('zadmin.mass_delete.confirm')).status_code,
403)
eq_(self.client.get(reverse('zadmin.addon-search')).status_code, 403)
eq_(self.client.get(reverse('zadmin.monthly_pick')).status_code, 403)
eq_(self.client.get(reverse('zadmin.features')).status_code, 403)

Просмотреть файл

@ -17,12 +17,15 @@ urlpatterns = patterns('',
url('^addon/recalc-hash/(?P<file_id>\d+)/', views.recalc_hash,
name='zadmin.recalc_hash'),
url('^env$', views.env, name='amo.env'),
url('^flagged', views.flagged, name='zadmin.flagged'),
url('^langpacks', views.langpacks, name='zadmin.langpacks'),
url('^flagged$', views.flagged, name='zadmin.flagged'),
url('^langpacks$', views.langpacks, name='zadmin.langpacks'),
url('^hera', views.hera, name='zadmin.hera'),
url('^memcache$', views.memcache, name='zadmin.memcache'),
url('^settings', views.show_settings, name='zadmin.settings'),
url('^fix-disabled', views.fix_disabled_file, name='zadmin.fix-disabled'),
url('^fix-disabled$', views.fix_disabled_file, name='zadmin.fix-disabled'),
url('^mass-delete$', views.mass_delete, name='zadmin.mass_delete'),
url('^mass-delete/confirm$', views.mass_delete_confirm,
name='zadmin.mass_delete.confirm'),
url(r'^validation/application_versions\.json$',
views.application_versions_json,
name='zadmin.application_versions_json'),

Просмотреть файл

@ -48,8 +48,10 @@ from . import tasks
from .decorators import admin_required
from .forms import (AddonStatusForm, BulkValidationForm, CompatForm,
DevMailerForm, FeaturedCollectionFormSet, FileFormSet,
JetpackUpgradeForm, MonthlyPickFormSet, NotifyForm,
OAuthConsumerForm, YesImSure)
JetpackUpgradeForm, MassDeleteForm, MassDeleteConfirmForm,
MonthlyPickFormSet, NotifyForm, OAuthConsumerForm,
YesImSure)
from .helpers import MassDeleteHelper
from .models import EmailPreviewTopic, ValidationJob, ValidationJobTally
log = commonware.log.getLogger('z.zadmin')
@ -181,7 +183,7 @@ def env(request):
return http.HttpResponse(u'<pre>%s</pre>' % (jinja2.escape(request)))
@admin.site.admin_view
@admin_required(reviewers=True)
def fix_disabled_file(request):
file_ = None
if request.method == 'POST' and 'file' in request.POST:
@ -194,6 +196,45 @@ def fix_disabled_file(request):
{'file': file_, 'file_id': request.POST.get('file', '')})
@admin_required
def mass_delete(request):
form = MassDeleteForm(request.POST or None)
if request.method == 'POST' and form.is_valid():
urls = form.cleaned_data['urls'].split()
helper = MassDeleteHelper(urls=urls)
form = MassDeleteConfirmForm({'objects': helper.object_types_json,
'reason': form.cleaned_data['reason']})
return render(request, 'zadmin/mass-delete-confirm.html',
{'deletion_helper': helper,
'unknown_urls': helper.unknown_urls,
'confirm_form': form})
return render(request, 'zadmin/mass-delete.html',
{'mass_delete_form': form})
@admin_required
def mass_delete_confirm(request):
form = MassDeleteConfirmForm(request.POST)
if request.method == 'POST':
print form.is_valid()
print form.cleaned_data
if not form.is_valid():
# Form is auto-generated. An invalid POST means user
# meddling.
raise PermissionDenied
helper = MassDeleteHelper(objects=form.cleaned_data['objects'],
reason=form.cleaned_data['reason'])
helper.delete_objects()
return redirect(reverse('zadmin.mass_delete.confirm'))
return render(request, 'zadmin/mass-delete-completed.html', {})
@login_required
@post_required
@json_view

Просмотреть файл

@ -108,6 +108,22 @@ div.popup input[type="text"] {
width: 588px;
}
.proper-lists ul li {
list-style: disc;
margin-left: 2em;
}
.admin-mass-delete h1 {
margin-top: 1em;
margin-bottom: 2em;
}
.admin-mass-delete h2,
.admin-mass-delete h3 {
margin-top: 1em;
margin-bottom: 1em;
}
.job-status {
color: #777777;
font-size: 11px;

Двоичные данные
media/img/zamboni/admin/cheers.jpg Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 257 KiB

Двоичные данные
media/img/zamboni/admin/warning-you.jpg Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 97 KiB