diff --git a/CHANGELOG.md b/CHANGELOG.md
index f26971c4f..d3ad6aec6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,15 @@ Changelog
See https://github.com/django-extensions/django-extensions/releases
+4.1
+---
+
+Changes:
+
+- Add: show_permissions command (#1920)
+- Improvement: graph_models, style per app (#1848)
+- Fix: JSONField, bulk_update's (#1924)
+
4.0
---
diff --git a/django_extensions/__init__.py b/django_extensions/__init__.py
index a9cdeb55d..87cef4971 100644
--- a/django_extensions/__init__.py
+++ b/django_extensions/__init__.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
from django.utils.version import get_version
-VERSION = (4, 0, 0, "final", 0)
+VERSION = (4, 1, 0, "final", 0)
__version__ = get_version(VERSION)
diff --git a/django_extensions/db/fields/json.py b/django_extensions/db/fields/json.py
index 42c114e67..435a8de8e 100644
--- a/django_extensions/db/fields/json.py
+++ b/django_extensions/db/fields/json.py
@@ -15,6 +15,7 @@ class LOL(models.Model):
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
+from django.db.models import expressions
def dumps(value):
@@ -94,12 +95,18 @@ def get_db_prep_save(self, value, connection, **kwargs):
"""Convert our JSON object to a string before we save"""
if value is None and self.null:
return None
+
# default values come in as strings; only non-strings should be
# run through `dumps`
- if not isinstance(value, str):
+ if (
+ not isinstance(value, str)
+ # https://github.com/django-extensions/django-extensions/issues/1924
+ # https://code.djangoproject.com/ticket/35167
+ and not isinstance(value, expressions.Expression)
+ ):
value = dumps(value)
- return value
+ return super().get_db_prep_save(value, connection)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
diff --git a/django_extensions/management/commands/graph_models.py b/django_extensions/management/commands/graph_models.py
index ca3b7807d..66610dde4 100644
--- a/django_extensions/management/commands/graph_models.py
+++ b/django_extensions/management/commands/graph_models.py
@@ -28,6 +28,28 @@
HAS_PYDOT = False
+def retheme(graph_data, app_style={}):
+ if isinstance(app_style, str):
+ if os.path.exists(app_style):
+ try:
+ with open(app_style, "rt") as f:
+ app_style = json.load(f)
+ except Exception as e:
+ print(f"Invalid app style file {app_style}")
+ raise Exception(e)
+ else:
+ return graph_data
+
+ for gc in graph_data["graphs"]:
+ for g in gc:
+ if "name" in g:
+ for m in g["models"]:
+ app_name = g["app_name"]
+ if app_name in app_style:
+ m["style"] = app_style[app_name]
+ return graph_data
+
+
class Command(BaseCommand):
help = "Creates a GraphViz dot file for the specified app names."
" You can pass multiple app names and they will all be combined into a"
@@ -47,6 +69,12 @@ def __init__(self, *args, **kwargs):
--disable-fields can be set in settings.GRAPH_MODELS['disable_fields'].
"""
self.arguments = {
+ "--app-style": {
+ "action": "store",
+ "help": "Path to style json to configure the style per app",
+ "dest": "app-style",
+ "default": ".app-style.json",
+ },
"--pygraphviz": {
"action": "store_true",
"default": False,
@@ -344,6 +372,7 @@ def handle(self, *args, **options):
)
template = loader.get_template(template_name)
+ graph_data = retheme(graph_data, app_style=options["app-style"])
dotdata = generate_dot(graph_data, template=template)
if output == "pygraphviz":
diff --git a/django_extensions/management/commands/show_permissions.py b/django_extensions/management/commands/show_permissions.py
new file mode 100644
index 000000000..d1eed7195
--- /dev/null
+++ b/django_extensions/management/commands/show_permissions.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+from django.contrib.contenttypes.models import ContentType
+from django.core.management.base import BaseCommand, CommandError
+
+
+class Command(BaseCommand):
+ help = (
+ "List all permissions for models. "
+ "By default, excludes admin, auth, contenttypes, and sessions apps."
+ )
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "app_label_model",
+ nargs="*",
+ help="[app_label.]model(s) to show permissions for.",
+ )
+ parser.add_argument(
+ "--all",
+ action="store_true",
+ help="Include results for admin, auth, contenttypes, and sessions.",
+ )
+ parser.add_argument("--app-label", help="App label to dump permissions for.")
+
+ def handle(self, *args, **options):
+ app_label_models = options["app_label_model"]
+ include_all = options["all"]
+ app_label_filter = options["app_label"]
+
+ if include_all:
+ content_types = ContentType.objects.order_by("app_label", "model")
+ elif app_label_filter:
+ content_types = ContentType.objects.filter(
+ app_label=app_label_filter.lower()
+ ).order_by("app_label", "model")
+ if not content_types:
+ raise CommandError(
+ f'No content types found for app label "{app_label_filter}".'
+ )
+ elif not app_label_models:
+ excluded = ["admin", "auth", "contenttypes", "sessions"]
+ content_types = ContentType.objects.exclude(
+ app_label__in=excluded
+ ).order_by("app_label", "model")
+ else:
+ content_types = []
+ for value in app_label_models:
+ if "." in value:
+ app_label, model = value.split(".")
+ qs = ContentType.objects.filter(app_label=app_label, model=model)
+ else:
+ qs = ContentType.objects.filter(model=value)
+
+ if not qs:
+ raise CommandError(f"Content type not found for '{value}'.")
+ content_types.extend(qs)
+
+ for ct in content_types:
+ self.stdout.write(f"Permissions for {ct}")
+ for perm in ct.permission_set.all():
+ self.stdout.write(f" {ct.app_label}.{perm.codename} | {perm.name}")
diff --git a/django_extensions/templates/django_extensions/graph_models/django2018style/digraph.dot b/django_extensions/templates/django_extensions/graph_models/django2018style/digraph.dot
new file mode 100644
index 000000000..e2a330611
--- /dev/null
+++ b/django_extensions/templates/django_extensions/graph_models/django2018style/digraph.dot
@@ -0,0 +1,27 @@
+{% block digraph %}digraph model_graph {
+ // Dotfile by Django-Extensions graph_models
+ // Created: {{ created_at }}
+ {% if cli_options %}// Cli Options: {{ cli_options }}{% endif %}
+
+ {% block digraph_options %}fontname = "Roboto"
+ fontsize = 8
+ splines = true
+ rankdir = "{{ rankdir }}"{% endblock %}
+
+ node [{% block node_options %}
+ fontname = "Roboto"
+ fontsize = 8
+ shape = "plaintext"
+ {% endblock %}]
+
+ edge [{% block edge_options %}
+ fontname = "Roboto"
+ fontsize = 8
+ {% endblock %}]
+
+ // Labels
+{% block labels %}{% for graph in graphs %}{% include "django_extensions/graph_models/django2018style/label.dot" %}{% endfor %}{% endblock %}
+
+ // Relations
+{% block relations %}{% for graph in graphs %}{% include "django_extensions/graph_models/django2018style/relation.dot" %}{% endfor %}{% endblock %}
+}{% endblock %}
diff --git a/django_extensions/templates/django_extensions/graph_models/django2018style/label.dot b/django_extensions/templates/django_extensions/graph_models/django2018style/label.dot
new file mode 100644
index 000000000..6949682e7
--- /dev/null
+++ b/django_extensions/templates/django_extensions/graph_models/django2018style/label.dot
@@ -0,0 +1,39 @@
+{% load indent_text %}{% if use_subgraph %} subgraph {{ graph.cluster_app_name }} {
+ label=<
+
+
+
+ {{ graph.app_name }}
+
+
+
+ >
+ color=olivedrab4
+ style="rounded"{% endif %}
+{% indentby 2 if use_subgraph %}{% for model in graph.models %}
+ {{ model.app_name }}_{{ model.name }} [label=<
+
+
+
+ {{ model.label }}{% if model.abstracts %} <{{ model.abstracts|join:"," }} >{% endif %}
+
+ {% if not disable_fields %}{% for field in model.fields %}
+ {% if disable_abstract_fields and field.abstract %}
+ {% else %}
+
+ {% if field.abstract %}{% endif %}{% if field.relation or field.primary_key %}{% endif %}{{ field.label }}{% if field.relation or field.primary_key %} {% endif %}{% if field.abstract %} {% endif %}
+
+ {% if field.abstract %}{% endif %}{% if field.relation or field.primary_key %}{% endif %}{{ field.type }}{% if field.relation or field.primary_key %} {% endif %}{% if field.abstract %} {% endif %}
+
+ {% endif %}
+ {% endfor %}{% endif %}
+
+ >]
+{% endfor %}{% endindentby %}
+{% if use_subgraph %} }{% endif %}
diff --git a/django_extensions/templates/django_extensions/graph_models/django2018style/relation.dot b/django_extensions/templates/django_extensions/graph_models/django2018style/relation.dot
new file mode 100644
index 000000000..c5ef3d731
--- /dev/null
+++ b/django_extensions/templates/django_extensions/graph_models/django2018style/relation.dot
@@ -0,0 +1,10 @@
+{% for model in graph.models %}{% for relation in model.relations %}{% if relation.needs_node %} {{ relation.target_app }}_{{ relation.target }} [label=<
+
+
+ {{ relation.target }}
+
+
+ >]{% endif %}
+ {{ model.app_name }}_{{ model.name }} -> {{ relation.target_app }}_{{ relation.target }}
+ [label=" {{ relation.label }}"] {{ relation.arrows }};
+{% endfor %}{% endfor %}
diff --git a/docs/show_permissions.rst b/docs/show_permissions.rst
new file mode 100644
index 000000000..7bdfae5d2
--- /dev/null
+++ b/docs/show_permissions.rst
@@ -0,0 +1,78 @@
+Show Permissions
+================
+
+:synopsis: Show all permissions for Django models
+
+Introduction
+------------
+
+The ``show_permissions`` management command lists all permissions for the models in your Django project.
+By default, it excludes built-in Django apps such as ``admin``, ``auth``, ``contenttypes``, and ``sessions``.
+
+This command is useful to quickly inspect the permissions assigned to models, especially when customizing permission logic or managing role-based access.
+
+Basic Usage
+-----------
+
+.. code-block:: bash
+
+ python manage.py show_permissions
+
+This will output the list of permissions for models in all installed apps **excluding** built-in Django apps.
+
+Examples
+--------
+
+Show permissions for specific apps and models:
+
+.. code-block:: bash
+
+ python manage.py show_permissions blog
+ python manage.py show_permissions blog.Post
+
+Show permissions including built-in Django apps:
+
+.. code-block:: bash
+
+ python manage.py show_permissions --all
+
+Show permissions for only a specific app using the `--app-label` option:
+
+.. code-block:: bash
+
+ python manage.py show_permissions --app-label blog
+
+Options
+-------
+
+* ``--all``
+ Include permissions for Django’s built-in apps (``admin``, ``auth``, ``contenttypes``, ``sessions``).
+
+* ``--app-label ``
+ Only show permissions for the specified app label.
+
+* ``app_label.model`` (positional argument)
+ Restrict output to specific model(s), optionally prefixed by app label.
+
+* ``--verbosity {0,1,2,3}``
+ Set verbosity level (default: 1).
+
+* ``--settings ``
+ Set a specific Django settings module.
+
+* ``--pythonpath ``
+ Add a path to the Python module search path.
+
+* ``--traceback``
+ Show full traceback on error.
+
+* ``--no-color`` / ``--force-color``
+ Toggle colored output.
+
+* ``--skip-checks``
+ Skip Django’s system checks.
+
+Conclusion
+----------
+
+The ``show_permissions`` command is a handy tool for developers and administrators to audit or debug permission settings within their Django project.
diff --git a/tests/management/commands/test_show_permissions.py b/tests/management/commands/test_show_permissions.py
new file mode 100644
index 000000000..31f157784
--- /dev/null
+++ b/tests/management/commands/test_show_permissions.py
@@ -0,0 +1,102 @@
+# -*- coding: utf-8 -*-
+import sys
+from io import StringIO
+
+from django.apps import apps
+from django.core.management import call_command
+from django.test import TestCase
+
+
+class ShowPermissionsTests(TestCase):
+ def _run_command(self, *args, **kwargs):
+ """
+ Utility to run the command and return captured output.
+ """
+ out = StringIO()
+ sys_stdout = sys.stdout
+ sys.stdout = out
+ try:
+ call_command("show_permissions", *args, **kwargs)
+ finally:
+ sys.stdout = sys_stdout
+ return out.getvalue()
+
+ def _check_header_in_output(self, app_labels, model_verbose, output):
+ """
+ Accepts a list of app label variants and checks if any of them exists with the model_verbose in the output.
+ """
+ for app_label in app_labels:
+ header = f"Permissions for {app_label} | {model_verbose}"
+ if header in output:
+ return
+ raise AssertionError(
+ f"None of the expected headers found in output. Tried: {[f'Permissions for {label} | {model_verbose}' for label in app_labels]}"
+ )
+
+ def test_should_list_permissions_for_all_apps_excluding_defaults(self):
+ output = self._run_command(verbosity=3)
+ auth_verbose = apps.get_app_config("auth").verbose_name
+ user_verbose = apps.get_model("auth", "user")._meta.verbose_name
+ self.assertNotIn(
+ f"Permissions for {auth_verbose} | {user_verbose}",
+ output,
+ "Should not list auth permissions without --all flag",
+ )
+ self.assertNotIn("auth.add_user", output)
+
+ def test_should_include_all_apps_with_flag(self):
+ output = self._run_command("--all", verbosity=3)
+
+ auth_config = apps.get_app_config("auth")
+ user_verbose = apps.get_model("auth", "user")._meta.verbose_name
+ self._check_header_in_output(
+ [auth_config.verbose_name, auth_config.label], user_verbose, output
+ )
+ self.assertIn("auth.add_user", output)
+
+ admin_config = apps.get_app_config("admin")
+ for model in admin_config.get_models():
+ model_verbose = model._meta.verbose_name
+ self._check_header_in_output(
+ [admin_config.verbose_name, admin_config.label], model_verbose, output
+ )
+
+ def test_should_filter_by_app_label(self):
+ output = self._run_command("--app-label", "auth", verbosity=3)
+
+ auth_config = apps.get_app_config("auth")
+ for model in auth_config.get_models():
+ model_verbose = model._meta.verbose_name
+ self._check_header_in_output(
+ [auth_config.verbose_name, auth_config.label], model_verbose, output
+ )
+
+ self.assertIn("auth.change_user", output)
+
+ def test_should_filter_by_app_and_model(self):
+ output = self._run_command("auth.user", verbosity=3)
+
+ auth_config = apps.get_app_config("auth")
+ user_verbose = apps.get_model("auth", "user")._meta.verbose_name
+ self._check_header_in_output(
+ [auth_config.verbose_name, auth_config.label], user_verbose, output
+ )
+ self.assertIn("auth.change_user", output)
+
+ def test_should_raise_error_for_invalid_model(self):
+ with self.assertRaisesMessage(
+ Exception, "Content type not found for 'fakeapp.nosuchmodel'"
+ ):
+ self._run_command("fakeapp.nosuchmodel", verbosity=3)
+
+ def test_should_return_permissions_for_test_model(self):
+ if apps.is_installed("tests"):
+ output = self._run_command("tests.samplemodel", verbosity=3)
+ self.assertIn("tests.samplemodel", output.lower())
+ self.assertIn("can add", output.lower())
+
+ def test_should_raise_error_for_invalid_app_label(self):
+ with self.assertRaisesMessage(
+ Exception, 'No content types found for app label "noapp".'
+ ):
+ self._run_command("--app-label", "noapp", verbosity=3)
diff --git a/tests/test_json_field.py b/tests/test_json_field.py
index 10f72eb53..8bcceb72b 100644
--- a/tests/test_json_field.py
+++ b/tests/test_json_field.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
from django.test import TestCase
-
-from .testapp.models import JSONFieldTestModel
from django_extensions.db.fields.json import dumps, loads, JSONField, JSONDict, JSONList
+from .testapp.models import JSONFieldTestModel
class JsonFieldTest(TestCase):
@@ -127,3 +126,12 @@ def test_to_python(self):
self.assertEqual(loads('[{"a": 1}]'), j_field.to_python('[{"a": 1}]'))
self.assertEqual(loads('[{"a": "1"}]'), j_field.to_python('[{"a": "1"}]'))
+
+ def test_bulk_update_custom_get_prep_value(self):
+ obj = JSONFieldTestModel.objects.create(a=1, j_field={"version": "1"})
+ obj.j_field["version"] = "1-alpha"
+ JSONFieldTestModel.objects.bulk_update([obj], ["j_field"])
+ self.assertSequenceEqual(
+ JSONFieldTestModel.objects.values("j_field"),
+ [{"j_field": {"version": "1-alpha"}}],
+ )