diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2246a57..6b76f23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.9' + python-version: '3.10' - name: Get pip cache dir id: pip-cache diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b8a64fe..5bc3114 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v5 @@ -35,7 +35,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] services: postgres: image: postgres:15 @@ -81,7 +81,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] services: mysql: image: mysql:8.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e57ca75..916a8bc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,24 +4,24 @@ repos: rev: 25.9.0 hooks: - id: black - language_version: python3.9 + language_version: python3.10 args: - "--target-version" - - "py39" + - "py310" - repo: https://github.com/PyCQA/flake8 rev: "7.3.0" hooks: - id: flake8 args: ["--max-line-length", "110"] - repo: https://github.com/PyCQA/isort - rev: 6.1.0 + rev: 7.0.0 hooks: - id: isort - repo: https://github.com/asottile/pyupgrade - rev: v3.20.0 + rev: v3.21.0 hooks: - id: pyupgrade - args: [--py39-plus] + args: [--py310-plus] - repo: https://github.com/adamchainz/django-upgrade rev: 1.29.0 hooks: diff --git a/CHANGELOG.md b/CHANGELOG.md index cc735ec..fead883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Next Release +#### Improvements + +- Add `AUDITLOG_USE_BASE_MANAGER` setting to override default manager use ([#766](https://github.com/jazzband/django-auditlog/pull/766)) +- Drop 'Python 3.9' support ([#773](https://github.com/jazzband/django-auditlog/pull/773)) + ## 3.3.0 (2025-09-18) #### Improvements diff --git a/auditlog/cid.py b/auditlog/cid.py index 8d2aa9f..3e2b78f 100644 --- a/auditlog/cid.py +++ b/auditlog/cid.py @@ -1,5 +1,4 @@ from contextvars import ContextVar -from typing import Optional from django.conf import settings from django.http import HttpRequest @@ -8,7 +7,7 @@ from django.utils.module_loading import import_string correlation_id = ContextVar("auditlog_correlation_id", default=None) -def set_cid(request: Optional[HttpRequest] = None) -> None: +def set_cid(request: HttpRequest | None = None) -> None: """ A function to read the cid from a request. If the header is not in the request, then we set it to `None`. @@ -40,11 +39,11 @@ def set_cid(request: Optional[HttpRequest] = None) -> None: correlation_id.set(cid) -def _get_cid() -> Optional[str]: +def _get_cid() -> str | None: return correlation_id.get() -def get_cid() -> Optional[str]: +def get_cid() -> str | None: """ Calls the cid getter function based on `settings.AUDITLOG_CID_GETTER` diff --git a/auditlog/conf.py b/auditlog/conf.py index 049275a..0839d32 100644 --- a/auditlog/conf.py +++ b/auditlog/conf.py @@ -66,3 +66,8 @@ settings.AUDITLOG_MASK_CALLABLE = getattr(settings, "AUDITLOG_MASK_CALLABLE", No settings.AUDITLOG_LOGENTRY_MODEL = getattr( settings, "AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry" ) + +# Use base model managers instead of default model managers +settings.AUDITLOG_USE_BASE_MANAGER = getattr( + settings, "AUDITLOG_USE_BASE_MANAGER", False +) diff --git a/auditlog/diff.py b/auditlog/diff.py index af975a8..0d69749 100644 --- a/auditlog/diff.py +++ b/auditlog/diff.py @@ -1,6 +1,6 @@ import json +from collections.abc import Callable from datetime import timezone -from typing import Callable, Optional from django.conf import settings from django.core.exceptions import ObjectDoesNotExist @@ -132,7 +132,7 @@ def is_primitive(obj) -> bool: return isinstance(obj, primitive_types) -def get_mask_function(mask_callable: Optional[str] = None) -> Callable[[str], str]: +def get_mask_function(mask_callable: str | None = None) -> Callable[[str], str]: """ Get the masking function to use based on the following priority: 1. Model-specific mask_callable if provided @@ -169,8 +169,8 @@ def mask_str(value: str) -> str: def model_instance_diff( - old: Optional[Model], - new: Optional[Model], + old: Model | None, + new: Model | None, fields_to_check=None, use_json_for_changes=False, ): diff --git a/auditlog/middleware.py b/auditlog/middleware.py index bd414c2..c27f485 100644 --- a/auditlog/middleware.py +++ b/auditlog/middleware.py @@ -1,5 +1,3 @@ -from typing import Optional - from django.conf import settings from django.contrib.auth import get_user_model @@ -39,7 +37,7 @@ class AuditlogMiddleware: return remote_addr @staticmethod - def _get_remote_port(request) -> Optional[int]: + def _get_remote_port(request) -> int | None: remote_port = request.headers.get("X-Forwarded-Port", "") try: @@ -70,4 +68,3 @@ class AuditlogMiddleware: with set_extra_data(context_data=self.get_extra_data(request)): return self.get_response(request) - diff --git a/auditlog/models.py b/auditlog/models.py index 0c94ea2..edc5dd2 100644 --- a/auditlog/models.py +++ b/auditlog/models.py @@ -1,9 +1,10 @@ import ast import contextlib import json +from collections.abc import Callable from copy import deepcopy from datetime import timezone -from typing import Any, Callable, Union +from typing import Any from dateutil import parser from dateutil.tz import gettz @@ -536,7 +537,7 @@ class AbstractLogEntry(models.Model): return changes_display_dict def _get_changes_display_for_fk_field( - self, field: Union[models.ForeignKey, models.OneToOneField], value: Any + self, field: models.ForeignKey | models.OneToOneField, value: Any ) -> str: """ :return: A string representing a given FK value and the field to which it belongs @@ -555,7 +556,9 @@ class AbstractLogEntry(models.Model): return value # Attempt to return the string representation of the object try: - return smart_str(field.related_model._default_manager.get(pk=pk_value)) + related_model_manager = _get_manager_from_settings(field.related_model) + + return smart_str(related_model_manager.get(pk=pk_value)) # ObjectDoesNotExist will be raised if the object was deleted. except ObjectDoesNotExist: return f"Deleted '{field.related_model.__name__}' ({value})" @@ -629,3 +632,16 @@ def _changes_func() -> Callable[[LogEntry], dict]: if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT: return json_then_text return default + + +def _get_manager_from_settings(model: type[models.Model]) -> models.Manager: + """ + Get model manager as selected by AUDITLOG_USE_BASE_MANAGER. + + - True: return model._meta.base_manager + - False: return model._meta.default_manager + """ + if settings.AUDITLOG_USE_BASE_MANAGER: + return model._meta.base_manager + else: + return model._meta.default_manager diff --git a/auditlog/receivers.py b/auditlog/receivers.py index 88cfe69..184bc26 100644 --- a/auditlog/receivers.py +++ b/auditlog/receivers.py @@ -5,6 +5,7 @@ from django.conf import settings from auditlog import get_logentry_model from auditlog.context import auditlog_disabled from auditlog.diff import model_instance_diff +from auditlog.models import _get_manager_from_settings from auditlog.signals import post_log, pre_log @@ -56,7 +57,7 @@ def log_update(sender, instance, **kwargs): """ if not instance._state.adding and instance.pk is not None: update_fields = kwargs.get("update_fields", None) - old = sender._default_manager.filter(pk=instance.pk).first() + old = _get_manager_from_settings(sender).filter(pk=instance.pk).first() _create_log_entry( action=get_logentry_model().Action.UPDATE, instance=instance, @@ -172,12 +173,12 @@ def make_log_m2m_changes(field_name): return LogEntry = get_logentry_model() + model_manager = _get_manager_from_settings(kwargs["model"]) + if action == "post_clear": - changed_queryset = kwargs["model"]._default_manager.all() + changed_queryset = model_manager.all() else: - changed_queryset = kwargs["model"]._default_manager.filter( - pk__in=kwargs["pk_set"] - ) + changed_queryset = model_manager.filter(pk__in=kwargs["pk_set"]) if action in ["post_add"]: LogEntry.objects.log_m2m_changes( diff --git a/auditlog/registry.py b/auditlog/registry.py index 176fd73..033ec96 100644 --- a/auditlog/registry.py +++ b/auditlog/registry.py @@ -1,7 +1,7 @@ import copy from collections import defaultdict -from collections.abc import Collection, Iterable -from typing import Any, Callable, Optional, Union +from collections.abc import Callable, Collection, Iterable +from typing import Any from django.apps import apps from django.db.models import ManyToManyField, Model @@ -38,7 +38,7 @@ class AuditlogModelRegistry: delete: bool = True, access: bool = True, m2m: bool = True, - custom: Optional[dict[ModelSignal, Callable]] = None, + custom: dict[ModelSignal, Callable] | None = None, ): from auditlog.receivers import log_access, log_create, log_delete, log_update @@ -62,14 +62,14 @@ class AuditlogModelRegistry: def register( self, model: ModelBase = None, - include_fields: Optional[list[str]] = None, - exclude_fields: Optional[list[str]] = None, - mapping_fields: Optional[dict[str, str]] = None, - mask_fields: Optional[list[str]] = None, - mask_callable: Optional[str] = None, - m2m_fields: Optional[Collection[str]] = None, + include_fields: list[str] | None = None, + exclude_fields: list[str] | None = None, + mapping_fields: dict[str, str] | None = None, + mask_fields: list[str] | None = None, + mask_callable: str | None = None, + m2m_fields: Collection[str] | None = None, serialize_data: bool = False, - serialize_kwargs: Optional[dict[str, Any]] = None, + serialize_kwargs: dict[str, Any] | None = None, serialize_auditlog_fields_only: bool = False, ): """ @@ -259,7 +259,7 @@ class AuditlogModelRegistry: ] return exclude_models - def _register_models(self, models: Iterable[Union[str, dict[str, Any]]]) -> None: + def _register_models(self, models: Iterable[str | dict[str, Any]]) -> None: models = copy.deepcopy(models) for model in models: if isinstance(model, str): diff --git a/auditlog_tests/auditlog b/auditlog_tests/auditlog new file mode 100644 index 0000000..e69de29 diff --git a/auditlog_tests/test_app/models.py b/auditlog_tests/test_app/models.py index dd240fc..e996a25 100644 --- a/auditlog_tests/test_app/models.py +++ b/auditlog_tests/test_app/models.py @@ -430,6 +430,40 @@ class SwappedManagerModel(models.Model): objects = SecretManager() + def __str__(self): + return str(self.name) + + +@auditlog.register() +class SecretRelatedModel(RelatedModelParent): + """ + A RelatedModel, but with a foreign key to an object that could be secret. + """ + + related = models.ForeignKey( + "SwappedManagerModel", related_name="related_models", on_delete=models.CASCADE + ) + one_to_one = models.OneToOneField( + to="SwappedManagerModel", + on_delete=models.CASCADE, + related_name="reverse_one_to_one", + ) + + history = AuditlogHistoryField(delete_related=True) + + def __str__(self): + return f"SecretRelatedModel #{self.pk} -> {self.related.id}" + + +class SecretM2MModel(models.Model): + m2m_related = models.ManyToManyField( + "SwappedManagerModel", related_name="m2m_related" + ) + name = models.CharField(max_length=255) + + def __str__(self): + return str(self.name) + class AutoManyRelatedModel(models.Model): related = models.ManyToManyField(SimpleModel) @@ -459,6 +493,8 @@ auditlog.register(ManyRelatedModel.recursive.through) m2m_only_auditlog.register(ManyRelatedModel, m2m_fields={"related"}) m2m_only_auditlog.register(ModelForReusableThroughModel, m2m_fields={"related"}) m2m_only_auditlog.register(OtherModelForReusableThroughModel, m2m_fields={"related"}) +m2m_only_auditlog.register(SecretM2MModel, m2m_fields={"m2m_related"}) +m2m_only_auditlog.register(SwappedManagerModel, m2m_fields={"m2m_related"}) auditlog.register(SimpleExcludeModel, exclude_fields=["text"]) auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."}) auditlog.register(AdditionalDataIncludedModel) diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index 6ace14e..c8969cb 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -48,6 +48,8 @@ from test_app.models import ( ProxyModel, RelatedModel, ReusableThroughRelatedModel, + SecretM2MModel, + SecretRelatedModel, SerializeNaturalKeyRelatedModel, SerializeOnlySomeOfThisModel, SerializePrimaryKeyRelatedModel, @@ -1369,7 +1371,7 @@ class RegisterModelSettingsTest(TestCase): self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel)) self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel)) - self.assertEqual(len(self.test_auditlog.get_models()), 34) + self.assertEqual(len(self.test_auditlog.get_models()), 36) def test_register_models_register_model_with_attrs(self): self.test_auditlog._register_models( @@ -2945,6 +2947,136 @@ class ModelManagerTest(TestCase): self.assertEqual(log.changes_dict["name"], ["Public", "Updated"]) +class BaseManagerSettingTest(TestCase): + """ + If the AUDITLOG_USE_BASE_MANAGER setting is enabled, "secret" objects + should be audited as if they were public, with full access to field + values. + """ + + def test_use_base_manager_setting_update(self): + """ + Model update. The default False case is covered by test_update_secret. + """ + secret = SwappedManagerModel.objects.create(is_secret=True, name="Secret") + with override_settings(AUDITLOG_USE_BASE_MANAGER=True): + secret.name = "Updated" + secret.save() + log = LogEntry.objects.get_for_object(secret).first() + self.assertEqual(log.action, LogEntry.Action.UPDATE) + self.assertEqual(log.changes_dict["name"], ["Secret", "Updated"]) + + def test_use_base_manager_setting_related_model(self): + """ + When AUDITLOG_USE_BASE_MANAGER is enabled, related model changes that + are normally invisible to the default model manager should remain + visible and not refer to "deleted" objects. + """ + t1 = datetime.datetime(2025, 1, 1, 12, tzinfo=datetime.timezone.utc) + with ( + override_settings(AUDITLOG_USE_BASE_MANAGER=False), + freezegun.freeze_time(t1), + ): + public_one = SwappedManagerModel.objects.create(name="Public One") + secret_one = SwappedManagerModel.objects.create( + is_secret=True, name="Secret One" + ) + instance_one = SecretRelatedModel.objects.create( + one_to_one=public_one, + related=secret_one, + ) + + log_one = instance_one.history.filter(timestamp=t1).first() + self.assertIsInstance(log_one, LogEntry) + display_dict = log_one.changes_display_dict + self.assertEqual(display_dict["related"][0], "None") + self.assertEqual( + display_dict["related"][1], + f"Deleted 'SwappedManagerModel' ({secret_one.id})", + "Default manager should have no visibility of secret object", + ) + self.assertEqual(display_dict["one to one"][0], "None") + self.assertEqual(display_dict["one to one"][1], "Public One") + + t2 = t1 + datetime.timedelta(days=20) + with ( + override_settings(AUDITLOG_USE_BASE_MANAGER=True), + freezegun.freeze_time(t2), + ): + public_two = SwappedManagerModel.objects.create(name="Public Two") + secret_two = SwappedManagerModel.objects.create( + is_secret=True, name="Secret Two" + ) + instance_two = SecretRelatedModel.objects.create( + one_to_one=public_two, + related=secret_two, + ) + + log_two = instance_two.history.filter(timestamp=t2).first() + self.assertIsInstance(log_two, LogEntry) + display_dict = log_two.changes_display_dict + self.assertEqual(display_dict["related"][0], "None") + self.assertEqual( + display_dict["related"][1], + "Secret Two", + "Base manager should have full visibility of secret object", + ) + self.assertEqual(display_dict["one to one"][0], "None") + self.assertEqual(display_dict["one to one"][1], "Public Two") + + def test_use_base_manager_setting_changes(self): + """ + When AUDITLOG_USE_BASE_MANAGER is enabled, registered many-to-many model + changes that refer to an object hidden from the default model manager + should remain visible and be logged. + """ + with override_settings(AUDITLOG_USE_BASE_MANAGER=False): + obj_one = SwappedManagerModel.objects.create( + is_secret=True, name="Secret One" + ) + m2m_one = SecretM2MModel.objects.create(name="M2M One") + m2m_one.m2m_related.add(obj_one) + + self.assertIn(m2m_one, obj_one.m2m_related.all(), "Secret One sees M2M One") + self.assertNotIn( + obj_one, m2m_one.m2m_related.all(), "M2M One cannot see Secret One" + ) + self.assertEqual( + 0, + LogEntry.objects.get_for_object(m2m_one).count(), + "No update with default manager", + ) + + with override_settings(AUDITLOG_USE_BASE_MANAGER=True): + obj_two = SwappedManagerModel.objects.create( + is_secret=True, name="Secret Two" + ) + m2m_two = SecretM2MModel.objects.create(name="M2M Two") + m2m_two.m2m_related.add(obj_two) + + self.assertIn(m2m_two, obj_two.m2m_related.all(), "Secret Two sees M2M Two") + self.assertNotIn( + obj_two, m2m_two.m2m_related.all(), "M2M Two cannot see Secret Two" + ) + self.assertEqual( + 1, + LogEntry.objects.get_for_object(m2m_two).count(), + "Update logged with base manager", + ) + + log_entry = LogEntry.objects.get_for_object(m2m_two).first() + self.assertEqual( + log_entry.changes, + { + "m2m_related": { + "type": "m2m", + "operation": "add", + "objects": [smart_str(obj_two)], + } + }, + ) + + class TestMaskStr(TestCase): """Test the mask_str function that masks sensitive data.""" diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 2a2c3c2..ab4d468 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -11,10 +11,10 @@ The repository can be found at https://github.com/jazzband/django-auditlog/. **Requirements** -- Python 3.9 or higher +- Python 3.10 or higher - Django 4.2, 5.0, 5.1, and 5.2 -Auditlog is currently tested with Python 3.9+ and Django 4.2, 5.0, 5.1, and 5.2. The latest test report can be found +Auditlog is currently tested with Python 3.10+ and Django 4.2, 5.0, 5.1, and 5.2. The latest test report can be found at https://github.com/jazzband/django-auditlog/actions. Adding Auditlog to your Django application diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 72acfa1..9dc4a95 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -141,7 +141,7 @@ For example, to use a custom masking function:: # In your_app/utils.py def custom_mask(value: str) -> str: return "****" + value[-4:] # Only show last 4 characters - + # In your models.py auditlog.register( MyModel, @@ -270,13 +270,13 @@ It will be considered when ``AUDITLOG_DISABLE_REMOTE_ADDR`` is `True`. You can use this setting to mask specific field values in all tracked models while still logging changes. This is useful when models contain sensitive fields -like `password`, `api_key`, or `secret_token`` that should not be logged +like `password`, `api_key`, or `secret_token` that should not be logged in plain text but need to be auditable. When a masked field changes, its value will be replaced with a masked representation (e.g., `****`) in the audit log instead of storing the actual value. -This setting will be applied only when `AUDITLOG_INCLUDE_ALL_MODELS`` is `True`. +This setting will be applied only when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`. .. code-block:: python @@ -377,6 +377,73 @@ This means that primitives such as booleans, integers, etc. will be represented .. versionadded:: 3.2.0 +**AUDITLOG_USE_BASE_MANAGER** + +This configuration variable determines whether to use `base managers +`_ for +tracked models instead of their default managers. + +This setting can be useful for applications where the default manager behaviour +hides some objects from the majority of ORM queries: + +.. code-block:: python + + class SecretManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(is_secret=False) + + + @auditlog.register() + class SwappedManagerModel(models.Model): + is_secret = models.BooleanField(default=False) + name = models.CharField(max_length=255) + + objects = SecretManager() + +In this example, when ``AUDITLOG_USE_BASE_MANAGER`` is set to `True`, objects +with the `is_secret` field set will be made visible to Auditlog. Otherwise you +may see inaccurate data in log entries, recording changes to a seemingly +"non-existent" object with empty fields. + +.. versionadded:: 3.4.0 + +**AUDITLOG_LOGENTRY_MODEL** + +This configuration variable allows you to specify a custom model to be used instead of the default +:py:class:`auditlog.models.LogEntry` model for storing audit records. + +By default, Auditlog stores change records in the built-in ``LogEntry`` model. +If you need to store additional information in each log entry (for example, a user role, request metadata, +or any other contextual data), you can define your own model by subclassing +:py:class:`auditlog.models.AbstractLogEntry` and configure it using this setting. + +.. code-block:: python + + from django.db import models + from auditlog.models import AbstractLogEntry + + class CustomLogEntryModel(AbstractLogEntry): + role = models.CharField(max_length=100, null=True, blank=True) + +Then, in your project settings: + +.. code-block:: python + + AUDITLOG_LOGENTRY_MODEL = 'custom_log_app.CustomLogEntryModel' + +Once defined, Auditlog will automatically use the specified model for all future log entries instead +of the default one. + +.. note:: + + - The custom model **must** inherit from :py:class:`auditlog.models.AbstractLogEntry`. + - All fields and behaviors defined in :py:class:`AbstractLogEntry` should remain intact to ensure compatibility. + - The app label and model name in ``AUDITLOG_LOGENTRY_MODEL`` must follow Django’s standard dotted notation + (for example, ``"app_name.ModelName"``). + +.. versionadded:: 3.5.0 + Custom LogEntry model configuration via ``AUDITLOG_LOGENTRY_MODEL`` + Actors ------ diff --git a/setup.py b/setup.py index 9147842..2f1cc5c 100644 --- a/setup.py +++ b/setup.py @@ -29,12 +29,11 @@ setup( description="Audit log app for Django", long_description=long_description, long_description_content_type="text/markdown", - python_requires=">=3.9", + python_requires=">=3.10", install_requires=["Django>=4.2", "python-dateutil>=2.7.0"], zip_safe=False, classifiers=[ "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", diff --git a/tox.ini b/tox.ini index b9befac..0f5e868 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,14 @@ [tox] envlist = - {py39,py310,py311}-{customlogmodel,defaultlogmodel}-django42 + {py310,py311}-{customlogmodel,defaultlogmodel}-django42 {py310,py311,py312}-{customlogmodel,defaultlogmodel}-django50 {py310,py311,py312,py313}-{customlogmodel,defaultlogmodel}-django51 {py310,py311,py312,py313}-{customlogmodel,defaultlogmodel}-django52 {py312,py313}-{customlogmodel,defaultlogmodel}-djangomain - py39-docs - py39-lint - py39-checkmigrations + py310-docs + py310-lint + py310-checkmigrations + [testenv] setenv = @@ -43,19 +44,18 @@ basepython = py312: python3.12 py311: python3.11 py310: python3.10 - py39: python3.9 -[testenv:py39-docs] +[testenv:py310-docs] changedir = docs/source deps = -rdocs/requirements.txt commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html -[testenv:py39-lint] +[testenv:py310-lint] deps = pre-commit commands = pre-commit run --all-files -[testenv:py39-checkmigrations] +[testenv:py310-checkmigrations] description = Check for missing migrations changedir = auditlog_tests deps = @@ -74,7 +74,6 @@ commands = [gh-actions] python = - 3.9: py39 3.10: py310 3.11: py311 3.12: py312