Fernando Alves

Populando um campo novo não-nulo no Django

Fala pessoal, esse é um post rápido no qual eu vou mostrar como popular uma migração criando um campo novo não-nulo no django

SAY WUUUUT?????

Seguinte, sabe quando você tem seu projeto rodando, banco de dados, models, front, em produção as porra tudo e então aparece um requisito novo e eis que surge um campo obrigatório, que ninguém pensou antes, nem o cliente, nem o product owner, nem ninguém! Essa é a situação!

Acontece que você usa as migrações do django e você quer que poder colocar esses campos usando os migrations, tanto migrando pra frente quanto pra trás, ok?

Chega de conversa, pra esse exemplo resolvi pegar um projeto pronto nas interwebz, acabei optando por um django polls feito em django 1.10 já pronto.

Então lá se vão os passos de sempre, clonar e criar virtualenv…

git clone git@github.com:garmoncheg/django-polls_1.10.git
cd django-polls_1.10/
mkvirtualenv --python=/usr/bin/python3 django-polls 
pip install django==1.10  # Nesse projeto o autor não criou um requirements.txt
python manage.py migrate  # rodando as migrações existentes
python manage.py createsuperuser 
python manage.py runserver

Obs: Esse projeto pronto que pegamos está com uma migração faltando, então se você estiver seguindo esse passo a passo rode um python manage.py makemigrations pra que seja criada a migração 0002 (que é só a mudança de um verbose_name)

Agora você acessa o admin (http://localhost:8000/admin/polls/question/add/) e cria o seu poll :

 


Aí você pode ir lá no app polls e ver sua pergunta, responder etc…
até aí OK, ainda não fizemos nada!

Bom, o ideal é criar mais umas perguntas com datas de publicação diferentes para a brincadeira
ficar boa.

Depois de começar a usar você vai perceber que qualquer enquete fica infinita no seu site, ou seja, todas as vezes que alguém entra no site ele tem a oportunidade de votar, você nunca encerra a votação.

Então nossa alteração no código será a seguinte: De agora em diante, todas as enquetes terão uma data de expiração. No momento do cadastro o usuário poderá colocar a data na qual a votação se encerrará e o usuário será direcionado diretamente para o resultado. Queremos que essa data de expiração seja um campo obrigatório! E para os que já estão funcionando na plataforma vamos determinar arbitrariamente que cada um tenha um mês de validade a partir da data de publicação.

Antigamente, antes de migrações como isso era feito em qualquer sistema? Via SQL você criava um campo que permitisse NULOS, depois você criava uma query que populava esse campo e por fim você alterava a tabela pra tornar aquela coluna obrigatória. Com as migrações é a mesma coisa.

Então vamos criar o campo novo nos models vou chamá-lo de expires_date:

expires_date = models.DateTimeField(‘expires at’, null=True)

E o model inteiro fica assim:

class Question(models.Model):
    question_text = models.CharField(max_length=200)
    pub_date = models.DateTimeField('date published')
    expires_date = models.DateTimeField('expires at', null=True)

    def __str__(self):
        return self.question_text

    def was_published_recently(self):
        now = timezone.now()
        return now - datetime.timedelta(days=1) <= self.pub_date <= now
    was_published_recently.admin_order_field = 'pub_date'
    was_published_recently.boolean = True
    was_published_recently.short_description = 'Published recently?'


Agora vamos criar a migração dessa mudança:

python manage.py makemigrations

E ele criará a migração 0003_question_expires_date. O conteúdo é o seguinte:

class Migration(migrations.Migration):

    dependencies = [
        ('polls', '0002_auto_20170429_2220'),
    ]

    operations = [
        migrations.AddField(
            model_name='question',
            name='expires_date',
            field=models.DateTimeField(null=True, verbose_name='expires at'),
        ),
    ]

 

Vamos modificar o código dessa migration, NO PANIC!

Populando o novo campo

Primeiro criamos uma função para popular o banco com as datas de expiração:

def populate_expires_date(apps, schema_editor):
    """
    Popula o campo data de expiração das perguntas já existentes colocando um mẽs de validade para cada.
    """
    from datetime import timedelta

    db_alias = schema_editor.connection.alias
    Question = apps.get_model('polls', 'Question')

    for row in Question.objects.using(db_alias).filter(expires_date__isnull=True):
        row.expires_date = row.pub_date + timedelta(days=30)
        row.save()

Originalmente usei esse código em um projeto que utiliza múltiplos bancos de dados, então precisei usar o db_alias e achei interessante deixá-lo aqui. Quanto ao Question, estou dando um import desse model usando o apps.get_model pois nesse momento que estamos rodando a migração o campo ainda não existe para o projeto, pois a migração não acabou de rodar, então é melhor que importar do model.

Agora, dentro da migração existe uma lista chamada operations. Nessa lista vamos adicionar os comandos para rodar a nossa função e em seguida, vamos adicionar a obrigatoriedade do campo: ficando dessa forma:

operations = [
    migrations.AddField(
        model_name='question',
        name='expires_date',
        field=models.DateTimeField(null=True, verbose_name='expires at'),
    ),
    migrations.RunPython(populate_expires_date, reverse_code=migrations.RunPython.noop),
    migrations.AlterField(
        model_name='question',
        name='expires_date',
        field=models.DateTimeField(verbose_name='expires at'),
    )
]

Você pode ver que utilizamos o migrations.RunPython para rodar nossa função durante a migração. O reverse_code serve quando alguém for dar um unapply da migração, nesse caso não existia o campo, então não faremos nada.

Logo em seguida adicionei a migração que altera o campo e ele deixa de ter null=True. Também poderíamos ter feito isso em outra migração, só retirando essa informação do model (que precisa ser retirada agora de qualquer forma) e ter criado uma nova migração.

O model ficará assim:

class Question(models.Model):
    question_text = models.CharField(max_length=200)
    pub_date = models.DateTimeField('date published')
    expires_date = models.DateTimeField('expires at')

    def __str__(self):
        return self.question_text

    def was_published_recently(self):
        now = timezone.now()
        return now - datetime.timedelta(days=1) <= self.pub_date <= now
    was_published_recently.admin_order_field = 'pub_date'
    was_published_recently.boolean = True
    was_published_recently.short_description = 'Published recently?'

agora você pode rodar o migrate tranquilamente:

python mange.py migrate

Pronto! Pra ver as alterações vou adicionar esse campo no admin, tanto para ser editado como no list_display:

class QuestionAdmin(admin.ModelAdmin):
    fieldsets = [
        (None,               {'fields': ['question_text']}),
        ('Date information', {'fields': ['pub_date', 'expires_date'], 'classes': ['collapse']}),
    ]
    inlines = [ChoiceInline]
    list_display = ('question_text', 'pub_date', 'expires_date', 'was_published_recently')
    list_filter = ['pub_date']
    search_fields = ['question_text']

E voilá, todos as Questions que você tinha no polls contam com um expires_date, obrigatório e com 30 dias adicionado por default para as Questions antigas.

É isso aí, agora tem esse campo que queremos! O projeto com as alterações está aqui: https://github.com/ffreitasalves/django-polls_1.10

Se gostou, compartilhe com os amigos e deixe um comentário. Se não gostou também! Abraços!

 

Fontes:

http://stackoverflow.com/questions/28012340/django-1-7-makemigration-non-nullable-field

http://stackoverflow.com/questions/29217706/django-view-sql-query-without-publishinhg-migrations

https://realpython.com/blog/python/data-migrations/

https://docs.djangoproject.com/en/1.10/howto/writing-migrations/#non-atomic-migrations

Sair da versão mobile