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 %} -{{content_type.content_type|get_verbose_name_plural}}- {% for object in content_type.object_list %} -
-
- {% endfor %}
- {{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 %} -
|