Tarquinius
Tarquinius

Reputation: 1948

How to add a new "comment" or "flag" field to every model field of existing model?

Disclaimer: I can wipe out the database anytime. So while answering this, please don't care about migrations and stuff.

Imagine me having a model with multiple values:

class Compound(models.Model):
    color = models.CharField(max_length=20, blank=True, default="")
    brand = models.CharField(max_length=200, blank=True, default="")
    temperature = models.FloatField(null=True, blank=True)
    melting_temp = models.FloatField(null=True, blank=True)
    # more (~20) especially numeric values as model fields

Now I want to add a comment to be stored for every value of that model. For example I want to add a comment "measured in winter" to the temperature model field.

What is the best approach to do that?

My brainstorming came up with:

  1. By hand add 20 more model fields like temperature_comment = ... but that sounds not very DRY
  2. Add one big json field which stores every comment. But how do I create a Form with such a json field? Because I want to separate each input field for related value. I would probably have to use javascript which I would want to avoid.
  3. Add a model called Value for every value and connect them to Compound via OneToOneFields. But how do I then create a Form for Compound? Because I want to create a Compound utilizing one form. I do not want to create every Value on its own. Also it is not as easy as before, to access and play around with the values inside the Compound model.

I guess this is a fairly abstract question for a usecase that comes up quite often. I do not know why I did not find resources on how to accomplish that.

Upvotes: 2

Views: 272

Answers (6)

nigel222
nigel222

Reputation: 8222

I would go with a JSONfield.

It's quite easy to dynamically create a form with a Charfield matching every field in a model. The keys to this are (a) the documented Model._meta interface (don't be perturbed by that underscore) and (b) the three-argument form of the Python type built-in.

I'm writing this off the top of my head, but let's add a JsonField comments to our object(s) and then construct a form with a Charfield for every field in Compound except comments:

def construct_form_class( obj):
    # obj can be an instance or a ModelClass.   
    # we probably don't want a comment for the primary key or the comments field
    exclude = ( 'id', 'comments')

    form_fields = {}
    for field in Compound._meta.get_fields() :
        name = field.name
        if name in exclude:
            continue
        charfield = forms.Charfield(
            max_length = 120,
            # anything else? Maybe look up suitable initial based on field name?
            # otherwise blank=True or initial='something'
        )
        form_fields[ name] = charfield

    # construct the form class
    MyForm = type( 'MyForm', (forms.Form, ), form_fields )
    return MyForm

Now a view for updating the comments on a particular instance compound. The class includes SingleObjectMixin, which provides everything we need to fetch that instance via get_object and the usual declarations. ( Classy CBVs comes in handy, as ever)

def UpdateCommentsView( SingleObjectMixin, FormView)

    template_name = 'app:update_comments.html'
    model = Compound
    ...

    def setup( self, request, *args, **kwargs):
         super().setup( request, *args, **kwargs)
         self.object = self.get_object()

    def get_form_class(self):
         return construct_form_class( self.object)  # above

    def get_initial(self)
         return self.object.comments.copy() 

    def form_valid( self, form):
         # form.cleaned_data that has passed validation is an
         # appropriate value {fieldname: value, ...} to store
         # in a JsonField.
         self.object.comments = form.cleaned_data
         self.object.save()

Think this is everything essential. No code change is needed if you add a field to the model with a subsequent migration. The dynamically generated form will automatically have a new field with the same name as the model field.

The template can be as simple as presenting {{form.as_p}} to the user.

Upvotes: 1

IDM
IDM

Reputation: 21

Depends on the access pattern of the model.

you could have a model1 for values, model2 for comments, one2one relation.

If you access one model more than the other you don't have to load and resolve text or varchar each time.

Upvotes: 0

Ipvikukiepki-KQS
Ipvikukiepki-KQS

Reputation: 137

One can also use django many-to-many field relationships to handle it, as specified in the documentation Django many-to-many. Since in winter there can be many temperature measurements with different timestamps, and vice-versa

from django.db import models
class Compound(models.Model):
    color = models.CharField(max_length=20, blank=True, default="")
    brand = models.CharField(max_length=200, blank=True, default="")
    temperature = models.FloatField(null=True, blank=True)
    melting_temp = models.FloatField(null=True, blank=True)
    # more (~20) especially numeric values as model fields

    class Meta:
        ordering = ['temperature']

    def __str__(self):
        return self.tempertaure

class Comment(models.Model):
    datetime = models.CharField(max_length=100)
    comment = models.ManyToManyField(Compound)

    class Meta:
        ordering = ['datetime']

    def __str__(self):
        return self.headline

One can use the python datetime time stamps as strings to save it along with the comments for every melting_temp

Upvotes: 0

vinkomlacic
vinkomlacic

Reputation: 1877

I think you're right by going with the 3rd approach. If every value needs to have a comment, than that value is kind of a complex field. The only issue is that it's going to make rendering kind of hard to do, but not too complex.

Also, I would go even further and use a one-to-many field between Compound and Value. One Compound has many values. Each of them has a name, value to be stored and a comment.

When dealing with a form for this, you can dynamically create a form in the __init__ method.

The issue might be that you lose the intended validation in your form, but maybe you can think of how to improve this. You could create different subtypes of Value, e.g. NumericValue and so on.

# models.py
class Compound(models.Model):

    def save(self, **kwargs)
        creating = self.pk is None
     
        super().save(**kwargs)

        if creating:
             # Initialize all values
             self.values.create(name='temperature')
             ...


class Value(models.Model):
    compound = models.ForeignKey(Compound, on_delete=models.CASCADE, related_name='values')
    # field name e.g. melting_temp
    name = models.CharField(max_length=255)  
    # verbose name e.g. Melting Temperature
    label = models.CharField(max_length=255, blank=True, null=True) 
    value = models.CharField(max_length=255, blank=True, null=True)
    note = models.TextField(blank=True, null=True)
# forms.py
class CompoundForm(forms.ModelForm):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # Create a field for each value
        for value in self.instance.values.all():
            # In this field, you enter the value
            self.fields[value.name] = forms.CharField(label=value.label)

            # In this field, you enter the comment (note)
            self.fields[f'{value.name}_note'] = forms.CharField(label='Note')
    
    def save(self, *args, **kwargs):
        instance = super().save(*args, **kwargs)
      
        # Save values
        # You can speed this up using Value.objects.bulk_update
        for value in instance.values.all():
            value.value = self.cleaned_data.get(value.name)
            value.note = self.cleaned_data.get(f'{value.name}_note')
            value.save()
        

Upvotes: 1

aaron
aaron

Reputation: 43108

The Pythonic way is to use a metaclass:

class CommentModelMeta(models.base.ModelBase):
    def __new__(mcs, name, bases, attrs, **kwargs):
        for attr_name, attr in list(attrs.items()):
            if isinstance(attr, models.Field):
                attrs[f'{attr_name}_comment'] = models.CharField(max_length=20, blank=True, default="")
        return super().__new__(mcs, name, bases, attrs, **kwargs)


class Compound(models.Model, metaclass=CommentModelMeta):
    color = models.CharField(max_length=20, blank=True, default="")
    ...

If you need code completion, you can use type hints:

class Compound(models.Model, metaclass=CommentModelMeta):
    color = models.CharField(max_length=20, blank=True, default="")
    brand = models.CharField(max_length=200, blank=True, default="")
    temperature = models.FloatField(null=True, blank=True)
    melting_temp = models.FloatField(null=True, blank=True)
    # more (~20) especially numeric values as model fields

    color_comment: models.CharField
    temperature_comment: models.CharField

Upvotes: 2

willeM_ Van Onsem
willeM_ Van Onsem

Reputation: 477608

You can make an abstract base model:

class CommentBase(models.Model):
    comment = models.CharField(
        max_length=128, blank=True, default=None, null=True
    )
    # …

    class Meta:
        abstract = True

Then you can mix that model in your models:

class Compound(CommentBase, models.Model):
    # …
    pass


class Other(CommentBase, models.Model):
    # ..
    pass


# …

It will automatically add the field(s) defined in CommentBase to the models that inherit from CommentBase.

Upvotes: 0

Related Questions