blog Django Unmanaged Models for Forgejo

Tags:
django forgejo

Most Django models are managed, and Django will take care of the entire lifecycle. There are times when working with an external database that it can be useful to use unmanaged models. Combined with Django’s inspectdb command, we can use it to interface with external systems.

Initial setup

I like to keep external models in their own Django app for clarity. I use django-environ to handle db_urls but our first step is to point at our database.

# settings.py

# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = {
    "default": env.db_url(
        var="DATABASE_URL",
        default=f"sqlite:///{BASE_DIR}/db.sqlite3",
    ),
    "django_unmanaged_forgejo": env.db_url("DATABASE_FORGEJO", default=None),
}

# https://docs.djangoproject.com/en/6.0/ref/settings/#database-routers
DATABASE_ROUTERS = ["django_unmanaged_forgejo.router.ForgejoRouter"]

Our default database will be for our specific project that might have its own regular managed models. We setup a second database django_unmanaged_forgejo which maps to the __package__ name we will use in our router. The names do not neccessarily need to match, but this makes it a bit easier in some cases.

We want to use automatic database routing so we will need to create a router class.

class ForgejoRouter:
    def db_for_read(self, model, **hints):
        if model._meta.app_label == __package__:
            return __package__
        return None

    def db_for_write(self, model, **hints):
        if model._meta.app_label == __package__:
            return __package__
        return None

We take advantage of the __package__ meta variable so that we can easily say that if the model is from our __package__ then we want to use the database name that matches our __package__.

Inspecting the database

With our basic database config and router in place, we can have Django inspect the database for us. Assuming our database servers are correct, our command will look something like this

uv run manage.py inspectdb --database django_unmanaged_forgejo > reference.py

We need to pass --database otherwise Django will use the default database.

If we look at our reference.py for the User object, it will look something like this.

# This is an auto-generated Django model module.
# You'll have to do the following manually to clean this up:
#   * Rearrange models' order
#   * Make sure each model has one field with primary_key=True
#   * Make sure each ForeignKey and OneToOneField has `on_delete` set to the desired behavior
#   * Remove `managed = False` lines if you wish to allow Django to create, modify, and delete the table
# Feel free to rename the models, but don't rename db_table values or field names.
from django.db import models

class User(models.Model):
    id = models.BigAutoField(primary_key=True)
    lower_name = models.CharField(unique=True, max_length=255)
    name = models.CharField(unique=True, max_length=255)
    full_name = models.CharField(max_length=255, blank=True, null=True)
    email = models.CharField(max_length=255)
    keep_email_private = models.BooleanField(blank=True, null=True)
    email_notifications_preference = models.CharField(max_length=20)
    passwd = models.CharField(max_length=255)
    passwd_hash_algo = models.CharField(max_length=255)
    must_change_password = models.BooleanField()
    login_type = models.IntegerField(blank=True, null=True)
    login_source = models.BigIntegerField()
    login_name = models.CharField(max_length=255, blank=True, null=True)
    type = models.IntegerField(blank=True, null=True)
    location = models.CharField(max_length=255, blank=True, null=True)
    website = models.CharField(max_length=255, blank=True, null=True)
    rands = models.CharField(max_length=32, blank=True, null=True)
    salt = models.CharField(max_length=32, blank=True, null=True)
    language = models.CharField(max_length=5, blank=True, null=True)
    description = models.CharField(max_length=255, blank=True, null=True)
    created_unix = models.BigIntegerField(blank=True, null=True)
    updated_unix = models.BigIntegerField(blank=True, null=True)
    last_login_unix = models.BigIntegerField(blank=True, null=True)
    last_repo_visibility = models.BooleanField(blank=True, null=True)
    max_repo_creation = models.IntegerField()
    is_active = models.BooleanField(blank=True, null=True)
    is_admin = models.BooleanField(blank=True, null=True)
    is_restricted = models.BooleanField()
    allow_git_hook = models.BooleanField(blank=True, null=True)
    allow_import_local = models.BooleanField(blank=True, null=True)
    allow_create_organization = models.BooleanField(blank=True, null=True)
    prohibit_login = models.BooleanField()
    avatar = models.CharField(max_length=2048)
    avatar_email = models.CharField(max_length=255)
    use_custom_avatar = models.BooleanField(blank=True, null=True)
    num_followers = models.IntegerField(blank=True, null=True)
    num_following = models.IntegerField()
    num_stars = models.IntegerField(blank=True, null=True)
    num_repos = models.IntegerField(blank=True, null=True)
    num_teams = models.IntegerField(blank=True, null=True)
    num_members = models.IntegerField(blank=True, null=True)
    visibility = models.IntegerField()
    repo_admin_change_team_access = models.BooleanField()
    diff_view_style = models.CharField(max_length=255)
    theme = models.CharField(max_length=255)
    keep_activity_private = models.BooleanField()
    enable_repo_unit_hints = models.BooleanField()
    pronouns = models.CharField(max_length=255, blank=True, null=True)
    keep_pronouns_private = models.BooleanField()

    class Meta:
        managed = False
        db_table = 'user'

this is a lot of fields. For starters, lets add something much smaller to our actual models.py

class User(models.Model):
    id = models.BigAutoField(primary_key=True)
    name = models.CharField(unique=True, max_length=255)
    type = models.IntegerField()
    visibility = models.IntegerField()
    avatar = models.CharField(max_length=2048)

    class Meta:
        managed = False
        db_table = "user"

Linking simple objects together

If we look at the exported Repository object, it is similar to our exported User object

class Repository(models.Model):
    id = models.BigAutoField(primary_key=True)
    owner_id = models.BigIntegerField(blank=True, null=True)
    owner_name = models.CharField(max_length=255, blank=True, null=True)
    lower_name = models.CharField(max_length=255)
    name = models.CharField(max_length=255)
    description = models.TextField(blank=True, null=True)
    website = models.CharField(max_length=2048, blank=True, null=True)
    ...

When we add it to our models.py we can make a slight modification

class Repository(models.Model):
    id = models.BigAutoField(primary_key=True)
    name = models.CharField(max_length=255)
    owner = models.ForeignKey(User, on_delete=models.CASCADE)
    is_private = models.BooleanField()
    updated_unix = models.BigIntegerField()
    topics = models.TextField()

Originally, Django can not tell that owner_id corisponds to a User model, but we can make it explicit with our change.

owner_id = models.BigIntegerField(blank=True, null=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE)

Django’s ForeignKey field knows that if the object is called owner that our database value will be <name>_id and can use that information to return to us the correct User object.

Sometimes we may want to name our model field different than the database field, which we can do with the db_column for example with our Issue model.

class Issue(models.Model):
    id = models.BigAutoField(primary_key=True)
    repository = models.ForeignKey(Repository, on_delete=models.CASCADE, db_column="repo_id")
    ...

Linking complex objects together

In the case of Issues assigned to User, we have a more complicated setup. Usually Django will create a through table automatically, but in this case, we need to handle it ourselves, because Django’s automaticly table name would not corrispond to Forgejo’s table name.

class User(models.Model):
    id = models.BigAutoField(primary_key=True)
    name = models.CharField(unique=True, max_length=255)
    ...
    assigned = models.ManyToManyField("Issue", through="IssueAssignees")

class Repository(models.Model):
    id = models.BigAutoField(primary_key=True)
    name = models.CharField(max_length=255)
    owner = models.ForeignKey(User, on_delete=models.CASCADE)

class Issue(models.Model):
    id = models.BigAutoField(primary_key=True)
    repository = models.ForeignKey(Repository, on_delete=models.CASCADE, db_column="repo_id")
    assignees = models.ManyToManyField("User", through="IssueAssignees")

class IssueAssignees(models.Model):
    id = models.BigAutoField(primary_key=True)
    assignee = models.ForeignKey(User, on_delete=models.CASCADE) # db_column defaults to assignee_id
    issue = models.ForeignKey(Issue, on_delete=models.CASCADE) # db_column defaults to issue_id

    class Meta:
        managed = False
        db_table = "issue_assignees"

Other Notes

When I am modifying Forgejo objects, I still tend to use its rest api to ensure that valdiation is performed there, but for speedier read-only access, this has been very useful for creating my own reports. I have uploaded my models to Codeberg and PyPI for those who might want to play around with it.

In the past I did something similar for a project that assisted tracking Grafana instances. I am thinking of doing something similar for Mastodon to more easily build my own reports for my own instance.