diff --git a/.evergreen/config.yml b/.evergreen/config.yml index f0b010b84..d59bfe079 100644 --- a/.evergreen/config.yml +++ b/.evergreen/config.yml @@ -68,7 +68,7 @@ tasks: - func: "run unit tests" buildvariants: - - name: tests-5-noauth-nossl + - name: tests-6-noauth-nossl display_name: Run Tests 6.0 NoAuth NoSSL run_on: rhel87-small expansions: @@ -79,7 +79,7 @@ buildvariants: tasks: - name: run-tests - - name: tests-5-auth-ssl + - name: tests-6-auth-ssl display_name: Run Tests 6.0 Auth SSL run_on: rhel87-small expansions: diff --git a/.github/workflows/mongodb_settings.py b/.github/workflows/mongodb_settings.py index f33d458ac..bdcc696cb 100644 --- a/.github/workflows/mongodb_settings.py +++ b/.github/workflows/mongodb_settings.py @@ -3,7 +3,7 @@ from django_mongodb_backend import parse_uri if mongodb_uri := os.getenv("MONGODB_URI"): - db_settings = parse_uri(mongodb_uri) + db_settings = parse_uri(mongodb_uri, db_name="dummy") # Workaround for https://github.com/mongodb-labs/mongo-orchestration/issues/268 if db_settings["USER"] and db_settings["PASSWORD"]: diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index 206076bc4..61021dc53 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -37,6 +37,7 @@ jobs: pre-publish: environment: release runs-on: ubuntu-latest + if: github.repository_owner == 'mongodb' || github.event_name == 'workflow_dispatch' permissions: id-token: write contents: write diff --git a/README.md b/README.md index cea132b0a..20a5ef543 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,15 @@ This backend is currently in development and is not advised for production workf changes may be made without notice. We welcome your feedback as we continue to explore and build. The best way to share this is via our [MongoDB Community Forum](https://www.mongodb.com/community/forums/tag/python). +## Index +* [Documentation](https://www.mongodb.com/docs/languages/python/django-mongodb/current/) +* [Getting Started](https://www.mongodb.com/docs/languages/python/django-mongodb/current/get-started/) +* [Model Your Data](https://www.mongodb.com/docs/languages/python/django-mongodb/current/model-data/) +* [Limitations & Future Work](https://www.mongodb.com/docs/languages/python/django-mongodb/current/limitations-upcoming/) + +The documentation in the "docs" directory is online at +https://django-mongodb-backend.readthedocs.io/en/latest/. + ## Install Use the version of `django-mongodb-backend` that corresponds to your version of @@ -20,8 +29,14 @@ $ pip install --pre django-mongodb-backend==5.0.* From your shell, run the following command to create a new Django project called `example` using our custom template. Make sure the zipfile referenced -at the end of the template link corresponds to your -version of Django. The snippet below specifies `5.0.x.zip` at the end of +at the end of the template link corresponds to your version of Django. + +You can check what version of Django you're using with: +```bash +$ django-admin --version +``` + +The snippet below specifies `5.0.x.zip` at the end of the template url to get the template for any Django version matching 5.0: ```bash @@ -31,24 +46,17 @@ $ django-admin startproject example --template https://github.com/mongodb-labs/d ### Connect to the database -Navigate to your `example/settings.py` file and find the variable named -`DATABASES` Replace the `DATABASES` variable with this: +Navigate to your `example/settings.py` file and replace the `DATABASES` +setting like so: ```python DATABASES = { - "default": django_mongodb_backend.parse_uri(""), + "default": django_mongodb_backend.parse_uri( + "", db_name="example" + ), } ``` -The MongoDB `` must also specify a database for the -`parse_uri` function to work. -If not already included, make sure you provide a value for `` -in your URI as shown in the example below: -```bash -mongodb+srv://myDatabaseUser:D1fficultP%40ssw0rd@cluster0.example.mongodb.net/?retryWrites=true&w=majority -``` - - ### Run the server To verify that you installed Django MongoDB Backend and correctly configured your project, run the following command from your project root: ```bash diff --git a/django_mongodb_backend/base.py b/django_mongodb_backend/base.py index a052a927b..e28fdc26b 100644 --- a/django_mongodb_backend/base.py +++ b/django_mongodb_backend/base.py @@ -3,6 +3,7 @@ from django.core.exceptions import ImproperlyConfigured from django.db.backends.base.base import BaseDatabaseWrapper +from django.utils.asyncio import async_unsafe from pymongo.collection import Collection from pymongo.driver_info import DriverInfo from pymongo.mongo_client import MongoClient @@ -172,6 +173,7 @@ def get_connection_params(self): **settings_dict["OPTIONS"], } + @async_unsafe def get_new_connection(self, conn_params): return MongoClient(**conn_params, driver=self._driver_info()) @@ -187,13 +189,15 @@ def _rollback(self): pass def set_autocommit(self, autocommit, force_begin_transaction_with_broken_autocommit=False): - pass + self.autocommit = autocommit + @async_unsafe def close(self): super().close() with contextlib.suppress(AttributeError): del self.database + @async_unsafe def cursor(self): return Cursor() diff --git a/django_mongodb_backend/features.py b/django_mongodb_backend/features.py index 303393b20..baa690ce1 100644 --- a/django_mongodb_backend/features.py +++ b/django_mongodb_backend/features.py @@ -34,7 +34,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): supports_temporal_subtraction = True # MongoDB stores datetimes in UTC. supports_timezones = False - # Not implemented: https://github.com/mongodb-labs/django-mongodb-backend/issues/7 + # Not implemented: https://github.com/mongodb/django-mongodb-backend/issues/7 supports_transactions = False supports_unspecified_pk = True uses_savepoints = False @@ -197,6 +197,8 @@ def django_test_expected_failures(self): "lookup.tests.LookupTests.test_in_ignore_none_with_unhashable_items", "m2m_through_regress.tests.ThroughLoadDataTestCase.test_sequence_creation", "many_to_many.tests.ManyToManyTests.test_add_remove_invalid_type", + "many_to_one.tests.ManyToOneTests.test_fk_to_smallautofield", + "many_to_one.tests.ManyToOneTests.test_fk_to_bigautofield", "migrations.test_operations.OperationTests.test_autofield__bigautofield_foreignfield_growth", "migrations.test_operations.OperationTests.test_model_with_bigautofield", "migrations.test_operations.OperationTests.test_smallfield_autofield_foreignfield_growth", @@ -205,6 +207,8 @@ def django_test_expected_failures(self): "model_fields.test_autofield.BigAutoFieldTests", "model_fields.test_autofield.SmallAutoFieldTests", "queries.tests.TestInvalidValuesRelation.test_invalid_values", + "schema.tests.SchemaTests.test_alter_autofield_pk_to_bigautofield_pk", + "schema.tests.SchemaTests.test_alter_autofield_pk_to_smallautofield_pk", }, "Converters aren't run on returning fields from insert.": { # Unsure this is needed for this backend. Can implement by request. @@ -223,6 +227,7 @@ def django_test_expected_failures(self): "queries.test_qs_combinators.QuerySetSetOperationTests.test_order_raises_on_non_selected_column", "queries.tests.RelatedLookupTypeTests.test_values_queryset_lookup", "queries.tests.ValuesSubqueryTests.test_values_in_subquery", + "sites_tests.tests.CreateDefaultSiteTests.test_no_site_id", }, "Cannot use QuerySet.delete() when querying across multiple collections on MongoDB.": { "admin_changelist.tests.ChangeListTests.test_distinct_for_many_to_many_at_second_level_in_search_fields", @@ -562,7 +567,7 @@ def django_test_expected_failures(self): "cache.tests.DBCacheWithTimeZoneTests", }, "FilteredRelation not supported.": { - # https://github.com/mongodb-labs/django-mongodb-backend/issues/157 + # https://github.com/mongodb/django-mongodb-backend/issues/157 "filtered_relation.tests.FilteredRelationAggregationTests", "filtered_relation.tests.FilteredRelationAnalyticalAggregationTests", "filtered_relation.tests.FilteredRelationTests", diff --git a/django_mongodb_backend/fields/auto.py b/django_mongodb_backend/fields/auto.py index 5bf84e67d..6a6f12af5 100644 --- a/django_mongodb_backend/fields/auto.py +++ b/django_mongodb_backend/fields/auto.py @@ -1,5 +1,3 @@ -from bson import ObjectId, errors -from django.core import exceptions from django.db.models.fields import AutoField from django.utils.functional import cached_property @@ -22,39 +20,13 @@ def deconstruct(self): return name, path, args, kwargs def get_prep_value(self, value): - if value is None: - return None - # Accept int for compatibility with Django's test suite which has many - # instances of manually assigned integer IDs, as well as for things - # like settings.SITE_ID which has a system check requiring an integer. - if isinstance(value, (ObjectId | int)): - return value - try: - return ObjectId(value) - except errors.InvalidId as e: - # A manually assigned integer ID? - if isinstance(value, str) and value.isdigit(): - return int(value) - raise ValueError(f"Field '{self.name}' expected an ObjectId but got {value!r}.") from e + # Override to omit super() which would call AutoField/IntegerField's + # implementation that requires value to be an integer. + return self.to_python(value) def get_internal_type(self): return "ObjectIdAutoField" - def to_python(self, value): - if value is None or isinstance(value, int): - return value - try: - return ObjectId(value) - except errors.InvalidId: - try: - return int(value) - except ValueError: - raise exceptions.ValidationError( - self.error_messages["invalid"], - code="invalid", - params={"value": value}, - ) from None - @cached_property def validators(self): # Avoid IntegerField validators inherited from AutoField. diff --git a/django_mongodb_backend/forms/fields/embedded_model.py b/django_mongodb_backend/forms/fields/embedded_model.py index b86e85e78..bbfa9c02c 100644 --- a/django_mongodb_backend/forms/fields/embedded_model.py +++ b/django_mongodb_backend/forms/fields/embedded_model.py @@ -20,9 +20,17 @@ def decompress(self, value): class EmbeddedModelBoundField(forms.BoundField): + def __init__(self, form, field, name, prefix_override=None): + super().__init__(form, field, name) + # prefix_override overrides the prefix in self.field.form_kwargs so + # that nested embedded model form elements have the correct name. + self.prefix_override = prefix_override + def __str__(self): """Render the model form as the representation for this field.""" form = self.field.model_form_cls(instance=self.value(), **self.field.form_kwargs) + if self.prefix_override: + form.prefix = self.prefix_override return mark_safe(f"{form.as_div()}") # noqa: S308 @@ -53,10 +61,21 @@ def compress(self, data_dict): return self.model_form._meta.model(**values) def get_bound_field(self, form, field_name): - return EmbeddedModelBoundField(form, self, field_name) + # Nested embedded model form fields need a double prefix. + prefix_override = f"{form.prefix}-{self.model_form.prefix}" if form.prefix else None + return EmbeddedModelBoundField(form, self, field_name, prefix_override) def bound_data(self, data, initial): if self.disabled: return initial # Transform the bound data into a model instance. return self.compress(data) + + def prepare_value(self, value): + # When rendering a form with errors, nested EmbeddedModelField data + # won't be compressed if MultiValueField.clean() raises ValidationError + # error before compress() is called. The data must be compressed here + # so that EmbeddedModelBoundField.value() returns a model instance + # (rather than a list) for initializing the form in + # EmbeddedModelBoundField.__str__(). + return self.compress(value) if isinstance(value, list) else value diff --git a/django_mongodb_backend/routers.py b/django_mongodb_backend/routers.py new file mode 100644 index 000000000..60e54bbd8 --- /dev/null +++ b/django_mongodb_backend/routers.py @@ -0,0 +1,18 @@ +from django.apps import apps + +from django_mongodb_backend.models import EmbeddedModel + + +class MongoRouter: + def allow_migrate(self, db, app_label, model_name=None, **hints): + """ + EmbeddedModels don't have their own collection and must be ignored by + dumpdata. + """ + if not model_name: + return None + try: + model = apps.get_model(app_label, model_name) + except LookupError: + return None + return False if issubclass(model, EmbeddedModel) else None diff --git a/django_mongodb_backend/utils.py b/django_mongodb_backend/utils.py index c389d93bc..95e3b5a8e 100644 --- a/django_mongodb_backend/utils.py +++ b/django_mongodb_backend/utils.py @@ -28,7 +28,7 @@ def check_django_compatability(): ) -def parse_uri(uri, conn_max_age=0, test=None): +def parse_uri(uri, *, db_name=None, conn_max_age=0, test=None): """ Convert the given uri into a dictionary suitable for Django's DATABASES setting. @@ -45,9 +45,12 @@ def parse_uri(uri, conn_max_age=0, test=None): host, port = nodelist[0] elif len(nodelist) > 1: host = ",".join([f"{host}:{port}" for host, port in nodelist]) + db_name = db_name or uri["database"] + if not db_name: + raise ImproperlyConfigured("You must provide the db_name parameter.") settings_dict = { "ENGINE": "django_mongodb_backend", - "NAME": uri["database"], + "NAME": db_name, "HOST": host, "PORT": port, "USER": uri.get("username"), @@ -55,6 +58,8 @@ def parse_uri(uri, conn_max_age=0, test=None): "OPTIONS": uri.get("options"), "CONN_MAX_AGE": conn_max_age, } + if "authSource" not in settings_dict["OPTIONS"] and uri["database"]: + settings_dict["OPTIONS"]["authSource"] = uri["database"] if test: settings_dict["TEST"] = test return settings_dict diff --git a/docs/source/conf.py b/docs/source/conf.py index 55bb84e14..183c4bbf2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -16,8 +16,8 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.append(str((Path(__file__).parent / "_ext").resolve())) -project = "django_mongodb_backend" -copyright = "2024, The MongoDB Python Team" +project = "Django MongoDB Backend" +copyright = "2025, The MongoDB Python Team" author = "The MongoDB Python Team" release = _version("django_mongodb_backend") @@ -39,12 +39,15 @@ intersphinx_mapping = { "django": ( "https://docs.djangoproject.com/en/5.0/", - "http://docs.djangoproject.com/en/5.0/_objects/", + "https://docs.djangoproject.com/en/5.0/_objects/", ), + "mongodb": ("https://www.mongodb.com/docs/languages/python/django-mongodb/v5.0/", None), "pymongo": ("https://pymongo.readthedocs.io/en/stable/", None), "python": ("https://docs.python.org/3/", None), } +root_doc = "contents" + # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output diff --git a/docs/source/contents.rst b/docs/source/contents.rst new file mode 100644 index 000000000..6a102569f --- /dev/null +++ b/docs/source/contents.rst @@ -0,0 +1,25 @@ +================= +Table of contents +================= + +.. toctree:: + :hidden: + + index + +.. toctree:: + :maxdepth: 2 + + intro/index + topics/index + ref/index + howto/index + faq + releases/index + internals + +Indices +======= + +* :ref:`genindex` +* :ref:`modindex` diff --git a/docs/source/faq.rst b/docs/source/faq.rst new file mode 100644 index 000000000..1ea0c2b48 --- /dev/null +++ b/docs/source/faq.rst @@ -0,0 +1,41 @@ +=== +FAQ +=== + +This page contains a list of some frequently asked questions. + +Troubleshooting +=============== + +Debug logging +------------- + +To troubleshoot MongoDB connectivity issues, you can enable :doc:`PyMongo's +logging ` using :doc:`Django's LOGGING setting +`. + +This is a minimal :setting:`LOGGING` setting that enables PyMongo's ``DEBUG`` +logging:: + + LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "loggers": { + "pymongo": { + "handlers": ["console"], + "level": "DEBUG", + }, + }, + } + +``dumpdata`` fails with ``CommandError: Unable to serialize database`` +---------------------------------------------------------------------- + +If running ``manage.py dumpdata`` results in ``CommandError: Unable to +serialize database: 'EmbeddedModelManager' object has no attribute using'``, +see :ref:`configuring-database-routers-setting`. diff --git a/docs/source/howto/contrib-apps.rst b/docs/source/howto/contrib-apps.rst new file mode 100644 index 000000000..7f4a0a01b --- /dev/null +++ b/docs/source/howto/contrib-apps.rst @@ -0,0 +1,26 @@ +================================= +Configuring Django's contrib apps +================================= + +Generally, Django's contribs app work out of the box, but here are some +required adjustments. + +``contrib.sites`` +================= + +Usually the :doc:`sites framework ` requires the +:setting:`SITE_ID` setting to be an integer corresponding to the primary key of +the :class:`~django.contrib.sites.models.Site` object. For MongoDB, however, +all primary keys are :class:`~bson.objectid.ObjectId`\s, and so +:setting:`SITE_ID` must be set accordingly:: + + from bson import ObjectId + + SITE_ID = ObjectId("000000000000000000000001") + +You must also use the :setting:`SILENCED_SYSTEM_CHECKS` setting to suppress +Django's system check requiring :setting:`SITE_ID` to be an integer:: + + SILENCED_SYSTEM_CHECKS = [ + "sites.E101", # SITE_ID must be an ObjectId for MongoDB. + ] diff --git a/docs/source/howto/index.rst b/docs/source/howto/index.rst new file mode 100644 index 000000000..95d7ef632 --- /dev/null +++ b/docs/source/howto/index.rst @@ -0,0 +1,13 @@ +============= +How-to guides +============= + +Practical guides covering common tasks and problems. + +Project configuration +===================== + +.. toctree:: + :maxdepth: 1 + + contrib-apps diff --git a/docs/source/index.rst b/docs/source/index.rst index 4f80bed6f..b39f574bd 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,19 +1,55 @@ -django-mongodb-backend 5.0.x documentation -========================================== +====================== +Django MongoDB Backend +====================== -.. toctree:: - :maxdepth: 1 - :caption: Contents: +version 5.0.x for Django 5.0.x - fields - querysets - forms - models - embedded-models +.. rubric:: Everything you need to know about Django MongoDB Backend. -Indices and tables -================== +First steps +=========== -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +**Getting started:** + +- :doc:`Installation ` +- :doc:`Configuring a project ` +- :doc:`howto/contrib-apps` + +Getting help +============ + +Having trouble? We’d like to help! + +- Try the :doc:`faq` – it’s got answers to many common questions. + +- Looking for specific information? Try the :ref:`genindex`, :ref:`modindex`, + or the detailed :doc:`table of contents `. + +- Didn't find an answer? You're welcome to ask questions or give feedback on + the `MongoDB Community Forum `_. + +- Report bugs and request features in our :ref:`issue tracker `. + +Models +====== + +**Reference material:** + +- :doc:`ref/models/fields` +- :doc:`ref/models/querysets` +- :doc:`ref/models/models` + +**Topic guides:** + +- :doc:`topics/embedded-models` + +Forms +===== + +- :doc:`ref/forms` + +Miscellaneous +============= + +- :doc:`releases/index` +- :doc:`internals` diff --git a/docs/source/internals.rst b/docs/source/internals.rst new file mode 100644 index 000000000..6403bcbec --- /dev/null +++ b/docs/source/internals.rst @@ -0,0 +1,25 @@ +================= +Project internals +================= + +Documentation for people working on Django MongoDB Backend itself. This is the +place to go if you'd like to help improve Django MongoDB Backend or learn about +how the project is managed. + +.. _issue-tracker: + +Issue tracker +============= + +To report a bug or to request a new feature in Django MongoDB Backend, please +open an issue in our issue tracker, JIRA: + +1. Create a `JIRA account `_. + +2. Navigate to the `Python Integrations project + `_. + +3. Click **Create Issue**. Please provide as much information as possible about + the issue and the steps to reproduce it. + +Bug reports in JIRA for this project can be viewed by everyone. diff --git a/docs/source/intro/configure.rst b/docs/source/intro/configure.rst new file mode 100644 index 000000000..261ef89a5 --- /dev/null +++ b/docs/source/intro/configure.rst @@ -0,0 +1,176 @@ +=================================================== +Configuring a project to use Django MongoDB Backend +=================================================== + +Aftering :doc:`installing Django MongoDB Backend `, you must take some +additional steps to configure your project. + +.. _specifying the-default-pk-field: + +Specifying the default primary key field +======================================== + +In your Django settings, you must specify that all models should use +:class:`~django_mongodb_backend.fields.ObjectIdAutoField`. + +You can create a new project that's configured based on these steps using a +project template: + +.. code-block:: bash + + $ django-admin startproject mysite --template https://github.com/mongodb-labs/django-mongodb-project/archive/refs/heads/5.0.x.zip + +(If you're using a version of Django other than 5.0.x, replace the two numbers +to match the first two numbers from your version.) + +This template includes the following line in ``settings.py``:: + + DEFAULT_AUTO_FIELD = "django_mongodb_backend.fields.ObjectIdAutoField" + +But this setting won't override any apps that have an ``AppConfig`` that +specifies :attr:`~django.apps.AppConfig.default_auto_field`. For those apps, +you'll need to create a custom :class:`~django.apps.AppConfig`. + +For example, the project template includes ``/apps.py``:: + + from django.contrib.admin.apps import AdminConfig + from django.contrib.auth.apps import AuthConfig + from django.contrib.contenttypes.apps import ContentTypesConfig + + + class MongoAdminConfig(AdminConfig): + default_auto_field = "django_mongodb_backend.fields.ObjectIdAutoField" + + + class MongoAuthConfig(AuthConfig): + default_auto_field = "django_mongodb_backend.fields.ObjectIdAutoField" + + + class MongoContentTypesConfig(ContentTypesConfig): + default_auto_field = "django_mongodb_backend.fields.ObjectIdAutoField" + +Each app reference in the :setting:`INSTALLED_APPS` setting must point to the +corresponding ``AppConfig``. For example, instead of ``'django.contrib.admin'``, +the template uses ``'.apps.MongoAdminConfig'``. + +Configuring migrations +====================== + +Because all models must use +:class:`~django_mongodb_backend.fields.ObjectIdAutoField`, each third-party +and contrib app you use needs to have its own migrations specific to MongoDB. + +For example, ``settings.py`` in the project template specifies:: + + MIGRATION_MODULES = { + "admin": "mongo_migrations.admin", + "auth": "mongo_migrations.auth", + "contenttypes": "mongo_migrations.contenttypes", + } + +The project template includes these migrations, but you can generate them if +you're setting things up manually or if you need to create migrations for +third-party apps. For example: + +.. code-block:: bash + + $ python manage.py makemigrations admin auth contenttypes + Migrations for 'admin': + mongo_migrations/admin/0001_initial.py + - Create model LogEntry + ... + +Creating Django applications +============================ + +Whenever you run ``python manage.py startapp``, you must remove the line:: + + default_auto_field = 'django.db.models.BigAutoField' + +from the new application's ``apps.py`` file (or change it to reference +``"django_mongodb_backend.fields.ObjectIdAutoField"``). + +Alternatively, you can use the following :djadmin:`startapp` template which +includes this change: + +.. code-block:: bash + + $ python manage.py startapp myapp --template https://github.com/mongodb-labs/django-mongodb-app/archive/refs/heads/5.0.x.zip + +(If you're using a version of Django other than 5.0.x, replace the two numbers +to match the first two numbers from your version.) + +.. _configuring-databases-setting: + +Configuring the ``DATABASES`` setting +===================================== + +After you've set up a project, configure Django's :setting:`DATABASES` setting +similar to this:: + + DATABASES = { + "default": { + "ENGINE": "django_mongodb_backend", + "HOST": "mongodb+srv://cluster0.example.mongodb.net", + "NAME": "my_database", + "USER": "my_user", + "PASSWORD": "my_password", + "PORT": 27017, + "OPTIONS": { + # Example: + "retryWrites": "true", + "w": "majority", + "tls": "false", + }, + }, + } + +For a localhost configuration, you can omit :setting:`HOST` or specify +``"HOST": "localhost"``. + +:setting:`HOST` only needs a scheme prefix for SRV connections +(``mongodb+srv://``). A ``mongodb://`` prefix is never required. + +:setting:`OPTIONS` is an optional dictionary of parameters that will be passed +to :class:`~pymongo.mongo_client.MongoClient`. + +Specify :setting:`USER` and :setting:`PASSWORD` if your database requires +authentication. + +:setting:`PORT` is optional if unchanged from MongoDB's default of 27017. + +For a replica set or sharded cluster where you have multiple hosts, include +all of them in :setting:`HOST`, e.g. +``"mongodb://mongos0.example.com:27017,mongos1.example.com:27017"``. + +Alternatively, if you prefer to simply paste in a MongoDB URI rather than parse +it into the format above, you can use +:func:`~django_mongodb_backend.utils.parse_uri`:: + + import django_mongodb_backend + + MONGODB_URI = "mongodb+srv://my_user:my_password@cluster0.example.mongodb.net/myDatabase?retryWrites=true&w=majority&tls=false" + DATABASES["default"] = django_mongodb_backend.parse_uri(MONGODB_URI) + +This constructs a :setting:`DATABASES` setting equivalent to the first example. + +.. _configuring-database-routers-setting: + +Configuring the ``DATABASE_ROUTERS`` setting +============================================ + +If you intend to use :doc:`embedded models `, you must +configure the :setting:`DATABASE_ROUTERS` setting so that a collection for +these models isn't created and so that embedded models won't be treated as +normal models by :djadmin:`dumpdata`:: + + DATABASE_ROUTERS = ["django_mongodb_backend.routers.MongoRouter"] + +(If you've used the :djadmin:`startproject` template, this line is already +present.) + +Congratulations, your project is ready to go! + +.. seealso:: + + :doc:`/howto/contrib-apps` diff --git a/docs/source/intro/index.rst b/docs/source/intro/index.rst new file mode 100644 index 000000000..30bcbbc52 --- /dev/null +++ b/docs/source/intro/index.rst @@ -0,0 +1,26 @@ +=============== +Getting started +=============== + +New to Django or MongoDB? Well, you came to the right place: read this material +to quickly get up and running. + +.. toctree:: + :maxdepth: 1 + + install + configure + +.. seealso:: + + If you're new to Django_, you might want to start by getting an idea of + what it's like. + + For a tutorial that covers using this library with MongoDB Atlas (a fully + managed cloud database service that hosts MongoDB), read + :doc:`mongodb:get-started`. + + Another excellent tutorial that covers Django in more detail is the + :doc:`official Django tutorial `. + + .. _Django: https://www.djangoproject.org/ diff --git a/docs/source/intro/install.rst b/docs/source/intro/install.rst new file mode 100644 index 000000000..5348374c0 --- /dev/null +++ b/docs/source/intro/install.rst @@ -0,0 +1,17 @@ +================================= +Installing Django MongoDB Backend +================================= + +Use the version of ``django-mongodb-backend`` that corresponds to your version +of Django. For example, to get the latest compatible release for Django 5.0.x: + +.. code-block:: bash + + $ pip install --pre django-mongodb-backend==5.0.* + +(Until the package is out of beta, you must use pip's ``--pre`` option.) + +The minor release number of Django doesn't correspond to the minor release +number of ``django-mongodb-backend``. Use the latest minor release of each. + +Next, you'll have to :doc:`configure your project `. diff --git a/docs/source/forms.rst b/docs/source/ref/forms.rst similarity index 100% rename from docs/source/forms.rst rename to docs/source/ref/forms.rst diff --git a/docs/source/ref/index.rst b/docs/source/ref/index.rst new file mode 100644 index 000000000..08fac9240 --- /dev/null +++ b/docs/source/ref/index.rst @@ -0,0 +1,10 @@ +============= +API reference +============= + +.. toctree:: + :maxdepth: 2 + + models/index + forms + utils diff --git a/docs/source/fields.rst b/docs/source/ref/models/fields.rst similarity index 97% rename from docs/source/fields.rst rename to docs/source/ref/models/fields.rst index d8f46297e..83bd48480 100644 --- a/docs/source/fields.rst +++ b/docs/source/ref/models/fields.rst @@ -248,7 +248,7 @@ Stores a model of type ``embedded_model``. class Book(models.Model): author = EmbeddedModelField(Author) -See :doc:`embedded-models` for more details and examples. +See :doc:`/topics/embedded-models` for more details and examples. .. admonition:: Migrations support is limited @@ -260,6 +260,14 @@ See :doc:`embedded-models` for more details and examples. created these models and then added an indexed field to ``Address``, the index created in the nested ``Book`` embed is not created. +``ObjectIdAutoField`` +--------------------- + +.. class:: ObjectIdAutoField + +This field is typically the default primary key field for all models stored in +MongoDB. See :ref:`specifying the-default-pk-field`. + ``ObjectIdField`` ----------------- diff --git a/docs/source/ref/models/index.rst b/docs/source/ref/models/index.rst new file mode 100644 index 000000000..d5cb63ce1 --- /dev/null +++ b/docs/source/ref/models/index.rst @@ -0,0 +1,12 @@ +====== +Models +====== + +Model API reference. + +.. toctree:: + :maxdepth: 1 + + fields + querysets + models diff --git a/docs/source/models.rst b/docs/source/ref/models/models.rst similarity index 81% rename from docs/source/models.rst rename to docs/source/ref/models/models.rst index d0d200547..b85505bdb 100644 --- a/docs/source/models.rst +++ b/docs/source/ref/models/models.rst @@ -7,8 +7,8 @@ One MongoDB-specific model is available in ``django_mongodb_backend.models``. .. class:: EmbeddedModel -An abstract model which all :doc:`embedded models ` must -subclass. +An abstract model which all :doc:`embedded models ` +must subclass. Since these models are not stored in their own collection, they do not have any of the normal ``QuerySet`` methods (``all()``, ``filter()``, ``delete()``, diff --git a/docs/source/querysets.rst b/docs/source/ref/models/querysets.rst similarity index 63% rename from docs/source/querysets.rst rename to docs/source/ref/models/querysets.rst index 4c024c773..e8a7afc5a 100644 --- a/docs/source/querysets.rst +++ b/docs/source/ref/models/querysets.rst @@ -1,6 +1,43 @@ +========================== ``QuerySet`` API reference ========================== +Supported ``QuerySet`` methods +============================== + +All of Django's :doc:`QuerySet methods ` are +supported, except: + + - :meth:`bulk_update() ` + - :meth:`dates() ` + - :meth:`datetimes() ` + - :meth:`distinct() ` + - :meth:`extra() ` + - :meth:`prefetch_related() ` + +In addition, :meth:`QuerySet.delete() ` +and :meth:`update() ` do not support +queries that span multiple collections. + +``QuerySet.explain()`` +====================== + +- :meth:`QuerySet.explain() ` supports + the `comment and verbosity options + `_. + + Example:: + + Model.objects.explain(comment="...", verbosity="...") + + Valid values for ``verbosity`` are ``"queryPlanner"`` (default), + ``"executionStats"``, and ``"allPlansExecution"``. + +MongoDB-specific ``QuerySet`` methods +===================================== + +.. class:: django_mongodb_backend.managers.MongoManager + Some MongoDB-specific ``QuerySet`` methods are available by adding a custom :class:`~django.db.models.Manager`, ``MongoManager``, to your model:: diff --git a/docs/source/ref/utils.rst b/docs/source/ref/utils.rst new file mode 100644 index 000000000..a5fb8ff3e --- /dev/null +++ b/docs/source/ref/utils.rst @@ -0,0 +1,41 @@ +=================== +Utils API reference +=================== + +.. module:: django_mongodb_backend.utils + :synopsis: Built-in utilities. + +This document covers the public API parts of ``django_mongodb_backend.utils``. +Most of the module's contents are designed for internal use and only the +following parts can be considered stable. + +``parse_uri()`` +=============== + +.. function:: parse_uri(uri, db_name=None, conn_max_age=0, test=None) + +Parses a MongoDB `connection string`_ into a dictionary suitable for Django's +:setting:`DATABASES` setting. + +.. _connection string: https://www.mongodb.com/docs/manual/reference/connection-string/ + +Example:: + + import django_mongodb_backend + + MONGODB_URI = "mongodb+srv://my_user:my_password@cluster0.example.mongodb.net/defaultauthdb?retryWrites=true&w=majority&tls=false" + DATABASES["default"] = django_mongodb_backend.parse_uri(MONGODB_URI, db_name="example") + +You must specify ``db_name`` (the :setting:`NAME` of your database) if the URI +doesn't specify ``defaultauthdb``. + +You can use the parameters to customize the resulting :setting:`DATABASES` +setting: + +- Use ``conn_max_age`` to configure :ref:`persistent database connections + `. +- Use ``test`` to provide a dictionary of settings for test databases in the + format of :setting:`TEST `. + +But for maximum flexibility, construct :setting:`DATABASES` manually as +described in :ref:`configuring-databases-setting`. diff --git a/docs/source/releases/5.0.x.rst b/docs/source/releases/5.0.x.rst new file mode 100644 index 000000000..c27c5f39f --- /dev/null +++ b/docs/source/releases/5.0.x.rst @@ -0,0 +1,29 @@ +============================ +Django MongoDB Backend 5.0.x +============================ + +5.0.0 beta 1 +============ + +*February 26, 2025* + +- Backward-incompatible: + :class:`~django_mongodb_backend.fields.ObjectIdAutoField` no longer accepts + integer values. The undocumented behavior eased testing with Django's test + suite which hardcodes many integer primary key values. +- Fixed the inability to save nested embedded model forms. +- Fixed :ref:`persistent database connections + `. +- Added :doc:`async ` support. +- Added the ``db_name`` parameter to + :func:`~django_mongodb_backend.utils.parse_uri`. +- Added ``django_mongodb_backend.routers.MongoRouter`` to allow + :djadmin:`dumpdata` to ignore embedded models. See + :ref:`configuring-database-routers-setting`. + +5.0.0 beta 0 +============ + +*January 29, 2025* + +Initial Public Preview release. diff --git a/docs/source/releases/index.rst b/docs/source/releases/index.rst new file mode 100644 index 000000000..391709840 --- /dev/null +++ b/docs/source/releases/index.rst @@ -0,0 +1,14 @@ +============= +Release notes +============= + +The release notes will tell you what's new in each version and will also +describe any backwards-incompatible changes. + +Below are release notes through Django MongoDB backend 5.0.x. Newer versions of +the documentation contain the release notes for any later releases. + +.. toctree:: + :maxdepth: 1 + + 5.0.x diff --git a/docs/source/embedded-models.rst b/docs/source/topics/embedded-models.rst similarity index 100% rename from docs/source/embedded-models.rst rename to docs/source/topics/embedded-models.rst diff --git a/docs/source/topics/index.rst b/docs/source/topics/index.rst new file mode 100644 index 000000000..63ff9a250 --- /dev/null +++ b/docs/source/topics/index.rst @@ -0,0 +1,12 @@ +============================ +Using Django MongoDB Backend +============================ + +Introductions to some of the key parts of Django MongoDB Backend you'll need to +know: + +.. toctree:: + :maxdepth: 2 + + embedded-models + known-issues diff --git a/docs/source/topics/known-issues.rst b/docs/source/topics/known-issues.rst new file mode 100644 index 000000000..005727868 --- /dev/null +++ b/docs/source/topics/known-issues.rst @@ -0,0 +1,102 @@ +============================ +Known issues and limitations +============================ + +This document summarizes some known issues and limitations of this library. +If you notice an issue not listed, use the :ref:`issue-tracker` to report a bug +or request a feature. + +Like any database, MongoDB has some particularities. Also keep in mind that +because MongoDB is a NoSQL database, it's impossible to implement SQL-specific +functionality. + +Model fields +============ + +- :class:`~django.db.models.DateTimeField` is limited to millisecond precision + (rather than microsecond like most other databases), and correspondingly, + :class:`~django.db.models.DurationField` stores milliseconds rather than + microseconds. + +- Some of Django's built-in fields aren't supported by MongoDB: + + - :class:`~django.db.models.AutoField` (including + :class:`~django.db.models.BigAutoField` and + :class:`~django.db.models.SmallAutoField`) + - :class:`~django.db.models.GeneratedField` + +Querying +======== + +- The following ``QuerySet`` methods aren't supported: + + - :meth:`bulk_update() ` + - :meth:`dates() ` + - :meth:`datetimes() ` + - :meth:`distinct() ` + - :meth:`extra() ` + - :meth:`prefetch_related() ` + +- :meth:`QuerySet.delete() ` and + :meth:`update() ` do not support queries + that span multiple collections. + +- When querying :class:`~django.db.models.JSONField`: + + - There is no way to distinguish between a JSON ``"null"`` (represented by + ``Value(None, JSONField())``) and a SQL ``null`` (queried using the + :lookup:`isnull` lookup). Both of these queries return both of these nulls. + - Some queries with ``Q`` objects, e.g. ``Q(value__foo="bar")``, don't work + properly, particularly with ``QuerySet.exclude()``. + - Filtering for a ``None`` key, e.g. ``QuerySet.filter(value__j=None)`` + incorrectly returns objects where the key doesn't exist. + - You can study the skipped tests in ``DatabaseFeatures.django_test_skips`` + for more details on known issues. + +Database functions +================== + +- Some of Django's built-in database functions aren't supported by MongoDB: + + - :class:`~django.db.models.functions.Chr` + - :class:`~django.db.models.functions.ExtractQuarter` + - :class:`~django.db.models.functions.LPad`, + :class:`~django.db.models.functions.RPad` + - :class:`~django.db.models.functions.MD5` + - :class:`~django.db.models.functions.Now` + - :class:`~django.db.models.functions.Ord` + - :class:`~django.db.models.functions.Repeat` + - :class:`~django.db.models.functions.Reverse` + - :class:`~django.db.models.functions.Right` + - :class:`~django.db.models.functions.SHA1`, + :class:`~django.db.models.functions.SHA224`, + :class:`~django.db.models.functions.SHA256`, + :class:`~django.db.models.functions.SHA384`, + :class:`~django.db.models.functions.SHA512` + - :class:`~django.db.models.functions.Sign` + +- The ``tzinfo`` parameter of the :class:`~django.db.models.functions.Trunc` + database functions doesn't work properly because MongoDB converts the result + back to UTC. + +Transaction management +====================== + +Query execution uses Django and MongoDB's default behavior of autocommit mode. +Each query is immediately committed to the database. + +Django's :doc:`transaction management APIs ` +are not supported. + +Database introspection +====================== + +Due to the lack of ability to introspect MongoDB collection schema, +:djadmin:`inspectdb` and :option:`migrate --fake-initial` aren't supported. + +Caching +======= + +:ref:`Database caching ` is not supported since the built-in +database cache backend requires SQL. A custom cache backend for MongoDB will be +provided in the future. diff --git a/pyproject.toml b/pyproject.toml index 676504748..65a377d71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,8 @@ docs = [ "sphinx>=7"] [project.urls] Homepage = "https://www.mongodb.org" Documentation = "https://django-mongodb-backend.readthedocs.io" -Source = "https://github.com/mongodb-labs/django-mongodb-backend" -Tracker = "https://github.com/mongodb-labs/django-mongodb-backend/issues" +Source = "https://github.com/mongodb/django-mongodb-backend" +Tracker = "https://github.com/mongodb/django-mongodb-backend/issues" [tool.hatch.version] path = "django_mongodb_backend/__init__.py" @@ -119,3 +119,6 @@ partial_branches = ["if (.*and +)*not _use_c( and.*)*:"] [tool.coverage.html] directory = "htmlcov" + +[tool.rstcheck] +report_level = "WARNING" diff --git a/tests/backend_/test_base.py b/tests/backend_/test_base.py index 689c6dde7..2ce48bbeb 100644 --- a/tests/backend_/test_base.py +++ b/tests/backend_/test_base.py @@ -1,6 +1,6 @@ from django.core.exceptions import ImproperlyConfigured from django.db import connection -from django.test import SimpleTestCase +from django.test import SimpleTestCase, TestCase from django_mongodb_backend.base import DatabaseWrapper @@ -12,3 +12,12 @@ def test_database_name_empty(self): msg = 'settings.DATABASES is missing the "NAME" value.' with self.assertRaisesMessage(ImproperlyConfigured, msg): DatabaseWrapper(settings).get_connection_params() + + +class DatabaseWrapperConnectionTests(TestCase): + def test_set_autocommit(self): + self.assertIs(connection.get_autocommit(), True) + connection.set_autocommit(False) + self.assertIs(connection.get_autocommit(), False) + connection.set_autocommit(True) + self.assertIs(connection.get_autocommit(), True) diff --git a/tests/backend_/utils/test_parse_uri.py b/tests/backend_/utils/test_parse_uri.py index c4d475f1f..a28983596 100644 --- a/tests/backend_/utils/test_parse_uri.py +++ b/tests/backend_/utils/test_parse_uri.py @@ -1,6 +1,7 @@ from unittest.mock import patch import pymongo +from django.core.exceptions import ImproperlyConfigured from django.test import SimpleTestCase from django_mongodb_backend import parse_uri @@ -12,11 +13,28 @@ def test_simple_uri(self): self.assertEqual(settings_dict["ENGINE"], "django_mongodb_backend") self.assertEqual(settings_dict["NAME"], "myDatabase") self.assertEqual(settings_dict["HOST"], "cluster0.example.mongodb.net") + self.assertEqual(settings_dict["OPTIONS"], {"authSource": "myDatabase"}) - def test_no_database(self): - settings_dict = parse_uri("mongodb://cluster0.example.mongodb.net") - self.assertIsNone(settings_dict["NAME"]) + def test_db_name(self): + settings_dict = parse_uri("mongodb://cluster0.example.mongodb.net/", db_name="myDatabase") + self.assertEqual(settings_dict["ENGINE"], "django_mongodb_backend") + self.assertEqual(settings_dict["NAME"], "myDatabase") + self.assertEqual(settings_dict["HOST"], "cluster0.example.mongodb.net") + self.assertEqual(settings_dict["OPTIONS"], {}) + + def test_db_name_overrides_default_auth_db(self): + settings_dict = parse_uri( + "mongodb://cluster0.example.mongodb.net/default_auth_db", db_name="myDatabase" + ) + self.assertEqual(settings_dict["ENGINE"], "django_mongodb_backend") + self.assertEqual(settings_dict["NAME"], "myDatabase") self.assertEqual(settings_dict["HOST"], "cluster0.example.mongodb.net") + self.assertEqual(settings_dict["OPTIONS"], {"authSource": "default_auth_db"}) + + def test_no_database(self): + msg = "You must provide the db_name parameter." + with self.assertRaisesMessage(ImproperlyConfigured, msg): + parse_uri("mongodb://cluster0.example.mongodb.net") def test_srv_uri_with_options(self): uri = "mongodb+srv://my_user:my_password@cluster0.example.mongodb.net/my_database?retryWrites=true&w=majority" @@ -30,35 +48,46 @@ def test_srv_uri_with_options(self): self.assertEqual(settings_dict["PASSWORD"], "my_password") self.assertIsNone(settings_dict["PORT"]) self.assertEqual( - settings_dict["OPTIONS"], {"retryWrites": True, "w": "majority", "tls": True} + settings_dict["OPTIONS"], + {"authSource": "my_database", "retryWrites": True, "w": "majority", "tls": True}, ) def test_localhost(self): - settings_dict = parse_uri("mongodb://localhost") + settings_dict = parse_uri("mongodb://localhost/db") self.assertEqual(settings_dict["HOST"], "localhost") self.assertEqual(settings_dict["PORT"], 27017) def test_localhost_with_port(self): - settings_dict = parse_uri("mongodb://localhost:27018") + settings_dict = parse_uri("mongodb://localhost:27018/db") self.assertEqual(settings_dict["HOST"], "localhost") self.assertEqual(settings_dict["PORT"], 27018) def test_hosts_with_ports(self): - settings_dict = parse_uri("mongodb://localhost:27017,localhost:27018") + settings_dict = parse_uri("mongodb://localhost:27017,localhost:27018/db") self.assertEqual(settings_dict["HOST"], "localhost:27017,localhost:27018") self.assertEqual(settings_dict["PORT"], None) def test_hosts_without_ports(self): - settings_dict = parse_uri("mongodb://host1.net,host2.net") + settings_dict = parse_uri("mongodb://host1.net,host2.net/db") self.assertEqual(settings_dict["HOST"], "host1.net:27017,host2.net:27017") self.assertEqual(settings_dict["PORT"], None) + def test_auth_source_in_query_string(self): + settings_dict = parse_uri("mongodb://localhost/?authSource=auth", db_name="db") + self.assertEqual(settings_dict["NAME"], "db") + self.assertEqual(settings_dict["OPTIONS"], {"authSource": "auth"}) + + def test_auth_source_in_query_string_overrides_defaultauthdb(self): + settings_dict = parse_uri("mongodb://localhost/db?authSource=auth") + self.assertEqual(settings_dict["NAME"], "db") + self.assertEqual(settings_dict["OPTIONS"], {"authSource": "auth"}) + def test_conn_max_age(self): - settings_dict = parse_uri("mongodb://localhost", conn_max_age=600) + settings_dict = parse_uri("mongodb://localhost/db", conn_max_age=600) self.assertEqual(settings_dict["CONN_MAX_AGE"], 600) def test_test_kwarg(self): - settings_dict = parse_uri("mongodb://localhost", test={"NAME": "test_db"}) + settings_dict = parse_uri("mongodb://localhost/db", test={"NAME": "test_db"}) self.assertEqual(settings_dict["TEST"], {"NAME": "test_db"}) def test_invalid_credentials(self): diff --git a/tests/indexes_/test_condition.py b/tests/indexes_/test_condition.py index b5b02e9b3..5077dd0bd 100644 --- a/tests/indexes_/test_condition.py +++ b/tests/indexes_/test_condition.py @@ -34,7 +34,7 @@ def test_negated_not_supported(self): Index( name="test", fields=["headline"], - condition=~Q(pk=True), + condition=~Q(pk__isnull=True), )._get_condition_mql(Article, schema_editor=editor) def test_xor_not_supported(self): @@ -43,7 +43,7 @@ def test_xor_not_supported(self): Index( name="test", fields=["headline"], - condition=Q(pk=True) ^ Q(pk=False), + condition=Q(pk__isnull=True) ^ Q(pk__isnull=False), )._get_condition_mql(Article, schema_editor=editor) def test_operations(self): diff --git a/tests/model_fields_/test_autofield.py b/tests/model_fields_/test_autofield.py index 1ba2edc41..82b606d5c 100644 --- a/tests/model_fields_/test_autofield.py +++ b/tests/model_fields_/test_autofield.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.test import SimpleTestCase from django_mongodb_backend.fields import ObjectIdAutoField @@ -15,6 +16,8 @@ def test_get_internal_type(self): f = ObjectIdAutoField() self.assertEqual(f.get_internal_type(), "ObjectIdAutoField") - def test_to_python(self): + def test_to_python_invalid_value(self): f = ObjectIdAutoField() - self.assertEqual(f.to_python("1"), 1) + msg = "“1” is not a valid Object Id." + with self.assertRaisesMessage(ValidationError, msg): + f.to_python("1") diff --git a/tests/model_fields_/test_embedded_model.py b/tests/model_fields_/test_embedded_model.py index 4beb2e254..eee0dd1a9 100644 --- a/tests/model_fields_/test_embedded_model.py +++ b/tests/model_fields_/test_embedded_model.py @@ -1,4 +1,5 @@ import operator +from datetime import timedelta from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db import models @@ -74,7 +75,9 @@ def test_pre_save(self): obj = Holder.objects.create(data=Data()) auto_now = truncate_ms(obj.data.auto_now) auto_now_add = truncate_ms(obj.data.auto_now_add) - self.assertEqual(auto_now, auto_now_add) + # auto_now and auto_now_add may differ by a millisecond since they + # aren't generated simultaneously. + self.assertAlmostEqual(auto_now, auto_now_add, delta=timedelta(microseconds=1000)) # save() updates auto_now but not auto_now_add. obj.save() self.assertEqual(truncate_ms(obj.data.auto_now_add), auto_now_add) @@ -253,7 +256,8 @@ class MyModel(models.Model): class SubqueryExistsTests(TestCase): - def setUpTestData(self): + @classmethod + def setUpTestData(cls): address1 = Address(city="New York", state="NY", zip_code=10001) address2 = Address(city="Boston", state="MA", zip_code=20002) author1 = Author(name="Alice", age=30, address=address1) diff --git a/tests/model_forms_/forms.py b/tests/model_forms_/forms.py index 7bfed3fbb..1ac7b92a9 100644 --- a/tests/model_forms_/forms.py +++ b/tests/model_forms_/forms.py @@ -1,9 +1,15 @@ from django import forms -from .models import Author +from .models import Author, Book class AuthorForm(forms.ModelForm): class Meta: fields = "__all__" model = Author + + +class BookForm(forms.ModelForm): + class Meta: + fields = "__all__" + model = Book diff --git a/tests/model_forms_/models.py b/tests/model_forms_/models.py index df3bd580f..4e7cd0d6c 100644 --- a/tests/model_forms_/models.py +++ b/tests/model_forms_/models.py @@ -16,3 +16,13 @@ class Author(models.Model): age = models.IntegerField() address = EmbeddedModelField(Address) billing_address = EmbeddedModelField(Address, blank=True, null=True) + + +class Publisher(EmbeddedModel): + name = models.CharField(max_length=50) + address = EmbeddedModelField(Address) + + +class Book(models.Model): + title = models.CharField(max_length=50) + publisher = EmbeddedModelField(Publisher) diff --git a/tests/model_forms_/test_embedded_model.py b/tests/model_forms_/test_embedded_model.py index 240f8c6d8..4447b59f2 100644 --- a/tests/model_forms_/test_embedded_model.py +++ b/tests/model_forms_/test_embedded_model.py @@ -1,7 +1,7 @@ from django.test import TestCase -from .forms import AuthorForm -from .models import Address, Author +from .forms import AuthorForm, BookForm +from .models import Address, Author, Book, Publisher class ModelFormTests(TestCase): @@ -128,3 +128,222 @@ def test_rendering(self): """, ) + + +class NestedFormTests(TestCase): + def test_update(self): + book = Book.objects.create( + title="Learning MongoDB", + publisher=Publisher( + name="Random House", address=Address(city="NYC", state="NY", zip_code="10001") + ), + ) + data = { + "title": "Learning MongoDB!", + "publisher-name": "Random House!", + "publisher-address-po_box": "", + "publisher-address-city": "New York City", + "publisher-address-state": "NY", + "publisher-address-zip_code": "10001", + } + form = BookForm(data, instance=book) + self.assertTrue(form.is_valid()) + form.save() + book.refresh_from_db() + self.assertEqual(book.title, "Learning MongoDB!") + self.assertEqual(book.publisher.name, "Random House!") + self.assertEqual(book.publisher.address.city, "New York City") + + def test_some_missing_data(self): + """A required field (zip_code) is missing.""" + book = Book.objects.create( + title="Learning MongoDB", + publisher=Publisher( + name="Random House", address=Address(city="NYC", state="NY", zip_code="10001") + ), + ) + data = { + "title": "Learning MongoDB!", + "publisher-name": "Random House!", + "publisher-address-po_box": "", + "publisher-address-city": "New York City", + "publisher-address-state": "NY", + "publisher-address-zip_code": "", + } + form = BookForm(data, instance=book) + self.assertFalse(form.is_valid()) + self.assertEqual(form.errors["publisher"], ["Enter all required values."]) + self.assertHTMLEqual( + str(form), + """ +
+ + +
+
+
+ Publisher: +
    +
  • Enter all required values.
  • +
+
+ + +
+
+
+ Address: +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
""", + ) + + def test_invalid_field_data(self): + """A field's data (state) is too long.""" + book = Book.objects.create( + title="Learning MongoDB", + publisher=Publisher( + name="Random House", address=Address(city="NYC", state="NY", zip_code="10001") + ), + ) + data = { + "title": "Learning MongoDB!", + "publisher-name": "Random House!", + "publisher-address-po_box": "", + "publisher-address-city": "New York City", + "publisher-address-state": "TOO LONG", + "publisher-address-zip_code": "10001", + } + form = BookForm(data, instance=book) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors["publisher"], + ["Ensure this value has at most 2 characters (it has 8)."], + ) + self.assertHTMLEqual( + str(form), + """ +
+ + +
+
+
+ Publisher: +
    +
  • Ensure this value has at most 2 characters (it has 8).
  • +
+
+ + +
+
+
+ Address: +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
""", + ) + + def test_all_missing_data(self): + """An embedded model with all data missing triggers a required error.""" + book = Book.objects.create( + title="Learning MongoDB", + publisher=Publisher( + name="Random House", address=Address(city="NYC", state="NY", zip_code="10001") + ), + ) + data = { + "title": "Learning MongoDB!", + "publisher-name": "Random House!", + "publisher-address-po_box": "", + "publisher-address-city": "", + "publisher-address-state": "", + "publisher-address-zip_code": "", + } + form = BookForm(data, instance=book) + self.assertFalse(form.is_valid()) + self.assertEqual(form.errors["publisher"], ["This field is required."]) + + def test_rendering(self): + form = BookForm() + self.assertHTMLEqual( + str(form.fields["publisher"].get_bound_field(form, "publisher")), + """ +
+ + +
+
+
+ Address: +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
""", + ) diff --git a/tests/models_/models.py b/tests/models_/models.py index e02edda0a..fa4e86ec7 100644 --- a/tests/models_/models.py +++ b/tests/models_/models.py @@ -1,5 +1,11 @@ +from django.db import models + from django_mongodb_backend.models import EmbeddedModel class Embed(EmbeddedModel): pass + + +class PlainModel(models.Model): + pass diff --git a/tests/models_/test_routers.py b/tests/models_/test_routers.py new file mode 100644 index 000000000..cec78240c --- /dev/null +++ b/tests/models_/test_routers.py @@ -0,0 +1,20 @@ +from django.test import SimpleTestCase + +from django_mongodb_backend.routers import MongoRouter + + +class TestRouter(SimpleTestCase): + def setUp(self): + self.router = MongoRouter() + + def test_no_model(self): + self.assertIsNone(self.router.allow_migrate("db", "models_")) + + def test_regular_model(self): + self.assertIsNone(self.router.allow_migrate("db", "models_", "plainmodel")) + + def test_nonexistent_model(self): + self.assertIsNone(self.router.allow_migrate("db", "models_", "nonexistentmodel")) + + def test_embedded_model(self): + self.assertIs(self.router.allow_migrate("db", "models_", "embed"), False)