|
| 1 | +import json |
| 2 | +from collections import namedtuple |
| 3 | + |
| 4 | +import git |
| 5 | +from django.core.management import call_command |
| 6 | +from django.core.management.base import BaseCommand, CommandError |
| 7 | + |
| 8 | +from django_rollback.models import AppsState |
| 9 | + |
| 10 | +COMMIT_MAX_LENGTH = 40 |
| 11 | +MIGRATE_COMMAND = 'migrate' |
| 12 | + |
| 13 | +MigrationRecord = namedtuple('MigrationRecord', ['id', 'app', 'name']) |
| 14 | + |
| 15 | + |
| 16 | +class Command(BaseCommand): |
| 17 | + help = 'Rollback migrations state of all django apps to chosen tag or commit if previously stored.' |
| 18 | + |
| 19 | + def add_arguments(self, parser): |
| 20 | + parser.add_argument('-t', '--tag', type=str, help='Git tag in witch needs to rollback migrations.') |
| 21 | + parser.add_argument('-c', '--commit', type=str, help='Git commit hash in witch needs to rollback migrations.') |
| 22 | + parser.add_argument('--fake', action='store_true', |
| 23 | + help='Used to block migrate commands execution, so it allow to only print info ' |
| 24 | + 'about processed actions without execution.') |
| 25 | + |
| 26 | + def handle(self, *args, **options): |
| 27 | + other_commit = self.get_commit_from_options(options) |
| 28 | + current_commit = self.get_current_commit() |
| 29 | + |
| 30 | + current_data = self.get_migrations_data_from_commit(current_commit) |
| 31 | + other_data = self.get_migrations_data_from_commit(other_commit) |
| 32 | + |
| 33 | + diff = self.get_migrations_diff(current=current_data, other=other_data) |
| 34 | + |
| 35 | + self.run_rollback(diff, fake=options['fake']) |
| 36 | + |
| 37 | + self.stdout.write(self.style.SUCCESS('Rollback successfully finished.')) |
| 38 | + |
| 39 | + def get_commit_from_options(self, options): |
| 40 | + if not options['tag'] and not options['commit']: |
| 41 | + raise CommandError('Tag or commit should be described by -t or -c arguments.') |
| 42 | + |
| 43 | + if options['tag'] and options['commit']: |
| 44 | + raise CommandError('Tag and commit arguments should not be described together.') |
| 45 | + |
| 46 | + if options['commit']: |
| 47 | + return options['commit'] |
| 48 | + |
| 49 | + tag = options['tag'] |
| 50 | + try: |
| 51 | + repo = git.Repo('.') |
| 52 | + if tag not in repo.tags: |
| 53 | + raise CommandError(f'Can not find tag `{tag}` in git repository.') |
| 54 | + |
| 55 | + return repo.tags[tag].commit.hexsha |
| 56 | + |
| 57 | + except CommandError as err: |
| 58 | + raise err |
| 59 | + |
| 60 | + except Exception as err: |
| 61 | + self.stdout.write(self.style.ERROR(f'WARNING: an error occurred while working with git repo!')) |
| 62 | + raise CommandError(err) |
| 63 | + |
| 64 | + def get_current_commit(self): |
| 65 | + try: |
| 66 | + repo = git.Repo('.') |
| 67 | + return repo.head.commit.hexsha |
| 68 | + |
| 69 | + except ValueError as err: |
| 70 | + self.stdout.write(self.style.ERROR(f'WARNING: an error occurred while working with git repo!')) |
| 71 | + raise CommandError(err) |
| 72 | + |
| 73 | + def get_migrations_data_from_commit(self, commit): |
| 74 | + queryset = AppsState.objects.filter(commit__istartswith=commit) |
| 75 | + |
| 76 | + count = queryset.count() |
| 77 | + |
| 78 | + if count == 0: |
| 79 | + raise CommandError(f'Can not find stored data of migrations state for commit `{commit}`.') |
| 80 | + |
| 81 | + if count > 1: |
| 82 | + is_short_commit = len(commit) < COMMIT_MAX_LENGTH |
| 83 | + raise CommandError(f'Found more than 1 ({count}) records for selected commit {commit}.' |
| 84 | + f'{" Please clarify commit hash for more identity." if is_short_commit else ""}') |
| 85 | + |
| 86 | + instance = queryset.first() |
| 87 | + return json.loads(instance.migrations) |
| 88 | + |
| 89 | + def get_migrations_diff(self, current, other): |
| 90 | + """ |
| 91 | + current and other is type of list of tuples in format: |
| 92 | + [(<id> : int, <app> : str, <name> : str), ...] |
| 93 | +
|
| 94 | + :return dict that indicates what migrations should be executed |
| 95 | + migration_id is useful to detect migration order (from higher to lower) |
| 96 | + { |
| 97 | + <migration_id>: <migrate command args> |
| 98 | + } |
| 99 | + """ |
| 100 | + result = [] |
| 101 | + |
| 102 | + current = [MigrationRecord(*rec) for rec in current] |
| 103 | + other = [MigrationRecord(*rec) for rec in other] |
| 104 | + other_apps = {migration.app for migration in other} |
| 105 | + |
| 106 | + # find what is changed in current relative to other |
| 107 | + diff = set(current) - set(other) |
| 108 | + for migration in diff: |
| 109 | + is_new_app = migration.app not in other_apps |
| 110 | + result.append(MigrationRecord( |
| 111 | + migration.id, |
| 112 | + migration.app, |
| 113 | + 'zero' if is_new_app else list(filter(lambda x: x.app == migration.app, other))[0].name, |
| 114 | + )) |
| 115 | + |
| 116 | + return result |
| 117 | + |
| 118 | + def run_rollback(self, migrations_diff_records, fake=False): |
| 119 | + """ |
| 120 | + sort all migrations by migration.id order from higher to lower and execute migrate command for them |
| 121 | + """ |
| 122 | + |
| 123 | + if not migrations_diff_records: |
| 124 | + self.stdout.write(f'>>> There is no migrations to rollback.') |
| 125 | + return |
| 126 | + |
| 127 | + for migration in sorted(migrations_diff_records, key=lambda r: int(r.id), reverse=True): |
| 128 | + execute_args = (MIGRATE_COMMAND, migration.app, migration.name) |
| 129 | + self.stdout.write(f'>>> Executing command: {" ".join(execute_args)}') |
| 130 | + |
| 131 | + if not fake: |
| 132 | + call_command(*execute_args) |
0 commit comments