MrBinWin
MrBinWin

Reputation: 1319

Django model fields with dynamic names

I'd like to add to existing models new CharFields via one common mixin or abstract model but names of these fields depend on configuraton. so one model will have someprefix1_title field and another model - someprefix2_title.

Is it possible to make this approach to work:

class AbstractModel(models.Model):
    self.fields_prefix + '_title' = models.CharField(max_length=255, blank=True, default='')

    class Meta:
        abstract = True

class ModelOne(AbstractModel):
    fields_prefix = 'someprefix1'
    id = models.AutoField(primary_key=True)

class ModelTwo(AbstractModel):
    fields_prefix = 'someprefix2'
    id = models.AutoField(primary_key=True)

so ModelOne could have fields id and someprefix1_title.

upd: what about monkey-patching with add_to_class() will it work or it's an antipattern and should not be used?

Upvotes: 11

Views: 5042

Answers (5)

Chris
Chris

Reputation: 6392

Another couple approaches I've had success with, based on the fantastic top answer from @whp, adding fields using a field, method or decorator. My use case was storing multiple fields for translations in the most plain, vanilla way possible, along with a locale-sensitive property.

My preferred approach is a field, because this is where most developers would expect this to be:

class TranslatedField:
    """
    A Django pseudo field that adds a field per supported language to a model, and a getter property that
    returns the translation for the language currently selected in Django (which is set by Django
    LocaleMiddleware).

    Usage example:

        # models.py

        # models.py
        class Acme(models.Model):
            code = CodeField()
            name = TranslatedField(models.CharField(blank=True, verbose_name=_("common name")))
            description = TranslatedField(models.CharField(max_length=800, verbose_name=_("description")))

        # Usage:
        obj = Acme(code="abc", name_en="English name", name_pt="Nome português")
        obj.name
        >> "English name"
        translation.activate("pt")
        obj.name
        >> "Nome português"
    """

    def __init__(self, field):
        self.field = field

    def contribute_to_class(self, cls, name, private_only=False):
        # Add language fields to cls and db, of type self.field, eg, obj.name_en = CharField()
        for language_code, language_name in settings.LANGUAGES:
            model_field_name = f"{name}_{language_code}"
            field = self.field.clone()
            field.verbose_name = f"{self.field.verbose_name} ({language_name})"
            field.contribute_to_class(cls=cls, name=model_field_name, private_only=private_only)

        # Add property that returns local translation, eg, obj.name == "Nome português"
        def local_translation_getter(obj):
            # Note that translation.get_language() can return, eg, en-us if this is in settings.LANGUAGE_CODE or settings.LANGUAGES[i][0] - you'll need to either strip after "-" or remove the locale code
            current_language_code = translation.get_language()
            model_field = f"{name}_{current_language_code}"
            return getattr(obj, model_field, "")  # translation.get_language() returns default language if none set

        setattr(cls, name, property(local_translation_getter))
        return cls

Alternatively a decorator, as decoration is a standard way of adding functionality to a class, and they have to appear right next to the model definition:

from django.conf import settings
from from django.db import models  # or django.contrib.gis.db.models
from django.utils import translation

def translatable_field(field_name, field):
    """
    Decorator to add translated fields and property getter for current language to a Django model.

    Usage example:

        # models.py

        @translatable_field("name", models.CharField(blank=True, verbose_name=_("common name")))
        @translatable_field("description", models.TextField())
        class Acme(models.Model):
            code = models.CharField()

        # Usage:
        obj = Acme(code="abc", name_en="English name", name_pt="nome português")
        obj.name
        >> "English name"
        translation.activate("pt")
        obj.name
        >> "nome português"
    """

    def decorate(model):
        # Add translated fields to model, eg, name_en
        for language_code, name in settings.LANGUAGES:
            model_field_name = f"{field_name}_{language_code}"
            field.clone().contribute_to_class(model, model_field_name)

        # Add property that returns local translation, eg, name
        def local_translation_getter(self):
            # Note that translation.get_language() can return, eg, en-us if this is in settings.LANGUAGE_CODE or settings.LANGUAGES[i][0] - you'll need to either strip after "-" or remove the locale code
            model_field = f"{field_name}_{translation.get_language()}"
            return getattr(self, model_field, "")

        setattr(model, field_name, property(local_translation_getter))
        return model

    return decorate

A method that does exactly the same:

def add_translatable_field_to_model(model, field_name, field):
    """
    This is functionally identical, except usage syntax.

    Example usage:

        # models.py
        class Acme(models.Model):
            code = models.CharField()

        add_translatable_field_to_model(Acme, "name", models.CharField(blank=True, verbose_name=_("common name")))
        add_translatable_field_to_model(Acme, "description", models.TextField())
    """
    for code, name in settings.LANGUAGES:
        field.clone().contribute_to_class(model, f"{field_name}_{code}")

    def local_translation_getter(self):
        # Note that translation.get_language() can return, eg, en-us if this is in settings.LANGUAGE_CODE or settings.LANGUAGES[i][0] - you'll need to either strip after "-" or remove the locale code
        language_code = translation.get_language()
        return getattr(self, f"{field_name}_{language_code}", "")

    setattr(model, field_name, property(local_translation_getter))

Here is a great write-up from Django on this machinery:

https://code.djangoproject.com/wiki/DevModelCreation

Upvotes: 0

whp
whp

Reputation: 1514

Try using a factory pattern to set up your different versions of AbstractModel.

With this approach, you can more strictly control the way AbstractModel is modified by way of the factory function dynamic_fieldname_model_factory.

We're also not modifying ModelOne or ModelTwo after their definitions -- other solutions have pointed out that this helps avoid maintainability problems.

models.py:

from django.db import models


def dynamic_fieldname_model_factory(fields_prefix):
    class AbstractModel(models.Model):

        class Meta:
            abstract = True

    AbstractModel.add_to_class(
        fields_prefix + '_title',
        models.CharField(max_length=255, blank=True, default=''),
    )
    return AbstractModel


class ModelOne(dynamic_fieldname_model_factory('someprefix1')):
    id = models.AutoField(primary_key=True)


class ModelTwo(dynamic_fieldname_model_factory('someprefix2')):
    id = models.AutoField(primary_key=True)

Here is the migration generated by this code:

# Generated by Django 2.1.7 on 2019-03-07 19:53

from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='ModelOne',
            fields=[
                ('someprefix1_title', models.CharField(blank=True, default='', max_length=255)),
                ('id', models.AutoField(primary_key=True, serialize=False)),
            ],
            options={
                'abstract': False,
            },
        ),
        migrations.CreateModel(
            name='ModelTwo',
            fields=[
                ('someprefix2_title', models.CharField(blank=True, default='', max_length=255)),
                ('id', models.AutoField(primary_key=True, serialize=False)),
            ],
            options={
                'abstract': False,
            },
        ),
    ]

Upvotes: 11

Mohit Harshan
Mohit Harshan

Reputation: 1996

Django models can be created with dynamic field names . Here is a simple Django model:

class Animal(models.Model):
    name = models.CharField(max_length=32)

And here is the equivalent class built using type():

attrs = {
    'name': models.CharField(max_length=32),
    '__module__': 'myapp.models'
}
Animal = type("Animal", (models.Model,), attrs)

Any Django model that can be defined in the normal fashion can be made using type().

To run migrations:South has a reliable set of functions to handle schema and database migrations for Django projects. When used in development, South can suggest migrations but does not attempt to automatically apply them

from south.db import db
model_class = generate_my_model_class()
fields = [(f.name, f) for f in model_class._meta.local_fields]
table_name = model_class._meta.db_table
db.create_table(table_name, fields)
# some fields (eg GeoDjango) require additional SQL to be executed
db.execute_deferred_sql()

Upvotes: 7

CrazyGeek
CrazyGeek

Reputation: 3447

Technically, Its bad practice by creating model fields dynamically because this breaks the standard rule of keeping the database schema history using the django's migration process.

All you need is to store some fields under a django model which have flexibility to store dynamic fields. So I would suggest you to use the HStoreField.

Using HStoreField you can store data in a key-value pair format or json. So this will resolve the problem of storing the dynamic fields.

Here is an example given by Django docs.

from django.contrib.postgres.fields import HStoreField
from django.db import models

class Dog(models.Model):
    name = models.CharField(max_length=200)
    data = HStoreField()

    def __str__(self):
        return self.name

This is how you can query the HStoreFields.

>>> Dog.objects.create(name='Rufus', data={'breed': 'labrador'})
>>> Dog.objects.create(name='Meg', data={'breed': 'collie'})

>>> Dog.objects.filter(data__breed='collie')
<QuerySet [<Dog: Meg>]>

I hope this would resolve your problem. Please do let me know if you have any queries.

Thanks

Upvotes: 0

Bernhard Vallant
Bernhard Vallant

Reputation: 50806

The cleanest way would probably be using add_to_class():

ModelOne.add_to_class(
    '%s_title' % field_prefix, 
    models.CharField(max_length=255, blank=True, default='')
)

Still this can be considered "monkey-patching" with all its downsides like making the app more difficult to maintain, have code that is more difficult to understand etc... Bu if your use case makes it really necessary to do something like that it would probably be the best solution as add_to_class() is some functionality provided from Django itself and has been stable for quite some time.

Upvotes: 4

Related Questions