Skip to content

Commit

Permalink
fix ObjectId in forms
Browse files Browse the repository at this point in the history
  • Loading branch information
timgraham committed Dec 9, 2024
1 parent e8a0d96 commit f95b8b4
Show file tree
Hide file tree
Showing 9 changed files with 105 additions and 23 deletions.
1 change: 1 addition & 0 deletions .github/workflows/runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"force_insert_update",
"foreign_object",
"forms_tests",
"forms_tests_",
"from_db_value",
"generic_inline_admin",
"generic_relations",
Expand Down
20 changes: 13 additions & 7 deletions django_mongodb/fields/objectid.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
from django.db.models.fields import Field
from django.utils.translation import gettext_lazy as _

from django_mongodb import forms


class ObjectIdMixin:
default_error_messages = {
"invalid": _("“%(value)s” value must be an Object Id."),
"invalid": _("“%(value)s” is not a valid Object Id."),
}
description = _("Object Id")

Expand All @@ -17,12 +19,8 @@ def rel_db_type(self, connection):
return "objectId"

def get_prep_value(self, value):
if value is None or isinstance(value, ObjectId):
return value
try:
return ObjectId(value)
except (errors.InvalidId, TypeError) as e:
raise ValueError(f"Field '{self.name}' expected an ObjectId but got {value!r}.") from e
value = super().get_prep_value(value)
return self.to_python(value)

def to_python(self, value):
if value is None:
Expand All @@ -36,6 +34,14 @@ def to_python(self, value):
params={"value": value},
) from None

def formfield(self, **kwargs):
return super().formfield(
**{
"form_class": forms.ObjectIdField,
**kwargs,
}
)


class ObjectIdField(ObjectIdMixin, Field):
def deconstruct(self):
Expand Down
3 changes: 3 additions & 0 deletions django_mongodb/forms/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .fields import ObjectIdField

__all__ = ["ObjectIdField"]
27 changes: 27 additions & 0 deletions django_mongodb/forms/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from bson import ObjectId
from bson.errors import InvalidId
from django.core.exceptions import ValidationError
from django.forms import Field
from django.utils.translation import gettext_lazy as _


class ObjectIdField(Field):
default_error_messages = {
"invalid": _("Enter a valid Object Id."),
}

def prepare_value(self, value):
if isinstance(value, ObjectId):
return str(value)
return value

def to_python(self, value):
value = super().to_python(value)
if value in self.empty_values:
return None
if not isinstance(value, ObjectId):
try:
value = ObjectId(value)
except InvalidId:
raise ValidationError(self.error_messages["invalid"], code="invalid") from None
return value
13 changes: 13 additions & 0 deletions docs/source/forms.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Forms API reference
===================

.. module:: django_mongodb.forms

Some MongoDB-specific fields are available in ``django_mongodb.forms``.

``ObjectIdField``
-----------------

.. class:: ObjectIdField

Stores an :class:`~bson.objectid.ObjectId`.
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ django-mongodb 5.0.x documentation

fields
querysets
forms

Indices and tables
==================
Expand Down
Empty file added tests/forms_tests_/__init__.py
Empty file.
33 changes: 33 additions & 0 deletions tests/forms_tests_/test_objectidfield.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from bson import ObjectId
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase

from django_mongodb.forms.fields import ObjectIdField


class ObjectIdFieldTests(SimpleTestCase):
def test_clean(self):
field = ObjectIdField()
value = field.clean("675747ec45260945758d76bc")
self.assertEqual(value, ObjectId("675747ec45260945758d76bc"))

def test_clean_objectid(self):
field = ObjectIdField()
value = field.clean(ObjectId("675747ec45260945758d76bc"))
self.assertEqual(value, ObjectId("675747ec45260945758d76bc"))

def test_clean_empty_string(self):
field = ObjectIdField(required=False)
value = field.clean("")
self.assertEqual(value, None)

def test_clean_invalid(self):
field = ObjectIdField()
with self.assertRaises(ValidationError) as cm:
field.clean("invalid")
self.assertEqual(cm.exception.messages[0], "Enter a valid Object Id.")

def test_prepare_value(self):
field = ObjectIdField()
value = field.prepare_value(ObjectId("675747ec45260945758d76bc"))
self.assertEqual(value, "675747ec45260945758d76bc")
30 changes: 14 additions & 16 deletions tests/model_fields_/test_objectidfield.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase, TestCase

from django_mongodb import forms
from django_mongodb.fields import ObjectIdField

from .models import NullableObjectIdModel, ObjectIdModel, PrimaryKeyObjectIdModel
Expand All @@ -15,6 +16,10 @@ def test_deconstruct(self):
self.assertEqual(args, [])
self.assertEqual(kwargs, {})

def test_formfield(self):
f = ObjectIdField().formfield()
self.assertIsInstance(f, forms.ObjectIdField)

def test_get_internal_type(self):
f = ObjectIdField()
self.assertEqual(f.get_internal_type(), "ObjectIdField")
Expand All @@ -32,9 +37,9 @@ def test_to_python_null(self):

def test_to_python_invalid_value(self):
f = ObjectIdField()
for invalid_value in ["None", {}, [], 123]:
for invalid_value in ["None", "", {}, [], 123]:
with self.subTest(invalid_value=invalid_value):
msg = f"['“{invalid_value}value must be an Object Id.']"
msg = f"['“{invalid_value}is not a valid Object Id.']"
with self.assertRaisesMessage(ValidationError, msg):
f.to_python(invalid_value)

Expand All @@ -46,22 +51,16 @@ def test_get_prep_value_objectid(self):
value = ObjectId("1" * 24)
self.assertEqual(ObjectIdField().get_prep_value(value), value)

def test_get_prep_value_empty(self):
# This is necessary to allow an empty ObjectIdField to be saved in
# forms, unless we add an ObjectId form field to do the conversion (see
# UUIDField for an example).
self.assertIsNone(ObjectIdField().get_prep_value(""))

def test_get_prep_value_null(self):
self.assertIsNone(ObjectIdField().get_prep_value(None))

def test_get_prep_value_invalid_values(self):
f = ObjectIdField()
f.name = "test"
for invalid_value in ["None", {}, [], 123]:
for invalid_value in ["None", "", {}, [], 123]:
with self.subTest(invalid_value=invalid_value):
msg = f"Field 'test' expected an ObjectId but got {invalid_value!r}."
with self.assertRaisesMessage(ValueError, msg):
msg = f"['“{invalid_value}” is not a valid Object Id.']"
with self.assertRaisesMessage(ValidationError, msg):
f.get_prep_value(invalid_value)


Expand All @@ -82,17 +81,16 @@ def test_null_handling(self):
self.assertIsNone(loaded.field)

def test_pk_validated(self):
# See https://code.djangoproject.com/ticket/24859
with self.assertRaisesMessage(TypeError, "must be an Object Id."):
with self.assertRaisesMessage(ValidationError, "is not a valid Object Id."):
PrimaryKeyObjectIdModel.objects.get(pk={})

with self.assertRaisesMessage(TypeError, "must be an Object Id."):
with self.assertRaisesMessage(ValidationError, "is not a valid Object Id."):
PrimaryKeyObjectIdModel.objects.get(pk=[])

def test_wrong_value(self):
# Copied from UUID tests. Raises ValueError, might be okay.
with self.assertRaisesMessage(ValidationError, "must be an Object Id."):
with self.assertRaisesMessage(ValidationError, "is not a valid Object Id."):
ObjectIdModel.objects.get(field="not-a-objectid")

with self.assertRaisesMessage(ValidationError, "must be an Object Id."):
with self.assertRaisesMessage(ValidationError, "is not a valid Object Id."):
ObjectIdModel.objects.create(field="not-a-objectid")

0 comments on commit f95b8b4

Please sign in to comment.