├── tests ├── __init__.py ├── helpers │ ├── __init__.py │ ├── test_logger.py │ └── test_file_system.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── test_handle_migration_zero_reset.py │ │ └── test_reset_local_migration_files.py ├── services │ ├── __init__.py │ ├── test_local.py │ └── test_deployment.py ├── test_managers.py ├── test_admin.py └── test_models.py ├── testapp ├── __init__.py ├── nested_app │ ├── __init__.py │ └── migrations │ │ └── __init__.py └── urls.py ├── django_migration_zero ├── helpers │ ├── __init__.py │ ├── logger.py │ └── file_system.py ├── services │ ├── __init__.py │ ├── local.py │ └── deployment.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── handle_migration_zero_reset.py │ │ └── reset_local_migration_files.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── locale │ └── de │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── __init__.py ├── exceptions.py ├── apps.py ├── admin.py ├── managers.py └── models.py ├── docs ├── features │ ├── 07_changelog.rst │ ├── 02_configuration.md │ ├── 05_architecture.md │ ├── 06_management_commands.md │ ├── 04_common_issues_and_solutions.md │ ├── 01_introduction.md │ └── 03_workflow.md ├── index.rst ├── Makefile ├── make.bat └── conf.py ├── scripts ├── windows │ ├── install_requirements.ps1 │ └── publish_to_pypi.ps1 └── unix │ ├── install_requirements.sh │ └── publish_to_pypi.sh ├── MANIFEST.in ├── .editorconfig ├── .ambient-package-update ├── templates │ └── snippets │ │ ├── tagline.tpl │ │ ├── installation.tpl │ │ └── content.tpl └── metadata.py ├── manage.py ├── .readthedocs.yaml ├── SECURITY.md ├── LICENSE.md ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── settings.py ├── CHANGES.md ├── README.md └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testapp/nested_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_migration_zero/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_migration_zero/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testapp/nested_app/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_migration_zero/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_migration_zero/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_migration_zero/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/features/07_changelog.rst: -------------------------------------------------------------------------------- 1 | 2 | .. mdinclude:: ../../CHANGES.md 3 | -------------------------------------------------------------------------------- /scripts/windows/install_requirements.ps1: -------------------------------------------------------------------------------- 1 | pip install -U uv 2 | uv sync --frozen --extra dev 3 | -------------------------------------------------------------------------------- /scripts/unix/install_requirements.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pip install -U uv 3 | uv sync --frozen --extra dev 4 | -------------------------------------------------------------------------------- /scripts/windows/publish_to_pypi.ps1: -------------------------------------------------------------------------------- 1 | uv build 2 | uv publish --publish-url https://test.pypi.org/legacy/ 3 | uv publish 4 | -------------------------------------------------------------------------------- /scripts/unix/publish_to_pypi.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | uv build 3 | uv publish --publish-url https://test.pypi.org/legacy/ 4 | uv publish 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE.md 3 | recursive-exclude * *.pyc 4 | recursive-include django-migration-zero *.py *.html *.js *.cfg *.mo *.po 5 | -------------------------------------------------------------------------------- /django_migration_zero/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambient-innovation/django-migration-zero/HEAD/django_migration_zero/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | urlpatterns = [ 5 | # django Admin 6 | path("admin/", admin.site.urls), 7 | ] 8 | -------------------------------------------------------------------------------- /django_migration_zero/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: E501 2 | """ 3 | Holistic implementation of "migration zero" pattern for Django covering local changes and in-production database adjustments. 4 | """ 5 | 6 | __version__ = "2.3.14" 7 | -------------------------------------------------------------------------------- /django_migration_zero/helpers/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def get_logger() -> logging.Logger: 5 | """ 6 | Returns an instance of the default logger of this package 7 | """ 8 | return logging.getLogger("django_migration_zero") 9 | -------------------------------------------------------------------------------- /django_migration_zero/exceptions.py: -------------------------------------------------------------------------------- 1 | class MissingMigrationZeroConfigRecordError(RuntimeError): 2 | pass 3 | 4 | 5 | class InvalidMigrationTreeError(RuntimeError): 6 | pass 7 | 8 | 9 | class InvalidMigrationAppsDirPathError(ValueError): 10 | pass 11 | -------------------------------------------------------------------------------- /tests/helpers/test_logger.py: -------------------------------------------------------------------------------- 1 | from logging import Logger 2 | 3 | from django.test import TestCase 4 | 5 | from django_migration_zero.helpers.logger import get_logger 6 | 7 | 8 | class HelperLoggerTest(TestCase): 9 | def test_get_logger_regular(self): 10 | self.assertIsInstance(get_logger(), Logger) 11 | -------------------------------------------------------------------------------- /django_migration_zero/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class MigrationZeroConfig(AppConfig): 6 | name = "django_migration_zero" 7 | verbose_name = _("Migration Zero Configuration") 8 | default_auto_field = "django.db.models.AutoField" 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.yml] 16 | indent_style = space 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /django_migration_zero/management/commands/handle_migration_zero_reset.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from django_migration_zero.services.deployment import DatabasePreparationService 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Prepares the database after resetting all migrations." 8 | 9 | def handle(self, *args, **options): 10 | service = DatabasePreparationService() 11 | service.process() 12 | -------------------------------------------------------------------------------- /.ambient-package-update/templates/snippets/tagline.tpl: -------------------------------------------------------------------------------- 1 | Welcome to **django-migration-zero** - the holistic implementation of "migration zero" pattern for 2 | Django covering local changes and CI/CD pipeline adjustments. 3 | 4 | This package implements the "migration zero" pattern to clean up your local migrations and provides convenient 5 | management commands to recreate your migration files and updating your migration history on your environments 6 | (like test or production systems). 7 | -------------------------------------------------------------------------------- /django_migration_zero/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from django_migration_zero.models import MigrationZeroConfiguration 4 | 5 | 6 | @admin.register(MigrationZeroConfiguration) 7 | class MigrationZeroAdmin(admin.ModelAdmin): 8 | list_display = ( 9 | "__str__", 10 | "migration_imminent", 11 | "migration_date", 12 | ) 13 | 14 | def has_add_permission(self, request): 15 | return False 16 | 17 | def has_delete_permission(self, request, obj=None): 18 | return False 19 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | django-migration-zero 2 | =================== 3 | 4 | .. mdinclude:: ../README.md 5 | 6 | .. toctree:: 7 | :maxdepth: 1 8 | :caption: Contents: 9 | 10 | features/01_introduction.md 11 | features/02_configuration.md 12 | features/03_workflow.md 13 | features/04_common_issues_and_solutions.md 14 | features/05_architecture.md 15 | features/06_management_commands.md 16 | features/07_changelog.rst 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`search` 23 | -------------------------------------------------------------------------------- /tests/management/commands/test_handle_migration_zero_reset.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.core.management import call_command 4 | from django.test import TestCase 5 | 6 | from django_migration_zero.services.deployment import DatabasePreparationService 7 | 8 | 9 | class ManagementCommandHandleMigrationZeroResetTest(TestCase): 10 | @mock.patch.object(DatabasePreparationService, "__init__", return_value=None) 11 | @mock.patch.object(DatabasePreparationService, "process") 12 | def test_call_parameter_no_parameters(self, mocked_process, mocked_init): 13 | call_command("handle_migration_zero_reset") 14 | mocked_init.assert_called_once_with() 15 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main(): 9 | """Run administrative tasks.""" 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 11 | try: 12 | from django.core.management import execute_from_command_line # noqa: PLC0415 13 | except ImportError as exc: 14 | raise ImportError( 15 | "Couldn't import Django. Are you sure it's installed and " 16 | "available on your PYTHONPATH environment variable? Did you " 17 | "forget to activate a virtual environment?" 18 | ) from exc 19 | execute_from_command_line(sys.argv) 20 | 21 | 22 | if __name__ == "__main__": 23 | main() 24 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the OS, Python version and other tools you might need 9 | build: 10 | os: ubuntu-24.04 11 | tools: 12 | python: "3.13" 13 | 14 | # Build documentation in the "docs/" directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # Optionally build your docs in additional formats such as PDF and ePub 19 | # formats: 20 | # - pdf 21 | # - epub 22 | 23 | # Optional but recommended, declare the Python requirements required 24 | # to build your documentation 25 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 26 | python: 27 | install: 28 | - method: pip 29 | path: . 30 | extra_requirements: 31 | - dev 32 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/features/02_configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | # Settings 4 | 5 | This package requires all local Django apps to be inside a `BASE_DIR` directory. This directory should be already 6 | defined in the main django settings. 7 | 8 | Note that this variable has to be of type `pathlib.Path`. 9 | 10 | ## Logging 11 | 12 | The scripts are quite chatty. If you want to see what's going on under the hood, just configure a quite default-looking 13 | logger in your Django settings. 14 | 15 | Note, that in this example, we are only logging to the console. 16 | 17 | ```python 18 | LOGGING = { 19 | "handlers": { 20 | "console": { 21 | "class": "logging.StreamHandler", 22 | }, 23 | }, 24 | "loggers": { 25 | "django_migration_zero": { 26 | "handlers": ["console"], 27 | "level": "INFO", 28 | "propagate": True, 29 | }, 30 | }, 31 | } 32 | ``` 33 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Security Issues 2 | 3 | The maintainers of this package take security bugs seriously. We appreciate your efforts to responsibly 4 | disclose your findings, and will make every effort to acknowledge your contributions. 5 | 6 | To report a security issue, please use the GitHub Security 7 | Advisory ["Report a Vulnerability"](https://github.com/ambient-innovation/django-migration-zero/security/advisories/new) 8 | tab. 9 | 10 | The maintainers will send a response indicating the next steps in handling your report. After the initial reply to 11 | your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask 12 | for additional information or guidance. 13 | 14 | Report security bugs in third-party modules to the person or team maintaining the module. 15 | 16 | ## Source 17 | 18 | This file was inspired by: https://github.com/electron/electron/blob/main/SECURITY.md 19 | -------------------------------------------------------------------------------- /django_migration_zero/management/commands/reset_local_migration_files.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management.base import BaseCommand 3 | 4 | from django_migration_zero.services.local import ResetMigrationFiles 5 | 6 | 7 | class Command(BaseCommand): 8 | def add_arguments(self, parser): 9 | parser.add_argument( 10 | "--dry-run", 11 | action="store_true", 12 | help="Shows affected files without actually deleting them.", 13 | ) 14 | parser.add_argument( 15 | "--exclude-initials", 16 | action="store_true", 17 | help="Won't delete initial migration files.", 18 | ) 19 | 20 | def handle(self, *args, **options): 21 | if not settings.DEBUG: 22 | print("Don't run this command in production!") 23 | return 24 | 25 | service = ResetMigrationFiles( 26 | dry_run=options.get("dry_run", False), exclude_initials=options.get("exclude_initials", False) 27 | ) 28 | service.process() 29 | -------------------------------------------------------------------------------- /.ambient-package-update/templates/snippets/installation.tpl: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | - Install the package via pip: 4 | 5 | `pip install {{ module_name|replace("_", "-") }}` 6 | 7 | or via pipenv: 8 | 9 | `pipenv install {{ module_name|replace("_", "-") }}` 10 | 11 | - Add module to `INSTALLED_APPS` within the main django `settings.py`: 12 | 13 | ```` 14 | INSTALLED_APPS = ( 15 | ... 16 | '{{ module_name }}', 17 | ) 18 | ```` 19 | 20 | {% if has_migrations %} 21 | - Apply migrations by running: 22 | 23 | `python ./manage.py migrate` 24 | {% endif %} 25 | 26 | - Add this block to your loggers in your main Django `settings.py` to show logs in your console. 27 | 28 | ```python 29 | LOGGING = { 30 | "handlers": { 31 | "console": { 32 | "class": "logging.StreamHandler", 33 | }, 34 | }, 35 | "loggers": { 36 | "django_migration_zero": { 37 | "handlers": ["console"], 38 | "level": "INFO", 39 | "propagate": True, 40 | }, 41 | }, 42 | } 43 | ``` 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ambient Innovation: GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /django_migration_zero/managers.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist 2 | from django.db import ProgrammingError, models 3 | 4 | from django_migration_zero.exceptions import MissingMigrationZeroConfigRecordError 5 | from django_migration_zero.helpers.logger import get_logger 6 | 7 | 8 | class MigrationZeroConfigurationManager(models.Manager): 9 | def fetch_singleton(self) -> None: 10 | logger = get_logger() 11 | try: 12 | config_singleton = self.select_for_update().get() 13 | except ProgrammingError: 14 | logger.warning( 15 | "The migration zero table is missing. This might be ok for the first installation of " 16 | '"django-migration-zero" but if you see this warning after that point, something went sideways.' 17 | ) 18 | config_singleton = None 19 | except MultipleObjectsReturned as e: 20 | raise MissingMigrationZeroConfigRecordError( 21 | "Too many configuration records detected. There can only be one." 22 | ) from e 23 | except ObjectDoesNotExist as e: 24 | raise MissingMigrationZeroConfigRecordError("No configuration record found in the database.") from e 25 | 26 | return config_singleton 27 | -------------------------------------------------------------------------------- /docs/features/05_architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | This page will describe the process and the decisions taken by the creators of this package. 4 | 5 | ## Resetting local files 6 | 7 | * The command for resetting your local migration files will iterate your Django app directory and looks for packages 8 | containing migration. 9 | * Then it detects all migration files, optionally leaving out the initials. 10 | * In the next step, every detected file will be deleted. You can run the process as a dry run, which won't delete the 11 | files. 12 | * Finally, the command calls Django's "makemigrations" to recreate the migration files neat and clean. 13 | 14 | ## Handle database adjustments 15 | 16 | * This command will first validate that the migration configuration database singleton (exactly one record in the 17 | database) exists and is valid 18 | * Afterward, it checks if the flag is active and the migration date is set to "today" 19 | * Then it will fetch the list of local Django apps and delete all records in the history table `django_migrations` 20 | where `app` equals current app label. Due to working a little around the framework, it's not possible to 21 | use `migrate [APP] --prune` or `migrate --fake [APP] zero` 22 | * In the next step, we'll populate the migration table with `migrate --fake` which will create a record per detected 23 | migration file 24 | * To ensure we have a clean state by now, we ask for Django's opinion via `migrate --check` 25 | * If the check passes, we disable the admin flag to avoid staring the process for the next deployment again 26 | -------------------------------------------------------------------------------- /django_migration_zero/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2023-10-19 13:51+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | #: .\migration_zero\apps.py:7 21 | msgid "Migration Zero Configuration" 22 | msgstr "Migration Zero-Konfiguration" 23 | 24 | #: .\migration_zero\models.py:11 25 | msgid "Migration imminent" 26 | msgstr "Bevorstehende Migration" 27 | 28 | #: .\migration_zero\models.py:13 29 | msgid "" 30 | "Enable this checkbox to prepare the database for a migration zero reset on " 31 | "the next deployment." 32 | msgstr "Aktivieren Sie diese Checkbox um für das nächste Deployment die Datenbank für Migration Zero-Datenbankreset " 33 | "vorzubereiten." 34 | 35 | #: .\migration_zero\models.py:15 36 | msgid "Migration date" 37 | msgstr "Migrationsdatum" 38 | 39 | #: .\migration_zero\models.py:20 40 | msgid "Configuration" 41 | msgstr "Konfiguration" 42 | 43 | #: .\migration_zero\models.py:21 44 | msgid "Configurations" 45 | msgstr "Konfigurationen" 46 | -------------------------------------------------------------------------------- /django_migration_zero/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2023-10-18 07:14 2 | import datetime 3 | 4 | from django.db import migrations, models 5 | from django.db.migrations import RunPython 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | def create_initial_record(apps, schema_editor): 15 | print("Creating configuration record for migration zero.") 16 | MigrationZeroConfiguration = apps.get_model("django_migration_zero", "MigrationZeroConfiguration") 17 | MigrationZeroConfiguration.objects.create(migration_imminent=False, migration_date=datetime.date(1970, 1, 1)) 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='MigrationZeroConfiguration', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('migration_imminent', models.BooleanField(default=False, 25 | help_text='Enable this checkbox to prepare the database for a migration zero reset on the next deployment.', 26 | verbose_name='Migration imminent')), 27 | ('migration_date', models.DateField(blank=True, null=True, verbose_name='Migration date')), 28 | ], 29 | options={'verbose_name': 'Configuration', 'verbose_name_plural': 'Configurations'}, 30 | ), 31 | RunPython(create_initial_record), 32 | ] 33 | -------------------------------------------------------------------------------- /docs/features/06_management_commands.md: -------------------------------------------------------------------------------- 1 | # Management Commands 2 | 3 | ## reset_local_migration_files 4 | 5 | This command will delete every migration file within your local Django apps and afterward call `makemigrations` to 6 | recreate new and shiny initial migrations. 7 | 8 | It comes with a couple of parameters: 9 | 10 | | Option | Explanation | 11 | |--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 12 | | --dry-run | Won't delete any files. Useful to check if your setup is correct. | 13 | | --exclude-initials | Won't replace and recreate initial migration files. Useful when you are merging a clean "migration zero" commit into your branch and just want to replace the migration delta to the source branch. | 14 | 15 | ## handle_migration_zero_reset 16 | 17 | This command will prepare and adjust Django's migration history table in the database to reflect the newly created 18 | initial migrations. It needs the migration zero configuration switch in your database to be active, otherwise it won't 19 | do anything. 20 | 21 | In a nutshell, it will remove all previous history records in the database and add one new record for every migration 22 | file coming in the "migration zero" commit. 23 | -------------------------------------------------------------------------------- /django_migration_zero/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from django_migration_zero.helpers.logger import get_logger 6 | from django_migration_zero.managers import MigrationZeroConfigurationManager 7 | 8 | 9 | class MigrationZeroConfiguration(models.Model): 10 | migration_imminent = models.BooleanField( 11 | _("Migration imminent"), 12 | default=False, 13 | help_text=_("Enable this checkbox to prepare the database for a migration zero reset on the next deployment."), 14 | ) 15 | migration_date = models.DateField(_("Migration date"), null=True, blank=True) 16 | 17 | objects = MigrationZeroConfigurationManager() 18 | 19 | class Meta: 20 | verbose_name = _("Configuration") 21 | verbose_name_plural = _("Configurations") 22 | 23 | def __str__(self): 24 | return "Configuration" 25 | 26 | @property 27 | def is_migration_applicable(self) -> bool: 28 | """ 29 | Checks if we are currently preparing for a "migration zero"-deployment 30 | """ 31 | logger = get_logger() 32 | if not self.migration_imminent: 33 | logger.info("Switch not active. Skipping migration zero process.") 34 | return False 35 | 36 | # Use timezone.localdate() to get the current date in the active timezone 37 | # This ensures the comparison works correctly regardless of the server's timezone 38 | if not self.migration_date == timezone.localdate(): 39 | logger.info("Security date doesn't match today. Skipping migration zero process.") 40 | return False 41 | 42 | return True 43 | -------------------------------------------------------------------------------- /django_migration_zero/services/local.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from django.core.management import call_command 4 | 5 | from django_migration_zero.helpers.file_system import ( 6 | delete_file, 7 | get_local_django_apps, 8 | get_migration_files, 9 | has_migration_directory, 10 | ) 11 | from django_migration_zero.helpers.logger import get_logger 12 | 13 | 14 | class ResetMigrationFiles: 15 | help = "Remove all local migrations files and create new initial ones." 16 | 17 | dry_run: bool 18 | exclude_initials: bool 19 | 20 | def __init__(self, dry_run: bool = False, exclude_initials: bool = False): 21 | super().__init__() 22 | 23 | self.dry_run = dry_run 24 | self.exclude_initials = exclude_initials 25 | 26 | def process(self): 27 | logger = get_logger() 28 | local_apps = get_local_django_apps() 29 | 30 | for app_config in local_apps: 31 | app_path = Path(app_config.path) 32 | 33 | if not has_migration_directory(app_path=app_path): 34 | logger.debug(f"Skipping app {app_config.label!r}. No migration package detected.") 35 | continue 36 | 37 | migration_file_list = get_migration_files( 38 | app_label=app_config.label, 39 | app_path=app_path, 40 | exclude_initials=self.exclude_initials, 41 | ) 42 | 43 | for migration_file in migration_file_list: 44 | delete_file(filename=migration_file, app_path=app_path, dry_run=self.dry_run) 45 | 46 | logger.info("Recreating new initial migration files...") 47 | call_command("makemigrations") 48 | 49 | logger.info("Process finished.") 50 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # you find the full pre-commit-tools docu under: 2 | # https://pre-commit.com/ 3 | 4 | repos: 5 | - repo: https://github.com/astral-sh/ruff-pre-commit 6 | rev: v0.14.8 7 | hooks: 8 | # Run the Ruff formatter. 9 | - id: ruff-format 10 | # Run the Ruff linter. 11 | - id: ruff-check 12 | args: [--fix, --exit-non-zero-on-fix] 13 | 14 | - repo: https://github.com/adamchainz/blacken-docs 15 | rev: 1.20.0 16 | hooks: 17 | - id: blacken-docs 18 | additional_dependencies: 19 | - black==25.9.0 20 | files: '(?:README\.md|\.ambient-package-update\/templates\/snippets\/.*\.tpl|docs\/.*\.(?:md|rst))' 21 | 22 | - repo: https://github.com/asottile/pyupgrade 23 | rev: v3.21.2 24 | hooks: 25 | - id: pyupgrade 26 | args: [ --py310-plus ] 27 | 28 | - repo: https://github.com/adamchainz/django-upgrade 29 | rev: 1.29.1 30 | hooks: 31 | - id: django-upgrade 32 | args: [--target-version, "4.2"] 33 | 34 | - repo: https://github.com/adamchainz/djade-pre-commit 35 | rev: 1.7.0 36 | hooks: 37 | - id: djade 38 | args: [--target-version, "4.2"] 39 | exclude: | 40 | (?x)^( 41 | charts/.* 42 | |.*\.py 43 | )$ 44 | 45 | - repo: https://github.com/pre-commit/pre-commit-hooks 46 | rev: v6.0.0 47 | hooks: 48 | - id: check-ast 49 | - id: check-builtin-literals 50 | - id: check-case-conflict 51 | - id: check-docstring-first 52 | - id: check-executables-have-shebangs 53 | - id: check-json 54 | - id: check-merge-conflict 55 | - id: check-toml 56 | - id: end-of-file-fixer 57 | - id: fix-byte-order-marker 58 | - id: mixed-line-ending 59 | - id: trailing-whitespace 60 | -------------------------------------------------------------------------------- /docs/features/04_common_issues_and_solutions.md: -------------------------------------------------------------------------------- 1 | # Common issues & solutions 2 | 3 | Here's a list of things that might occur once in a while. 4 | 5 | ## I deleted a (data) migration I still need 6 | 7 | Hopefully, you didn't deploy it yet. If not, just go to your git history and get it back. If not, you have to fix it 8 | manually in your database. Just see what the migration does and follow those steps manually. 9 | 10 | ## I had to make a hotfix on master containing a migration 11 | 12 | As always, if you do hotfixes, make sure to merge them downstream ASAP. The quicker you do it, the less hassle you'll 13 | have in the future. 14 | 15 | ## I deployed a regular commit with the admin flag active 16 | 17 | If you deploy to your environment and by accident have the admin switch active, you shouldn't encounter issues. The 18 | migration history table will have a bunch of updated timestamps but apart from that, you should be fine. 19 | 20 | ## I deployed a migration zero commit with the admin flag not being active 21 | 22 | If you pushed your migration zero commit with all those removed and changed migration files without having the flag 23 | active, you'll encounter a crash when running `manage.py migrate`. Since the migration history is out-of-sync with the 24 | files you just deployed, the migration shouldn't do anything except failing, and you should be OK in 95% of all cases. 25 | Just activate the flag and redeploy. 26 | 27 | ## What happens to data migrations (RunPython)? They won't be recreated by Django! 28 | 29 | To start with this process, you must make sure that all migrations have been applied to every environment. This means 30 | that all custom-built data migration you might have, are obsolete by the point you want to start cleaning up your 31 | migrations. Therefore, you can let them be deleted without any second thoughts. 32 | -------------------------------------------------------------------------------- /tests/management/commands/test_reset_local_migration_files.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.core.management import call_command 4 | from django.test import TestCase, override_settings 5 | 6 | from django_migration_zero.services.local import ResetMigrationFiles 7 | 8 | 9 | class ManagementCommandResetLocalMigrationFilesTest(TestCase): 10 | @override_settings(DEBUG=False) 11 | @mock.patch.object(ResetMigrationFiles, "__init__") 12 | def test_debug_barrier(self, mocked_init): 13 | call_command("reset_local_migration_files") 14 | mocked_init.assert_not_called() 15 | 16 | @override_settings(DEBUG=True) 17 | @mock.patch.object(ResetMigrationFiles, "__init__", return_value=None) 18 | @mock.patch.object(ResetMigrationFiles, "process") 19 | def test_call_parameter_no_parameters(self, mocked_process, mocked_init): 20 | call_command("reset_local_migration_files") 21 | mocked_init.assert_called_once_with(dry_run=False, exclude_initials=False) 22 | 23 | @override_settings(DEBUG=True) 24 | @mock.patch.object(ResetMigrationFiles, "__init__", return_value=None) 25 | @mock.patch.object(ResetMigrationFiles, "process") 26 | def test_call_parameter_parameter_dry_run_set(self, mocked_process, mocked_init): 27 | call_command("reset_local_migration_files", dry_run=True) 28 | mocked_init.assert_called_once_with(dry_run=True, exclude_initials=False) 29 | 30 | @override_settings(DEBUG=True) 31 | @mock.patch.object(ResetMigrationFiles, "__init__", return_value=None) 32 | @mock.patch.object(ResetMigrationFiles, "process") 33 | def test_call_parameter_parameter_exclude_initials_set(self, mocked_process, mocked_init): 34 | call_command("reset_local_migration_files", exclude_initials=True) 35 | mocked_init.assert_called_once_with(dry_run=False, exclude_initials=True) 36 | -------------------------------------------------------------------------------- /.ambient-package-update/metadata.py: -------------------------------------------------------------------------------- 1 | from ambient_package_update.metadata.author import PackageAuthor 2 | from ambient_package_update.metadata.constants import ( 3 | DEV_DEPENDENCIES, 4 | LICENSE_MIT, 5 | SUPPORTED_DJANGO_VERSIONS, 6 | SUPPORTED_PYTHON_VERSIONS, 7 | ) 8 | from ambient_package_update.metadata.maintainer import PackageMaintainer 9 | from ambient_package_update.metadata.package import PackageMetadata 10 | from ambient_package_update.metadata.readme import ReadmeContent 11 | from ambient_package_update.metadata.ruff_ignored_inspection import RuffIgnoredInspection 12 | 13 | METADATA = PackageMetadata( 14 | package_name="django-migration-zero", 15 | github_package_group="ambient-innovation", 16 | authors=[ 17 | PackageAuthor( 18 | name="Ambient Digital", 19 | email="hello@ambient.digital", 20 | ), 21 | ], 22 | maintainer=PackageMaintainer(name="Ambient Digital", url="https://ambient.digital/", email="hello@ambient.digital"), 23 | licenser="Ambient Innovation: GmbH", 24 | license=LICENSE_MIT, 25 | license_year=2023, 26 | development_status="5 - Production/Stable", 27 | has_migrations=True, 28 | claim="Holistic implementation of 'migration zero' pattern for Django covering local changes and " 29 | "in-production database adjustments.", 30 | readme_content=ReadmeContent(uses_internationalisation=True), 31 | dependencies=[ 32 | f"Django>={SUPPORTED_DJANGO_VERSIONS[0]}", 33 | ], 34 | supported_django_versions=SUPPORTED_DJANGO_VERSIONS, 35 | supported_python_versions=SUPPORTED_PYTHON_VERSIONS, 36 | optional_dependencies={ 37 | "dev": [*DEV_DEPENDENCIES, "unittest-parametrize~=1.3", "freezegun~=1.5"], 38 | }, 39 | ruff_ignore_list=[ 40 | RuffIgnoredInspection(key="TRY003", comment="Avoid specifying long messages outside the exception class"), 41 | ], 42 | ) 43 | -------------------------------------------------------------------------------- /.ambient-package-update/templates/snippets/content.tpl: -------------------------------------------------------------------------------- 1 | ## Features 2 | 3 | * Remove all existing local migration files and recreate them as initial migrations 4 | * Configuration singleton in Django admin to prepare your clean-up deployment 5 | * Management command for your pipeline to update Django's migration history table to reflect the changed migrations 6 | 7 | ## Motivation 8 | 9 | Working with any proper ORM will result in database changes which are reflected in migration files to update your 10 | different environment's database structure. These files are versioned in your repository and if you follow any of the 11 | most popular deployment approaches, they won't be needed when they are deployed on production. This means, they clutter 12 | your repo, might lead to merge conflicts in the future and will slow down your test setup. 13 | 14 | Django's default way of handling this is called "squashing". This approach is covered broadly in the 15 | [official documentation](https://docs.djangoproject.com/en/dev/topics/migrations/#migration-squashing). The main 16 | drawback here is, that you have to take care of circular dependencies between models. Depending on your project's 17 | size, this can take a fair amount of time. 18 | 19 | The main benefit of squashing migrations is, that the history stays intact, therefore it can be used for example in 20 | package which can be installed by anybody and you don't have control over their database. 21 | 22 | If you are working on a "regular" application, you have full control over your data(bases) and once everything has 23 | been applied on the "last" system, typically production, the migrations are obsolete. To avoid spending much time on 24 | fixing squashed migrations you won't need, you can use the "migration zero" pattern. In a nutshell, this means: 25 | 26 | * Delete all your local migration files 27 | * Recreate initial migration files containing your current model state 28 | * Fix the migration history on every of your environments 29 | -------------------------------------------------------------------------------- /tests/test_managers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from unittest import mock 3 | 4 | from django.db import ProgrammingError 5 | from django.test import TestCase 6 | 7 | from django_migration_zero.exceptions import MissingMigrationZeroConfigRecordError 8 | from django_migration_zero.managers import MigrationZeroConfigurationManager 9 | from django_migration_zero.models import MigrationZeroConfiguration 10 | 11 | 12 | class MigrationZeroConfigurationManagerTest(TestCase): 13 | def test_fetch_singleton_regular(self): 14 | config = MigrationZeroConfiguration.objects.fetch_singleton() 15 | self.assertIsInstance(config, MigrationZeroConfiguration) 16 | self.assertFalse(config.migration_imminent) 17 | self.assertEqual(config.migration_date, datetime.date(1970, 1, 1)) 18 | 19 | def test_fetch_singleton_singleton_exists_via_migration(self): 20 | self.assertEqual(MigrationZeroConfiguration.objects.all().count(), 1) 21 | 22 | @mock.patch.object(MigrationZeroConfigurationManager, "select_for_update", side_effect=ProgrammingError) 23 | def test_fetch_singleton_database_error(self, *args): 24 | self.assertIsNone(MigrationZeroConfiguration.objects.fetch_singleton()) 25 | 26 | def test_fetch_singleton_failure_on_multiple_objects(self): 27 | MigrationZeroConfiguration.objects.create() 28 | 29 | # Sanity check 30 | self.assertEqual(MigrationZeroConfiguration.objects.all().count(), 2) 31 | 32 | # Assertion 33 | with self.assertRaisesMessage( 34 | MissingMigrationZeroConfigRecordError, "Too many configuration records detected. There can only be one." 35 | ): 36 | MigrationZeroConfiguration.objects.fetch_singleton() 37 | 38 | def test_fetch_singleton_failure_on_zero_objects(self): 39 | # Setup 40 | MigrationZeroConfiguration.objects.all().delete() 41 | 42 | # Sanity check 43 | self.assertEqual(MigrationZeroConfiguration.objects.all().count(), 0) 44 | 45 | # Assertion 46 | with self.assertRaisesMessage( 47 | MissingMigrationZeroConfigRecordError, "No configuration record found in the database." 48 | ): 49 | MigrationZeroConfiguration.objects.fetch_singleton() 50 | -------------------------------------------------------------------------------- /django_migration_zero/services/deployment.py: -------------------------------------------------------------------------------- 1 | from logging import Logger 2 | 3 | from django.core.management import call_command 4 | from django.db import transaction 5 | from django.db.migrations.recorder import MigrationRecorder 6 | 7 | from django_migration_zero.exceptions import InvalidMigrationTreeError 8 | from django_migration_zero.helpers.logger import get_logger 9 | from django_migration_zero.models import MigrationZeroConfiguration 10 | 11 | 12 | class DatabasePreparationService: 13 | """ 14 | Service to prepare the database for an upcoming commit in the CI/CD pipeline. 15 | """ 16 | 17 | logger: Logger 18 | 19 | def __init__(self): 20 | super().__init__() 21 | 22 | self.logger = get_logger() 23 | 24 | @transaction.atomic 25 | def process(self): 26 | self.logger.info("Starting migration zero database adjustments...") 27 | 28 | # Fetch configuration singleton from database 29 | config_singleton = MigrationZeroConfiguration.objects.fetch_singleton() 30 | 31 | # If we encountered a problem or are not planning to do a migration reset, we are done here 32 | if not (config_singleton and config_singleton.is_migration_applicable): 33 | return 34 | 35 | # Reset migration history in database for all apps because there might be dependency issues if we keep the 36 | # records of the other ones 37 | self.logger.info("Resetting migration history for all apps...") 38 | 39 | MigrationRecorder.Migration.objects.all().delete() 40 | 41 | # Apply migrations via fake because the database is already up-to-date 42 | self.logger.info("Populating migration history.") 43 | call_command("migrate", fake=True) 44 | 45 | # Check if migration tree is valid 46 | self.logger.info("Checking migration integrity.") 47 | migrate_check = call_command("migrate", check=True) 48 | 49 | if not migrate_check: 50 | self.logger.info("All good.") 51 | else: 52 | raise InvalidMigrationTreeError( 53 | 'The command "migrate --check" returned a non-zero error code. ' 54 | "Your migration structure seems to be invalid." 55 | ) 56 | 57 | # Process finished, deactivate migration zero switch 58 | self.logger.info("Deactivating migration zero switch in database.") 59 | config_singleton.migration_imminent = False 60 | config_singleton.save() 61 | 62 | self.logger.info("Process successfully finished.") 63 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | ## Setup package for development 4 | 5 | - Create a Python virtualenv and activate it 6 | - Install "uv" with `pip install -U uv` 7 | - Sync the requirements with `uv sync --frozen --extra dev` 8 | 9 | ## Add functionality 10 | 11 | - Create a new branch for your feature 12 | - Change the dependency in your requirements.txt to a local (editable) one that points to your local file system: 13 | `-e /Users/workspace/django-migration-zero` or via pip `pip install -e /Users/workspace/django-migration-zero` 14 | - Ensure the code passes the tests 15 | - Create a pull request 16 | 17 | ## Run tests 18 | 19 | - Run tests 20 | ```` 21 | pytest --ds settings tests 22 | ```` 23 | 24 | - Check coverage 25 | ```` 26 | coverage run -m pytest --ds settings tests 27 | coverage report -m 28 | ```` 29 | 30 | ## Git hooks (via pre-commit) 31 | 32 | We use pre-push hooks to ensure that only linted code reaches our remote repository and pipelines aren't triggered in 33 | vain. 34 | 35 | To enable the configured pre-push hooks, you need to [install](https://pre-commit.com/) pre-commit and run once: 36 | 37 | pre-commit install -t pre-push -t pre-commit --install-hooks 38 | 39 | This will permanently install the git hooks for both, frontend and backend, in your local 40 | [`.git/hooks`](./.git/hooks) folder. 41 | The hooks are configured in the [`.pre-commit-config.yaml`](templates/.pre-commit-config.yaml.tpl). 42 | 43 | You can check whether hooks work as intended using the [run](https://pre-commit.com/#pre-commit-run) command: 44 | 45 | pre-commit run [hook-id] [options] 46 | 47 | Example: run single hook 48 | 49 | pre-commit run ruff --all-files 50 | 51 | Example: run all hooks of pre-push stage 52 | 53 | pre-commit run --all-files 54 | 55 | ## Update documentation 56 | 57 | - To build the documentation, run: `sphinx-build docs/ docs/_build/html/`. 58 | - Open `docs/_build/html/index.html` to see the documentation. 59 | 60 | ### Translation files 61 | 62 | If you have added custom text, make sure to wrap it in `_()` where `_` is 63 | gettext_lazy (`from django.utils.translation import gettext_lazy as _`). 64 | 65 | How to create translation file: 66 | 67 | * Navigate to `django_migration_zero` 68 | * `python manage.py makemessages -l de` 69 | * Have a look at the new/changed files within `django_migration_zero/locale` 70 | 71 | How to compile translation files: 72 | 73 | * Navigate to `django_migration_zero` 74 | * `python manage.py compilemessages` 75 | * Have a look at the new/changed files within `django_migration_zero/locale` 76 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from http import HTTPStatus 5 | 6 | from django.contrib.admin.sites import AdminSite, all_sites 7 | from django.contrib.auth.models import User 8 | from django.db.models import Model 9 | from django.test import TestCase, override_settings 10 | from django.urls import reverse 11 | from unittest_parametrize import ParametrizedTestCase, param, parametrize 12 | 13 | each_model_admin = parametrize( 14 | "site,model,model_admin", 15 | [ 16 | param( 17 | site, 18 | model, 19 | model_admin, 20 | id=f"{site.name}" 21 | + f"{re.sub(r'(? str: 50 | return reverse(f"{site.name}:{model._meta.app_label}_{model._meta.model_name}_{page}") 51 | 52 | @each_model_admin 53 | def test_changelist(self, site, model, model_admin): 54 | url = self.make_url(site, model, "changelist") 55 | response = self.client.get(url, {"q": "example.com"}) 56 | assert response.status_code == HTTPStatus.OK 57 | 58 | @each_model_admin 59 | def test_add(self, site, model, model_admin): 60 | url = self.make_url(site, model, "add") 61 | response = self.client.get(url) 62 | self.assertIn( 63 | response.status_code, 64 | ( 65 | HTTPStatus.OK, 66 | HTTPStatus.FORBIDDEN, # some admin classes blanket disallow "add" 67 | ), 68 | f'Response "{response.status_code}" not in ({HTTPStatus.OK}, {HTTPStatus.FORBIDDEN}).', 69 | ) 70 | -------------------------------------------------------------------------------- /docs/features/01_introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ## Why do I need this package? 4 | 5 | Migrations will pile up in your application over the course of time. They will clutter your repo, slow down your tests 6 | (time is after all money), and can lead to some nasty problems when you merge branches. 7 | 8 | [Squashing](https://docs.djangoproject.com/en/dev/topics/migrations/#migration-squashing) takes time to resolve 9 | all those circular dependencies within your models and in the end, you do work for something that you don't need. All 10 | migrations have been applied, so the structural and data changes are not relevant for you anymore. 11 | 12 | The "migration zero" approach will help you get rid of those migration files. But most solutions or packages 13 | will only help you do a local clean-up while the tricky part is to update your migration history on 14 | your deployed systems. 15 | 16 | Surely, you can log into your production system and run a series of commands manually but this is always a high-stress 17 | situation. What if something goes sideways? Secondly, not everybody has access to all environments. And lastly, this 18 | task is tedious and takes time. And we all know that you won't implement a habit (like cleaning up your migrations 19 | regularly) when it's annoying and time-consuming. 20 | 21 | That's why we've built this package. It helps you clean up all those files AND will handle all the things that need to 22 | happen on your databases, no matter where they live and how you can access them. 23 | 24 | ## Requirements 25 | 26 | * All local Django apps are inside one directory 27 | * You are in full control of every database your application runs on 28 | * This wouldn't be the case if you are creating a python package. 29 | Use [squashing](https://docs.djangoproject.com/en/dev/topics/migrations/#migration-squashing) in that case. 30 | * You're using some kind of CI/CD pipeline for an automated deployment 31 | * You have dedicated branches for every environment you deploy to 32 | * e.g. `master` deploys to your production system, `develop` deploys to your test system 33 | * All migrations in every deployment branch have been applied to their database 34 | * Having access to the Django admin of every environment you want to reset the migrations on 35 | 36 | ## Literature 37 | 38 | * [“Migrations zero” or how to handle migrations on a large Django project, X. Dubuc, 2018](https://medium.com/@xavier.dubuc/migrations-zero-or-how-to-handle-migrations-on-a-large-django-project-643627938449) 39 | * [How to Reset Migrations, V. Freitas, 2016](https://simpleisbetterthancomplex.com/tutorial/2016/07/26/how-to-reset-migrations.html) 40 | 41 | ## Alternatives 42 | 43 | ### [django-zeromigrations](https://pypi.org/project/django-zeromigrations/) 44 | 45 | Implements the local cleanup quite verbosely, including a backup functionality. Lacks the CI/CD part, though. 46 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | import django 17 | from django.conf import settings 18 | 19 | sys.path.insert(0, os.path.abspath("..")) # so that we can access the "django-migration-zero" package 20 | settings.configure( 21 | INSTALLED_APPS=[ 22 | "django.contrib.admin", 23 | "django.contrib.auth", 24 | "django.contrib.contenttypes", 25 | "django.contrib.sessions", 26 | "django.contrib.messages", 27 | "django.contrib.staticfiles", 28 | "django_migration_zero", 29 | ], 30 | SECRET_KEY="ASDFjklö123456890", 31 | ) 32 | django.setup() 33 | 34 | from django_migration_zero import __version__ # noqa: E402 35 | 36 | # -- Project information ----------------------------------------------------- 37 | 38 | project = "django-migration-zero" 39 | copyright = "2025, Ambient Innovation: GmbH" # noqa: A001 40 | author = "Ambient Digital " 41 | version = __version__ 42 | release = __version__ 43 | 44 | # -- General configuration --------------------------------------------------- 45 | 46 | # Add any Sphinx extension module names here, as strings. They can be 47 | # extensions coming with Sphinx (named "sphinx.ext.*") or your custom 48 | # ones. 49 | extensions = [ 50 | "sphinx_rtd_theme", 51 | "sphinx.ext.autodoc", 52 | "m2r2", 53 | ] 54 | 55 | source_suffix = [".rst", ".md"] 56 | 57 | # Add any paths that contain templates here, relative to this directory. 58 | templates_path = ["_templates"] 59 | 60 | # List of patterns, relative to source directory, that match files and 61 | # directories to ignore when looking for source files. 62 | # This pattern also affects html_static_path and html_extra_path. 63 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 64 | 65 | # -- Options for HTML output ------------------------------------------------- 66 | 67 | # The theme to use for HTML and HTML Help pages. See the documentation for 68 | # a list of builtin themes. 69 | html_theme = "sphinx_rtd_theme" 70 | html_theme_options = { 71 | "display_version": False, 72 | "style_external_links": False, 73 | } 74 | 75 | # Add any paths that contain custom static files (such as style sheets) here, 76 | # relative to this directory. They are copied after the builtin static files, 77 | # so a file named "default.css" will overwrite the builtin "default.css". 78 | html_static_path = ["_static"] 79 | 80 | # Set master doc file 81 | master_doc = "index" 82 | -------------------------------------------------------------------------------- /django_migration_zero/helpers/file_system.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from os.path import isdir 4 | from pathlib import Path 5 | 6 | from django.apps import apps 7 | from django.apps.config import AppConfig 8 | from django.conf import settings 9 | 10 | from django_migration_zero.helpers.logger import get_logger 11 | 12 | logger = get_logger() 13 | 14 | 15 | def build_migration_directory_path(*, app_path: Path) -> Path: 16 | """ 17 | Get directory to the migration directory of a given local Django app 18 | """ 19 | return app_path / "migrations" 20 | 21 | 22 | def get_local_django_apps() -> list[AppConfig]: 23 | """ 24 | Iterate all installed Django apps and detect local ones. 25 | """ 26 | local_apps = [] 27 | local_path = str(settings.BASE_DIR).replace("\\", "/") 28 | logger.info("Getting local Django apps...") 29 | for app_config in apps.get_app_configs(): 30 | app_path = str(app_config.path).replace("\\", "/") 31 | if app_path.startswith(local_path) and "site-packages" not in app_path: 32 | logger.info(f"Local app {app_config.label!r} discovered.") 33 | local_apps.append(app_config) 34 | else: 35 | logger.debug(f"App {app_config.label!r} ignored since it's not local.") 36 | 37 | return local_apps 38 | 39 | 40 | def has_migration_directory(*, app_path: Path) -> bool: 41 | """ 42 | Determines if the given Django app has a migrations directory and therefore migrations 43 | """ 44 | possible_migration_dir = build_migration_directory_path(app_path=app_path) 45 | return True if isdir(possible_migration_dir) else False 46 | 47 | 48 | def get_migration_files(*, app_label: str, app_path: Path, exclude_initials: bool = False) -> list[str]: 49 | """ 50 | Returns a list of all migration files detected in the given Django app. 51 | """ 52 | migration_file_list = [] 53 | 54 | logger.info(f"Getting migration files from app {app_label!r}...") 55 | migration_dir = build_migration_directory_path(app_path=app_path) 56 | file_pattern = r"^\d{4,}_\w+\.py$" 57 | for filename in os.listdir(migration_dir): 58 | if re.match(file_pattern, filename): 59 | if exclude_initials: 60 | initial_pattern = r"^\d{4,}_initial.py$" 61 | if re.match(initial_pattern, filename): 62 | logger.debug(f"File {filename!r} ignored since it's an initial migration.") 63 | continue 64 | 65 | logger.info(f"Migration file {filename!r} detected.") 66 | migration_file_list.append(filename) 67 | else: 68 | logger.debug(f"File {filename!r} ignored since it's not fitting the migration name pattern.") 69 | 70 | return migration_file_list 71 | 72 | 73 | def delete_file(*, filename: str, app_path: Path, dry_run: bool = False) -> None: 74 | """ 75 | Physically delete the given file 76 | """ 77 | file_path = build_migration_directory_path(app_path=app_path) / filename 78 | if not dry_run: 79 | try: 80 | os.unlink(file_path) 81 | logger.info(f"Deleted file {filename!r}.") 82 | except OSError: 83 | logger.warning(f"Unable to delete file {file_path!r}.") 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | .pybuilder/ 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pdm 85 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 86 | #pdm.lock 87 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 88 | # in version control. 89 | # https://pdm.fming.dev/#use-with-ide 90 | .pdm.toml 91 | 92 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 93 | __pypackages__/ 94 | 95 | # Celery stuff 96 | celerybeat-schedule 97 | celerybeat.pid 98 | 99 | # SageMath parsed files 100 | *.sage.py 101 | 102 | # Environments 103 | .env 104 | .venv 105 | env/ 106 | venv/ 107 | ENV/ 108 | env.bak/ 109 | venv.bak/ 110 | 111 | # Spyder project settings 112 | .spyderproject 113 | .spyproject 114 | 115 | # Rope project settings 116 | .ropeproject 117 | 118 | # mkdocs documentation 119 | /site 120 | 121 | # mypy 122 | .mypy_cache/ 123 | .dmypy.json 124 | dmypy.json 125 | 126 | # Pyre type checker 127 | .pyre/ 128 | 129 | # pytype static type analyzer 130 | .pytype/ 131 | 132 | # Cython debug symbols 133 | cython_debug/ 134 | 135 | # PyCharm 136 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 137 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 138 | # and can be added to the global gitignore or merged into this file. For a more nuclear 139 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 140 | .idea/ 141 | 142 | # Deprecated dependency files 143 | requirements.txt 144 | 145 | # sphinx build folder 146 | _build 147 | 148 | # Compiled source # 149 | ################### 150 | *.com 151 | *.class 152 | *.dll 153 | *.exe 154 | *.o 155 | 156 | # Test databases 157 | *.sqlite 158 | 159 | # AI 160 | .claude/ 161 | -------------------------------------------------------------------------------- /tests/services/test_local.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest import mock 3 | 4 | from django.apps import apps 5 | from django.test import TestCase 6 | 7 | from django_migration_zero.services.local import ResetMigrationFiles 8 | 9 | 10 | @mock.patch("django_migration_zero.services.local.delete_file") 11 | class ResetMigrationFilesTest(TestCase): 12 | def setUp(self): 13 | self.django_migration_zero_config = apps.get_app_config("django_migration_zero") 14 | self.testapp_config = apps.get_app_config("testapp") 15 | self.nested_app_config = apps.get_app_config("nested_app") 16 | 17 | def test_init_regular(self, *args): 18 | service = ResetMigrationFiles(dry_run=True, exclude_initials=True) 19 | 20 | self.assertTrue(service.dry_run) 21 | self.assertTrue(service.exclude_initials) 22 | 23 | @mock.patch("django_migration_zero.services.local.has_migration_directory", return_value=True) 24 | def test_process_case_app_with_migration_found(self, mocked_has_migration_directory, *args): 25 | service = ResetMigrationFiles() 26 | 27 | with mock.patch( 28 | "django_migration_zero.services.local.get_local_django_apps", 29 | return_value=[self.django_migration_zero_config], 30 | ): 31 | service.process() 32 | 33 | # Assertion 34 | mocked_has_migration_directory.assert_called() 35 | 36 | @mock.patch("django_migration_zero.services.local.has_migration_directory", return_value=False) 37 | def test_process_case_app_with_no_migration_found(self, mocked_has_migration_directory, *args): 38 | service = ResetMigrationFiles() 39 | 40 | with mock.patch( 41 | "django_migration_zero.services.local.get_local_django_apps", 42 | return_value=[self.testapp_config, self.nested_app_config], 43 | ): 44 | service.process() 45 | 46 | # Assertion 47 | mocked_has_migration_directory.assert_called() 48 | 49 | def test_process_delete_file_called(self, mocked_delete_file): 50 | service = ResetMigrationFiles() 51 | service.process() 52 | 53 | # Assertion 54 | mocked_delete_file.assert_called_with( 55 | filename="0001_initial.py", app_path=Path(self.django_migration_zero_config.path), dry_run=False 56 | ) 57 | 58 | def test_process_delete_file_case_dry_run(self, mocked_delete_file): 59 | service = ResetMigrationFiles(dry_run=True) 60 | service.process() 61 | 62 | # Assertion 63 | mocked_delete_file.assert_called_with( 64 | filename="0001_initial.py", app_path=Path(self.django_migration_zero_config.path), dry_run=True 65 | ) 66 | 67 | def test_process_delete_file_case_exclude_initials(self, mocked_delete_file): 68 | service = ResetMigrationFiles(exclude_initials=True) 69 | service.process() 70 | 71 | # Assertion 72 | mocked_delete_file.assert_not_called() 73 | 74 | @mock.patch("django_migration_zero.services.local.call_command") 75 | def test_process_makemigrations_called(self, mocked_call_command, *args): 76 | service = ResetMigrationFiles() 77 | service.process() 78 | 79 | # Assertion 80 | mocked_call_command.assert_called_with("makemigrations") 81 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | linting: 11 | runs-on: ubuntu-24.04 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Python 3.13 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.13" 19 | 20 | - name: Install required packages 21 | run: pip install pre-commit 22 | 23 | - name: Run pre-commit hooks 24 | run: pre-commit run --all-files 25 | 26 | validate_migrations: 27 | name: Validate migrations 28 | runs-on: ubuntu-24.04 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - uses: actions/setup-python@v5 33 | with: 34 | python-version: '3.13' 35 | 36 | - name: Install dependencies 37 | run: python -m pip install -U uv && uv sync --frozen --extra dev 38 | 39 | - name: Validate migration integrity 40 | run: uv run python manage.py makemigrations --check --dry-run 41 | 42 | tests: 43 | name: Python ${{ matrix.python-version }}, django ${{ matrix.django-version }} 44 | runs-on: ubuntu-24.04 45 | strategy: 46 | matrix: 47 | python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', ] 48 | django-version: ['42', '52', '60', ] 49 | 50 | exclude: 51 | - python-version: '3.11' 52 | django-version: 60 53 | - python-version: '3.10' 54 | django-version: 60 55 | - python-version: '3.9' 56 | django-version: 52 57 | - python-version: "3.13" 58 | django-version: 42 59 | 60 | steps: 61 | - uses: actions/checkout@v4 62 | - name: setup python 63 | uses: actions/setup-python@v5 64 | with: 65 | python-version: ${{ matrix.python-version }} 66 | - name: Install uv 67 | uses: astral-sh/setup-uv@v5 68 | - name: Install tox 69 | run: uv pip install --system tox tox-uv 70 | - name: Run Tests 71 | env: 72 | TOXENV: django${{ matrix.django-version }} 73 | run: tox 74 | - name: Upload coverage data 75 | uses: actions/upload-artifact@v4 76 | with: 77 | name: coverage-data-${{ matrix.python-version }}-${{ matrix.django-version }} 78 | path: '${{ github.workspace }}/.coverage' 79 | include-hidden-files: true 80 | if-no-files-found: error 81 | 82 | coverage: 83 | name: Coverage 84 | runs-on: ubuntu-24.04 85 | needs: tests 86 | steps: 87 | - uses: actions/checkout@v4 88 | 89 | - uses: actions/setup-python@v5 90 | with: 91 | python-version: '3.13' 92 | 93 | - name: Install dependencies 94 | run: python -m pip install --upgrade coverage[toml] 95 | 96 | - name: Download data 97 | uses: actions/download-artifact@v4 98 | with: 99 | path: ${{ github.workspace }}/coverage-reports 100 | pattern: coverage-data-* 101 | merge-multiple: false 102 | 103 | - name: Combine coverage and fail if it's <100.0% 104 | run: | 105 | python -m coverage combine coverage-reports/*/.coverage 106 | python -m coverage xml 107 | python -m coverage html --skip-covered --skip-empty 108 | python -m coverage report --fail-under=100.0 109 | echo "## Coverage summary" >> $GITHUB_STEP_SUMMARY 110 | python -m coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 111 | -------------------------------------------------------------------------------- /tests/helpers/test_file_system.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from unittest import mock 4 | 5 | from django.apps import apps 6 | from django.test import TestCase 7 | 8 | from django_migration_zero.helpers.file_system import ( 9 | build_migration_directory_path, 10 | delete_file, 11 | get_local_django_apps, 12 | get_migration_files, 13 | has_migration_directory, 14 | ) 15 | 16 | 17 | class HelperFileSystemTest(TestCase): 18 | def setUp(self): 19 | self.django_migration_zero_config = apps.get_app_config("django_migration_zero") 20 | self.testapp_config = apps.get_app_config("testapp") 21 | self.nested_app_config = apps.get_app_config("nested_app") 22 | 23 | def test_build_migration_directory_path_regular(self, *args): 24 | path = Path("/user/workspace/migration_zero/ilyta") 25 | self.assertEqual( 26 | build_migration_directory_path(app_path=path), Path("/user/workspace/migration_zero/ilyta/migrations") 27 | ) 28 | 29 | def test_get_local_django_apps_regular(self): 30 | self.assertEqual( 31 | get_local_django_apps(), 32 | [ 33 | self.django_migration_zero_config, 34 | self.testapp_config, 35 | self.nested_app_config, 36 | ], 37 | ) 38 | 39 | def test_has_migration_directory_positive_case(self): 40 | app_path = Path(self.django_migration_zero_config.path) 41 | self.assertTrue(has_migration_directory(app_path=app_path)) 42 | 43 | def test_has_migration_directory_negative_case(self): 44 | app_path = Path(self.testapp_config.path) 45 | self.assertFalse(has_migration_directory(app_path=app_path)) 46 | 47 | def test_has_migration_directory_positive_case_no_migrations(self): 48 | app_path = Path(self.nested_app_config.path) 49 | self.assertTrue(has_migration_directory(app_path=app_path)) 50 | 51 | def test_get_migration_files_regular(self): 52 | self.assertEqual( 53 | get_migration_files( 54 | app_label=self.django_migration_zero_config.label, 55 | app_path=Path(self.django_migration_zero_config.path), 56 | ), 57 | ["0001_initial.py"], 58 | ) 59 | 60 | def test_get_migration_files_exclude_initials(self): 61 | self.assertEqual( 62 | get_migration_files( 63 | app_label=self.django_migration_zero_config.label, 64 | app_path=Path(self.django_migration_zero_config.path), 65 | exclude_initials=True, 66 | ), 67 | [], 68 | ) 69 | 70 | @mock.patch.object(os, "listdir", return_value=["0002_my_migration.py"]) 71 | def test_get_migration_files_exclude_initials_no_initials(self, *args): 72 | self.assertEqual( 73 | get_migration_files( 74 | app_label=self.django_migration_zero_config.label, 75 | app_path=Path(self.django_migration_zero_config.path), 76 | exclude_initials=True, 77 | ), 78 | ["0002_my_migration.py"], 79 | ) 80 | 81 | @mock.patch("django_migration_zero.helpers.file_system.os.unlink") 82 | def test_delete_file_regular(self, mocked_unlink): 83 | delete_file(app_path=Path(self.testapp_config.path), filename="my_file.py") 84 | mocked_unlink.assert_called_once() 85 | 86 | @mock.patch("django_migration_zero.helpers.file_system.os.unlink") 87 | def test_delete_file_dry_run(self, mocked_unlink): 88 | delete_file(app_path=Path(self.testapp_config.path), filename="my_file.py", dry_run=True) 89 | mocked_unlink.assert_not_called() 90 | 91 | @mock.patch("django_migration_zero.helpers.file_system.os.unlink", side_effect=OSError) 92 | def test_delete_file_os_error(self, *args): 93 | self.assertIsNone(delete_file(app_path=Path(self.testapp_config.path), filename="my_file.py", dry_run=False)) 94 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for django_migration_zero project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "django-insecure-1&mcje13kx93k5+gzpbq=9%hu#e43p=_7*db-xgt1sh@kj)o*$b" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | # Django 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | # Third-party 42 | "django_migration_zero", 43 | # Local 44 | "testapp", 45 | "testapp.nested_app", 46 | ] 47 | 48 | MIDDLEWARE = [ 49 | "django.middleware.security.SecurityMiddleware", 50 | "django.contrib.sessions.middleware.SessionMiddleware", 51 | "django.middleware.common.CommonMiddleware", 52 | "django.middleware.csrf.CsrfViewMiddleware", 53 | "django.contrib.auth.middleware.AuthenticationMiddleware", 54 | "django.contrib.messages.middleware.MessageMiddleware", 55 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 56 | ] 57 | 58 | TEMPLATES = [ 59 | { 60 | "BACKEND": "django.template.backends.django.DjangoTemplates", 61 | "DIRS": [], 62 | "APP_DIRS": True, 63 | "OPTIONS": { 64 | "context_processors": [ 65 | "django.template.context_processors.debug", 66 | "django.template.context_processors.request", 67 | "django.contrib.auth.context_processors.auth", 68 | "django.contrib.messages.context_processors.messages", 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 77 | 78 | DATABASES = { 79 | "default": { 80 | "ENGINE": "django.db.backends.sqlite3", 81 | "NAME": BASE_DIR / "db.sqlite3", 82 | } 83 | } 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 91 | }, 92 | { 93 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 94 | }, 95 | { 96 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 97 | }, 98 | { 99 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 106 | 107 | LANGUAGE_CODE = "en-us" 108 | 109 | TIME_ZONE = "UTC" 110 | 111 | USE_I18N = True 112 | 113 | USE_TZ = True 114 | 115 | LOCALE_PATHS = [str(BASE_DIR) + "/django_migration_zero/locale"] 116 | 117 | # Routing 118 | ROOT_URLCONF = "testapp.urls" 119 | 120 | # Static files (CSS, JavaScript, Images) 121 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 122 | 123 | STATIC_URL = "static/" 124 | 125 | # Default primary key field type 126 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 127 | 128 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 129 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from django.test import TestCase, override_settings 4 | from django.utils import timezone 5 | from freezegun import freeze_time 6 | 7 | from django_migration_zero.models import MigrationZeroConfiguration 8 | 9 | 10 | @freeze_time("2023-09-19") 11 | class MigrationZeroConfigurationTest(TestCase): 12 | @classmethod 13 | def setUpTestData(cls): 14 | super().setUpTestData() 15 | 16 | cls.config, _ = MigrationZeroConfiguration.objects.get_or_create() 17 | 18 | def test_str_regular(self): 19 | self.assertEqual(str(self.config), "Configuration") 20 | 21 | def test_is_migration_applicable_regular(self): 22 | # Setup 23 | self.config.migration_imminent = True 24 | self.config.migration_date = timezone.now().date() 25 | self.config.save() 26 | 27 | self.assertTrue(self.config.is_migration_applicable) 28 | 29 | def test_is_migration_applicable_migration_flag_false(self): 30 | # Setup 31 | self.config.migration_date = timezone.now().date() 32 | self.config.save() 33 | 34 | self.assertFalse(self.config.is_migration_applicable) 35 | 36 | def test_is_migration_applicable_migration_date_wrong(self): 37 | # Setup 38 | self.config.migration_imminent = True 39 | self.config.save() 40 | 41 | self.assertFalse(self.config.is_migration_applicable) 42 | 43 | @freeze_time("2023-09-19 10:00:00") 44 | @override_settings(TIME_ZONE="America/Los_Angeles") 45 | def test_is_migration_applicable_pacific_timezone(self): 46 | """Test that migration is applicable when using Pacific timezone""" 47 | self.config.migration_imminent = True 48 | self.config.migration_date = date(2023, 9, 19) 49 | self.config.save() 50 | 51 | self.assertTrue(self.config.is_migration_applicable) 52 | 53 | @freeze_time("2023-09-19 23:00:00") # 11 PM UTC 54 | @override_settings(TIME_ZONE="America/New_York") 55 | def test_is_migration_applicable_eastern_timezone_late_evening(self): 56 | """ 57 | Test edge case: 11 PM UTC is 7 PM Eastern (same day) 58 | Migration should be applicable 59 | """ 60 | self.config.migration_imminent = True 61 | self.config.migration_date = date(2023, 9, 19) 62 | self.config.save() 63 | 64 | self.assertTrue(self.config.is_migration_applicable) 65 | 66 | @freeze_time("2023-09-20 02:00:00") # 2 AM UTC on Sept 20 67 | @override_settings(TIME_ZONE="America/Los_Angeles") 68 | def test_is_migration_applicable_pacific_timezone_previous_day(self): 69 | """ 70 | Test edge case: 2 AM UTC on Sept 20 is 7 PM Pacific on Sept 19 71 | If migration_date is Sept 19, it should be applicable in Pacific time 72 | """ 73 | self.config.migration_imminent = True 74 | self.config.migration_date = date(2023, 9, 19) 75 | self.config.save() 76 | 77 | self.assertTrue(self.config.is_migration_applicable) 78 | 79 | @freeze_time("2023-09-19 08:00:00") # 8 AM UTC on Sept 19 80 | @override_settings(TIME_ZONE="Asia/Tokyo") 81 | def test_is_migration_applicable_tokyo_timezone_next_day(self): 82 | """ 83 | Test edge case: 8 AM UTC on Sept 19 is 5 PM Tokyo on Sept 19 84 | Migration should be applicable for Sept 19 85 | """ 86 | self.config.migration_imminent = True 87 | self.config.migration_date = date(2023, 9, 19) 88 | self.config.save() 89 | 90 | self.assertTrue(self.config.is_migration_applicable) 91 | 92 | @freeze_time("2023-09-19 10:00:00") 93 | @override_settings(TIME_ZONE="Europe/London") 94 | def test_is_migration_applicable_london_timezone(self): 95 | """Test that migration is applicable when using London timezone""" 96 | self.config.migration_imminent = True 97 | self.config.migration_date = date(2023, 9, 19) 98 | self.config.save() 99 | 100 | self.assertTrue(self.config.is_migration_applicable) 101 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | **2.3.14** (2025-12-11) 4 | * Maintenance updates via ambient-package-update 5 | 6 | **2.3.13** (2025-10-15) 7 | * Maintenance updates via ambient-package-update 8 | 9 | **2.3.12** (2025-10-13) 10 | * Fixed TZ issue for migration date 11 | 12 | **2.3.11** (2025-10-10) 13 | * Maintenance updates via ambient-package-update 14 | 15 | **2.3.10** (2025-10-01) 16 | * Fixed wrong module name in setup docs 17 | * Fixed missing console handler in setup docs 18 | * Minor changes via `ambient-package-update` 19 | 20 | **2.3.9** (2025-05-29) 21 | * Maintenance updates via ambient-package-update 22 | 23 | **2.3.8** (2025-04-03) 24 | * Maintenance updates via ambient-package-update 25 | 26 | **2.3.7** (2025-02-15) 27 | * Maintenance updates via ambient-package-update 28 | 29 | * *2.3.6* (2024-11-15) 30 | * Internal updates via `ambient-package-update` 31 | 32 | * *2.3.5* (2024-10-14) 33 | * Added Python 3.13 support 34 | * Added Djade linter to pre-commit 35 | * Improved GitHub action triggers 36 | * Updated dev dependencies and linters 37 | 38 | * *2.3.4* (2024-09-11) 39 | * Added GitHub action trigger for PRs 40 | 41 | * *2.3.3* (2024-09-11) 42 | * Fixed coverage setup due to changes at GitHub 43 | 44 | * *2.3.2* (2024-09-11) 45 | * Fixed package name 46 | 47 | * *2.3.1* (2024-08-12) 48 | * Fixed test matrix 49 | 50 | * *2.3.0* (2024-08-12) 51 | * Added Django 5.1 support 52 | 53 | * *2.2.0* (2024-07-18) 54 | * Use ORM to reset `django_migrations` table 55 | * Lock rows to enable parallel deployments 56 | * Dropped Python 3.8 support 57 | * Added multiple ruff linters and updated packages 58 | * Updated GitHub actions 59 | * Added SECURITY.md 60 | * Internal updates via `ambient-package-update` 61 | 62 | * *2.1.0* (2024-07-09) 63 | * Discover apps in nested directories 64 | * Use `BASE_DIR` instead of `MIGRATION_ZERO_APPS_DIR` 65 | * Fixed bug for migrations with > 4 leading digits 66 | 67 | * *2.0.3* (2024-06-21) 68 | * Linted docs with `blacken-docs` via `ambient-package-update` 69 | 70 | * *2.0.2* (2024-06-20) 71 | * Internal updates via `ambient-package-update` 72 | 73 | * *2.0.1* (2024-06-14) 74 | * Internal updates via `ambient-package-update` 75 | 76 | * *2.0.0* (2024-04-11) 77 | * Dropped Django 3.2 & 4.1 support (via `ambient-package-update`) 78 | * Internal updates via `ambient-package-update` 79 | 80 | * *1.1.2* (2023-12-15) 81 | * Improved documentation 82 | 83 | * *1.1.1* (2023-12-06) 84 | * Improved documentation 85 | 86 | * *1.1.0* (2023-12-05) 87 | * Added Django 5.0 support 88 | 89 | * *1.0.6* (2023-11-06) 90 | * Added "Alternatives" section to docs 91 | 92 | * *1.0.5* (2023-11-03) 93 | * Switched formatter from `black` to `ruff` 94 | 95 | * *1.0.4* (2023-10-31) 96 | * Added `default_auto_field` to app config 97 | * Linting and test fixes 98 | 99 | * *1.0.3* (2023-10-30) 100 | * Changed django migration table clean-up to delete everything to avoid issue with dependencies 101 | 102 | * *1.0.2* (2023-10-27) 103 | * Set correct min. Django version in requirements 104 | * Fixed typo in Changelog 105 | 106 | * *1.0.1* (2023-10-26) 107 | * Fixed RTD build 108 | 109 | * *1.0.0* (2023-10-26) 110 | * Official stable release 111 | 112 | * *0.1.10* (2023-10-26) 113 | * Added comprehensive documentation 114 | * Improved logger warning message 115 | 116 | * *0.1.9* (2023-10-24) 117 | * Reimplemented migration history pruning 118 | 119 | * *0.1.8* (2023-10-23) 120 | * Small improvements 121 | 122 | * *0.1.7* (2023-10-23) 123 | * Migration dir settings improved 124 | 125 | * *0.1.6* (2023-10-20) 126 | * Fixes and adjustments 127 | 128 | * *0.1.5* (2023-10-20) 129 | * Coverage 130 | * Dependency updates 131 | 132 | * *0.1.4* (2023-10-20) 133 | * Unit-tests 134 | 135 | * *0.1.3* (2023-10-20) 136 | * Improved clean-up command 137 | 138 | * *0.1.2* (2023-10-19) 139 | * Translations, bugfixes and improvements 140 | 141 | * *0.1.1* (2023-10-19) 142 | * Bugfixes and improved logging 143 | 144 | * *0.1.0* (2023-10-19) 145 | * Initial release 146 | -------------------------------------------------------------------------------- /tests/services/test_deployment.py: -------------------------------------------------------------------------------- 1 | from logging import Logger 2 | from threading import Thread 3 | from unittest import mock 4 | from unittest.mock import Mock, call 5 | 6 | from django.test import TestCase, TransactionTestCase 7 | from django.utils import timezone 8 | from freezegun import freeze_time 9 | 10 | from django_migration_zero.exceptions import InvalidMigrationTreeError 11 | from django_migration_zero.managers import MigrationZeroConfigurationManager 12 | from django_migration_zero.models import MigrationZeroConfiguration 13 | from django_migration_zero.services.deployment import DatabasePreparationService 14 | 15 | 16 | @freeze_time("2023-06-26") 17 | class DatabasePreparationServiceTest(TestCase): 18 | config: MigrationZeroConfiguration 19 | 20 | @classmethod 21 | def setUpTestData(cls): 22 | super().setUpTestData() 23 | 24 | cls.service = DatabasePreparationService() 25 | cls.config, _ = MigrationZeroConfiguration.objects.get_or_create() 26 | 27 | def test_init_logger_set(self): 28 | self.assertIsInstance(self.service.logger, Logger) 29 | 30 | def test_process_regular(self): 31 | # Setup 32 | self.config.migration_imminent = True 33 | self.config.migration_date = timezone.now().date() 34 | self.config.save() 35 | 36 | # Assertions 37 | self.assertIsNone(self.service.process()) 38 | 39 | self.config.refresh_from_db() 40 | self.assertFalse(self.config.migration_imminent) 41 | 42 | @mock.patch.object(MigrationZeroConfiguration, "is_migration_applicable", return_value=False) 43 | @mock.patch.object(MigrationZeroConfigurationManager, "fetch_singleton", return_value=None) 44 | def test_process_case_is_migration_applicable_false(self, *args): 45 | # Setup 46 | self.config.delete() 47 | 48 | # Assertion 49 | self.assertIsNone(self.service.process()) 50 | 51 | @mock.patch("django_migration_zero.services.deployment.call_command", return_value=1) 52 | def test_process_case_migration_check_failed(self, *args): 53 | # Setup 54 | self.config.migration_imminent = True 55 | self.config.migration_date = timezone.now().date() 56 | self.config.save() 57 | 58 | # Assertion 59 | with self.assertRaisesMessage( 60 | InvalidMigrationTreeError, 61 | 'The command "migrate --check" returned a non-zero error code. ' 62 | "Your migration structure seems to be invalid", 63 | ): 64 | self.service.process() 65 | 66 | @mock.patch("django_migration_zero.services.deployment.call_command", return_value=0) 67 | def test_process_validate_all_commands_are_executed(self, mocked_call_command): 68 | # Setup 69 | self.config.migration_imminent = True 70 | self.config.migration_date = timezone.now().date() 71 | self.config.save() 72 | 73 | # Testable 74 | self.service.process() 75 | 76 | # Assertions 77 | calls = mocked_call_command.call_args_list 78 | 79 | self.assertEqual(mocked_call_command.call_count, 2) 80 | 81 | self.assertEqual(calls[0].args, ("migrate",)) 82 | self.assertEqual(calls[0].kwargs, {"fake": True}) 83 | 84 | self.assertEqual(calls[1].args, ("migrate",)) 85 | self.assertEqual(calls[1].kwargs, {"check": True}) 86 | 87 | 88 | class DatabasePreparationServiceTestParallelDeployment(TransactionTestCase): 89 | @mock.patch("django_migration_zero.services.deployment.get_logger") 90 | def test_process_multiple_threads(self, mock_get_logger): 91 | """Test parallel deployments to multiple pods.""" 92 | # Setup 93 | mock_logger_info = Mock(return_value=None) 94 | mock_get_logger.return_value = Mock(info=mock_logger_info) 95 | config, _ = MigrationZeroConfiguration.objects.update_or_create( 96 | defaults={ 97 | "migration_imminent": True, 98 | "migration_date": timezone.now().date(), 99 | } 100 | ) 101 | 102 | # Testable 103 | number_of_pods = 1 104 | threads = [Thread(target=DatabasePreparationService().process) for _ in range(number_of_pods)] 105 | 106 | for thread in threads: 107 | thread.start() 108 | 109 | for thread in threads: 110 | thread.join() 111 | 112 | # Assertions 113 | self.assertEqual(mock_logger_info.call_count, 6 + number_of_pods, msg=mock_logger_info.call_args_list) 114 | mock_logger_info.assert_has_calls( 115 | [ 116 | call("Starting migration zero database adjustments..."), 117 | call("Resetting migration history for all apps..."), 118 | call("Populating migration history."), 119 | call("Checking migration integrity."), 120 | call("All good."), 121 | call("Deactivating migration zero switch in database."), 122 | call("Process successfully finished."), 123 | ], 124 | any_order=True, 125 | ) 126 | self.assertEqual( 127 | len( 128 | [ 129 | mock_call 130 | for mock_call in mock_logger_info.call_args_list 131 | if mock_call == call("Starting migration zero database adjustments...") 132 | ] 133 | ), 134 | number_of_pods, 135 | ) 136 | 137 | config.refresh_from_db() 138 | self.assertFalse(config.migration_imminent) 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI release](https://img.shields.io/pypi/v/django-migration-zero.svg)](https://pypi.org/project/django-migration-zero/) 2 | [![Downloads](https://static.pepy.tech/badge/django-migration-zero)](https://pepy.tech/project/django-migration-zero) 3 | [![Coverage](https://img.shields.io/badge/Coverage-100.0%25-success)](https://github.com/ambient-innovation/django-migration-zero/actions?workflow=CI) 4 | [![Linting](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 5 | [![Coding Style](https://img.shields.io/badge/code%20style-Ruff-000000.svg)](https://github.com/astral-sh/ruff) 6 | [![Documentation Status](https://readthedocs.org/projects/django-migration-zero/badge/?version=latest)](https://django-migration-zero.readthedocs.io/en/latest/?badge=latest) 7 | 8 | Welcome to **django-migration-zero** - the holistic implementation of "migration zero" pattern for 9 | Django covering local changes and CI/CD pipeline adjustments. 10 | 11 | This package implements the "migration zero" pattern to clean up your local migrations and provides convenient 12 | management commands to recreate your migration files and updating your migration history on your environments 13 | (like test or production systems). 14 | 15 | [PyPI](https://pypi.org/project/django-migration-zero/) | [GitHub](https://github.com/ambient-innovation/django-migration-zero) | [Full documentation](https://django-migration-zero.readthedocs.io/en/latest/index.html) 16 | 17 | Creator & Maintainer: [Ambient Digital](https://ambient.digital/) 18 | 19 | ## Features 20 | 21 | * Remove all existing local migration files and recreate them as initial migrations 22 | * Configuration singleton in Django admin to prepare your clean-up deployment 23 | * Management command for your pipeline to update Django's migration history table to reflect the changed migrations 24 | 25 | ## Motivation 26 | 27 | Working with any proper ORM will result in database changes which are reflected in migration files to update your 28 | different environment's database structure. These files are versioned in your repository and if you follow any of the 29 | most popular deployment approaches, they won't be needed when they are deployed on production. This means, they clutter 30 | your repo, might lead to merge conflicts in the future and will slow down your test setup. 31 | 32 | Django's default way of handling this is called "squashing". This approach is covered broadly in the 33 | [official documentation](https://docs.djangoproject.com/en/dev/topics/migrations/#migration-squashing). The main 34 | drawback here is, that you have to take care of circular dependencies between models. Depending on your project's 35 | size, this can take a fair amount of time. 36 | 37 | The main benefit of squashing migrations is, that the history stays intact, therefore it can be used for example in 38 | package which can be installed by anybody and you don't have control over their database. 39 | 40 | If you are working on a "regular" application, you have full control over your data(bases) and once everything has 41 | been applied on the "last" system, typically production, the migrations are obsolete. To avoid spending much time on 42 | fixing squashed migrations you won't need, you can use the "migration zero" pattern. In a nutshell, this means: 43 | 44 | * Delete all your local migration files 45 | * Recreate initial migration files containing your current model state 46 | * Fix the migration history on every of your environments 47 | 48 | ## Installation 49 | 50 | - Install the package via pip: 51 | 52 | `pip install django-migration-zero` 53 | 54 | or via pipenv: 55 | 56 | `pipenv install django-migration-zero` 57 | 58 | - Add module to `INSTALLED_APPS` within the main django `settings.py`: 59 | 60 | ```` 61 | INSTALLED_APPS = ( 62 | ... 63 | 'django_migration_zero', 64 | ) 65 | ```` 66 | 67 | - Apply migrations by running: 68 | 69 | `python ./manage.py migrate` 70 | 71 | - Add this block to your loggers in your main Django `settings.py` to show logs in your console. 72 | 73 | ```python 74 | LOGGING = { 75 | "handlers": { 76 | "console": { 77 | "class": "logging.StreamHandler", 78 | }, 79 | }, 80 | "loggers": { 81 | "django_migration_zero": { 82 | "handlers": ["console"], 83 | "level": "INFO", 84 | "propagate": True, 85 | }, 86 | }, 87 | } 88 | ``` 89 | 90 | ### Publish to ReadTheDocs.io 91 | 92 | - Fetch the latest changes in GitHub mirror and push them 93 | - Trigger new build at ReadTheDocs.io (follow instructions in admin panel at RTD) if the GitHub webhook is not yet set 94 | up. 95 | 96 | ### Preparation and building 97 | 98 | This package uses [uv](https://github.com/astral-sh/uv) for dependency management and building. 99 | 100 | - Update documentation about new/changed functionality 101 | 102 | - Update the `CHANGES.md` 103 | 104 | - Increment version in main `__init__.py` 105 | 106 | - Create pull request / merge to "master" 107 | 108 | - This project uses uv to publish to PyPI. This will create distribution files in the `dist/` directory. 109 | 110 | ```bash 111 | uv build 112 | ``` 113 | 114 | ### Publishing to PyPI 115 | 116 | To publish to the production PyPI: 117 | 118 | ```bash 119 | uv publish 120 | ``` 121 | 122 | To publish to TestPyPI first (recommended for testing): 123 | 124 | ```bash 125 | uv publish --publish-url https://test.pypi.org/legacy/ 126 | ``` 127 | 128 | You can then test the installation from TestPyPI: 129 | 130 | ```bash 131 | uv pip install --index-url https://test.pypi.org/simple/ ambient-package-update 132 | ``` 133 | 134 | ### Maintenance 135 | 136 | Please note that this package supports the [ambient-package-update](https://pypi.org/project/ambient-package-update/). 137 | So you don't have to worry about the maintenance of this package. This updater is rendering all important 138 | configuration and setup files. It works similar to well-known updaters like `pyupgrade` or `django-upgrade`. 139 | 140 | To run an update, refer to the [documentation page](https://pypi.org/project/ambient-package-update/) 141 | of the "ambient-package-update". 142 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-migration-zero" 7 | authors = [ 8 | {'name' = 'Ambient Digital', 'email' = 'hello@ambient.digital'}, 9 | ] 10 | readme = "README.md" 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Environment :: Web Environment", 14 | "Framework :: Django", 15 | "Framework :: Django :: 4.2", 16 | "Framework :: Django :: 5.2", 17 | "Framework :: Django :: 6.0", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT License", 20 | "Natural Language :: English", 21 | "Operating System :: OS Independent", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Programming Language :: Python :: 3.14", 29 | "Topic :: Utilities", 30 | ] 31 | dynamic = ["version"] 32 | description = "Holistic implementation of 'migration zero' pattern for Django covering local changes and in-production database adjustments." 33 | license = {"file" = "LICENSE.md"} 34 | requires-python = ">=3.10" 35 | dependencies = [ 36 | 'Django>=4.2', 37 | ] 38 | 39 | [project.optional-dependencies] 40 | dev = [ 41 | 'typer~=0.19', 42 | 'pytest-cov~=7.0', 43 | 'pytest-django~=4.11', 44 | 'pytest-mock~=3.15', 45 | 'pre-commit~=4.3', 46 | 'sphinx~=7.4', 47 | 'sphinx-rtd-theme~=3.0', 48 | 'm2r2~=0.3', 49 | 'uv~=0.9', 50 | 'ambient-package-update', 51 | 'unittest-parametrize~=1.3', 52 | 'freezegun~=1.5', 53 | ] 54 | 55 | [project.urls] 56 | 'Homepage' = 'https://github.com/ambient-innovation/django-migration-zero/' 57 | 'Documentation' = 'https://django-migration-zero.readthedocs.io/en/latest/index.html' 58 | 'Maintained by' = 'https://ambient.digital/' 59 | 'Bugtracker' = 'https://github.com/ambient-innovation/django-migration-zero/issues' 60 | 'Changelog' = 'https://django-migration-zero.readthedocs.io/en/latest/features/changelog.html' 61 | 62 | [tool.ruff] 63 | lint.select = [ 64 | "E", # pycodestyle errors 65 | "W", # pycodestyle warnings 66 | "F", # Pyflakes 67 | "N", # pep8-naming 68 | "I", # isort 69 | "B", # flake8-bugbear 70 | "A", # flake8-builtins 71 | "DTZ", # flake8-datetimez 72 | "DJ", # flake8-django 73 | "TD", # flake8-to-do 74 | "RUF", # Ruff-specific rules 75 | "YTT", # Avoid non-future-prove usages of "sys" 76 | "C4", # Checks for unnecessary conversions 77 | "PIE", # Bunch of useful rules 78 | "INT", # Validates your gettext translation strings 79 | "PERF", # PerfLint 80 | "PGH", # No all-purpose "# noqa" and eval validation 81 | "PL", # PyLint 82 | "LOG", # flake8-logging 83 | "TID", # flake8-tidy-imports 84 | "PLR2004", # Magic numbers 85 | "BLE", # Checks for except clauses that catch all exceptions 86 | "ANN401", # Checks that function arguments are annotated with a more specific type than Any 87 | "TRY", # Clean try/except 88 | "ERA", # Commented out code 89 | "INP", # Ban PEP-420 implicit namespace packages 90 | "C90", # McCabe code complexity 91 | "FURB", # Refurbish Python code 92 | ] 93 | lint.ignore = [ 94 | "TRY003", # Avoid specifying long messages outside the exception class 95 | ] 96 | 97 | # Allow autofix for all enabled rules (when `--fix`) is provided. 98 | lint.fixable = [ 99 | "E", # pycodestyle errors 100 | "W", # pycodestyle warnings 101 | "F", # Pyflakes 102 | "N", # pep8-naming 103 | "I", # isort 104 | "B", # flake8-bugbear 105 | "A", # flake8-builtins 106 | "DTZ", # flake8-datetimez 107 | "DJ", # flake8-django 108 | "TD", # flake8-to-do 109 | "RUF", # Ruff-specific rules 110 | "YTT", # Avoid non-future-prove usages of "sys" 111 | "C4", # Checks for unnecessary conversions 112 | "PIE", # Bunch of useful rules 113 | "INT", # Validates your gettext translation strings 114 | "PERF", # PerfLint 115 | "PGH", # No all-purpose "# noqa" and eval validation 116 | "PL", # PyLint 117 | "LOG", # flake8-logging 118 | "TID", # flake8-tidy-imports 119 | "PLR2004", # Magic numbers 120 | "BLE", # Checks for except clauses that catch all exceptions 121 | "ANN401", # Checks that function arguments are annotated with a more specific type than Any 122 | "TRY", # Clean try/except 123 | "ERA", # Commented out code 124 | "INP", # Ban PEP-420 implicit namespace packages 125 | "C90", # McCabe code complexity 126 | "FURB", # Refurbish Python code 127 | ] 128 | lint.unfixable = [] 129 | 130 | exclude = [ 131 | ".bzr", 132 | ".direnv", 133 | ".eggs", 134 | ".git", 135 | ".hg", 136 | ".mypy_cache", 137 | ".nox", 138 | ".pants.d", 139 | ".pytype", 140 | ".ruff_cache", 141 | ".svn", 142 | ".tox", 143 | ".venv", 144 | "__pypackages__", 145 | "_build", 146 | "buck-out", 147 | "build", 148 | "dist", 149 | "node_modules", 150 | "venv", 151 | "*/migrations/*" 152 | ] 153 | 154 | # Same as Black. 155 | line-length = 120 156 | 157 | # Allow unused variables when underscore-prefixed. 158 | lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 159 | 160 | # Assume Python 3.13 161 | target-version = "py313" 162 | 163 | [tool.ruff.format] 164 | # Like Black, use double quotes for strings. 165 | quote-style = "double" 166 | 167 | # Like Black, indent with spaces, rather than tabs. 168 | indent-style = "space" 169 | 170 | # Like Black, respect magic trailing commas. 171 | skip-magic-trailing-comma = false 172 | 173 | # Like Black, automatically detect the appropriate line ending. 174 | line-ending = "auto" 175 | 176 | [tool.ruff.lint.per-file-ignores] 177 | "**/__init__.py" = [ 178 | # Allow seemingly unused imports 179 | "F401", 180 | ] 181 | "**/tests/**/test_*.py" = [ 182 | # Allow boolean positional params in tests (for assertIs()) 183 | "FBT003", 184 | ] 185 | "scripts/*.py" = [ 186 | # Checks for packages that are missing an __init__.py file 187 | "INP001", 188 | ] 189 | ".ambient-package-update/*.py" = [ 190 | # Checks for packages that are missing an __init__.py file 191 | "INP001", 192 | ] 193 | "docs/*.py" = [ 194 | # Checks for packages that are missing an __init__.py file 195 | "INP001", 196 | ] 197 | 198 | [tool.tox] 199 | requires = ["tox>=4", "tox-uv>=1.0.0"] 200 | env_list = ["django42", "django52", "django60", ] 201 | 202 | [tool.tox.env_run_base] 203 | # Django deprecation overview: https://www.djangoproject.com/download/ 204 | package = "wheel" 205 | wheel_build_env = ".pkg" 206 | runner = "uv-venv-lock-runner" 207 | extras = ["dev", ] 208 | commands = [ 209 | ["pytest", "--cov=django_migration_zero", "--cov-report=term", "--cov-report=xml", "--ds", "settings", "tests"] 210 | ] 211 | 212 | [tool.tox.env.django42] 213 | deps = ["Django==4.2.*"] 214 | 215 | [tool.tox.env.django52] 216 | deps = ["Django==5.2.*"] 217 | 218 | [tool.tox.env.django60] 219 | deps = ["Django==6.0.*"] 220 | 221 | [tool.tox.gh_actions.python] 222 | "3.10" = "py310" 223 | "3.11" = "py311" 224 | "3.12" = "py312" 225 | "3.13" = "py313" 226 | "3.14" = "py314" 227 | 228 | [tool.pytest.ini_options] 229 | python_files = [ 230 | "tests.py", 231 | "test_*.py", 232 | "*_tests.py", 233 | ] 234 | 235 | [tool.coverage.run] 236 | branch = true 237 | parallel = true 238 | source = [ 239 | "django_migration_zero", 240 | "tests", 241 | ] 242 | omit = [ 243 | "setup.py", 244 | "*_test.py", 245 | "tests.py", 246 | "testapp/*", 247 | "tests/*", 248 | ] 249 | 250 | [tool.coverage.report] 251 | precision = 2 252 | show_missing = true 253 | # Regexes for lines to exclude from consideration 254 | exclude_also = [ 255 | # Don't complain if tests don't hit defensive assertion code: 256 | "raise AssertionError", 257 | "raise NotImplementedError", 258 | # Don't check type hinting imports 259 | "if typing.TYPE_CHECKING:", 260 | "if TYPE_CHECKING:", 261 | ] 262 | 263 | [tool.coverage.path] 264 | source = [ 265 | "django_migration_zero", 266 | ".tox/**/site-packages", 267 | ] 268 | 269 | [tool.hatch.version] 270 | path = "django_migration_zero/__init__.py" 271 | -------------------------------------------------------------------------------- /docs/features/03_workflow.md: -------------------------------------------------------------------------------- 1 | # Workflow 2 | 3 | Using the migration zero patterns requires a number of steps and prerequisites which have to be executed in a very 4 | specific order to avoid breaking the production database. The following steps will show you how you reset migrations in 5 | a given branch. 6 | 7 | ## TL;DR 8 | 9 | 1. Add `manage.py handle_migration_zero_reset` to your migration/post-deploy script 10 | 2. Remove and recreate local migration files with `manage.py reset_local_migration_files` 11 | 3. Commit local changes to a new branch 12 | 4. Go to admin and set flag and timestamp 13 | 5. Run deployment 14 | 6. Read this page properly before starting any of this! ☝️ 15 | 16 | ## 1. Setup 17 | 18 | * Install this package and **DEPLOY IT IN A SEPARATE COMMIT** before you start deleting migrations. 19 | * Add the following command to your script which runs your migrations on deployment and add it BEFORE you 20 | run `manage.py migrate` 21 | 22 | * If you have non-superusers accessing your admin, make sure to only give the 23 | permission `migration_zero.view_migrationzeroconfiguration` to technical administrators. If you have non-technical 24 | admin users, consider creating groups instead of the superuser flag to avoid that somebody by accident activates the 25 | migration zero deployment. 26 | 27 | ```shell 28 | python ./manage.py handle_migration_zero_reset 29 | ``` 30 | 31 | ## 2. Prerequisites 32 | 33 | * Ensure that your deployment branches are properly merged into each other. This means, all commits from `master` are 34 | in `stage`, all commits from `stage` are in `develop`. Your branches might have other names but the important thing is 35 | that you don't have any commits lying around in upstream branches which are not yet inside the downstream ones. 36 | 37 | * Ensure that all migrations in the branch you are working on have been applied in its database 38 | 39 | ## 3. Local clean-up 40 | 41 | Ensure that you have no model changes whatsoever in your application 42 | 43 | ```shell 44 | python ./manage.py makemigrations 45 | ``` 46 | 47 | * Ensure that all your migrations are applied 48 | 49 | ```shell 50 | python ./manage.py migrate 51 | ``` 52 | 53 | * Create a new branch called something remotely resembling "refactor/migration-zero-cleanup" 54 | 55 | * Now you can run the provided management command to clean up your local migrations. This will track your local files, 56 | delete them and run `manage.py makemigrations` to create neat and shiny initial migrations per local app 57 | 58 | * Add all changes to your branch and commit it 59 | 60 | * Create a merge/pull request. **DO NOT merge it yet.** 61 | 62 | ```shell 63 | python ./manage.py reset_local_migration_files 64 | ``` 65 | 66 | ## 4. Preparing the target system 67 | 68 | Log into the Django admin, look for the "Migration Zero Configuration" and enable the switch and change the date to 69 | the date of the deployment (probably "today"). **This step is crucial! Don't forget it!** If you don't do this, your 70 | migration reset deployment won't do any database clean-up and your next migration run will fail. 71 | 72 | ## 5. Deployment 73 | 74 | Merge your merge/pull request and deploy to your target system. This will then 75 | execute `manage.py handle_migration_zero_reset` and adjust Django's migration history to reflect the new initial 76 | migrations. 77 | 78 | # Updating depending environments 79 | 80 | ## Case: Merge all your environments upstream in "one go" 81 | 82 | If you have a fast deployment cycle, and you can directly merge your develop branch upstream to your stage, demo, 83 | production or what-ever system, the next steps are easy. Just merge your just migration-zero-updated branch upstream. 84 | Since all upstream commits are already in your current branch (see prerequisites), all migrations are already squashed. 85 | Here's what you have to do: 86 | 87 | ### 1. Merge the downstream branch 88 | 89 | Imagine, your develop branch has just been updated, now you want to reset your `stage` migrations. Then you 90 | merge `develop` into `stage` locally and create a merge/pull request. **DO NOT merge it yet.** 91 | 92 | ### 2. Preparing the target system 93 | 94 | Log into the Django admin, look for the "Migration Zero Configuration" and enable the switch and change the date to 95 | the date of the deployment (probably "today"). **This step is crucial! Don't forget it!** Otherwise, you can't "prepare" 96 | the migration reset release in the Django admin and your next migration run will crash. 97 | 98 | ### 3. Deployment 99 | 100 | Merge your merge/pull request and deploy to your target system. This will then execute 101 | `manage.py handle_migration_zero_reset` and adjust Django's migration history to reflect the new initial migrations. 102 | 103 | ## Case: Starting upstream and merging changes downstream 104 | 105 | Some projects are in a state where you can just merge all your deployment branches upstream. Usually, this reflects in a 106 | slow deployment cycle which is a thing you want to avoid. Nevertheless, if you have this situation, it might be even 107 | more pressing to clean up your migrations from time to time to avoid fixing migration mismatches due to hotfixes and 108 | cherry-picking. 109 | 110 | Here's what you have to do: 111 | 112 | ### 1. Prepare the most upstream branch 113 | 114 | * Usually, production is the most upstream system. Follow the instructions from the beginning of this page. 115 | 116 | ### 2. Check for data migrations 117 | 118 | Django can only create structural migrations. This would contain adding, renaming or deleting a table or field. Data 119 | migrations are custom code you'll execute 120 | with [RunPython](https://docs.djangoproject.com/en/4.2/ref/migration-operations/#django.db.migrations.operations.RunPython) 121 | to change your data. The provided script won't actively look for data migrations, so you have to manually check for 122 | all migration files which have not yet been applied to all systems and "rescue" them manually. This means in most 123 | cases, you copy the code somewhere, recreate all migrations and then create a new migration file and add your code 124 | again. 125 | 126 | ### 3. Merge downstream 127 | 128 | Merge your branch into the next one downstream. This branch will most likely contain migrations you didn't have yet in 129 | your previous branch. You can use the reset management command with the `--exclude-initials` parameter to just 130 | recreate what's different in your current branch. Afterward, create a merge/pull request and add your changed files. 131 | **DO NOT merge it yet.** 132 | 133 | ```shell 134 | python ./manage.py reset_local_migration_files --exclude-initials 135 | ``` 136 | 137 | ### 4. Preparing the target system 138 | 139 | Log into the Django admin, look for the "Migration Zero Configuration" and enable the switch and change the date to 140 | the date of the deployment (probably "today"). **This step is crucial! Don't forget it!** 141 | 142 | ### 5. Deployment 143 | 144 | Merge your merge/pull request and deploy to your target system. This will then execute 145 | `manage.py handle_migration_zero_reset` and adjust Django's migration history to reflect the new initial migrations. 146 | 147 | ### 6. Repeat these steps downstream until you've reached the last environment 148 | 149 | Usually, this means you start at `master`, continue to `stage` and end at `develop`. 150 | 151 | ## Case: Updating a not-yet-merged branch 152 | 153 | Once the migrations are cleaned up in `develop`, all your developers will have to adjust all of their oopen 154 | feature/bugfix/refactoring branches. The pattern is basically the same as *"Starting upstream and merging 155 | changes downstream"*. 156 | 157 | ### 1. Check for data migrations 158 | 159 | Django can only create structural migrations. This would contain adding, renaming or deleting a table or field. Data 160 | migrations are custom code you'll execute 161 | with [RunPython](https://docs.djangoproject.com/en/4.2/ref/migration-operations/#django.db.migrations.operations.RunPython) 162 | to change your data. The provided script won't actively look for data migrations, so you have to manually check for 163 | all migration files which have not yet been applied to all systems and "rescue" them manually. This means in most 164 | cases, you copy the code somewhere, recreate all migrations and then create a new migration file and add your code 165 | again. 166 | 167 | ### 2. Merge downstream 168 | 169 | Merge your branch into the next one downstream. This branch will most likely contain migrations you didn't have yet in 170 | your previous branch. You can use the reset management command with the `--exclude-initials` parameter to just 171 | recreate what's different in your current branch. Afterward, create a merge/pull request and add your changed files. 172 | **DO NOT merge it yet.** 173 | 174 | ### 3. Fixing your local database 175 | 176 | Obviously, you have to update the migration history of your local database as well. The easy way is to just import an 177 | already updated database from - for example - your test system. If you want to keep your database, just navigate to the 178 | local admin interface, enable the migration switch and run the adjustment command. 179 | 180 | ```shell 181 | python ./manage.py handle_migration_zero_reset 182 | ``` 183 | --------------------------------------------------------------------------------