diff --git a/CHANGELOG b/CHANGELOG index a0d67dc..0a7d2ed 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,8 @@ Unreleased +* Migrate linkcheck views to `ModelAdmin` (Timo Brembeck, #186) + * Add `ModelAdmin` for Url and Link models + * Delete `report` view * Fix encoding of utf-8 domain names (Timo Brembeck, #190) * Move coverage view to management command (Timo Brembeck, #187) * Add new management command `linkcheck_suggest_config` diff --git a/MANIFEST.in b/MANIFEST.in index b979465..d0e8b8b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,4 +4,5 @@ include README.rst include linkcheck/locale/*/LC_MESSAGES/django.mo exclude linkcheck/locale/*/LC_MESSAGES/django.po recursive-include linkcheck/templates/linkcheck * +recursive-include linkcheck/static/linkcheck * recursive-include linkcheck/tests/media * diff --git a/README.rst b/README.rst index 7da35fd..74bb0de 100644 --- a/README.rst +++ b/README.rst @@ -13,7 +13,7 @@ django-linkcheck A fairly flexible app that will analyze and report on links in any model that you register with it. -.. image:: https://github.com/DjangoAdminHackers/django-linkcheck/raw/master/linkcheck.jpg +.. image:: examples/linkcheck.png Links can be bare (urls or image and file fields) or embedded in HTML (linkcheck handles the parsing). It's fairly easy to override @@ -50,11 +50,16 @@ Basic usage #. Run ``./manage.py migrate``. -#. Add to your root url config:: +#. Register linkcheck models in your admin:: - path('admin/linkcheck/', include('linkcheck.urls')) + from django.contrib import admin + from linkcheck.models import Link, Url + from linkcheck.admin import LinkAdmin, UrlAdmin -#. View ``/admin/linkcheck/`` from your browser. + admin.site.register(Url, UrlAdmin) + admin.site.register(Link, LinkAdmin) + +#. View ``/admin/linkcheck/url/`` from your browser. We are aware that this documentation is on the brief side of things so any suggestions for elaboration or clarification would be gratefully accepted. diff --git a/examples/linkcheck.jpg b/examples/linkcheck.jpg index 2172184..4bcfb70 100644 Binary files a/examples/linkcheck.jpg and b/examples/linkcheck.jpg differ diff --git a/linkcheck.jpg b/linkcheck.jpg deleted file mode 100644 index 2172184..0000000 Binary files a/linkcheck.jpg and /dev/null differ diff --git a/linkcheck/admin/__init__.py b/linkcheck/admin/__init__.py new file mode 100644 index 0000000..5d03e8f --- /dev/null +++ b/linkcheck/admin/__init__.py @@ -0,0 +1,6 @@ +from .link_admin import LinkAdmin +from .url_admin import UrlAdmin + +__all__ = [ + "LinkAdmin", "UrlAdmin" +] diff --git a/linkcheck/admin/link_admin.py b/linkcheck/admin/link_admin.py new file mode 100644 index 0000000..f11dd90 --- /dev/null +++ b/linkcheck/admin/link_admin.py @@ -0,0 +1,124 @@ +from django.contrib import admin, messages +from django.template.defaultfilters import yesno +from django.utils.translation import gettext_lazy as _ + +from ..models import Link, Url +from ..templatetags.linkcheck_admin_tags import ( + linkcheck_actions, + linkcheck_source, + linkcheck_status_icon, + linkcheck_url, + linkcheck_wrap_in_div, +) + + +class LinkAdmin(admin.ModelAdmin): + list_display = [ + 'list_url', + 'list_status', + 'list_text', + 'list_content_object', + 'content_type', + 'list_field', + 'list_ignore', + 'list_actions', + ] + actions = ['recheck', 'ignore', 'unignore'] + list_per_page = 15 + + @admin.display(ordering='url', description=Url._meta.get_field('url').verbose_name) + def list_url(self, link): + return linkcheck_url(link.url) + + @admin.display(ordering='url__status', description=Url._meta.get_field('status').verbose_name) + def list_status(self, link): + return linkcheck_status_icon(link.url) + + @admin.display(description=_('link text')) + def list_text(self, link): + return linkcheck_wrap_in_div(link.text) + + @admin.display(ordering='object_id', description=_('source')) + def list_content_object(self, link): + return linkcheck_source(link) + + @admin.display(ordering='field', description=Link._meta.get_field('field').verbose_name) + def list_field(self, link): + return type(link.content_object)._meta.get_field(link.field).verbose_name + + @admin.display(ordering='ignore', description=Link._meta.get_field('ignore').verbose_name) + def list_ignore(self, link): + return yesno(link.ignore) + + @admin.display(description=_('Actions')) + def list_actions(self, link): + return linkcheck_actions(link.url, obj=link) + + def has_add_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + @admin.action(description=_('Recheck selected links')) + def recheck(self, request, queryset): + for link in queryset: + link.url.check_url(external_recheck_interval=0) + if len(queryset) == 1: + messages.success( + request, + _('The link "{}" was rechecked.').format(queryset[0].url), + ) + else: + messages.success( + request, + _('The selected links were rechecked.'), + ) + + @admin.action(description=_('Ignore selected links')) + def ignore(self, request, queryset): + queryset.update(ignore=True) + if len(queryset) == 1: + messages.success( + request, + _('The link "{}" is now ignored.').format(queryset[0].url), + ) + else: + messages.success( + request, + _('The selected links are now ignored.'), + ) + + @admin.action(description=_('No longer ignore selected links')) + def unignore(self, request, queryset): + queryset.update(ignore=False) + if len(queryset) == 1: + messages.success( + request, + _('The link "{}" is no longer ignored.').format(queryset[0].url), + ) + else: + messages.success( + request, + _('The selected links are no longer ignored.'), + ) + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + if request.GET.get('ignore__exact') == '1': + title = _('Ignored links') + elif request.GET.get('ignore__exact') == '0': + title = _('Not ignored links') + else: + title = _('Links') + extra_context['title'] = title + return super().changelist_view(request, extra_context=extra_context) + + class Media: + css = { + 'all': ['linkcheck/css/style.css'], + } + js = ['linkcheck/js/actions.js'] diff --git a/linkcheck/admin/url_admin.py b/linkcheck/admin/url_admin.py new file mode 100644 index 0000000..a2845ed --- /dev/null +++ b/linkcheck/admin/url_admin.py @@ -0,0 +1,240 @@ +from django.contrib import admin, messages +from django.db.models import Count, Exists, OuterRef +from django.template.defaultfilters import yesno +from django.utils.translation import gettext_lazy as _ + +from ..models import TYPE_CHOICES, Link, Url +from ..templatetags.linkcheck_admin_tags import ( + linkcheck_actions, + linkcheck_anchor_icon, + linkcheck_links, + linkcheck_ssl_icon, + linkcheck_status_code, + linkcheck_status_icon, + linkcheck_url, + linkcheck_wrap_in_div, +) + + +class StatusFilter(admin.BooleanFieldListFilter): + """ + A custom status filter to include the ignore-status of links + """ + title = _('status') + parameter_name = 'status' + ignore_kwarg = 'ignore' + + def __init__(self, field, request, params, model, model_admin, field_path): + self.ignore_val = params.get(self.ignore_kwarg) + super().__init__(field, request, params, model, model_admin, field_path) + + def expected_parameters(self): + return super().expected_parameters() + [self.ignore_kwarg] + + def choices(self, changelist): + qs = Url.objects.annotate( + ignore=Exists(Link.objects.filter(url=OuterRef('pk'), ignore=True)) + ) + field_choices = dict(self.field.flatchoices) + return [ + { + 'selected': self.lookup_val is None and self.ignore_val is None and not self.lookup_val2, + 'query_string': changelist.get_query_string( + {self.lookup_kwarg: None}, + [self.lookup_kwarg2, self.ignore_kwarg] + ), + 'display': _('All') + f' ({Url.objects.count()})', + }, + { + 'selected': self.lookup_val == '1' and self.ignore_val == 'False' and not self.lookup_val2, + 'query_string': changelist.get_query_string( + {self.lookup_kwarg: '1', self.ignore_kwarg: 'False'}, + [self.lookup_kwarg2] + ), + 'display': field_choices.get(True) + f' ({qs.filter(status=True, ignore=False).count()})', + }, + { + 'selected': self.lookup_val == '0' and self.ignore_val == 'False' and not self.lookup_val2, + 'query_string': changelist.get_query_string( + {self.lookup_kwarg: '0', self.ignore_kwarg: 'False'}, + [self.lookup_kwarg2] + ), + 'display': field_choices.get(False) + f' ({qs.filter(status=False, ignore=False).count()})', + }, + { + 'selected': self.lookup_val2 == 'True' and self.ignore_val == 'False' and not self.lookup_val, + 'query_string': changelist.get_query_string( + {self.lookup_kwarg2: 'True', self.ignore_kwarg: 'False'}, + [self.lookup_kwarg] + ), + 'display': field_choices.get(None) + f' ({qs.filter(status=None, ignore=False).count()})', + }, + { + 'selected': self.ignore_val == 'True' and not self.lookup_val and not self.lookup_val2, + 'query_string': changelist.get_query_string( + {self.ignore_kwarg: 'True'}, + [self.lookup_kwarg, self.lookup_kwarg2] + ), + 'display': _('Ignored') + f' ({qs.filter(ignore=True).count()})', + } + ] + + +class TypeFilter(admin.SimpleListFilter): + title = _('type') + parameter_name = 'type' + + def lookups(self, request, model_admin): + return TYPE_CHOICES + + def queryset(self, request, queryset): + if not self.value(): + return queryset + urls = [url.pk for url in queryset if url.type == self.value()] + return queryset.filter(pk__in=urls) + + +class UrlAdmin(admin.ModelAdmin): + list_display = [ + 'list_url', + 'list_status', + 'list_ssl_status', + 'list_anchor_status', + 'list_status_code', + 'list_get_message', + 'list_type', + 'list_links', + 'list_ignore', + 'list_actions', + ] + list_filter = [('status', StatusFilter), TypeFilter] + sortable_by = [ + 'list_url', + 'list_status', + 'list_ssl_status', + 'list_anchor_status', + 'list_status_code', + 'list_links', + ] + actions = ['recheck', 'ignore', 'unignore'] + empty_value_display = '' + list_per_page = 15 + + @admin.display(ordering='url', description=Url._meta.get_field('url').verbose_name) + def list_url(self, url): + return linkcheck_url(url) + + @admin.display(ordering='status', description=Url._meta.get_field('status').verbose_name) + def list_status(self, url): + return linkcheck_status_icon(url) + + @admin.display(ordering='ssl_status', description=_('SSL')) + def list_ssl_status(self, url): + return linkcheck_ssl_icon(url) + + @admin.display(ordering='anchor_status', description=_('Anchor')) + def list_anchor_status(self, url): + return linkcheck_anchor_icon(url) + + @admin.display(ordering='status_code', description=Url._meta.get_field('status_code').verbose_name) + def list_status_code(self, url): + return linkcheck_status_code(url) + + @admin.display(description=_('message')) + def list_get_message(self, url): + return linkcheck_wrap_in_div(url.get_message) + + @admin.display(ordering='type', description=_('type')) + def list_type(self, url): + return url.get_type_display() + + @admin.display(ordering='links__count', description=Link._meta.verbose_name_plural) + def list_links(self, url): + return linkcheck_links(url) + + @admin.display(description=Link._meta.get_field('ignore').verbose_name) + def list_ignore(self, url): + return yesno(url.ignore) + + @admin.display(description=_('Actions')) + def list_actions(self, url): + return linkcheck_actions(url) + + def has_add_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + @admin.action(description=_('Recheck selected URLs')) + def recheck(self, request, queryset): + for url in queryset: + url.check_url(external_recheck_interval=0) + if len(queryset) == 1: + messages.success( + request, + _('The URL "{}" was rechecked.').format(queryset[0]), + ) + else: + messages.success( + request, + _('The selected URLs were rechecked.'), + ) + + @admin.action(description=_('Ignore selected URLs')) + def ignore(self, request, queryset): + Link.objects.filter(url__in=queryset).update(ignore=True) + if len(queryset) == 1: + messages.success( + request, + _('The URL "{}" is now ignored.').format(queryset[0]), + ) + else: + messages.success( + request, + _('The selected URLs are now ignored.'), + ) + + @admin.action(description=_('No longer ignore selected URLs')) + def unignore(self, request, queryset): + Link.objects.filter(url__in=queryset).update(ignore=False) + if len(queryset) == 1: + messages.success( + request, + _('The URL "{}" is no longer ignored.').format(queryset[0]), + ) + else: + messages.success( + request, + _('The selected URLs are no longer ignored.'), + ) + + def get_queryset(self, request): + return super().get_queryset(request).annotate( + Count('links'), + ignore=Exists(Link.objects.filter(url=OuterRef('pk'), ignore=True)) + ) + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + if request.GET.get('status__exact') == '1': + title = _('Valid URLs') + elif request.GET.get('status__exact') == '0': + title = _('Invalid URLs') + elif request.GET.get('status__isnull') == 'True': + title = _('Unchecked URLs') + elif request.GET.get('ignore') == 'True': + title = _('Ignored URLs') + else: + title = _('URLs') + extra_context['title'] = title + return super().changelist_view(request, extra_context=extra_context) + + class Media: + css = { + 'all': ['linkcheck/css/style.css'], + } + js = ['linkcheck/js/actions.js'] diff --git a/linkcheck/locale/de/LC_MESSAGES/django.po b/linkcheck/locale/de/LC_MESSAGES/django.po index 3e1bc89..6bb5615 100644 --- a/linkcheck/locale/de/LC_MESSAGES/django.po +++ b/linkcheck/locale/de/LC_MESSAGES/django.po @@ -1,13 +1,157 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-02-28 23:01+0100\n" +"POT-Creation-Date: 2023-11-21 02:07+0100\n" "Language: German\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: admin/link_admin.py:37 models.py:551 +msgid "link text" +msgstr "Link-Text" + +#: admin/link_admin.py:41 +msgid "source" +msgstr "Quelle" + +#: admin/link_admin.py:53 admin/url_admin.py:159 +msgid "Actions" +msgstr "Aktionen" + +#: admin/link_admin.py:66 +msgid "Recheck selected links" +msgstr "Ausgewählte Links erneut prüfen" + +#: admin/link_admin.py:73 +msgid "The link \"{}\" was rechecked." +msgstr "Der Link \"{}\" wurde erneut überprüft." + +#: admin/link_admin.py:78 +msgid "The selected links were rechecked." +msgstr "Die ausgewählten Links wurden erneut überprüft." + +#: admin/link_admin.py:81 +msgid "Ignore selected links" +msgstr "Ausgewählte Links ignorieren" + +#: admin/link_admin.py:87 +msgid "The link \"{}\" is now ignored." +msgstr "Der Link \"{}\" wird nun ignoriert." + +#: admin/link_admin.py:92 +msgid "The selected links are now ignored." +msgstr "Die ausgewählten Links werden nun ignoriert." + +#: admin/link_admin.py:95 +msgid "No longer ignore selected links" +msgstr "Ausgewählte Links nicht mehr ignorieren" + +#: admin/link_admin.py:101 +msgid "The link \"{}\" is no longer ignored." +msgstr "Der Link \"{}\" wird nicht mehr ignoriert." + +#: admin/link_admin.py:106 +msgid "The selected links are no longer ignored." +msgstr "Die ausgewählten Links werden nicht mehr ignoriert." + +#: admin/link_admin.py:112 +msgid "Ignored links" +msgstr "Ignorierte Links" + +#: admin/link_admin.py:114 +msgid "Not ignored links" +msgstr "Nicht ignorierte Links" + +#: admin/link_admin.py:116 +msgid "Links" +msgstr "Links" + +#: admin/url_admin.py:23 models.py:90 +msgid "status" +msgstr "Status" + +#: admin/url_admin.py:46 +msgid "All" +msgstr "Alle" + +#: admin/url_admin.py:78 +msgid "Ignored" +msgstr "ignoriert" + +#: admin/url_admin.py:84 admin/url_admin.py:147 +msgid "type" +msgstr "Typ" + +#: admin/url_admin.py:131 +msgid "SSL" +msgstr "" + +#: admin/url_admin.py:135 models.py:68 +msgid "Anchor" +msgstr "Anker" + +#: admin/url_admin.py:143 models.py:97 +msgid "message" +msgstr "Nachricht" + +#: admin/url_admin.py:172 +msgid "Recheck selected URLs" +msgstr "Ausgewählte URLs erneut prüfen" + +#: admin/url_admin.py:179 +msgid "The URL \"{}\" was rechecked." +msgstr "Die URL \"{}\" wurde erneut überprüft." + +#: admin/url_admin.py:184 +msgid "The selected URLs were rechecked." +msgstr "Die ausgewählten URLs wurden erneut überprüft." + +#: admin/url_admin.py:187 +msgid "Ignore selected URLs" +msgstr "Ausgewählte URLs ignorieren" + +#: admin/url_admin.py:193 +msgid "The URL \"{}\" is now ignored." +msgstr "Die URL \"{}\" wird nun ignoriert." + +#: admin/url_admin.py:198 +msgid "The selected URLs are now ignored." +msgstr "Die ausgewählten URLs werden nun ignoriert." + +#: admin/url_admin.py:201 +msgid "No longer ignore selected URLs" +msgstr "Ausgewählte URLs nicht mehr ignorieren" + +#: admin/url_admin.py:207 +msgid "The URL \"{}\" is no longer ignored." +msgstr "Die URL \"{}\" wird nicht mehr ignoriert." + +#: admin/url_admin.py:212 +msgid "The selected URLs are no longer ignored." +msgstr "Die ausgewählten URLs werden nicht mehr ignoriert." + +#: admin/url_admin.py:224 +msgid "Valid URLs" +msgstr "Gültige URLs" + +#: admin/url_admin.py:226 +msgid "Invalid URLs" +msgstr "Ungültige URLs" + +#: admin/url_admin.py:228 +msgid "Unchecked URLs" +msgstr "Nicht geprüfte URLs" + +#: admin/url_admin.py:230 +msgid "Ignored URLs" +msgstr "Ignorierte URLs" + +#: admin/url_admin.py:232 models.py:536 +msgid "URLs" +msgstr "URLs" + #: filebrowser.py:43 msgid "Uploading {} has corrected {} broken link." msgid_plural "Uploading {} has corrected {} broken links." @@ -53,220 +197,287 @@ msgstr[0] "" msgstr[1] "" "Das Löschen von {} hat dazu geführt, dass {} Links nicht mehr funktionieren." -#: models.py:118 +#: models.py:61 +msgid "Valid" +msgstr "Gültig" + +#: models.py:62 models.py:73 +msgid "Invalid" +msgstr "Ungültig" + +#: models.py:63 +msgid "Unchecked" +msgstr "Nicht geprüft" + +#: models.py:66 +msgid "External" +msgstr "Extern" + +#: models.py:67 +msgid "Internal" +msgstr "Intern" + +#: models.py:69 +msgid "File" +msgstr "Datei" + +#: models.py:70 +msgid "E-mail" +msgstr "Email" + +#: models.py:71 +msgid "Phone number" +msgstr "Telefonnummer" + +#: models.py:72 +msgid "Empty" +msgstr "Leer" + +#: models.py:86 models.py:535 models.py:550 +msgid "URL" +msgstr "URL" + +#: models.py:87 +msgid "last checked" +msgstr "zuletzt geprüft" + +#: models.py:88 +msgid "anchor status" +msgstr "Anker-Status" + +#: models.py:89 +msgid "SSL status" +msgstr "SSL-Status" + +#: models.py:91 +msgid "status code" +msgstr "Status-Code" + +#: models.py:95 +msgid "redirect status code" +msgstr "Status-Code der Weiterleitung" + +#: models.py:98 +msgid "error message" +msgstr "Fehlermeldung" + +#: models.py:99 +msgid "redirects to" +msgstr "Leitet weiter zu" + +#: models.py:140 msgid "Working empty anchor" msgstr "Funktionierender leerer Anker" -#: models.py:120 +#: models.py:142 msgid "Anchor could not be checked" msgstr "Anker konnte nicht geprüft werden" -#: models.py:122 +#: models.py:144 msgid "Broken anchor" msgstr "Ungültiger Anker" -#: models.py:123 +#: models.py:145 msgid "Working anchor" msgstr "Funktionierender Anker" -#: models.py:130 +#: models.py:152 msgid "Insecure link" msgstr "Unsicherer Link" -#: models.py:132 +#: models.py:154 msgid "SSL certificate could not be checked" msgstr "SSL-Zertifikat konnte nicht überprüft werden" -#: models.py:134 +#: models.py:156 msgid "Broken SSL certificate" msgstr "Fehlerhaftes SSL-Zertifikat" -#: models.py:135 +#: models.py:157 msgid "Valid SSL certificate" msgstr "Valides SSL-Zertifikat" -#: models.py:140 +#: models.py:162 msgid "URL Not Yet Checked" msgstr "URL noch nicht geprüft" -#: models.py:142 +#: models.py:164 msgid "Empty link" msgstr "Leerer Link" -#: models.py:144 +#: models.py:166 msgid "Invalid URL" msgstr "Ungültige URL" -#: models.py:146 +#: models.py:168 msgid "Email link" msgstr "Email-Link" -#: models.py:146 models.py:148 models.py:150 +#: models.py:168 models.py:170 models.py:172 msgid "not automatically checked" msgstr "nicht automatisch geprüft" -#: models.py:148 +#: models.py:170 msgid "Phone number link" msgstr "Telefonnummern-Link" -#: models.py:150 +#: models.py:172 msgid "Anchor link" msgstr "Anker-Link" -#: models.py:152 +#: models.py:174 msgid "Working file link" msgstr "Funktionierender Datei-Link" -#: models.py:152 +#: models.py:174 msgid "Missing file" msgstr "Fehlende Datei" -#: models.py:156 +#: models.py:178 msgid "Working external link" msgstr "Funktionierender externer Link" -#: models.py:156 +#: models.py:178 msgid "Working internal link" msgstr "Funktionierender interner Link" -#: models.py:160 +#: models.py:182 msgid "Working permanent redirect" msgstr "Funktionierende dauerhafte Weiterleitung" -#: models.py:160 +#: models.py:182 msgid "Working temporary redirect" msgstr "Funktionierende temporäre Weiterleitung" -#: models.py:162 +#: models.py:184 msgid "Broken permanent redirect" msgstr "Fehlerhafte dauerhafte Weiterleitung" -#: models.py:162 +#: models.py:184 msgid "Broken temporary redirect" msgstr "Fehlerhafte temporäre Weiterleitung" -#: models.py:163 +#: models.py:185 msgid "Broken external link" msgstr "Fehlerhafter externer Link" -#: models.py:163 +#: models.py:185 msgid "Broken internal link" msgstr "Fehlerhafter interner Link" -#: templates/linkcheck/base_linkcheck.html:5 -#: templates/linkcheck/base_linkcheck.html:11 -#: templates/linkcheck/base_linkcheck.html:17 -#: templates/linkcheck/coverage.html:14 -msgid "Link Checker" -msgstr "" +#: models.py:546 +msgid "source model" +msgstr "Quell-Modell" -#: templates/linkcheck/base_linkcheck.html:10 -#: templates/linkcheck/coverage.html:13 -msgid "Home" -msgstr "" +#: models.py:547 +msgid "source object id" +msgstr "Quell-Objekt ID" -#: templates/linkcheck/coverage.html:8 templates/linkcheck/coverage.html:15 -msgid "Coverage" -msgstr "Abdeckung" +#: models.py:549 +msgid "field" +msgstr "Feld" -#: templates/linkcheck/coverage.html:22 -msgid "Model" -msgstr "Datenbank-Modell" +#: models.py:552 +msgid "ignored" +msgstr "ignoriert" -#: templates/linkcheck/coverage.html:23 -msgid "Covered" -msgstr "Überprüft" +#: models.py:572 +msgid "link" +msgstr "Link" -#: templates/linkcheck/coverage.html:24 -msgid "Suggested config" -msgstr "Empfohlene Konfiguration" +#: models.py:573 +msgid "links" +msgstr "Links" -#: templates/linkcheck/coverage.html:30 -msgid "Yes,No" -msgstr "Ja,Nein" +#: templatetags/linkcheck_admin_tags.py:104 +msgid "Recheck" +msgstr "Erneut prüfen" -#: templates/linkcheck/paginator.html:7 -msgid "First" -msgstr "Erste" +#: templatetags/linkcheck_admin_tags.py:110 +msgid "Unignore" +msgstr "Nicht ignorieren" -#: templates/linkcheck/paginator.html:11 templates/linkcheck/paginator.html:13 -msgid "Previous" -msgstr "Vorherige" +#: templatetags/linkcheck_admin_tags.py:110 +msgid "Ignore" +msgstr "Ignorieren" -#: templates/linkcheck/paginator.html:17 -#, python-format -msgid "Page %(current)s of %(max)s" -msgstr "Seite %(current)s von %(max)s" +#: templatetags/linkcheck_admin_tags.py:125 +msgid "view" +msgstr "anzeigen" -#: templates/linkcheck/paginator.html:21 templates/linkcheck/paginator.html:23 -msgid "Next" -msgstr "Nächste" +#: templatetags/linkcheck_admin_tags.py:136 +msgid "edit" +msgstr "bearbeiten" -#: templates/linkcheck/paginator.html:27 templates/linkcheck/paginator.html:29 -msgid "Last" -msgstr "Letze" +#: templatetags/linkcheck_admin_tags.py:143 +msgid "filter" +msgstr "filtern" -#: templates/linkcheck/report.html:125 -msgid "Show" -msgstr "Anzeigen" +#~ msgid "Coverage" +#~ msgstr "Abdeckung" -#: templates/linkcheck/report.html:126 views.py:83 -msgid "Valid links" -msgstr "Gültige Links" +#~ msgid "Model" +#~ msgstr "Datenbank-Modell" -#: templates/linkcheck/report.html:127 views.py:92 -msgid "Broken links" -msgstr "Ungültige Links" +#~ msgid "Covered" +#~ msgstr "Überprüft" -#: templates/linkcheck/report.html:128 views.py:86 -msgid "Untested links" -msgstr "Ungetestete Links" +#~ msgid "Suggested config" +#~ msgstr "Empfohlene Konfiguration" -#: templates/linkcheck/report.html:129 views.py:89 -msgid "Ignored links" -msgstr "Ignorierte Links" +#~ msgid "Yes,No" +#~ msgstr "Ja,Nein" -#: templates/linkcheck/report.html:140 -#, python-format -msgid "View %(content_type_name)s" -msgstr "%(content_type_name)s anzeigen" +#~ msgid "First" +#~ msgstr "Erste" + +#~ msgid "Previous" +#~ msgstr "Vorherige" -#: templates/linkcheck/report.html:141 #, python-format -msgid "Edit %(content_type_name)s" -msgstr "%(content_type_name)s bearbeiten" +#~ msgid "Page %(current)s of %(max)s" +#~ msgstr "Seite %(current)s von %(max)s" -#: templates/linkcheck/report.html:143 -msgid "Destination" -msgstr "Ziel" +#~ msgid "Next" +#~ msgstr "Nächste" -#: templates/linkcheck/report.html:144 -msgid "Linked Text" -msgstr "Link-Text" +#~ msgid "Last" +#~ msgstr "Letze" -#: templates/linkcheck/report.html:145 -msgid "Field to edit" -msgstr "Zu bearbeitendes Feld" +#~ msgid "Show" +#~ msgstr "Anzeigen" -#: templates/linkcheck/report.html:146 -msgid "Status" -msgstr "" +#~ msgid "Valid links" +#~ msgstr "Gültige Links" -#: templates/linkcheck/report.html:157 -msgid "Recheck" -msgstr "Erneut prüfen" +#~ msgid "Broken links" +#~ msgstr "Ungültige Links" -#: templates/linkcheck/report.html:164 -msgid "Ignore" -msgstr "Ignorieren" +#~ msgid "Untested links" +#~ msgstr "Ungetestete Links" -#: templates/linkcheck/report.html:166 -msgid "Unignore" -msgstr "Nicht ignorieren" +#, python-format +#~ msgid "View %(content_type_name)s" +#~ msgstr "%(content_type_name)s anzeigen" -#: templates/linkcheck/report.html:173 -msgid "Redirects to" -msgstr "Leitet weiter zu" +#, python-format +#~ msgid "Edit %(content_type_name)s" +#~ msgstr "%(content_type_name)s bearbeiten" + +#~ msgid "Destination" +#~ msgstr "Ziel" + +#~ msgid "Linked Text" +#~ msgstr "Link-Text" + +#~ msgid "Field to edit" +#~ msgstr "Zu bearbeitendes Feld" + +#~ msgid "Status" +#~ msgstr "Status" + +#~ msgid "Redirects to" +#~ msgstr "Leitet weiter zu" #~ msgid "Link to section on same page" #~ msgstr "Link zu Abschnitt auf derselben Seite" diff --git a/linkcheck/migrations/0011_add_verbose_names.py b/linkcheck/migrations/0011_add_verbose_names.py new file mode 100644 index 0000000..7480af8 --- /dev/null +++ b/linkcheck/migrations/0011_add_verbose_names.py @@ -0,0 +1,116 @@ +import django.db.models.deletion +from django.db import migrations, models + +from linkcheck.models import STATUS_CODE_CHOICES + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('linkcheck', '0010_url_add_error_message'), + ] + + operations = [ + migrations.AlterModelOptions( + name='link', + options={'verbose_name': 'link', 'verbose_name_plural': 'links'}, + ), + migrations.AlterModelOptions( + name='url', + options={'verbose_name': 'URL', 'verbose_name_plural': 'URLs'}, + ), + migrations.AlterField( + model_name='link', + name='content_type', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='contenttypes.contenttype', + verbose_name='source model' + ), + ), + migrations.AlterField( + model_name='link', + name='field', + field=models.CharField(max_length=128, verbose_name='field'), + ), + migrations.AlterField( + model_name='link', + name='ignore', + field=models.BooleanField(default=False, verbose_name='ignored'), + ), + migrations.AlterField( + model_name='link', + name='object_id', + field=models.PositiveIntegerField(verbose_name='source object id'), + ), + migrations.AlterField( + model_name='link', + name='text', + field=models.CharField(default='', max_length=256, verbose_name='link text'), + ), + migrations.AlterField( + model_name='link', + name='url', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='links', + to='linkcheck.url', + verbose_name='URL' + ), + ), + migrations.AlterField( + model_name='url', + name='anchor_status', + field=models.BooleanField(null=True, verbose_name='anchor status'), + ), + migrations.AlterField( + model_name='url', + name='error_message', + field=models.CharField(blank=True, default='', max_length=1024, verbose_name='error message'), + ), + migrations.AlterField( + model_name='url', + name='last_checked', + field=models.DateTimeField(blank=True, null=True, verbose_name='last checked'), + ), + migrations.AlterField( + model_name='url', + name='message', + field=models.CharField(blank=True, max_length=1024, null=True, verbose_name='message'), + ), + migrations.AlterField( + model_name='url', + name='redirect_status_code', + field=models.IntegerField(choices=STATUS_CODE_CHOICES, null=True, verbose_name='redirect status code'), + ), + migrations.AlterField( + model_name='url', + name='redirect_to', + field=models.TextField(blank=True, verbose_name='redirects to'), + ), + migrations.AlterField( + model_name='url', + name='ssl_status', + field=models.BooleanField(null=True, verbose_name='SSL status'), + ), + migrations.AlterField( + model_name='url', + name='status', + field=models.BooleanField( + choices=[(True, 'Valid'), (False, 'Invalid'), (None, 'Unchecked')], + null=True, + verbose_name='status' + ), + ), + migrations.AlterField( + model_name='url', + name='status_code', + field=models.IntegerField(choices=STATUS_CODE_CHOICES, null=True, verbose_name='status code'), + ), + migrations.AlterField( + model_name='url', + name='url', + field=models.CharField(max_length=1024, unique=True, verbose_name='URL'), + ), + ] diff --git a/linkcheck/models.py b/linkcheck/models.py index d9b55b4..8c1e20a 100644 --- a/linkcheck/models.py +++ b/linkcheck/models.py @@ -15,7 +15,7 @@ from django.utils.encoding import iri_to_uri from django.utils.functional import cached_property from django.utils.timezone import now -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from requests.exceptions import ConnectionError, ReadTimeout try: @@ -57,6 +57,21 @@ def html_decode(s): return s +STATUS_CHOICES = [ + (True, _('Valid')), + (False, _('Invalid')), + (None, _('Unchecked')), +] +TYPE_CHOICES = [ + ('external', _('External')), + ('internal', _('Internal')), + ('anchor', _('Anchor')), + ('file', _('File')), + ('mailto', _('E-mail')), + ('phone', _('Phone number')), + ('empty', _('Empty')), + ('invalid', _('Invalid')), +] STATUS_CODE_CHOICES = [(s.value, f'{s.value} {s.phrase}') for s in HTTPStatus] DEFAULT_USER_AGENT = f'{settings.SITE_DOMAIN} Linkchecker' FALLBACK_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0' @@ -68,16 +83,20 @@ class Url(models.Model): A single Url can have multiple Links associated with it. """ # See http://www.boutell.com/newfaq/misc/urllength.html - url = models.CharField(max_length=MAX_URL_LENGTH, unique=True) - last_checked = models.DateTimeField(blank=True, null=True) - anchor_status = models.BooleanField(null=True) - ssl_status = models.BooleanField(null=True) - status = models.BooleanField(null=True) - status_code = models.IntegerField(choices=STATUS_CODE_CHOICES, null=True) - redirect_status_code = models.IntegerField(choices=STATUS_CODE_CHOICES, null=True) - message = models.CharField(max_length=1024, blank=True, null=True) - error_message = models.CharField(max_length=1024, default='', blank=True) - redirect_to = models.TextField(blank=True) + url = models.CharField(max_length=MAX_URL_LENGTH, unique=True, verbose_name=_('URL')) + last_checked = models.DateTimeField(blank=True, null=True, verbose_name=_('last checked')) + anchor_status = models.BooleanField(null=True, verbose_name=_('anchor status')) + ssl_status = models.BooleanField(null=True, verbose_name=_('SSL status')) + status = models.BooleanField(choices=STATUS_CHOICES, null=True, verbose_name=_('status')) + status_code = models.IntegerField(choices=STATUS_CODE_CHOICES, null=True, verbose_name=_('status code')) + redirect_status_code = models.IntegerField( + choices=STATUS_CODE_CHOICES, + null=True, + verbose_name=_('redirect status code') + ) + message = models.CharField(max_length=1024, blank=True, null=True, verbose_name=_('message')) + error_message = models.CharField(max_length=1024, default='', blank=True, verbose_name=_('error message')) + redirect_to = models.TextField(blank=True, verbose_name=_('redirects to')) @property def redirect_ok(self): @@ -102,6 +121,9 @@ def type(self): else: return 'invalid' + def get_type_display(self): + return dict(TYPE_CHOICES).get(self.type) + @property def has_anchor(self): return '#' in self.url @@ -118,7 +140,7 @@ def anchor_message(self): return _('Working empty anchor') if self.anchor_status is None: return _('Anchor could not be checked') - elif self.anchor_status is False: + if self.anchor_status is False: return _('Broken anchor') return _('Working anchor') @@ -509,6 +531,10 @@ def check_anchor(self, html): self.status = False return self.anchor_status, self.anchor_message + class Meta: + verbose_name = _("URL") + verbose_name_plural = _("URLs") + class Link(models.Model): """ @@ -517,13 +543,13 @@ class Link(models.Model): Such as a HTML or Rich Text field. Multiple Links can reference a single Url """ - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField() + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, verbose_name=_('source model')) + object_id = models.PositiveIntegerField(_('source object id')) content_object = GenericForeignKey('content_type', 'object_id') - field = models.CharField(max_length=128) - url = models.ForeignKey(Url, related_name="links", on_delete=models.CASCADE) - text = models.CharField(max_length=256, default='') - ignore = models.BooleanField(default=False) + field = models.CharField(max_length=128, verbose_name=_('field')) + url = models.ForeignKey(Url, related_name="links", on_delete=models.CASCADE, verbose_name=_('URL')) + text = models.CharField(max_length=256, default='', verbose_name=_('link text')) + ignore = models.BooleanField(default=False, verbose_name=_('ignored')) @property def display_url(self): @@ -542,6 +568,10 @@ def __str__(self): def __repr__(self): return f"" + class Meta: + verbose_name = _("link") + verbose_name_plural = _("links") + def link_post_delete(sender, instance, **kwargs): try: diff --git a/linkcheck/static/linkcheck/css/style.css b/linkcheck/static/linkcheck/css/style.css new file mode 100644 index 0000000..1f4416a --- /dev/null +++ b/linkcheck/static/linkcheck/css/style.css @@ -0,0 +1,109 @@ +#result_list > tbody > tr > td, th { + max-width: 500px; +} + +.nowrap { + white-space: unset; +} + +.field-list_url { + overflow-wrap: anywhere; +} + +.field-list_get_message > div, +.field-list_text > div, +.field-list_url > div { + max-height: 64px; + overflow: hidden; +} + +.column-list_anchor_status, +.column-list_links, +.column-list_ssl_status, +.column-list_status, +.field-list_anchor_status, +.field-list_links, +.field-list_ssl_status, +.field-list_status { + text-align: center; +} + +.field-content_type, +.field-list_content_object, +.field-list_get_message, +.field-list_text { + overflow-wrap: anywhere; + -moz-hyphens: auto; + -webkit-hyphens: auto; + hyphens: auto; +} + +.field-list_content_object > span { + display: inline-block; +} + +.column-list_actions, +.column-list_ignore, +.field-list_actions, +.field-list_ignore { + text-align: right; +} + +.field-list_links { + padding: 5px; +} + +.field-list_actions > a, +.field-list_links > a { + background: #e3e3e3; + display: inline-block; + font-weight: bold; + padding: 3px 8px; + text-align: center; +} + +.field-list_links > a { + -moz-border-radius: 50%; + -webkit-border-radius: 50%; + border-radius: 50%; +} + +.field-list_actions > a { + -moz-border-radius: 15px; + -webkit-border-radius: 15px; + border-radius: 15px; + margin-bottom: 5px; + white-space: nowrap; +} + +@media (prefers-color-scheme: dark) { + .field-list_links > a, + .field-list_actions > a { + background: #373737; + } +} + +.linkcheck-icon { + display: inline-block; + vertical-align: middle; + width: 13px; + height: 13px; + -webkit-mask-size: contain; + -webkit-mask-position: center; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + mask-repeat: no-repeat; +} + +.linkcheck-lock { + background: #70bf2b; + -webkit-mask-image: url(../img/lock.svg); + mask-image: url(../img/lock.svg); +} + +.linkcheck-lock-open { + background: #dd4646; + -webkit-mask-image: url(../img/lock-open.svg); + mask-image: url(../img/lock-open.svg); +} diff --git a/linkcheck/static/linkcheck/img/lock-open.svg b/linkcheck/static/linkcheck/img/lock-open.svg new file mode 100644 index 0000000..8f86a84 --- /dev/null +++ b/linkcheck/static/linkcheck/img/lock-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/linkcheck/static/linkcheck/img/lock.svg b/linkcheck/static/linkcheck/img/lock.svg new file mode 100644 index 0000000..38e13cc --- /dev/null +++ b/linkcheck/static/linkcheck/img/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/linkcheck/static/linkcheck/js/actions.js b/linkcheck/static/linkcheck/js/actions.js new file mode 100644 index 0000000..56c54fb --- /dev/null +++ b/linkcheck/static/linkcheck/js/actions.js @@ -0,0 +1,19 @@ +window.addEventListener("load", function () { + const changeListForm = document.getElementById("changelist-form"); + const actionButtons = Array.from(document.querySelectorAll(".field-list_actions > a")); + actionButtons.forEach((button) => + button.addEventListener("click", ({ target }) => { + const action = changeListForm.querySelector("select[name='action']"); + const currentCheckbox = target.closest("tr").querySelector(".action-select"); + const allCheckboxes = Array.from( + changeListForm.querySelectorAll('input[type="checkbox"].action-select:checked') + ); + action.value = target.getAttribute("data-action"); + allCheckboxes.forEach((checkbox) => { + checkbox.checked = false; + }); + currentCheckbox.checked = true; + changeListForm.submit(); + }) + ); +}); diff --git a/linkcheck/templates/linkcheck/base_linkcheck.html b/linkcheck/templates/linkcheck/base_linkcheck.html deleted file mode 100644 index 03bdcd8..0000000 --- a/linkcheck/templates/linkcheck/base_linkcheck.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "admin/change_list.html" %} -{% load i18n %} - -{% block title %} - {% translate "Link Checker" %} {{ block.super }} -{% endblock %} - -{% block breadcrumbs %} - -{% endblock %} - -{% block content %} -
-

{% translate "Link Checker" %}

-
- {% block innercontent %} - {% endblock %} -
-
-{% endblock %} diff --git a/linkcheck/templates/linkcheck/paginator.html b/linkcheck/templates/linkcheck/paginator.html deleted file mode 100644 index d48eb0d..0000000 --- a/linkcheck/templates/linkcheck/paginator.html +++ /dev/null @@ -1,32 +0,0 @@ -{% load i18n %} -
- - {% if pages.number > 1 %} - < First - {% else %} - < {% translate "First" %} - {% endif %} - - {% if pages.has_previous %} - << {% translate "Previous" %} - {% else %} - << {% translate "Previous" %} - {% endif %} - - - {% blocktrans with current=pages.number max=pages.paginator.num_pages %}Page {{ current }} of {{ max }}{% endblocktrans %} - - - {% if pages.has_next %} - {% translate "Next" %} >> - {% else %} - {% translate "Next" %} >> - {% endif %} - - {% if pages.number != pages.paginator.num_pages %} - {% translate "Last" %} > - {% else %} - {% translate "Last" %} > - {% endif %} - -
diff --git a/linkcheck/templates/linkcheck/report.html b/linkcheck/templates/linkcheck/report.html deleted file mode 100644 index 161a968..0000000 --- a/linkcheck/templates/linkcheck/report.html +++ /dev/null @@ -1,186 +0,0 @@ -{% extends "linkcheck/base_linkcheck.html" %} -{% load i18n %} -{% load linkcheck_model_tags %} -{% block extrahead %} -{{ block.super }} - - - -{% endblock %} - -{% block innercontent %} - -
- {% translate "Show" %}:   - {% if filter == 'show_valid' %}{% translate "Valid links" %}{% else %}{% translate "Valid links" %}{% endif %}   - {% if filter == 'show_invalid' %}{% translate "Broken links" %}{% else %}{% translate "Broken links" %}{% endif %}   - {% if filter == 'show_unchecked' %}{% translate "Untested links" %}{% else %}{% translate "Untested links" %}{% endif %}   - {% if filter == 'ignored' %}{% translate "Ignored links" %}{% else %}{% translate "Ignored links" %}{% endif %} - ({{ ignored_count }}) -
-
- - {% for content_type in content_types_list %} - - -

{{content_type.content_type|get_verbose_name_plural}}

- {% for object in content_type.object_list %} -
-

{{report_type}} in '{{object.object}}'

   - {% blocktrans with content_type_name=content_type.content_type.name %}View {{ content_type_name }}{% endblocktrans %}   - {% if object.admin_url %}{% blocktrans with content_type_name=content_type.content_type.name %}Edit {{ content_type_name }}{% endblocktrans %}{% endif %} - - - - - - - - {% for link in object.link_list %} - - - - - - - - - {% if link.url.redirect_to %} - - {% endif %} - {% endfor %} - -
- {% endfor %} -
- {% endfor %} - {% csrf_token %} - {% if content_types_list %} - {% include "linkcheck/paginator.html" %} - {% endif %} -{% endblock %} diff --git a/linkcheck/templatetags/linkcheck_admin_tags.py b/linkcheck/templatetags/linkcheck_admin_tags.py new file mode 100644 index 0000000..131f96a --- /dev/null +++ b/linkcheck/templatetags/linkcheck_admin_tags.py @@ -0,0 +1,151 @@ +from django import template +from django.contrib.admin.utils import display_for_value +from django.urls import NoReverseMatch, reverse +from django.utils.html import format_html, mark_safe +from django.utils.text import Truncator +from django.utils.translation import gettext as _ + +register = template.Library() + +MAX_URL_CHARS = 100 + + +@register.simple_tag +def linkcheck_url(url): + url_tag = format_html( + '
{}
', + url.url, + Truncator(url.url).chars(MAX_URL_CHARS), + ) + if url.redirect_to: + url_tag += format_html( + '
↪ {}
', + url.redirect_to, + Truncator(url.redirect_to).chars(MAX_URL_CHARS), + ) + return url_tag + + +@register.simple_tag +def linkcheck_status_icon(url): + icon_tag = display_for_value(url.status, '', boolean=True) + return format_html( + '{}', + url.get_status_display(), + mark_safe(icon_tag), + ) + + +@register.simple_tag +def linkcheck_ssl_icon(url): + if url.internal: + return '' + if url.external_url.startswith('http://'): + icon_tag = display_for_value(False, '', boolean=True) + elif url.ssl_status is None: + icon_tag = display_for_value(None, '', boolean=True) + else: + icon_class = 'linkcheck-lock' if url.ssl_status else 'linkcheck-lock-open' + icon_tag = format_html('', icon_class) + return format_html( + '{}', + url.ssl_message, + mark_safe(icon_tag), + ) + + +@register.simple_tag +def linkcheck_anchor_icon(url): + if not url.has_anchor or not url.last_checked: + return '' + icon_tag = display_for_value(url.anchor_status, '', boolean=True) + return format_html( + '{}', + f'{url.anchor_message}: #{url.anchor}', + mark_safe(icon_tag), + ) + + +@register.simple_tag +def linkcheck_status_code(url): + return url.get_redirect_status_code_display() or url.get_status_code_display() + + +@register.simple_tag +def linkcheck_wrap_in_div(text): + return format_html( + '
{}
', + text, + ) + + +@register.simple_tag +def linkcheck_links(url): + try: + link_count = url.links__count + except AttributeError: + link_count = url.links.count() + return format_html( + '{}', + reverse('admin:linkcheck_link_changelist'), + url.pk, + link_count, + ) + + +@register.simple_tag +def linkcheck_actions(url, obj=None): + if not obj: + obj = url + recheck_link = '' if url.status else format_html( + '{}', + 'recheck', + obj.pk, + _('Recheck'), + ) + ignore_link = format_html( + '{}', + 'unignore' if obj.ignore else 'ignore', + obj.pk, + _('Unignore') if obj.ignore else _('Ignore'), + ) + return format_html( + '{} {}', + mark_safe(recheck_link), + mark_safe(ignore_link), + ) + + +@register.simple_tag +def linkcheck_source(link): + try: + view_link = format_html( + '{} | ', + link.content_object.get_absolute_url(), + _('view'), + ) + except AttributeError: + view_link = '' + try: + edit_link = format_html( + '{} | ', + reverse( + f'admin:{link.content_type.app_label}_{link.content_type.model}_change', + kwargs={'object_id': link.object_id} + ), + _('edit'), + ) + except NoReverseMatch: + edit_link = '' + filter_link = format_html( + '{}', + f'?content_type_id__exact={link.content_type_id}&object_id__exact={link.object_id}', + _('filter'), + ) + return format_html( + '{} ({}{}{})', + link.content_object, + mark_safe(view_link), + mark_safe(edit_link), + mark_safe(filter_link), + ) diff --git a/linkcheck/tests/sampleapp/admin.py b/linkcheck/tests/sampleapp/admin.py new file mode 100644 index 0000000..1fd5452 --- /dev/null +++ b/linkcheck/tests/sampleapp/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +from linkcheck.admin import LinkAdmin, UrlAdmin +from linkcheck.models import Link, Url + +admin.site.register(Url, UrlAdmin) +admin.site.register(Link, LinkAdmin) diff --git a/linkcheck/tests/test_linkcheck.py b/linkcheck/tests/test_linkcheck.py index 22fdeb2..8dc3c0f 100644 --- a/linkcheck/tests/test_linkcheck.py +++ b/linkcheck/tests/test_linkcheck.py @@ -25,7 +25,6 @@ unregister_listeners, ) from linkcheck.models import Link, Url -from linkcheck.views import get_jquery_min_js from .sampleapp.models import Author, Book, Journal, Page @@ -1128,51 +1127,49 @@ def test_display_url(self): set(["http://www.example.org", "http://www.example.org#john"]), ) - def test_report_view(self): + def test_url_list(self): self.client.force_login(self.user) - response = self.client.get(reverse('linkcheck_report')) - self.assertContains(response, "

Link Checker

") + response = self.client.get(reverse('admin:linkcheck_url_changelist')) + self.assertContains(response, "

URLs

") + self.assertEqual(response.status_code, 200) - def test_report_ignore_unignore(self): + def test_link_list(self): + self.client.force_login(self.user) + response = self.client.get(reverse('admin:linkcheck_link_changelist')) + self.assertContains(response, "

Links

") + self.assertEqual(response.status_code, 200) + + def test_url_ignore_unignore(self): Author.objects.create(name="John Smith", website="http://www.example.org/john") self.client.force_login(self.user) link = Link.objects.first() self.assertFalse(link.ignore) response = self.client.post( - reverse('linkcheck_report') + f"?ignore={link.pk}", - HTTP_X_REQUESTED_WITH='XMLHttpRequest' + reverse('admin:linkcheck_url_changelist'), + data={'action': 'ignore', '_selected_action': link.pk} ) - self.assertEqual(response.json(), {'link': link.pk}) + self.assertEqual(response.status_code, 302) link.refresh_from_db() self.assertTrue(link.ignore) response = self.client.post( - reverse('linkcheck_report') + f"?unignore={link.pk}", - HTTP_X_REQUESTED_WITH='XMLHttpRequest' + reverse('admin:linkcheck_url_changelist'), + data={'action': 'unignore', '_selected_action': link.pk} ) - self.assertEqual(response.json(), {'link': link.pk}) + self.assertEqual(response.status_code, 302) link.refresh_from_db() self.assertFalse(link.ignore) - def test_report_recheck(self): + def test_url_recheck(self): Author.objects.create(name="John Smith", website="http://www.example.org/john") self.client.force_login(self.user) link = Link.objects.first() response = self.client.post( - reverse('linkcheck_report') + f"?recheck={link.pk}", - HTTP_X_REQUESTED_WITH='XMLHttpRequest' - ) - self.assertEqual(response.json(), { - 'colour': 'red', - 'links': [link.pk], - 'message': '404 Not Found', - }) - - -class GetJqueryMinJsTestCase(TestCase): - def test(self): - self.assertEqual( - 'admin/js/vendor/jquery/jquery.min.js', get_jquery_min_js() + reverse('admin:linkcheck_url_changelist'), + data={'action': 'recheck', '_selected_action': link.pk} ) + self.assertEqual(response.status_code, 302) + link.refresh_from_db() + self.assertIsNotNone(link.ignore) class FixtureTestCase(TestCase): diff --git a/linkcheck/tests/urls.py b/linkcheck/tests/urls.py index 408675f..d768295 100644 --- a/linkcheck/tests/urls.py +++ b/linkcheck/tests/urls.py @@ -1,6 +1,6 @@ from django import http from django.contrib import admin -from django.urls import include, path +from django.urls import path from django.views.generic import RedirectView from linkcheck.tests.sampleapp import views @@ -11,7 +11,6 @@ def handler404(*args, **kwargs): urlpatterns = [ - path('admin/linkcheck/', include('linkcheck.urls')), path('admin/', admin.site.urls), path('public/', views.http_response, {'code': '200'}), path('http//', views.http_response), diff --git a/linkcheck/urls.py b/linkcheck/urls.py deleted file mode 100644 index 10a4f07..0000000 --- a/linkcheck/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path - -from . import views - -urlpatterns = [ - path('', views.report, name='linkcheck_report'), -] diff --git a/linkcheck/views.py b/linkcheck/views.py index 41de93e..7033034 100644 --- a/linkcheck/views.py +++ b/linkcheck/views.py @@ -1,20 +1,6 @@ -from itertools import groupby -from operator import itemgetter - -from django import forms from django.contrib.admin.views.decorators import staff_member_required -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.paginator import Paginator -from django.http import JsonResponse from django.shortcuts import render -from django.templatetags.static import static -from django.urls import NoReverseMatch, reverse -from django.utils.translation import gettext as _ -from linkcheck import update_lock -from linkcheck.linkcheck_settings import RESULTS_PER_PAGE -from linkcheck.models import Link from linkcheck.utils import get_coverage_data @@ -33,152 +19,3 @@ def coverage(request): context = {'coverage_data': coverage_data} return render(request, template, context) - - -@staff_member_required -def report(request): - - outerkeyfunc = itemgetter('content_type_id') - content_types_list = [] - - if request.method == 'POST': - - ignore_link_id = request.GET.get('ignore', None) - if ignore_link_id is not None: - link = Link.objects.get(id=ignore_link_id) - link.ignore = True - link.save() - if is_ajax(request): - json_data = {'link': link.pk} - return JsonResponse(json_data) - - unignore_link_id = request.GET.get('unignore', None) - if unignore_link_id is not None: - link = Link.objects.get(id=unignore_link_id) - link.ignore = False - link.save() - if is_ajax(request): - json_data = {'link': link.pk} - return JsonResponse(json_data) - - recheck_link_id = request.GET.get('recheck', None) - if recheck_link_id is not None: - link = Link.objects.get(id=recheck_link_id) - url = link.url - url.check_url(external_recheck_interval=0) - links = [x[0] for x in url.links.values_list('id')] - if is_ajax(request): - json_data = ({ - 'links': links, - 'message': url.message, - 'colour': url.colour, - }) - return JsonResponse(json_data) - - link_filter = request.GET.get('filters', 'show_invalid') - - qset = Link.objects.order_by('-url__last_checked') - if link_filter == 'show_valid': - qset = qset.filter(ignore=False, url__status__exact=True) - report_type = _('Valid links') - elif link_filter == 'show_unchecked': - qset = qset.filter(ignore=False, url__last_checked__exact=None) - report_type = _('Untested links') - elif link_filter == 'ignored': - qset = qset.filter(ignore=True) - report_type = _('Ignored links') - else: - qset = qset.filter(ignore=False, url__status__exact=False) - report_type = _('Broken links') - - paginated_links = Paginator(qset, RESULTS_PER_PAGE, 0, True) - - try: - page = int(request.GET.get("page", "1")) - except ValueError: - page = 0 - # offset = (page - 1) * RESULTS_PER_PAGE - links = paginated_links.page(page) - - # This code groups links into nested lists by content type and object id - # It's a bit nasty but we can't use groupby unless be get values() - # instead of a queryset because of the 'Object is not subscriptable' error - - t = sorted(links.object_list.values(), key=outerkeyfunc) - for tk, tg in groupby(t, outerkeyfunc): - innerkeyfunc = itemgetter('object_id') - objects = [] - tg = sorted(tg, key=innerkeyfunc) - for ok, og in groupby(tg, innerkeyfunc): - content_type = ContentType.objects.get(pk=tk) - og = list(og) - try: - object = None - if content_type.model_class(): - object = content_type.model_class().objects.get(pk=ok) - except ObjectDoesNotExist: - pass - try: - admin_url = object.get_admin_url() # TODO allow method name to be configurable - except AttributeError: - try: - admin_url = reverse(f'admin:{content_type.app_label}_{content_type.model}_change', args=[ok]) - except NoReverseMatch: - admin_url = None - - objects.append({ - 'object': object, - # Convert values_list back to queryset. Do we need to get values() or do we just need a list of ids? - 'link_list': Link.objects.in_bulk([x['id'] for x in og]).values(), - 'admin_url': admin_url, - }) - content_types_list.append({ - 'content_type': content_type, - 'object_list': objects - }) - - # Pass any querystring data back to the form minus page - rqst = request.GET.copy() - if 'page' in rqst: - del rqst['page'] - - return render(request, 'linkcheck/report.html', { - 'content_types_list': content_types_list, - 'pages': links, - 'filter': link_filter, - 'media': forms.Media(js=[static(get_jquery_min_js())]), - 'qry_data': rqst.urlencode(), - 'report_type': report_type, - 'ignored_count': Link.objects.filter(ignore=True).count(), - }, - ) - - -def get_jquery_min_js(): - """ - Return the location of jquery.min.js. It's an entry point to adapt the path - when it changes in Django. - """ - return 'admin/js/vendor/jquery/jquery.min.js' - - -def get_status_message(): - if update_lock.locked(): - return "Still checking. Please refresh this page in a short while. " - else: - broken_links = Link.objects.filter(ignore=False, url__status=False).count() - if broken_links: - return ( - "We've found {} broken link{}.
" - "View/fix broken links".format( - broken_links, - "s" if broken_links > 1 else "", - reverse('linkcheck_report'), - ) - ) - else: - return '' - - -def is_ajax(request): - return request.META.get('HTTP_X_REQUESTED_WITH') == 'XMLHttpRequest'