Skip to content

Commit 088cb89

Browse files
committed
Add ModelAdmin for Url and Link models
1 parent 3d6188e commit 088cb89

File tree

16 files changed

+974
-135
lines changed

16 files changed

+974
-135
lines changed

CHANGELOG

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
Unreleased
22

3+
* Migrate linkcheck views to `ModelAdmin` (Timo Ludwig, #186)
4+
* Add `ModelAdmin` for Url and Link models
35
* Fix internal redirect checker (Timo Ludwig, #180)
46
* Fix SSL status of unreachable domains (Timo Ludwig, #184)
57
* Fix URL message for internal server errorrs (Timo Ludwig, #182)

README.rst

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ django-linkcheck
1313
A fairly flexible app that will analyze and report on links in any model that
1414
you register with it.
1515

16-
.. image:: https://github.com/DjangoAdminHackers/django-linkcheck/raw/master/linkcheck.jpg
16+
.. image:: examples/linkcheck.jpg
1717

1818
Links can be bare (urls or image and file fields) or
1919
embedded in HTML (linkcheck handles the parsing). It's fairly easy to override
@@ -46,11 +46,16 @@ Basic usage
4646

4747
#. Run ``./manage.py migrate``.
4848

49-
#. Add to your root url config::
49+
#. Register linkcheck models in your admin::
5050

51-
path('admin/linkcheck/', include('linkcheck.urls'))
51+
from django.contrib import admin
52+
from linkcheck.models import Link, Url
53+
from linkcheck.admin import LinkAdmin, UrlAdmin
5254

53-
#. View ``/admin/linkcheck/`` from your browser.
55+
admin.site.register(Url, UrlAdmin)
56+
admin.site.register(Link, LinkAdmin)
57+
58+
#. View ``/admin/linkcheck/url/`` from your browser.
5459

5560
We are aware that this documentation is on the brief side of things so any
5661
suggestions for elaboration or clarification would be gratefully accepted.

examples/linkcheck.jpg

664 KB
Loading

linkcheck.jpg

-69.5 KB
Binary file not shown.

linkcheck/admin/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from .link_admin import LinkAdmin
2+
from .url_admin import UrlAdmin
3+
4+
__all__ = [
5+
"LinkAdmin", "UrlAdmin"
6+
]

linkcheck/admin/link_admin.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from django.contrib import admin, messages
2+
from django.utils.translation import gettext_lazy as _
3+
4+
from django.template.defaultfilters import yesno
5+
6+
from ..models import Link, Url
7+
from ..templatetags.linkcheck_admin_tags import (
8+
linkcheck_url,
9+
linkcheck_status_icon,
10+
linkcheck_source,
11+
)
12+
13+
14+
class LinkAdmin(admin.ModelAdmin):
15+
list_display = [
16+
'list_url',
17+
'list_status',
18+
'text',
19+
'list_content_object',
20+
'content_type',
21+
'list_field',
22+
'list_ignore',
23+
]
24+
actions = ['ignore', 'unignore']
25+
26+
@admin.display(ordering='url', description=Url._meta.get_field('url').verbose_name)
27+
def list_url(self, link):
28+
return linkcheck_url(link.url)
29+
30+
@admin.display(ordering='url__status', description=Url._meta.get_field('status').verbose_name)
31+
def list_status(self, link):
32+
return linkcheck_status_icon(link.url)
33+
34+
@admin.display(ordering='object_id', description=_('source'))
35+
def list_content_object(self, link):
36+
return linkcheck_source(link)
37+
38+
@admin.display(ordering='field', description=Link._meta.get_field('field').verbose_name)
39+
def list_field(self, link):
40+
return type(link.content_object)._meta.get_field(link.field).verbose_name
41+
42+
@admin.display(ordering='ignore', description=Link._meta.get_field('ignore').verbose_name)
43+
def list_ignore(self, link):
44+
return yesno(link.ignore)
45+
46+
def has_add_permission(self, request, obj=None):
47+
return False
48+
49+
def has_change_permission(self, request, obj=None):
50+
return False
51+
52+
@admin.action(description=_('Ignore selected links'))
53+
def ignore(self, request, queryset):
54+
queryset.update(ignore=True)
55+
messages.success(
56+
request,
57+
_('The selected links are now ignored.'),
58+
)
59+
60+
@admin.action(description=_('No longer ignore selected links'))
61+
def unignore(self, request, queryset):
62+
queryset.update(ignore=False)
63+
messages.success(
64+
request,
65+
_('The selected links are no longer ignored.'),
66+
)
67+
68+
def changelist_view(self, request, extra_context=None):
69+
extra_context = extra_context or {}
70+
if request.GET.get('ignore__exact') == '1':
71+
title = _('Ignored links')
72+
elif request.GET.get('ignore__exact') == '0':
73+
title = _('Not ignored links')
74+
else:
75+
title = _('Links')
76+
extra_context['title'] = title
77+
return super().changelist_view(request, extra_context=extra_context)
78+
79+
class Media:
80+
css = {
81+
'all': ['linkcheck/css/style.css'],
82+
}

linkcheck/admin/url_admin.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
from django.contrib import admin, messages
2+
from django.utils.translation import gettext_lazy as _
3+
from django.db.models import Count, Exists, OuterRef
4+
5+
from django.template.defaultfilters import yesno
6+
from ..models import Link, Url, TYPE_CHOICES
7+
from ..templatetags.linkcheck_admin_tags import (
8+
linkcheck_url,
9+
linkcheck_status_icon,
10+
linkcheck_ssl_icon,
11+
linkcheck_anchor_icon,
12+
linkcheck_status_code,
13+
linkcheck_links,
14+
)
15+
16+
17+
class StatusFilter(admin.BooleanFieldListFilter):
18+
"""
19+
A custom status filter to include the ignore-status of links
20+
"""
21+
title = _('status')
22+
parameter_name = 'status'
23+
ignore_kwarg = 'ignore'
24+
25+
def __init__(self, field, request, params, model, model_admin, field_path):
26+
self.ignore_val = params.get(self.ignore_kwarg)
27+
super().__init__(field, request, params, model, model_admin, field_path)
28+
29+
def expected_parameters(self):
30+
return super().expected_parameters() + [self.ignore_kwarg]
31+
32+
def choices(self, changelist):
33+
qs = Url.objects.annotate(
34+
ignore=Exists(Link.objects.filter(url=OuterRef('pk'), ignore=True))
35+
)
36+
field_choices = dict(self.field.flatchoices)
37+
return [
38+
{
39+
'selected': self.lookup_val is None and self.ignore_val is None and not self.lookup_val2,
40+
'query_string': changelist.get_query_string(
41+
{self.lookup_kwarg: None},
42+
[self.lookup_kwarg2, self.ignore_kwarg]
43+
),
44+
'display': _('All') + f' ({Url.objects.count()})',
45+
},
46+
{
47+
'selected': self.lookup_val == '1' and self.ignore_val == 'False' and not self.lookup_val2,
48+
'query_string': changelist.get_query_string(
49+
{self.lookup_kwarg: '1', self.ignore_kwarg: 'False'},
50+
[self.lookup_kwarg2]
51+
),
52+
'display': field_choices.get(True) + f' ({qs.filter(status=True, ignore=False).count()})',
53+
},
54+
{
55+
'selected': self.lookup_val == '0' and self.ignore_val == 'False' and not self.lookup_val2,
56+
'query_string': changelist.get_query_string(
57+
{self.lookup_kwarg: '0', self.ignore_kwarg: 'False'},
58+
[self.lookup_kwarg2]
59+
),
60+
'display': field_choices.get(False) + f' ({qs.filter(status=False, ignore=False).count()})',
61+
},
62+
{
63+
'selected': self.lookup_val2 == 'True' and self.ignore_val == 'False' and not self.lookup_val,
64+
'query_string': changelist.get_query_string(
65+
{self.lookup_kwarg2: 'True', self.ignore_kwarg: 'False'},
66+
[self.lookup_kwarg]
67+
),
68+
'display': field_choices.get(None) + f' ({qs.filter(status=None, ignore=False).count()})',
69+
},
70+
{
71+
'selected': self.ignore_val == 'True' and not self.lookup_val and not self.lookup_val2,
72+
'query_string': changelist.get_query_string(
73+
{self.ignore_kwarg: 'True'},
74+
[self.lookup_kwarg, self.lookup_kwarg2]
75+
),
76+
'display': _('Ignored') + f' ({qs.filter(ignore=True).count()})',
77+
}
78+
]
79+
80+
81+
class TypeFilter(admin.SimpleListFilter):
82+
title = _('type')
83+
parameter_name = 'type'
84+
85+
def lookups(self, request, model_admin):
86+
return TYPE_CHOICES
87+
88+
def queryset(self, request, queryset):
89+
if not self.value():
90+
return queryset
91+
urls = [url.pk for url in queryset if url.type == self.value()]
92+
return queryset.filter(pk__in=urls)
93+
94+
95+
class UrlAdmin(admin.ModelAdmin):
96+
list_display = [
97+
'list_url',
98+
'list_status',
99+
'list_ssl_status',
100+
'list_anchor_status',
101+
'list_status_code',
102+
'list_get_message',
103+
'list_type',
104+
'list_links',
105+
'list_ignore',
106+
]
107+
list_display_links = None
108+
list_filter = [('status', StatusFilter), TypeFilter]
109+
sortable_by = [
110+
'list_url',
111+
'list_status',
112+
'list_ssl_status',
113+
'list_anchor_status',
114+
'list_status_code',
115+
'list_links',
116+
]
117+
actions = ['recheck', 'ignore', 'unignore']
118+
empty_value_display = ''
119+
120+
@admin.display(ordering='url', description=Url._meta.get_field('url').verbose_name)
121+
def list_url(self, url):
122+
return linkcheck_url(url)
123+
124+
@admin.display(ordering='status', description=Url._meta.get_field('status').verbose_name)
125+
def list_status(self, url):
126+
return linkcheck_status_icon(url)
127+
128+
@admin.display(ordering='ssl_status', description=_('SSL'))
129+
def list_ssl_status(self, url):
130+
return linkcheck_ssl_icon(url)
131+
132+
@admin.display(ordering='anchor_status', description=_('Anchor'))
133+
def list_anchor_status(self, url):
134+
return linkcheck_anchor_icon(url)
135+
136+
@admin.display(ordering='status_code', description=Url._meta.get_field('status_code').verbose_name)
137+
def list_status_code(self, url):
138+
return linkcheck_status_code(url)
139+
140+
@admin.display(description=_('message'))
141+
def list_get_message(self, url):
142+
return url.get_message
143+
144+
@admin.display(ordering='type', description=_('type'))
145+
def list_type(self, url):
146+
return url.get_type_display()
147+
148+
@admin.display(ordering='links__count', description=Link._meta.verbose_name_plural)
149+
def list_links(self, url):
150+
return linkcheck_links(url)
151+
152+
@admin.display(description=Link._meta.get_field('ignore').verbose_name)
153+
def list_ignore(self, url):
154+
return yesno(url.ignore)
155+
156+
def has_add_permission(self, request, obj=None):
157+
return False
158+
159+
def has_change_permission(self, request, obj=None):
160+
return False
161+
162+
@admin.action(description=_('Recheck selected URLs'))
163+
def recheck(self, request, queryset):
164+
for url in queryset:
165+
url.check_url(external_recheck_interval=0)
166+
messages.success(
167+
request,
168+
_('The selected URLs were rechecked.'),
169+
)
170+
171+
@admin.action(description=_('Ignore selected URLs'))
172+
def ignore(self, request, queryset):
173+
Link.objects.filter(url__in=queryset).update(ignore=True)
174+
messages.success(
175+
request,
176+
_('The selected URLs are now ignored.'),
177+
)
178+
179+
@admin.action(description=_('No longer ignore selected URLs'))
180+
def unignore(self, request, queryset):
181+
Link.objects.filter(url__in=queryset).update(ignore=False)
182+
messages.success(
183+
request,
184+
_('The selected URLs are no longer ignored.'),
185+
)
186+
187+
def get_queryset(self, request):
188+
return super().get_queryset(request).annotate(
189+
Count('links'),
190+
ignore=Exists(Link.objects.filter(url=OuterRef('pk'), ignore=True))
191+
)
192+
193+
def changelist_view(self, request, extra_context=None):
194+
extra_context = extra_context or {}
195+
if request.GET.get('status__exact') == '1':
196+
title = _('Valid URLs')
197+
elif request.GET.get('status__exact') == '0':
198+
title = _('Invalid URLs')
199+
elif request.GET.get('status__isnull') == 'True':
200+
title = _('Unchecked URLs')
201+
elif request.GET.get('ignore') == 'True':
202+
title = _('Ignored URLs')
203+
else:
204+
title = _('URLs')
205+
extra_context['title'] = title
206+
return super().changelist_view(request, extra_context=extra_context)
207+
208+
class Media:
209+
css = {
210+
'all': ['linkcheck/css/style.css'],
211+
}

0 commit comments

Comments
 (0)