diff --git a/AUTHORS b/AUTHORS index 1fe38b5666..c29f30741b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1036,6 +1036,7 @@ answer newbie questions, and generally made Django that much better: Tyson Clugg Tyson Tate Unai Zalakain + Ülgen Sarıkavak Valentina Mukhamedzhanova valtron Vasiliy Stavenko diff --git a/django/core/management/commands/listurls.py b/django/core/management/commands/listurls.py new file mode 100644 index 0000000000..325e102443 --- /dev/null +++ b/django/core/management/commands/listurls.py @@ -0,0 +1,275 @@ +import json +from importlib import import_module +from io import StringIO + +from django.conf import settings +from django.contrib.admindocs.views import ( + extract_views_from_urlpatterns, + simplify_regex, +) +from django.core.management import color +from django.core.management.base import BaseCommand, CommandError, CommandParser +from django.utils import termcolors + +FORMATS = ( + "aligned", + "table", + "verbose", + "json", + "pretty-json", +) + + +COLORLESS_FORMATS = ( + "json", + "pretty-json", +) + + +class Command(BaseCommand): + help = "List URL patterns in the project with optional filtering by prefixes." + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.style.HEADER = termcolors.make_style(opts=("bold",)) + self.style.ROUTE = termcolors.make_style(opts=("bold",)) + self.style.VIEW_NAME = termcolors.make_style(fg="yellow", opts=("bold",)) + self.style.NAME = termcolors.make_style(fg="red", opts=("bold",)) + + def add_arguments(self, parser: CommandParser): + super().add_arguments(parser) + + # Sorting + parser.add_argument( + "--unsorted", + "-u", + action="store_true", + dest="unsorted", + help="Filter URLs by the given prefix[es]", + ) + + # Prefix + parser.add_argument( + "--prefix", + "-p", + dest="prefixes", + help="Only list URLs with these prefixes.", + nargs="*", + ) + + # Format + parser.add_argument( + "--format", + "-f", + choices=FORMATS, + default="aligned", + dest="format", + help="Formatting style of the output", + ) + + def handle(self, *args, **options): + # Make sure the prefixes are a list + prefixes = options["prefixes"] + if prefixes: + prefixes = prefixes if isinstance(prefixes, list) else [prefixes] + + url_patterns = get_url_patterns(prefixes=prefixes) + + if not url_patterns: + raise CommandError("There are no URL patterns that match given prefixes") + + unsorted = options["unsorted"] + no_color = options["no_color"] + format = options["format"] + + # Apply sorting + if not unsorted: + url_patterns.sort() + + # Apply colors + if ( + color.supports_color() + and (not no_color) + and (format not in COLORLESS_FORMATS) + ): + url_patterns = self.apply_color(url_patterns=url_patterns) + + # Apply formatting + url_patterns = self.apply_format( + url_patterns=url_patterns, + format=format, + ) + + return url_patterns + + def apply_color(self, url_patterns): + colored_url_patterns = [] + + for url_pattern in url_patterns: + # Route + route = url_pattern[0] + route = self.style.ROUTE(route) + + # View + view = url_pattern[1] + module_path, module_name = view.rsplit(".", 1) + module_name = self.style.VIEW_NAME(module_name) + view = f"{module_path}.{module_name}" + + # Name + name = url_pattern[2] + if name: + namespace, name = name.rsplit(":", 1) if ":" in name else ("", name) + name = self.style.NAME(name) + name = f"{namespace}:{name}" if namespace else name + + # Append to the list + colored_url_patterns.append((route, view, name)) + + return colored_url_patterns + + def apply_format(self, url_patterns, format): + format_method_name = f"format_{format.replace('-', '_')}" + format_method = getattr(self, format_method_name) + + return format_method(url_patterns) + + def format_table(self, url_patterns): + formatted_str = StringIO() + + widths = [] + margin = 2 + for columns in zip(*url_patterns, strict=False): + widths.append(len(max(columns, key=len)) + margin) + + # Headers + headers = ( + self.style.HEADER("Route"), + self.style.HEADER("View"), + self.style.HEADER("Name"), + ) + + header_parts = [] + for width, header in zip(widths, headers, strict=False): + header_parts.append(header.ljust(width)) + + formatted_str.write(" | ".join(header_parts)) + formatted_str.write("\n") + + # Header - content seperator + formatted_str.write("-+-".join("-" * width for width in widths)) + formatted_str.write("\n") + + # Rows (content) + for row in url_patterns: + row_parts = [] + + for width, cdata in zip(widths, row, strict=False): + row_parts.append(cdata.ljust(width)) + + formatted_str.write(" | ".join(row_parts)) + formatted_str.write("\n") + + return formatted_str.getvalue() + + def format_aligned(self, url_patterns): + formatted_str = StringIO() + + widths = [] + for columns in zip(*url_patterns, strict=False): + margin = 2 + widths.append(len(max(columns, key=len)) + margin) + + for row in url_patterns: + for width, cdata in zip(widths, row, strict=False): + formatted_str.write(cdata.ljust(width)) + + formatted_str.write("\n") + + return formatted_str.getvalue() + + def format_verbose(self, url_patterns): + formatted_str = StringIO() + + for route, view, name in url_patterns: + route_str = f"{self.style.HEADER('Route:')} {route}" + view_str = f"{self.style.HEADER('View:')} {view}" + name_str = f"{self.style.HEADER('Name:')} {name}" if name else "" + + seperator = "-" * 20 + "\n" + parts = ( + route_str, + view_str, + name_str, + seperator, + ) + + formatted_str.write("\n".join(part for part in parts if part)) + + return formatted_str.getvalue() + + def format_json(self, url_patterns, pretty=False): + indent = 4 if pretty else None + + # Having keys in the resulting JSON makes it more useful + url_pattern_dicts = [] + for route, view, name in url_patterns: + url_pattern_dict = { + "route": route, + "view": view, + "name": name, + } + url_pattern_dicts.append(url_pattern_dict) + + return json.dumps(url_pattern_dicts, indent=indent) + + def format_pretty_json(self, url_patterns): + return self.format_json(url_patterns, pretty=True) + + +def get_url_patterns(prefixes=None): + """ + Returns a list of URL patterns in the project with given prefixes. + + Each object in the returned list is a tuple[str] with 3 elements: + (route, view, name) + """ + + url_patterns = [] + urlconf = import_module(settings.ROOT_URLCONF) + + for view_func, regex, namespace_list, name in extract_views_from_urlpatterns( + urlconf.urlpatterns + ): + # Route + route = simplify_regex(regex) + + # View + view = "{}.{}".format( + view_func.__module__, + getattr(view_func, "__name__", view_func.__class__.__name__), + ) + + # Name + namespace = "" + + if namespace_list: + for part in namespace_list: + namespace += part + ":" + + name = namespace + name if name else None + name = name or "" + + # Append to the list + url_patterns.append((route, view, name)) + + # Filter out when prefixes are given but the pattern's route doesn't match + if prefixes: + url_patterns = [ + url_pattern + for url_pattern in url_patterns + if any(url_pattern[0].startswith(prefix) for prefix in prefixes) + ] + + return url_patterns diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index 128abe5587..8316cbb7e4 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -498,6 +498,18 @@ Only support for PostgreSQL is implemented. If this option is provided, models are also created for database views. +``listurls`` +------------ + +.. django-admin:: listurls [url_prefix ...] + +Displays a list of the URLs and their associated short names and views in the +project. Optionally restrict to one or more prefixes. + +.. django-admin-option:: --table + +Output the list of URLs as a table, one URL per line. + ``loaddata`` ------------ diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt index 88a1daa45d..3a142934ee 100644 --- a/docs/releases/5.2.txt +++ b/docs/releases/5.2.txt @@ -255,6 +255,9 @@ Management Commands ``Command.autodetector`` attribute for subclasses to override in order to use a custom autodetector class. +* Introduce a :djadmin:`listurls` command that lists the URLs in the application, + including the view function and name, if present. + Migrations ~~~~~~~~~~ diff --git a/tests/admin_scripts/app_with_urls/__init__.py b/tests/admin_scripts/app_with_urls/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/admin_scripts/app_with_urls/root_urls.py b/tests/admin_scripts/app_with_urls/root_urls.py new file mode 100644 index 0000000000..e15dd4316d --- /dev/null +++ b/tests/admin_scripts/app_with_urls/root_urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path + +urlpatterns = [ + path( + route="nons/", + view=include("admin_scripts.app_with_urls.urls_nons"), + ), + path( + route="namespaced/", + view=include("admin_scripts.app_with_urls.urls_namespaced", namespace="ns"), + ), +] diff --git a/tests/admin_scripts/app_with_urls/urls_namespaced.py b/tests/admin_scripts/app_with_urls/urls_namespaced.py new file mode 100644 index 0000000000..a993271c79 --- /dev/null +++ b/tests/admin_scripts/app_with_urls/urls_namespaced.py @@ -0,0 +1,17 @@ +from django.urls import path + +from . import views + +app_name = "app_with_urls" + +urlpatterns = [ + path( + route="unnamed", + view=views.view_func_namespaced_unnamed, + ), + path( + route="named", + view=views.view_func_namespaced_named, + name="named", + ), +] diff --git a/tests/admin_scripts/app_with_urls/urls_nons.py b/tests/admin_scripts/app_with_urls/urls_nons.py new file mode 100644 index 0000000000..e957d09fa2 --- /dev/null +++ b/tests/admin_scripts/app_with_urls/urls_nons.py @@ -0,0 +1,17 @@ +from django.urls import path + +from . import views + +app_name = "app_with_urls" + +urlpatterns = [ + path( + route="unnamed", + view=views.view_func_nons_unnamed, + ), + path( + route="named", + view=views.view_func_nons_named, + name="named", + ), +] diff --git a/tests/admin_scripts/app_with_urls/views.py b/tests/admin_scripts/app_with_urls/views.py new file mode 100644 index 0000000000..ad014bb807 --- /dev/null +++ b/tests/admin_scripts/app_with_urls/views.py @@ -0,0 +1,14 @@ +def view_func_namespaced_unnamed(request): + pass + + +def view_func_namespaced_named(request): + pass + + +def view_func_nons_unnamed(request): + pass + + +def view_func_nons_named(request): + pass diff --git a/tests/admin_scripts/tests.py b/tests/admin_scripts/tests.py index 6fdd873661..72c50e8265 100644 --- a/tests/admin_scripts/tests.py +++ b/tests/admin_scripts/tests.py @@ -4,6 +4,7 @@ advertised - especially with regards to the handling of the DJANGO_SETTINGS_MODULE and default settings.py files. """ +import json import os import re import shutil @@ -3148,6 +3149,286 @@ class Dumpdata(AdminScriptTestCase): self.assertNoOutput(out) +class Listurls(AdminScriptTestCase): + """ + Tests for the listurls command. + """ + + @override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls") + def test_default(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls"] + out, err = self.run_manage(args) + + self.assertNoOutput(err) + + # Check route, view and (if defined) name for each URL + self.assertOutput(out, "/namespaced/named") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_named", + ) + self.assertOutput(out, "ns:named") + + self.assertOutput(out, "/namespaced/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_unnamed", + ) + + self.assertOutput(out, "/nons/named") + self.assertOutput(out, "admin_scripts.app_with_urls.views.view_func_nons_named") + self.assertOutput(out, "app_with_urls:named") + + self.assertOutput(out, "/nons/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_nons_unnamed", + ) + + @override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls") + def test_aligned(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "-f", "aligned"] + out, err = self.run_manage(args) + + self.assertNoOutput(err) + + # Check route, view and (if defined) name for each URL + self.assertOutput(out, "/namespaced/named") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_named", + ) + self.assertOutput(out, "ns:named") + + self.assertOutput(out, "/namespaced/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_unnamed", + ) + + self.assertOutput(out, "/nons/named") + self.assertOutput(out, "admin_scripts.app_with_urls.views.view_func_nons_named") + self.assertOutput(out, "app_with_urls:named") + + self.assertOutput(out, "/nons/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_nons_unnamed", + ) + + @override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls") + def test_table(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "-f", "table"] + out, err = self.run_manage(args) + + self.assertNoOutput(err) + + # Check table headers + self.assertOutput(out, "Route") + self.assertOutput(out, "View") + self.assertOutput(out, "Name") + self.assertOutput(out, "---+---") + + # Check route, view and (if defined) name for each URL + self.assertOutput(out, "/namespaced/named") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_named", + ) + self.assertOutput(out, "ns:named") + + self.assertOutput(out, "/namespaced/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_unnamed", + ) + + self.assertOutput(out, "/nons/named") + self.assertOutput(out, "admin_scripts.app_with_urls.views.view_func_nons_named") + self.assertOutput(out, "app_with_urls:named") + + self.assertOutput(out, "/nons/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_nons_unnamed", + ) + + @override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls") + def test_verbose(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "-f", "verbose"] + out, err = self.run_manage(args) + + self.assertNoOutput(err) + + self.assertOutput(out, "Route:") + self.assertOutput(out, "View:") + self.assertOutput(out, "Name:") + self.assertOutput(out, "-" * 20) + + self.assertOutput(out, "/namespaced/named") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_named", + ) + self.assertOutput(out, "ns:named") + + self.assertOutput(out, "/namespaced/unnamed") + self.assertOutput( + out, "admin_scripts.app_with_urls.views.view_func_namespaced_unnamed" + ) + + self.assertOutput(out, "/nons/named") + self.assertOutput(out, "app_with_urls:named") + self.assertOutput(out, "admin_scripts.app_with_urls.views.view_func_nons_named") + + self.assertOutput(out, "/nons/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_nons_unnamed", + ) + + @override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls") + def test_json(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "-f", "json"] + out, err = self.run_manage(args) + + self.assertNoOutput(err) + + try: + json.loads(out) + except json.JSONDecodeError: + self.fail("Output is not valid JSON") + + self.assertOutput(out, '"route": "/namespaced/named"') + self.assertOutput( + out, + '"view": "admin_scripts.app_with_urls.views.view_func_namespaced_named"', + ) + self.assertOutput(out, '"name": "ns:named"') + + self.assertOutput(out, '"route": "/namespaced/unnamed"') + self.assertOutput( + out, + '"view": "admin_scripts.app_with_urls.views.view_func_namespaced_unnamed"', + ) + + self.assertOutput(out, '"route": "/nons/named"') + self.assertOutput(out, "admin_scripts.app_with_urls.views.view_func_nons_named") + self.assertOutput(out, "app_with_urls:named") + + self.assertOutput(out, "/nons/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_nons_unnamed", + ) + + @override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls") + def test_pretty_json(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "-f", "pretty-json"] + out, err = self.run_manage(args) + + self.assertNoOutput(err) + + self.assertOutput(out, "/namespaced/named") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_named", + ) + self.assertOutput(out, "ns:named") + + self.assertOutput(out, "/namespaced/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_unnamed", + ) + + self.assertOutput(out, "/nons/named") + self.assertOutput(out, "app_with_urls:named") + self.assertOutput(out, "admin_scripts.app_with_urls.views.view_func_nons_named") + + self.assertOutput(out, "/nons/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_nons_unnamed", + ) + + @override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls") + def test_unsorted(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + # JSON format is the easiest to parse and test + args = ["listurls", "-f", "json", "--unsorted"] + out, err = self.run_manage(args) + url_patterns = json.loads(out) + + self.assertNotEqual( + url_patterns, + sorted(url_patterns, key=lambda u: u["route"]), + ) + + def test_no_urls(self): + self.write_settings("settings.py") + + args = ["listurls"] + out, err = self.run_manage(args) + + self.assertOutput(err, "There are no URL patterns that match given prefixes") + self.assertNoOutput(out) + + @override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls") + def test_prefixes(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "-p", "/namespaced"] + out, err = self.run_manage(args) + + self.assertNoOutput(err) + + self.assertOutput(out, "/namespaced/named") + self.assertOutput(out, "ns:named") + self.assertOutput(out, "/namespaced/unnamed") + + self.assertNotInOutput(out, "/nons/named") + self.assertNotInOutput(out, "app_with_urls:named") + self.assertNotInOutput(out, "/nons/unnamed") + + class MainModule(AdminScriptTestCase): """python -m django works like django-admin."""