使用South重构带有继承的Django模型

32

我想知道使用Django的South是否可以进行以下迁移,并且仍然保留数据。

之前:

我目前有两个应用程序,一个名为tv,一个名为movies,每个应用程序都有一个VideoFile模型(在此处简化):

tv/models.py:

class VideoFile(models.Model):
    show = models.ForeignKey(Show, blank=True, null=True)
    name = models.CharField(max_length=1024, blank=True)
    size = models.IntegerField(blank=True, null=True)
    ctime = models.DateTimeField(blank=True, null=True)

电影/models.py:

class VideoFile(models.Model):
    movie = models.ForeignKey(Movie, blank=True, null=True)
    name = models.CharField(max_length=1024, blank=True)
    size = models.IntegerField(blank=True, null=True)
    ctime = models.DateTimeField(blank=True, null=True)

之后:

由于这两个videofile对象非常相似,我想要消除重复并在名为media的单独应用程序中创建一个新模型,其中包含一个通用的VideoFile类,并使用继承来扩展它:

media/models.py:

class VideoFile(models.Model):
    name = models.CharField(max_length=1024, blank=True)
    size = models.IntegerField(blank=True, null=True)
    ctime = models.DateTimeField(blank=True, null=True)

tv/models.py:

class VideoFile(media.models.VideoFile):
    show = models.ForeignKey(Show, blank=True, null=True)

电影/models.py:

class VideoFile(media.models.VideoFile):
    movie = models.ForeignKey(Movie, blank=True, null=True)
所以我的问题是,我如何在使用django-south的同时完成这个任务并保留现有数据?
这三个应用程序都已经由south迁移管理了,根据south文档,将模式(migration)和数据迁移结合起来是不好的做法,并且他们推荐应该分几步来完成。
我认为可以使用单独的迁移来完成,像这样(假设media.VideoFile已经创建):
1. 模式迁移,将tv.VideoFile和movies.VideoFile中所有需要移动到新的media.VideoFile模型的字段进行重命名,比如old_name、old_size等。 2. 模式迁移,让tv.VideoFile和movies.VideoFile继承自media.VideoFile。 3. 数据迁移,将old_name复制到name,将old_size复制到size,以此类推。 4. 模式迁移,删除old_字段。
在我开始这些工作之前,你认为这样行得通吗?还有更好的方法吗?
如果您感兴趣,该项目存储在这里:http://code.google.com/p/medianav/
4个回答

49

请查看Paul的下面的回答,了解与新版本Django/South的兼容性相关的一些注释。


这似乎是一个有趣的问题,我正在成为South的粉丝,所以我决定稍微研究一下。我按照您上面描述的内容建立了一个测试项目,并成功使用South执行了您要求的迁移。在我们进入代码之前,这里有几个注意事项:

  • South文档建议将模式迁移和数据迁移分开,我也这样做了。

  • 在后端,Django通过在继承模型上自动创建OneToOne字段来表示继承表

  • 基于此,我们的South迁移需要手动正确处理OneToOne字段,但是在尝试时,似乎South(或者可能是Django本身)不能在具有相同名称的多个继承表上创建OneToOne文件夹。因此,我将movies/tv应用程序中的每个子表重命名为其自己的应用程序(即MovieVideoFile/ShowVideoFile)。

  • 在实际数据迁移代码中,尝试赋值给OneToOne字段会导致South崩溃。对于所有South的酷炫功能而言,为先创建OneToOne字段,然后为其分配数据似乎是一个公平的折衷方案。

所以,说了这么多,我尝试记录下发出的控制台命令。必要时我将插入评论。最终代码在底部。

命令历史记录

django-admin.py startproject southtest
manage.py startapp movies
manage.py startapp tv
manage.py syncdb
manage.py startmigration movies --initial
manage.py startmigration tv --initial
manage.py migrate
manage.py shell          # added some fake data...
manage.py startapp media
manage.py startmigration media --initial
manage.py migrate
# edited code, wrote new models, but left old ones intact
manage.py startmigration movies unified-videofile --auto
# create a new (blank) migration to hand-write data migration
manage.py startmigration movies videofile-to-movievideofile-data 
manage.py migrate
# edited code, wrote new models, but left old ones intact
manage.py startmigration tv unified-videofile --auto
# create a new (blank) migration to hand-write data migration
manage.py startmigration tv videofile-to-movievideofile-data
manage.py migrate
# removed old VideoFile model from apps
manage.py startmigration movies removed-videofile --auto
manage.py startmigration tv removed-videofile --auto
manage.py migrate

出于空间考虑,并且由于最终模型的外观总是相同的,我只会用“电影”应用程序进行演示。

电影/models.py

from django.db import models
from media.models import VideoFile as BaseVideoFile

# This model remains until the last migration, which deletes 
# it from the schema.  Note the name conflict with media.models
class VideoFile(models.Model):
    movie = models.ForeignKey(Movie, blank=True, null=True)
    name = models.CharField(max_length=1024, blank=True)
    size = models.IntegerField(blank=True, null=True)
    ctime = models.DateTimeField(blank=True, null=True)

class MovieVideoFile(BaseVideoFile):
    movie = models.ForeignKey(Movie, blank=True, null=True, related_name='shows')

电影/migrations/0002_unified-videofile.py (模式迁移)

from south.db import db
from django.db import models
from movies.models import *

class Migration:

    def forwards(self, orm):

        # Adding model 'MovieVideoFile'
        db.create_table('movies_movievideofile', (
            ('videofile_ptr', orm['movies.movievideofile:videofile_ptr']),
            ('movie', orm['movies.movievideofile:movie']),
        ))
        db.send_create_signal('movies', ['MovieVideoFile'])

    def backwards(self, orm):

        # Deleting model 'MovieVideoFile'
        db.delete_table('movies_movievideofile')

电影/迁移/0003_videofile-to-movievideofile-data.py(数据迁移)

from south.db import db
from django.db import models
from movies.models import *

class Migration:

    def forwards(self, orm):
        for movie in orm['movies.videofile'].objects.all():
            new_movie = orm.MovieVideoFile.objects.create(movie = movie.movie,)
            new_movie.videofile_ptr = orm['media.VideoFile'].objects.create()

            # videofile_ptr must be created first before values can be assigned
            new_movie.videofile_ptr.name = movie.name
            new_movie.videofile_ptr.size = movie.size
            new_movie.videofile_ptr.ctime = movie.ctime
            new_movie.videofile_ptr.save()

    def backwards(self, orm):
        print 'No Backwards'

South 太棒了!

好的,标准免责声明:你正在处理实时数据。这里提供的代码是可行的,但请使用--db-dry-run测试您的模式。在尝试任何操作之前,请始终备份并保持谨慎。

兼容性通知

我将保持原始消息不变,但是 South 已经将命令manage.py startmigration 更改为 manage.py schemamigration


非常感谢您为此付出的所有努力。如果我可以投票超过一次,我一定会这样做!我想我会采纳您的建议,将模型命名不同以避免未来的名称冲突。这太棒了。 - Andre Miller
4
几点说明:1)startmigration 实际上被拆分为 schemamigrationdatamigration。后者不需要使用 --auto--initial 标志,只需指定应用程序名称和迁移名称,即可获得一个带有空白的 forwardsbackwards 方法的迁移文件。2)根据 South 文档,你应该使用 raise RuntimeError("Cannot reverse this migration.") 代替 print 'No Backwards' - Mike DeSimone
2
你为什么没有使用 class Meta: abstract = True - muudscope
1
@TStone,非常感谢您在这方面付出了这么多工作,它确实帮助我了解如何使用south来完成这个任务。 - Paul
1
我在这个解决方案中遇到了一些问题,我认为这主要是由于Django 1.2和South 0.7的更改所致。请参见下面最终适用于我的解决方案。 - Paul
显示剩余3条评论

9

我尝试按照T Stone提供的解决方案,但是我遇到了一些问题。尽管它是一个非常好的起点,并且解释了应该如何完成任务,但我仍然遇到了一些困难。

我认为现在大多数情况下你不需要再创建父类的表项了,也就是说你不需要:

new_movie.videofile_ptr = orm['media.VideoFile'].objects.create()

不再需要手动添加null=True了。Django现在会自动为您执行此操作(如果有非空字段,则上述方法对我不起作用并导致数据库错误)。

我认为这可能是由于Django和South的更改,以下是适用于我在Ubuntu 10.10上使用Django 1.2.3和South 0.7.1的版本。模型略有不同,但您将获得要点:

初始设置

post1/models.py:

class Author(models.Model):
    first = models.CharField(max_length=30)
    last = models.CharField(max_length=30)

class Tag(models.Model):
    name = models.CharField(max_length=30, primary_key=True)

class Post(models.Model):
    created_on = models.DateTimeField()
    author = models.ForeignKey(Author)
    tags = models.ManyToManyField(Tag)
    title = models.CharField(max_length=128, blank=True)
    content = models.TextField(blank=True)

post2/models.py:

class Author(models.Model):
    first = models.CharField(max_length=30)
    middle = models.CharField(max_length=30)
    last = models.CharField(max_length=30)

class Tag(models.Model):
    name = models.CharField(max_length=30)

class Category(models.Model):
    name = models.CharField(max_length=30)

class Post(models.Model):
    created_on = models.DateTimeField()
    author = models.ForeignKey(Author)
    tags = models.ManyToManyField(Tag)
    title = models.CharField(max_length=128, blank=True)
    content = models.TextField(blank=True)
    extra_content = models.TextField(blank=True)
    category = models.ForeignKey(Category)

很明显有许多重叠之处,所以我想将共同点因素提取出来放在一个通用帖子模型中,只保留其他模型类的差异。

新设置:

genpost/models.py:

class Author(models.Model):
    first = models.CharField(max_length=30)
    middle = models.CharField(max_length=30, blank=True)
    last = models.CharField(max_length=30)

class Tag(models.Model):
    name = models.CharField(max_length=30, primary_key=True)

class Post(models.Model):
    created_on = models.DateTimeField()
    author = models.ForeignKey(Author)
    tags = models.ManyToManyField(Tag)
    title = models.CharField(max_length=128, blank=True)
    content = models.TextField(blank=True)

post1/models.py:

import genpost.models as gp

class SimplePost(gp.Post):
    class Meta:
        proxy = True

post2/models.py:

import genpost.models as gp

class Category(models.Model):
    name = models.CharField(max_length=30)

class ExtPost(gp.Post):
    extra_content = models.TextField(blank=True)
    category = models.ForeignKey(Category)

如果您想跟进,首先需要将这些模型导入到South:

$./manage.py schemamigration post1 --initial
$./manage.py schemamigration post2 --initial
$./manage.py migrate

数据迁移

如何进行数据迁移?首先编写新的应用程序 genpost,并使用 south 进行初始迁移:

$./manage.py schemamigration genpost --initial

(我使用$来代表shell提示符,所以不要输入它。)

接下来在post1/models.py和post2/models.py中分别创建新的类SimplePostExtPost(不要删除其他类)。然后为这两个类创建模式迁移:

$./manage.py schemamigration post1 --auto
$./manage.py schemamigration post2 --auto

现在我们可以应用所有这些迁移:
$./manage.py migrate

让我们来到问题的核心,将post1和post2的数据迁移到genpost:

$./manage.py datamigration genpost post1_and_post2_to_genpost --freeze post1 --freeze post2

然后编辑genpost/migrations/0002_post1_and_post2_to_genpost.py:

class Migration(DataMigration):

    def forwards(self, orm):

        # 
        # Migrate common data into the new genpost models
        #
        for auth1 in orm['post1.author'].objects.all():
            new_auth = orm.Author()
            new_auth.first = auth1.first
            new_auth.last = auth1.last
            new_auth.save()

        for auth2 in orm['post2.author'].objects.all():
            new_auth = orm.Author()
            new_auth.first = auth2.first
            new_auth.middle = auth2.middle
            new_auth.last = auth2.last
            new_auth.save()

        for tag in orm['post1.tag'].objects.all():
            new_tag = orm.Tag()
            new_tag.name = tag.name
            new_tag.save()

        for tag in orm['post2.tag'].objects.all():
            new_tag = orm.Tag()
            new_tag.name = tag.name
            new_tag.save()

        for post1 in orm['post1.post'].objects.all():
            new_genpost = orm.Post()

            # Content
            new_genpost.created_on = post1.created_on
            new_genpost.title = post1.title
            new_genpost.content = post1.content

            # Foreign keys
            new_genpost.author = orm['genpost.author'].objects.filter(\
                    first=post1.author.first,last=post1.author.last)[0]

            new_genpost.save() # Needed for M2M updates
            for tag in post1.tags.all():
                new_genpost.tags.add(\
                        orm['genpost.tag'].objects.get(name=tag.name))

            new_genpost.save()
            post1.delete()

        for post2 in orm['post2.post'].objects.all():
            new_extpost = p2.ExtPost() 
            new_extpost.created_on = post2.created_on
            new_extpost.title = post2.title
            new_extpost.content = post2.content

            # Foreign keys
            new_extpost.author_id = orm['genpost.author'].objects.filter(\
                    first=post2.author.first,\
                    middle=post2.author.middle,\
                    last=post2.author.last)[0].id

            new_extpost.extra_content = post2.extra_content
            new_extpost.category_id = post2.category_id

            # M2M fields
            new_extpost.save()
            for tag in post2.tags.all():
                new_extpost.tags.add(tag.name) # name is primary key

            new_extpost.save()
            post2.delete()

        # Get rid of author and tags in post1 and post2
        orm['post1.author'].objects.all().delete()
        orm['post1.tag'].objects.all().delete()
        orm['post2.author'].objects.all().delete()
        orm['post2.tag'].objects.all().delete()


    def backwards(self, orm):
        raise RuntimeError("No backwards.")

现在应用这些迁移:
$./manage.py migrate

接下来,您可以从post1/models.py和post2/models.py中删除现在不再需要的部分,然后创建模式迁移以将表更新为新状态:

$./manage.py schemamigration post1 --auto
$./manage.py schemamigration post2 --auto
$./manage.py migrate

就是这样!希望所有的都能正常工作并且你已经重构了你的模型。


我建议在genpost的datamigration中为post1和post2添加depends_on,以防止任何执行顺序意外。这样,其他人只需键入./manage.py migrate即可。(http://south.readthedocs.org/en/latest/dependencies.html) - hurturk

3

Abstract Model

class VideoFile(models.Model):
    name = models.CharField(max_length=1024, blank=True)
    size = models.IntegerField(blank=True, null=True)
    ctime = models.DateTimeField(blank=True, null=True)
    class Meta:
        abstract = True

也许通用关系对你也会有帮助。

如果我将父类定义为抽象模型,那么我是否可以跳过整个迁移过程?South 仍然会同步吗? - Andre Miller
抽象模型不同步。只有它的“子级”会同步。 - Oduvan
抱歉,我之前没有使用south。 - Oduvan
我知道如何手动完成,但我已经有现有的用户群,并希望使用South自动迁移,而不会让他们丢失任何数据。 - Andre Miller
如果您像这样使用抽象继承,我认为您根本不需要进行任何迁移。所以这是一个优点。负面的一面是,在数据库级别仍然存在重复,并且无法同时查询所有VideoFiles。 - Carl Meyer

1

我做了一个类似的迁移,选择了分步进行。除了创建多个迁移之外,我还创建了一个回滚迁移,以防出现问题时可以回退。然后,我获取了一些测试数据,并进行了前向和后向迁移,直到确定在前向迁移时输出正确为止。最后,我迁移了生产站点。


你有迁移过程的示例吗?你是如何将旧模式中的数据复制到新模式中的? - Andre Miller
@Andre 你看过有关数据迁移的South文档吗?它几乎就像平常使用ORM一样,只是通过传递给你的backwards/forwards方法的"orm"参数来操作(这样,无论你的模型处于什么状态,你都将始终拥有正确版本的模型以运行该迁移)。 - Carl Meyer
我看到了它并且和它玩过了。我只是想知道我上面提到的步骤,首先重命名字段以避免冲突,是否是最简单的方法。 - Andre Miller

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接