Skip to content

Commit f441a66

Browse files
committed
rework commands, add logging, add list feature, reimplement some rollback logic with clearing rollbacked states
1 parent 0df07b2 commit f441a66

File tree

8 files changed

+376
-170
lines changed

8 files changed

+376
-170
lines changed

README.md

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,18 @@ This package allow to easy manage apps migrations based on GIT repository (it us
33
So you can return to any previous state that is saved in DB by one command.
44

55
## Version
6-
Current version is `0.2.1`
6+
Current version is `0.3.0`
77

88
It works with:
9-
- django == 1.11
9+
- django >= 1.11.3
1010
- GitPython >= 2.1.8
1111
- PostgreSQL
1212

1313
## Installing
1414

1515
First you need to install package with pip:
1616
```bash
17-
pip install git+https://github.com/freenoth/django-rollback.git@0.2.1
17+
pip install git+https://github.com/freenoth/django-rollback.git@0.3.0
1818
```
1919

2020
Then install it to your `INSTALLED_APPS`
@@ -30,48 +30,76 @@ You are also should run `./manage.py migrate` before using additional management
3030
## Using
3131
There are two commands to manage migrations state.
3232

33-
For both commands you can pass the `PATH` argument to specify path to git repository directory (local). Default path is current dir : `'.'`.
33+
Both commands have common arguments:
34+
```bash
35+
-p PATH, --path PATH Git repository path.
36+
-l LOGGER, --logger LOGGER
37+
Logger name for logging.
38+
--log-level LOG_LEVEL
39+
Log level for logging. INFO, DEBUG, etc.
40+
41+
```
42+
`PATH` argument used to specify path to git repository directory (local). Default path is current dir : `'.'`.
43+
44+
`LOGGER` and `LOG_LEVEL` arguments can be used to setup internal logging. For example, you can use one of django_logging loggers (to push it to slack, write console, file, etc.). There is no default value, so by default additional logging disabled.
3445

3546
### Saving current state
3647
```bash
3748
./manage.py save_migrations_state
3849
```
3950
Help message below:
4051
```bash
41-
usage: manage.py save_migrations_state [-p PATH]
52+
usage: manage.py save_migrations_state [-p PATH] [-l LOGGER]
53+
[--log-level LOG_LEVEL]
4254

43-
Save migrations state for current commit.
55+
Save migrations state for current commit. It also check if commit already
56+
exists and print warning if it`s not the latest state that may be a symptom of
57+
inconsistent state for migrations.
4458
4559
optional arguments:
4660
-p PATH, --path PATH Git repository path.
61+
-l LOGGER, --logger LOGGER
62+
Logger name for logging.
63+
--log-level LOG_LEVEL
64+
Log level for logging. INFO, DEBUG, etc.
65+
4766
```
48-
This command used to save apps migrations state of current commit to DB (it create new or update existing state).
67+
This command used to save apps migrations state of current commit to DB. It try to create new state, but if already exists it checks is this state the latest.
68+
If state for current commit is not the latest - it may be a symptom of problems and rollback from current commit will not work for it.
4969

5070
Successful output example below:
5171
```bash
5272
Data = [(4, 'admin', '0002_logentry_remove_auto_add'), (12, 'auth', '0008_alter_user_username_max_length'), (5, 'contenttypes', '0002_remove_content_type_name'), (16, 'django_auto_rollback', '0001_initial'), (17, 'django_rollback', '0001_initial'), (14, 'sessions', '0001_initial')].
5373
Successfully created for commit "84e47461a95fa325d9e933bbe8cca8c52bbea203".
5474
```
5575

56-
### Return to previous state
76+
### Return to previous state (rollback)
5777
```bash
5878
./manage.py rollback_migrations
5979
```
6080
Help message below:
6181
```bash
62-
usage: manage.py rollback_migrations [-t TAG] [-c COMMIT] [-p PATH] [--fake]
82+
usage: manage.py rollback_migrations [-p PATH] [-l LOGGER] [--log-level LOG_LEVEL]
83+
[--list] [-t TAG] [-c COMMIT] [--fake]
6384
6485
Rollback migrations state of all django apps to chosen tag or commit if
65-
previously saved.
86+
previously saved. Also you may not specify commit or tag to rollback, so the
87+
previous tag will be used. Also it can run in fake mode, only to print
88+
generated commands for rollback. You also can view current DB state for all
89+
saved states using list argument.
6690
6791
optional arguments:
92+
-p PATH, --path PATH Git repository path.
93+
-l LOGGER, --logger LOGGER
94+
Logger name for logging.
95+
--log-level LOG_LEVEL
96+
Log level for logging. INFO, DEBUG, etc.
97+
--list Show the sorted list of all stored states.
6898
-t TAG, --tag TAG Git tag to which to rollback migrations.
6999
-c COMMIT, --commit COMMIT
70100
Git commit hash to which to rollback migrations.
71-
-p PATH, --path PATH Git repository path.
72101
--fake It allow to only print info about processed actions
73102
without execution (no changes for DB).
74-
75103
```
76104
77105
You can use git commit hash (hex) directly. And you don`t need to specify full commit hash, you just can use first letters.
@@ -87,17 +115,6 @@ Or you can use git tag (it will be translated to related commit).
87115
./manage.py rollback_migrations --tag v.0.0.2
88116
```
89117
90-
Either tag or commit argument is required:
91-
```bash
92-
./manage.py rollback_migrations
93-
CommandError: Tag or commit should be described by -t or -c arguments.
94-
```
95-
And it can`t be used together:
96-
```bash
97-
./manage.py rollback_migrations -c 0e02e74 -t v.0.0.1
98-
CommandError: Tag and commit arguments should not be described together.
99-
```
100-
101118
Successful output example below:
102119
```bash
103120
./manage.py rollback_migrations -c c257a23
@@ -112,8 +129,11 @@ Rollback successfully finished.
112129
113130
As you can see above, apps can be rollbacked to `zero` state too, if in previous state this app not used.
114131
132+
After successful rollback, selected state will be selected as current, so all older states will be deleted.
133+
That`s way current state all the time should be the latest in DB and correspond to current service state.
134+
115135
### Successful rollback conditions
116136
So rollback will be successfully finished if two conditions are satisfied:
117137
- state for current commit was saved (if not - use `./manage.py save_migrations_state` command)
118-
- state for specified commit or commit which relates to specified tag was saved in the past
119-
138+
- state for specified commit, commit which relates to specified tag was saved in the past
139+
- if commit not specified, just the previous state should exists

django_rollback/consts.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
DEFAULT_REPO_PATH = '.'
2+
COMMIT_MAX_LENGTH = 40
3+
MIGRATE_COMMAND = 'migrate'

django_rollback/management/base.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import json
2+
import logging
3+
from collections import namedtuple
4+
5+
import git
6+
from django.core.management import call_command
7+
from django.core.management.base import BaseCommand, CommandError
8+
from django.db import connection
9+
10+
from django_rollback.consts import DEFAULT_REPO_PATH, COMMIT_MAX_LENGTH, MIGRATE_COMMAND
11+
from django_rollback.models import AppsState
12+
from django_rollback.sql import MIGRATIONS_STATE_SQL
13+
14+
MigrationRecord = namedtuple('MigrationRecord', ['id', 'app', 'name'])
15+
16+
17+
class BaseRollbackCommand(BaseCommand):
18+
19+
def __init__(self, *args, **kwargs):
20+
super().__init__(*args, **kwargs)
21+
self._logger = None
22+
23+
def add_arguments(self, parser):
24+
parser.add_argument('-p', '--path', type=str, default=DEFAULT_REPO_PATH, help='Git repository path.')
25+
parser.add_argument('-l', '--logger', type=str, help='Logger name for logging.')
26+
parser.add_argument('--log-level', type=str, help='Log level for logging. INFO, DEBUG, etc.')
27+
28+
def configure_logger(self, options):
29+
if options['logger']:
30+
logger = logging.getLogger(options['logger'])
31+
32+
log_level = logging.getLevelName(options['log_level'])
33+
if isinstance(log_level, int):
34+
logger.setLevel(log_level)
35+
36+
self._logger = logger
37+
38+
def handle(self, *args, **options):
39+
raise NotImplemented
40+
41+
def get_current_commit(self, path):
42+
try:
43+
repo = git.Repo(path)
44+
return repo.head.commit.hexsha
45+
except ValueError as err:
46+
message = f'An error occurred while working with git repo!'
47+
self.stdout.write(self.style.ERROR(message))
48+
if self._logger:
49+
self._logger.error(message + f'\n{__name__}')
50+
raise CommandError(err)
51+
52+
@staticmethod
53+
def get_last_apps_state():
54+
return AppsState.objects.all().order_by('timestamp').last()
55+
56+
@staticmethod
57+
def get_current_migrations_state():
58+
"""
59+
return a data in format:
60+
[(<id> : int, <app> : str, <name> : str), ...]
61+
"""
62+
with connection.cursor() as cursor:
63+
cursor.execute(MIGRATIONS_STATE_SQL)
64+
return cursor.fetchall()
65+
66+
def get_apps_state_by_commit(self, commit):
67+
queryset = AppsState.objects.filter(commit__istartswith=commit)
68+
69+
count = queryset.count()
70+
71+
if count == 0:
72+
message = f'Can not find stored data of migrations state for commit `{commit}`.'
73+
if self._logger:
74+
self._logger.warning(message)
75+
raise CommandError(message)
76+
77+
if count > 1:
78+
is_short_commit = len(commit) < COMMIT_MAX_LENGTH
79+
message = (f'Found more than 1 ({count}) records for selected commit {commit}.'
80+
f'{" Please clarify commit hash for more identity." if is_short_commit else ""}')
81+
if self._logger:
82+
self._logger.warning(message)
83+
raise CommandError(message)
84+
85+
return queryset.first()
86+
87+
def get_migrations_data_by_commit(self, commit):
88+
apps_state = self.get_apps_state_by_commit(commit)
89+
return json.loads(apps_state.migrations)
90+
91+
def get_migrations_diff(self, current, other):
92+
"""
93+
current and other has type list of tuples in format:
94+
[(<id> : int, <app> : str, <name> : str), ...]
95+
it gets from get_migrations_data_from_commit()
96+
97+
:return list that indicates what migrations should be executed
98+
migration_id is useful to detect migration order (from higher to lower)
99+
[
100+
namedtuple('MigrationRecord', ['id', 'app', 'name']),
101+
...
102+
]
103+
"""
104+
result = []
105+
106+
current = [MigrationRecord(*rec) for rec in current]
107+
other = [MigrationRecord(*rec) for rec in other]
108+
other_apps = {migration.app for migration in other}
109+
110+
# find what is changed in current relative to other
111+
diff = set(current) - set(other)
112+
for migration in diff:
113+
is_new_app = migration.app not in other_apps
114+
result.append(MigrationRecord(
115+
migration.id,
116+
migration.app,
117+
'zero' if is_new_app else list(filter(lambda x: x.app == migration.app, other))[0].name,
118+
))
119+
120+
if self._logger:
121+
self._logger.info(f'Migrations diff:\n{result}')
122+
123+
return result
124+
125+
def run_rollback(self, migrations_diff_records, fake=False):
126+
"""
127+
migrations_diff_records: List[MigrationRecord], result of get_migrations_diff()
128+
sort all migrations by migration.id order from higher to lower and execute migrate command for them
129+
"""
130+
131+
if not migrations_diff_records:
132+
message = 'There is no migrations to rollback.'
133+
self.stdout.write(f'>>> {message}')
134+
if self._logger:
135+
self._logger.info(message)
136+
return
137+
138+
for migration in sorted(migrations_diff_records, key=lambda r: int(r.id), reverse=True):
139+
execute_args = (MIGRATE_COMMAND, migration.app, migration.name)
140+
message = f'Executing command: {" ".join(execute_args)}'
141+
self.stdout.write(f'>>> {message}')
142+
if self._logger:
143+
self._logger.info(message)
144+
145+
if not fake:
146+
call_command(*execute_args)
147+
148+
def make_the_last_state_for_commit(self, commit):
149+
apps_state = self.get_apps_state_by_commit(commit)
150+
AppsState.objects.filter(id_gt=apps_state.id).delete()
151+
152+
message = f'state for commit "{commit}" now is the last state in DB'
153+
self.stdout.write(f'>>> {message}')
154+
if self._logger:
155+
self._logger.info(message)

0 commit comments

Comments
 (0)