artscoop / django-approval

Easy moderation of changes made to models. Django 3.2 and above, beta quality.
https://artscoop.github.io/django-approval/
MIT License
22 stars 1 forks source link

Object of type ManyRelatedManager not JSON Serializable. #10

Open josidridolfo opened 7 months ago

josidridolfo commented 7 months ago

Hi! First, thanks for making this library!

I'm having an issue with it and m2m models using postgres and psycopg.

Here are is a relevant set of models, one of which inherits the MonitoredModel and the other of which is the Approval that is in the Sandbox.

from django.db import models
import uuid
from suppliers.models import Supplier
from django.urls import reverse
from approval.models import MonitoredModel, Sandbox, SandboxMeta
from django.conf import settings

class Bid(MonitoredModel):
    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False
    )
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.DO_NOTHING,
        related_name="bids",
        null=True,
        blank=True,
    )
    is_visible = models.BooleanField(
        default=True,
        verbose_name="visible"
    )
    code = models.CharField(
        max_length=24,
        null=False,
        blank=False,
        default="BID Code",
        unique=True,
    )
    name = models.CharField(
        max_length=256,
        null=True,
        blank=False,
        default="BID Name",
    )
    suppliers_invited = models.ManyToManyField(
        Supplier,
        related_name="suppliers_invited",
        blank=True,
    )
    suppliers_responded = models.ManyToManyField(
        Supplier,
        related_name="suppliers_responded",
        blank=True,
        verbose_name="Suppliers who responded to the tender",
    )
    suppliers_technically_responsive = models.ManyToManyField(
        Supplier,
        related_name="suppliers_technically_responsive",
        blank=True,
    )
    suppliers_financially_responsive = models.ManyToManyField(
        Supplier,
        related_name="suppliers_financially_responsive",
        blank=True,
    )

    class Meta:
        ordering = ['code']
    def __str__(self):
        return self.code

    def get_absolute_url(self):
        return reverse('bid_details', kwargs={'pk': self.id})

class BidApproval(Sandbox, metaclass=SandboxMeta):
    base = Bid
    approval_fields = ['code', 'name',
                       'suppliers_invited',
                       'suppliers_responded',
                       'suppliers_technically_responsive',
                       'suppliers_financially_responsive',
                       ]
    approval_default = {'is_visible': False, }
    auto_approve_staff = False
    auto_approve_new = False
    auto_approve_by_request = False
    delete_on_approval = False

    def _get_authors(self):
        return [self.source.user]

Here is the output from the interactive shell when I create and try to save a Bid that inherits the MonitoredModel class. First, the relevant content:

>>> from bids.models import *
>>> test = Bid(name="test", code="test")
>>> all_bids = Bid.objects.all()
>>> print(all_bids)
<QuerySet []>
>>> test.save()
Traceback (most recent call last):
...
  File "/usr/local/lib/python3.12/site-packages/approval/listeners/monitored.py", line 37, in before_save
    instance.approval._update_sandbox()
  File "/usr/local/lib/python3.12/site-packages/approval/models/monitoring.py", line 215, in _update_sandbox
    self.save()
  File "/usr/local/lib/python3.12/site-packages/approval/models/monitoring.py", line 117, in save
    return super().save(**options)
           ^^^^^^^^^^^^^^^^^^^^^^^
...

Now, the full error message:

>>> from bids.models import *
>>> test = Bid(name="test", code="test")
>>> all_bids = Bid.objects.all()
>>> print(all_bids)
<QuerySet []>
>>> test.save()
Traceback (most recent call last):
  File "/usr/local/lib/python3.12/site-packages/django/db/backends/utils.py", line 114, in debug_sql
    yield
  File "/usr/local/lib/python3.12/site-packages/django/db/backends/utils.py", line 102, in execute
    return super().execute(sql, params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/django/db/backends/utils.py", line 67, in execute
    return self._execute_with_wrappers(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/django/db/backends/utils.py", line 80, in _execute_with_wrappers
    return executor(sql, params, many, context)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/django/db/backends/utils.py", line 89, in _execute
    return self.cursor.execute(sql, params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/psycopg/cursor.py", line 728, in execute
    self._conn.wait(
  File "/usr/local/lib/python3.12/site-packages/psycopg/connection.py", line 969, in wait
    return waiting.wait(gen, self.pgconn.socket, timeout=timeout)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "psycopg_binary/_psycopg/waiting.pyx", line 190, in psycopg_binary._psycopg.wait_c
  File "/usr/local/lib/python3.12/site-packages/psycopg/cursor.py", line 210, in _execute_gen
    pgq = self._convert_query(query, params)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/psycopg/client_cursor.py", line 79, in _convert_query
    pgq.convert(query, params)
  File "/usr/local/lib/python3.12/site-packages/psycopg/_queries.py", line 213, in convert
    self.dump(vars)
  File "/usr/local/lib/python3.12/site-packages/psycopg/_queries.py", line 223, in dump
    self.params = tuple(
                  ^^^^^^
  File "/usr/local/lib/python3.12/site-packages/psycopg/_queries.py", line 224, in <genexpr>
    self._tx.as_literal(p) if p is not None else b"NULL" for p in params
    ^^^^^^^^^^^^^^^^^^^^^^
  File "psycopg_binary/_psycopg/transform.pyx", line 206, in psycopg_binary._psycopg.Transformer.as_literal
  File "psycopg_binary/_psycopg/transform.pyx", line 215, in psycopg_binary._psycopg.Transformer.as_literal
  File "/usr/local/lib/python3.12/site-packages/psycopg/adapt.py", line 57, in quote
    value = self.dump(obj)
            ^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/psycopg/types/json.py", line 151, in dump
    data = dumps(obj)
           ^^^^^^^^^^
  File "/usr/local/lib/python3.12/json/__init__.py", line 238, in dumps
    **kw).encode(obj)
          ^^^^^^^^^^^
  File "/usr/local/lib/python3.12/json/encoder.py", line 200, in encode
    chunks = self.iterencode(o, _one_shot=True)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/json/encoder.py", line 258, in iterencode
    return _iterencode(o, 0)
           ^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/django/core/serializers/json.py", line 106, in default
    return super().default(o)
           ^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/json/encoder.py", line 180, in default
    raise TypeError(f'Object of type {o.__class__.__name__} '
**TypeError: Object of type ManyRelatedManager is not JSON serializable**

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/usr/local/lib/python3.12/site-packages/django/db/models/base.py", line 814, in save
    self.save_base(
  File "/usr/local/lib/python3.12/site-packages/django/db/models/base.py", line 861, in save_base
    pre_save.send(
  File "/usr/local/lib/python3.12/site-packages/django/dispatch/dispatcher.py", line 177, in send
    (receiver, receiver(signal=self, sender=sender, **named))
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  **_File "/usr/local/lib/python3.12/site-packages/approval/listeners/monitored.py", line 37, in before_save
    instance.approval._update_sandbox()
  File "/usr/local/lib/python3.12/site-packages/approval/models/monitoring.py", line 215, in _update_sandbox
    self.save()
  File "/usr/local/lib/python3.12/site-packages/approval/models/monitoring.py", line 117, in save
    return super().save(**options)
           ^^^^^^^^^^^^^^^^^^^^^^^_**
  File "/usr/local/lib/python3.12/site-packages/django/db/models/base.py", line 814, in save
    self.save_base(
  File "/usr/local/lib/python3.12/site-packages/django/db/models/base.py", line 877, in save_base
    updated = self._save_table(
              ^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/django/db/models/base.py", line 1020, in _save_table
    results = self._do_insert(
              ^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/django/db/models/base.py", line 1061, in _do_insert
    return manager._insert(
           ^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/django/db/models/manager.py", line 87, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/django/db/models/query.py", line 1805, in _insert
    return query.get_compiler(using=using).execute_sql(returning_fields)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/django/db/models/sql/compiler.py", line 1822, in execute_sql
    cursor.execute(sql, params)
  File "/usr/local/lib/python3.12/site-packages/django/db/backends/utils.py", line 101, in execute
    with self.debug_sql(sql, params, use_last_executed_query=True):
  File "/usr/local/lib/python3.12/contextlib.py", line 158, in __exit__
    self.gen.throw(value)
  File "/usr/local/lib/python3.12/site-packages/django/db/backends/utils.py", line 119, in debug_sql
    sql = self.db.ops.last_executed_query(self.cursor, sql, params)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/django/db/backends/postgresql/operations.py", line 301, in last_executed_query
    return self.compose_sql(sql, params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/django/db/backends/postgresql/operations.py", line 194, in compose_sql
    return mogrify(sql, params, self.connection)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/django/db/backends/postgresql/psycopg_any.py", line 22, in mogrify
    return ClientCursor(cursor.connection).mogrify(sql, params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/psycopg/client_cursor.py", line 40, in mogrify
    pgq = self._convert_query(query, params)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/psycopg/client_cursor.py", line 79, in _convert_query
    pgq.convert(query, params)
  File "/usr/local/lib/python3.12/site-packages/psycopg/_queries.py", line 213, in convert
    self.dump(vars)
  File "/usr/local/lib/python3.12/site-packages/psycopg/_queries.py", line 223, in dump
    self.params = tuple(
                  ^^^^^^
  File "/usr/local/lib/python3.12/site-packages/psycopg/_queries.py", line 224, in <genexpr>
    self._tx.as_literal(p) if p is not None else b"NULL" for p in params
    ^^^^^^^^^^^^^^^^^^^^^^
  File "psycopg_binary/_psycopg/transform.pyx", line 206, in psycopg_binary._psycopg.Transformer.as_literal
  File "psycopg_binary/_psycopg/transform.pyx", line 215, in psycopg_binary._psycopg.Transformer.as_literal
  File "/usr/local/lib/python3.12/site-packages/psycopg/adapt.py", line 57, in quote
    value = self.dump(obj)
            ^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/psycopg/types/json.py", line 151, in dump
    data = dumps(obj)
           ^^^^^^^^^^
  File "/usr/local/lib/python3.12/json/__init__.py", line 238, in dumps
    **kw).encode(obj)
          ^^^^^^^^^^^
  File "/usr/local/lib/python3.12/json/encoder.py", line 200, in encode
    chunks = self.iterencode(o, _one_shot=True)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/json/encoder.py", line 258, in iterencode
    return _iterencode(o, 0)
           ^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/django/core/serializers/json.py", line 106, in default
    return super().default(o)
           ^^^^^^^^^^^^^^^^^^
  **File "/usr/local/lib/python3.12/json/encoder.py", line 180, in default
    raise TypeError(f'Object of type {o.__class__.__name__} '
TypeError: Object of type ManyRelatedManager is not JSON serializable**

I'm unsure if this is an error that others are facing when trying to monitor models with m2m relations. Relevant contents of my Pipfile:

django = "==4.2.11"
psycopg = {extras = ["binary"] }
django-approval = "*"

Looks like the save() method in the DynamicSandbox class might be the culprit.

Is there a way to add a check to see if the model has any attributes that are m2m and if so, pass those fields off to separate methods that serialize and deserialize them by grabbing each related model's pk, storing them in a dictionary one-by-one (maybe changing the key so that it appends '-#' to the end of each key), adding that dictionary of related objects and their PKs into the JSON object, and then doing the reverse in the _update_source method?

Alternatively, if this is a known issue - or if this is something that might be unique to my setup - please let me know.

artscoop commented 6 months ago

Hello, The plugin is pretty young, and I realize that I forgot some considerations; Many-to-many fields are not stored in the model table, and the resulting intermediate table is just two foreign keys to model records.

If I had to support this kind of relationship (and not GenericForeignKey of course), I would have to make a special case for M2MRelatedManagers. I'll have some time today to try something, I'll keep you updated, but probably not quickly