Modifying our DJA Project¶
Let’s say we’ve decided that a change needs to be made to our project:
We found a mistake in the CourseTerm Model: The term_identifier
is supposed to look like 20183COMSW1002
which is 14 characters (not 10) and unique. What will this involve:
- Update the CourseTerm Model
- Make a new migration
- Fix the data loader client
- Fix our test and sample fixtures.
Update CourseTerm¶
GIT TAG: courseterm-unique-term_id
This is pretty simple:
diff --git a/myapp/models.py b/myapp/models.py
index 524ae40..49ed0d1 100644
--- a/myapp/models.py
+++ b/myapp/models.py
@@ -49,7 +49,7 @@ class CourseTerm(CommonModel):
A specific course term (year+semester) instance.
e.g. 20183COMSW1002
"""
- term_identifier = models.TextField(max_length=10)
+ term_identifier = models.TextField(max_length=14, unique=True)
audit_permitted_code = models.PositiveIntegerField(blank=True, default=0)
exam_credit_flag = models.BooleanField(default=True)
course = models.ForeignKey('myapp.Course', related_name='course_terms', on_delete=models.CASCADE, null=True,
Make a new migration¶
- Make the new migration.
- Show the current status of migrations.
- Attempt to migrate (and fail).
(env) django-training$ ./manage.py makemigrations
Migrations for 'myapp':
myapp/migrations/0003_auto_20181109_1923.py
- Alter field term_identifier on courseterm
(env) django-training$ ./manage.py showmigrations
admin
[X] 0001_initial
[X] 0002_logentry_remove_auto_add
[X] 0003_logentry_add_action_flag_choices
auth
[X] 0001_initial
[X] 0002_alter_permission_name_max_length
[X] 0003_alter_user_email_max_length
[X] 0004_alter_user_username_opts
[X] 0005_alter_user_last_login_null
[X] 0006_require_contenttypes_0002
[X] 0007_alter_validators_add_error_messages
[X] 0008_alter_user_username_max_length
[X] 0009_alter_user_last_name_max_length
contenttypes
[X] 0001_initial
[X] 0002_remove_content_type_name
myapp
[X] 0001_initial
[X] 0002_auto_20181019_1821
[ ] 0003_auto_20181109_1923
oauth2_provider
[X] 0001_initial
[X] 0002_08_updates
[X] 0003_auto_20160316_1503
[X] 0004_auto_20160525_1623
[X] 0005_auto_20170514_1141
[X] 0006_auto_20171214_2232
sessions
[X] 0001_initial
(env) django-training$ cat myapp/migrations/0003_auto_20181109_1923.py
# Generated by Django 2.1.3 on 2018-11-09 19:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('myapp', '0002_auto_20181019_1821'),
]
operations = [
migrations.AlterField(
model_name='courseterm',
name='term_identifier',
field=models.TextField(max_length=14, unique=True),
),
]
(env) django-training$ ./manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, myapp, oauth2_provider, sessions
Running migrations:
Applying myapp.0003_auto_20181109_1923...Traceback (most recent call last):
File "/Users/alan/src/django-training/env/lib/python3.6/site-packages/django/db/backends/utils.py", line 85, in _execute
return self.cursor.execute(sql, params)
File "/Users/alan/src/django-training/env/lib/python3.6/site-packages/django/db/backends/sqlite3/base.py", line 296, in execute
return Database.Cursor.execute(self, query, params)
sqlite3.IntegrityError: UNIQUE constraint failed: myapp_courseterm.term_identifier
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
... lots of stack trace here ...
Customizing the migration¶
This migration doesn’t “just work” because our test data has a repeated non-unique field which we have to fix to be unique. We can describe this pretty easily as “Concatentate old non-unique term_identifier (e.g. ‘20181’) with course.course_identifier(‘COMSW1002’) to create new term_identifier (‘20181COMSW1002’)”.
One approach would be to just manually “fix” the data. But a much cooler approach is to develop some migration code, which documents what we fixed and is reproducible and reversible. See this migration how-to for an example.
Let’s try it:
- Change the migration to alter the
term_identifier
field length but not yet make it unique. - Run some custom code to fix the
term_identifier
. - Alter it to unique now.
Write a custom migration script¶
Start by renaming the auto migration to a name that makes it clear this is a custom migration:
(env) django-training$ mv myapp/migrations/0003_auto_20181109_1923.py myapp/migrations/0003_unique_term_identifier.py
Then edit it to add the forward and reverse migration functions:
from django.db import migrations, models
def fix_term_id(apps, schema_editor):
"""
Concatentate old non-unique term_identifier (e.g. '20181') with course.course_identifier('COMSW1002')
to create new term_identifier ('20181COMSW1002')
"""
CourseTerm = apps.get_model('myapp', 'CourseTerm')
for row in CourseTerm.objects.all():
if row.course: # there's a parent course relationship
course_id = row.course.course_identifier
row.term_identifier = row.term_identifier + course_id
row.save(update_fields=['term_identifier'])
else: # there's no parent course so throw this row away
row.delete()
def undo_fix_term_id(apps, schema_editor):
"""
Revert fix_term_id().
"""
CourseTerm = apps.get_model('myapp', 'CourseTerm')
for row in CourseTerm.objects.all():
row.term_identifier = row.term_identifier[:5]
row.save(update_fields=['term_identifier'])
class Migration(migrations.Migration):
dependencies = [
('myapp', '0002_auto_20181019_1821'),
]
operations = [
migrations.AlterField(
model_name='courseterm',
name='term_identifier',
field=models.TextField(max_length=14),
),
migrations.RunPython(
fix_term_id,
reverse_code=undo_fix_term_id
),
migrations.AlterField(
model_name='courseterm',
name='term_identifier',
field=models.TextField(unique=True),
),
]
Confirm the current database schema and non-unique content¶
Let’s first take a look at our current database before the migration (noting that term_identifier
is text NOT NULL
).
(env) django-training$ sqlite3 db.sqlite3
-- Loading resources from /Users/ac45/.sqliterc
SQLite version 3.24.0 2018-06-04 14:10:15
Enter ".help" for usage hints.
sqlite> .schema --indent myapp_courseterm
CREATE TABLE IF NOT EXISTS "myapp_courseterm"(
"term_identifier" text NOT NULL,
"id" char(32) NOT NULL PRIMARY KEY,
"effective_start_date" date NULL,
"effective_end_date" date NULL,
"last_mod_user_name" varchar(80) NULL,
"last_mod_date" date NOT NULL,
"audit_permitted_code" integer unsigned NOT NULL,
"exam_credit_flag" bool NOT NULL,
"course_id" char(32) NULL REFERENCES "myapp_course"("id") DEFERRABLE INITIALLY DEFERRED
);
CREATE INDEX "myapp_courseterm_course_id_edb833e8" ON "myapp_courseterm"(
"course_id"
);
sqlite> select term_identifier from myapp_courseterm;
term_identifier
---------------
20181
20191
20181
20191
20181
20191
20181
20191
20181
20182
20181
20183
20181
20191
20181
20183
20181
20192
sqlite>
Run the custom migration¶
(env) django-training$ ./manage.py showmigrations
admin
[X] 0001_initial
[X] 0002_logentry_remove_auto_add
[X] 0003_logentry_add_action_flag_choices
auth
[X] 0001_initial
[X] 0002_alter_permission_name_max_length
[X] 0003_alter_user_email_max_length
[X] 0004_alter_user_username_opts
[X] 0005_alter_user_last_login_null
[X] 0006_require_contenttypes_0002
[X] 0007_alter_validators_add_error_messages
[X] 0008_alter_user_username_max_length
[X] 0009_alter_user_last_name_max_length
contenttypes
[X] 0001_initial
[X] 0002_remove_content_type_name
myapp
[X] 0001_initial
[X] 0002_auto_20181019_1821
[ ] 0003_unique_term_identifier
oauth2_provider
[X] 0001_initial
[X] 0002_08_updates
[X] 0003_auto_20160316_1503
[X] 0004_auto_20160525_1623
[X] 0005_auto_20170514_1141
[X] 0006_auto_20171214_2232
sessions
[X] 0001_initial
(env) django-training$ ./manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, myapp, oauth2_provider, sessions
Running migrations:
Applying myapp.0003_unique_term_identifier... OK
Confirm the custom migration did what was expected¶
term_identifier
is now text NOT NULL UNIQUE
and the values are the concatentation of the
prior term_identifer
and course_identifier
.
(env) django-training$ sqlite3 db.sqlite3
-- Loading resources from /Users/ac45/.sqliterc
SQLite version 3.24.0 2018-06-04 14:10:15
Enter ".help" for usage hints.
sqlite> .schema --indent myapp_courseterm
CREATE TABLE IF NOT EXISTS "myapp_courseterm"(
"id" char(32) NOT NULL PRIMARY KEY,
"effective_start_date" date NULL,
"effective_end_date" date NULL,
"last_mod_user_name" varchar(80) NULL,
"last_mod_date" date NOT NULL,
"audit_permitted_code" integer unsigned NOT NULL,
"exam_credit_flag" bool NOT NULL,
"course_id" char(32) NULL REFERENCES "myapp_course"("id") DEFERRABLE INITIALLY DEFERRED,
"term_identifier" text NOT NULL UNIQUE
);
CREATE INDEX "myapp_courseterm_course_id_edb833e8" ON "myapp_courseterm"(
"course_id"
);
sqlite> select term_identifier from myapp_courseterm;
term_identifier
---------------
20181ACCT7022B
20181ACCT8122B
20181AMST3704X
20181ANTH3160V
20181APPH9143E
20181AUPH1010O
20181BUEC7255B
20181CIEN3304E
20181COMS3102W
20182AUPH1010O
20183CIEN3304E
20183COMS3102W
20191ACCT7022B
20191ACCT8122B
20191AMST3704X
20191ANTH3160V
20191APPH9143E
20192BUEC7255B
sqlite>
Reverse the migration¶
And, here’s the coolest part: You can reverse a migration to go back to a previous state, assuming
you included areverse_code
migration script:
(env) django-training$ ./manage.py migrate myapp 0002_auto_20181019_1821
Operations to perform:
Target specific migration: 0002_auto_20181019_1821, from myapp
Running migrations:
Rendering model states... DONE
Unapplying myapp.0003_unique_term_identifier... OK
Do another migration¶
Finally, as an optional exercise, notice that I inadvertently made term_identifier = models.TextField
when it should have been term_identifier = models.CharField
. Fix that.
Fix the test fixtures.¶
Assuming the only data in the sqlite3 database was what’s in the myapp/fixtures/testcases.yaml
, dump
out an updated version of those fixtures.
(env) django-training$ ./manage.py dumpdata --format yaml myapp >myapp/fixtures/testcases.yaml
And look at a diff of a small portion of it:
- model: myapp.courseterm
pk: bca761f7-03f6-4ff5-bbb8-b58467ef3970
fields: {effective_start_date: null, effective_end_date: null, last_mod_user_name: loader,
- last_mod_date: 2018-08-03, term_identifier: '20181', audit_permitted_code: 0,
+ last_mod_date: 2018-08-03, term_identifier: 20181BUEC7255B, audit_permitted_code: 0,
exam_credit_flag: false, course: 046741cd-c700-4752-b57a-e37a948ebc44}
Here’s a hint if the data is not just the testcases:
- Reverse the migration as above.
- Delete the content of the
course
andcourseterm
tables: - Load the testcases fixture:
./manage.py loaddata myapp/fixtures/testcases.yaml
- Migrate forward.
- Dump the new testcases fixture:
./manage.py dumpdata --format yaml myapp >myapp/fixtures/testcases.yaml
Now do the same for the big courseterm.yaml
fixture. This could take a while! Actually, with DEBUG turned off,
it went pretty quickly:
(env) django-training$ ./manage.py migrate myapp 0002_auto_20181019_1821
Operations to perform:
Target specific migration: 0002_auto_20181019_1821, from myapp
Running migrations:
Rendering model states... DONE
Unapplying myapp.0004_auto_20181109_2050... OK
Unapplying myapp.0003_unique_term_identifier... OK
(env) django-training$ sqlite3 db.sqlite3
-- Loading resources from /Users/ac45/.sqliterc
SQLite version 3.24.0 2018-06-04 14:10:15
Enter ".help" for usage hints.
sqlite> delete from myapp_courseterm;
sqlite> delete from myapp_course;
sqlite> ^D
(env) django-training$ ./manage.py loaddata myapp/fixtures/courseterm.yaml
Installed 8944 object(s) from 1 fixture(s)
(env) django-training$ ./manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, myapp, oauth2_provider, sessions
Running migrations:
Applying myapp.0003_unique_term_identifier... OK
Applying myapp.0004_auto_20181109_2050... OK
(env) django-training$ ./manage.py dumpdata --format yaml myapp >myapp/fixtures/courseterm.yaml