Scott's Recipes Logo

Django versus Rails 02 - Working in Python with the Db Layer using ChatGPT to Help Learn

Yesterday’s blog post focused on adapting to Django from a Rails environment perspective. Today I’ll focus on getting used to coding in Python and Django.

Goal: Write a Management Command

My goal for today is to write a management command – something very similar to a Rake task – which:

In the Rails world, I refer to this as a find_or_create action so I named my command accordingly:

project_root
  manager
    management
      commands
        find_or_add_customer.py

In that find_or_add_customer.py file, I have the basics for a Python class:

# NOTE: Brought in by copy/paste may not all be needed
from tokenize import String
from unicodedata import name
from django.core.management.base import BaseCommand, CommandError
from manager.models import SourceClubCustomer

# We probably dont have to have pandas for this project. It feels a little heavy
import pandas as pd

class Command(BaseCommand):
    # customer name input (comes in from a param or web form)
    customer_name = "Foo Company"
    
    # look up customer in db
    
    # if found customer then return it
    
    # if not found then create it
    
    # and then return it

That’s my starting point.

Learning to Query SQL Alchemy

My understanding is that default Django equivalent to ActiveRecord is SQL Alchemy. Now to flesh out the logic, I’ll go into the enhanced Django shell and experiment:

./manage.py shell_plus

Let’s start with a basic count of the rows in the table:

SourceClubCustomer.objects.count()

Note: Two differences from ActiveRecord – the use of .objects instead of .all and the () after count.

Even though we know – right now – we don’t have any rows in our table, we still need to write the equivalent of an ActiveRecord where clause. And while I could have looked this up, I instead decided to try ChatGPT with this prompt:

Query a python Django model for anything matching “foo” in a name field

ChatGPT responded with this:

from myapp.models import MyModel

def my_view(request):
    matching_objects = MyModel.objects.filter(name__contains='foo')
    # do something with the matching objects

NOTE: There is also icontains as an operator which presumably is the case insensitive Postgres like that depends on your table type.

The use of contains makes me think this is an SQL like operation where we really want an exact match so I rewrote my prompt:

Query a python Django model for anything equal to “foo” in a name field

and ChatGPT gave me this:

from myapp.models import MyModel

def my_view(request):
    matching_objects = MyModel.objects.filter(name__exact='foo')
    # do something with the matching objects

It also gave me this:

matching_objects = MyModel.objects.filter(name='foo')

as an alternative (which I already had written experimentally in the shell).

Moving from a QuerySet to an Object

The code above returns a QuerySet which can be verified with:

>>> matching_objects.__class__
<class 'django.db.models.query.QuerySet'>

This is similar to an ActiveRecord collection and you can call a .first() method to get the underlying object:

>>> matching_objects = SourceClubCustomer.objects.filter(name__contains=customer_name).first()

At this point you can also check the class and see:

>>> matching_objects.__class__
<class 'NoneType'>

And this brings us, alas, to creation …

Creating a New Object

So I now need to create a new object but I realized that I just have no idea how do this so, again, ChatGPT:

How do I create a new object in Django based on a name field

from myapp.models import MyModel

def my_view(request):
    new_object = MyModel.objects.create(name='foo')
    # do something with the new object

And as an alternative, ChatGPT gives me:

new_object = MyModel(name='foo')
new_object.save()

Putting It All Together

Putting all of the above together, we get:

class Command(BaseCommand):
    # customer name input (comes in from a param or web form); currently just static
    customer_name = "Foo Company";
    
    # look up customer in db
    customer = SourceClubCustomer.objects.filter(name__contains=customer_name)

    # if found customer then return it
    if customer.count() == 1
        print("Found customer so about to return ")
        return customer.first()
    
    # if not found then create it and return it
    new_customer = SourceClubCustomer(name=customer_name)
    
    if new_customer.save()
        print("Into save routine before returning new customer")
        return new_customer

If you are a regular Python user then you are no doubt roaring with laughter going “That will never work” and since I have no issue documenting my (myriad) flaws, here’s the result of executing the code above.

Note: The way you execute a Django management command is the following:

python manage.py find_or_add_customer

The name of the file containing the command is what you give to Django’s manage.py as an argument. This is, naturally, the equivalent of rake task:action with the difference of one action per file.

❯ python manage.py find_or_add_customer
Traceback (most recent call last):
  File "/Users/sjohnson/Sync/coding/django/sourceclub-product-matcher/manage.py", line 22, in <module>
    main()
  File "/Users/sjohnson/Sync/coding/django/sourceclub-product-matcher/manage.py", line 18, in main
    execute_from_command_line(sys.argv)
  File "/Users/sjohnson/Sync/coding/django/sourceclub-product-matcher/env/lib/python3.10/site-packages/django/core/management/__init__.py", line 446, in execute_from_command_line
    utility.execute()
  File "/Users/sjohnson/Sync/coding/django/sourceclub-product-matcher/env/lib/python3.10/site-packages/django/core/management/__init__.py", line 440, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File "/Users/sjohnson/Sync/coding/django/sourceclub-product-matcher/env/lib/python3.10/site-packages/django/core/management/__init__.py", line 279, in fetch_command
    klass = load_command_class(app_name, subcommand)
  File "/Users/sjohnson/Sync/coding/django/sourceclub-product-matcher/env/lib/python3.10/site-packages/django/core/management/__init__.py", line 48, in load_command_class
    module = import_module("%s.management.commands.%s" % (app_name, name))
  File "/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1050, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1027, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1006, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 688, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 879, in exec_module
  File "<frozen importlib._bootstrap_external>", line 1017, in get_code
  File "<frozen importlib._bootstrap_external>", line 947, in source_to_code
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "/Users/sjohnson/Sync/coding/django/sourceclub-product-matcher/manager/management/commands/find_or_add_customer.py", line 18
    if customer.count() == 1
                            ^
SyntaxError: expected ':'

The first and most glaring errors are obviously the missing : which delimit control structures so this rewrites our code as follows:

class Command(BaseCommand):
    # customer name input (comes in from a param or web form); currently just static
    customer_name = "Foo Company";
    
    # look up customer in db
    customer = SourceClubCustomer.objects.filter(name__contains=customer_name)

    # if found customer then return it
    if customer.count() == 1:
        print("Found customer so about to return ")
        return customer.first()
    
    # if not found then create it and return it
    new_customer = SourceClubCustomer(name=customer_name)
    
    if new_customer.save():
        print("Into save routine before returning new customer")
        return new_customer

Executing the code above illustrates the next error:

❯ python manage.py find_or_add_customer
Traceback (most recent call last):
  File "/Users/sjohnson/Sync/coding/django/sourceclub-product-matcher/manage.py", line 22, in <module>
    main()
  ...
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "/Users/sjohnson/Sync/coding/django/sourceclub-product-matcher/manager/management/commands/find_or_add_customer.py", line 20
    return customer.first()
    ^^^^^^^^^^^^^^^^^^^^^^^
SyntaxError: 'return' outside function

And this then brings me to an existential crisis – by implementing this as a management “function”, I actually have a conceptual error – I can’t return anything because there isn’t a function. This means I need to re-implement the guts of this in the underlying SourceClubCustomer class as follows:

# source club customers or potential customers
class SourceClubCustomer(Timestamped):
    name = models.CharField(max_length=50)

    # customer = SourceClubCustomer.find_or_create('foo')
    def find_or_create(customer_name):
        # look up customer in db; NOTE did not use the find method above deliberately
        customer = SourceClubCustomer.objects.filter(name__contains=customer_name)

        # if found customer then return it
        if customer.count() == 1:
            print("Found customer so about to return")
            return customer.first()
    
        # if not found then create it and return it
        new_customer = SourceClubCustomer(name=customer_name)
    
        if new_customer.save():
            print("Into save routine before returning new customer")
            return new_customer

And now we have a single find_or_create method which handles everything. Additionally we can implement this – since it is just a method on a Python class in a management command or a Django view or whatever. So our management command now becomes:

# NOTE: Brought in by copy/paste may not all be needed
from tokenize import String
from unicodedata import name
from django.core.management.base import BaseCommand, CommandError
from manager.models import SourceClubCustomer

class Command(BaseCommand):
    
    def handle(self, *args, **options):
        # customer name input (comes in from a param or web form); currently just static
        customer_name = "Foo Company";

        customer = SourceClubCustomer.find_or_create(customer_name)

The def handle is a required part of all management commands that my earlier errors hid. Once I fixed those, I quickly got a:

NotImplementedError: subclasses of BaseCommand must provide a handle() method

A little bit of research and I figured it out.

Defining a New Model

In order to add a new model, we need to add a Python class to the file:

manager/models.py

Let’s add a simple class for tracking imports of data with two fields:

This gives us the following class:

# these are the imports that are processed
class FileImport(Timestamped):
     filename = models.CharField(max_length=50)
     created_at = models.DateTimeField(auto_now_add=True)

Now this brings us to the workflow for migrations. From a Rails perspective, we tend create the migration either before the class or concurrently so my natural thought was that I can now run the migration script:

❯ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, manager, sessions
Running migrations:
  No migrations to apply.
  Your models in app(s): 'manager' have changes that are not yet reflected in a migration, and so won't be applied.
  Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them.    

So clearly there is another step here:

python manage.py makemigrations
Migrations for 'manager':
  manager/migrations/0002_fileimport.py
    - Create model FileImport

The file manager/migrations/0002_fileimport.py now needs to be reviewed and edited. Here’s what it looks like:

# Generated by Django 4.1.4 on 2023-01-18 15:54

from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('manager', '0001_initial'),
    ]

    operations = [
        migrations.CreateModel(
            name='FileImport',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('created_at', models.DateTimeField(auto_now_add=True)),
                ('updated_at', models.DateTimeField(auto_now=True)),
                ('deleted_at', models.DateTimeField(null=True)),
                ('filename', models.CharField(max_length=50)),
                ('processed_at', models.DateTimeField(auto_now_add=True)),
            ],
            options={
                'abstract': False,
            },
        ),
    ]

My input to the migration was simply filename and processed_at and it has been expanded into a much more extensive data structure. My intent was that the processed_at entry indicates when the file was imported. And while it can be argued that you could simply assume created_at indicates this – particularly given that they will initially have the same value, the processed_at field lends itself to the data being reprocessed – a fairly likely scenario.

Summary

Django and Rails are clearly cousins at heart. Knowledge that you have from one easily applies to the other. Rails, to me, still feels far more elegant but I think that’s the nature of Ruby over Python (and, yes, this is a taste difference).