KristianOellegaard / django-hvad

Painless translations in django, using the regular ORM. Integrates easily into existing projects and apps. Easy convertible from django-multilingual-ng.
https://github.com/KristianOellegaard/django-hvad
Other
533 stars 127 forks source link

M2M problem with admin #284

Open Alkalit opened 8 years ago

Alkalit commented 8 years ago

Hi, I have a tricky problem with m2m field and admin intergration. I want to make m2m field translatable. Let say I have a model Product:

class Product(TranslatableModel):
    translations = TranslatedFields(
        title=models.CharField(
            max_length=255,
            db_index=True
        )
    )

    specification_file = models.ManyToManyField(
        File,
        blank=True,
        through='ProductSpecification',
        related_name='file_products'
    )

and model ProductSpecification that looks like this:

class ProductSpecification(models.Model):
    product = models.ForeignKey(Product)
    specification = models.ForeignKey(
        File,
        related_name='spec_product_spec',
    )
    class Meta:
        unique_together = ['product', 'specification']

Now I want to make Product.specification_file translatable and I do:

class Product(TranslatableModel):
    translations = TranslatedFields(
        title=models.CharField(
            max_length=255,
            db_index=True
        ),

    specification_file = models.ManyToManyField(
        File,
        blank=True,
        through='ProductSpecification',
        related_name='file_products'
    ),
)

wich gives me:

ERRORS:
app.ProductSpecification: (fields.E336) The model is used as an intermediate model by 'app.ProductTranslation.specification_file', but it does not have a foreign key to 'ProductTranslation' or 'File

on makemigrations. Then I do

class ProductSpecification(models.Model):
    product = models.ForeignKey(ProductTranslation)

Wich is probably works, but when I go to admin page after migrations it gives me:

'app.ProductSpecification' has no ForeignKey to 'app.Product'.

Wich I dont know how to resolve.

Also admin.py (with some cuts)

class SpecificationAdmin(SortableTabularInline):
    model = ProductSpecification
    extra = 1

class ProductSetForm(TranslatableModelForm):
    class Meta:
        model = Product

class ProductAdmin(TranslatableAdmin):
    def __init__(self, *args, **kwargs):
        super(ProductAdmin, self).__init__(*args, **kwargs)
        self.save_as = True
        self.inlines = [ValAdmin, StdAdmin, SpecificationAdmin]

        self.exclude = ('values', 'specification_file',)
        self.fieldsets = (
            (None, {
                'fields': (
                    'title', 'price', 'tags', 'images',
                )
            }),
        )
        self.form = ProductSetForm

        self.filter_horizontal = ['specification_file', 'images', 'tags']

admin.site.register(Product, ProductAdmin)
spectras commented 8 years ago

Hello,

ManyToManyField are not directly supported as translated fields in hvad. There is no check, because some projects do use them, but this involves digging through some internals. The central paradigm of hvad is making a TranslatableModel behave like it is a regular model. Which means, from other models, translations are not normally visible as a separate entity, they are just drop-in replacements for field values.

There is inherent ambiguity in “translating a m2m field”. Depending on context, this could mean:

  1. you want the target object to be translatable. This is actually supported, it is a untranslated m2m field to a translatable model.
  2. you want the relation to change depending on the language. This breaks hvad paradigm, by making other models (the m2m target and the through model) aware of the existence of specific translations.
  3. you want the through model to have translatable fields. This is supported, as long as neither the forward nor the backward foreign keys are translatable.

If you are in case 2, what this actually means is you are not just adding translations to your database structure. Instead, your database actually interacts with language in a complex way, which you need to design by yourself. This goes beyond the scope of model translation packages that I know of, hvad included.

You may still use hvad to create the structure, but most of the syntactic sugar will be mostly useless for this specific relation. If you choose to follow that path, there are two ways you can achieve this:

1) Store a relation to the main model, and add a language field.

class ProductSpecification(models.Model):
    product = models.ForeignKey(Product)
    product_language = models.CharField(max_length=8)
    specification = models.ForeignKey(
        File,
        related_name='spec_product_spec',
    )
    class Meta:
        unique_together = ['product', 'product_language', 'specification']

Advantage: does not break hvad's paradigm. Drawback: database integrity cannot be strictly enforced. There may be ProductSpecification entries that reference an Product in a language it is not translated in. You'll have to check how your code reacts when this occurs.

2) Break through hvad abstraction layer to reference the translation directly. Basically what you did:

class ProductSpecification(models.Model):
    product_translation = models.ForeignKey(Product._meta.translations_model)
    specification = models.ForeignKey(
        File,
        related_name='spec_product_spec',
    )
    class Meta:
        unique_together = ['product_translation', 'specification']

(Notice how we create a foreign key to the translations model)

Advantage: integrity is guaranteed. Drawback: queries that come from the through model will bypass hvad. This means your code will have to know about hvad internals, which could mean harder upgrade path if they change in the future.

As you cannot use hvad's syntactic sugar, you'll have to know that the translation model has the following internal fields, which you will need to build your queries:

As you have seen, TranslatableAdmin will not handle those correctly, because the M2M relation actually points back to ProductTranslation not to Product. Therefore ProductSetForm(TranslatableModelForm) should actually be ProductTranslationSetForm(ModelForm). Depending on how you want it to work, you may want to filter the inline queryset so it only shows one language. You may also want to allow the creation of a full Product and not just new translations of existing products. You will also have to rule whether deleting an entry in one language should remove just that entry or the whole product.

There are numerous edge cases, and appropriate behaviors depend on application specifications. This is why it is out of the scope of a general module such as hvad. Still, if you do this and need help with the hvad side of the things, feel free to ask, I'll do my best.

Alkalit commented 8 years ago

Thank you for quick response. Under "make translatable" I just mean that, depending on language, Product should have its own set of files. For example:

product_en = Product.objects.language('en').get(id=1)
product_en.specification_file.all()
# [<File: file_name_1>, <File: file_name_2>]

product_ru = Product.objects.language('ru').get(id=1)
product_ru.specification_file.all()
# [<File: file_name_3>, <File: file_name_4>]
spectras commented 8 years ago

In this specific example, would it be correct to assume the language is not a “display language” like te on hvad uses, but is part of the properties of the specification file? It would seem logical, as a specification for another country is not just a matter of translating, but a completely different document.

In this case you could represent it like this:

class File(models.Model):
    # other fields
    applicable_language = models.CharField(max_length=10, db_index=True)

class Product(TranslatableModel):
    specification_file = models.ManyToManyField(File, blank=True, related_name='file_products')
    translations = TranslatedFields(
        title=models.CharField(max_length=255, db_index=True),
    )

You would then do this:

product_en = Product.objects.language('en').get(pk=1)
product_en.specification_file.filter(applicable_language='en')
# [<File: file_name_1>, <File: file_name_2>]

product_ru = Product.objects.language('ru').get(pk=1)
product_ru.specification_file.filter(applicable_language='ru')
# [<File: file_name_3>, <File: file_name_4>]

It might look like the language is repeated, but in fact it is not, because display language and applicable language are not the same thing. In fact, you could even do this:

product_en = Product.objects.language('en').get(pk=1)
product_en.specification_file.filter(applicable_language='ru')
# [<File: file_name_3>, <File: file_name_4>]

… this would make sense, for instance if you have a product manager who speaks English, yet wants to check which files are applicable for Russia.

Alkalit commented 8 years ago

Ok, so I solve this with adding language field on File

class File(models.Model):
    # other fields
    language_code = models.CharField(max_length=10, default='ru', db_index=True)

and then in admin:

class SpecificationAdmin(SortableTabularInline):
    model = ProductSpecification
    extra = 1

    def formfield_for_foreignkey(self, db_field, request, **kwargs):
        lang = get_language()

        if db_field.name == "specification":
            kwargs["queryset"] = File.objects.filter(language_code=lang)

        return super(SpecificationAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)

    def get_queryset(self, request):

        lang = get_language()

        qs = super(SpecificationAdmin, self).get_queryset(request)
        qs = qs.filter(specification__language_code=lang)

        return qs

PS: I also looked deeper into doc and didn't find any notes about improper m2m fields support. Maybe it should to note?

spectras commented 8 years ago

I'm glad you could solve your problem.

You're right, the explanation I wrote here should be included somewhere in the documentation. It's not that it's unsupported per se — you can actually create them. Rather, it's the inherent ambiguity in what they should mean that requires handling them explicitly and manually. Perhaps hvad should expose a bit more of its internals through a formal API as well. But there is the usual trade-off, the more it exposes, the less it can evolve and improve freely.