plain.models 0.41.1__py3-none-any.whl → 0.43.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plain/models/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # plain-models changelog
2
2
 
3
+ ## [0.43.0](https://github.com/dropseed/plain/releases/plain-models@0.43.0) (2025-09-12)
4
+
5
+ ### What's changed
6
+
7
+ - The `related_name` parameter is now required for ForeignKey and ManyToManyField relationships if you want a reverse accessor. The `"+"` suffix to disable reverse relations has been removed, and automatic `_set` suffixes are no longer generated ([89fa03979f](https://github.com/dropseed/plain/commit/89fa03979f))
8
+ - Refactored related descriptors and managers for better internal organization and type safety ([9f0b03957a](https://github.com/dropseed/plain/commit/9f0b03957a))
9
+ - Added docstrings and return type annotations to model `query` property and related manager methods for improved developer experience ([544d85b60b](https://github.com/dropseed/plain/commit/544d85b60b))
10
+
11
+ ### Upgrade instructions
12
+
13
+ - Remove any `related_name="+"` usage - if you don't want a reverse accessor, simply omit the `related_name` parameter entirely
14
+ - Update any code that relied on automatic `_set` suffixes - these are no longer generated, so you must use explicit `related_name` values
15
+ - Add explicit `related_name` arguments to all ForeignKey and ManyToManyField definitions where you want reverse access (e.g., `models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles")`)
16
+ - Consider removing `related_name` arguments that are not used in practice
17
+
18
+ ## [0.42.0](https://github.com/dropseed/plain/releases/plain-models@0.42.0) (2025-09-12)
19
+
20
+ ### What's changed
21
+
22
+ - The model manager interface has been renamed from `.objects` to `.query` ([037a239](https://github.com/dropseed/plain/commit/037a239ef4))
23
+ - Manager functionality has been merged into QuerySet, simplifying the architecture - custom QuerySets can now be set directly via `Meta.queryset_class` ([bbaee93](https://github.com/dropseed/plain/commit/bbaee93839))
24
+ - The `objects` manager is now set directly on the Model class for better type checking ([fccc5be](https://github.com/dropseed/plain/commit/fccc5be13e))
25
+ - Database backups are now created automatically during migrations when in DEBUG mode ([c8023074](https://github.com/dropseed/plain/commit/c8023074e9))
26
+ - Removed several legacy manager features: `default_related_name`, `base_manager_name`, `creation_counter`, `use_in_migrations`, `auto_created`, and routing hints ([multiple commits](https://github.com/dropseed/plain/compare/plain-models@0.41.1...037a239ef4))
27
+
28
+ ### Upgrade instructions
29
+
30
+ - Replace all usage of `Model.objects` with `Model.query` in your codebase (e.g., `User.objects.filter()` becomes `User.query.filter()`)
31
+ - If you have custom managers, convert them to custom QuerySets and set them using `Meta.queryset_class` instead of assigning to class attributes (if there is more than one custom manager on a class, invoke the new QuerySet class directly or add a shortcut on the Model using `@classmethod`)
32
+ - Remove any usage of the removed manager features: `default_related_name`, `base_manager_name`, manager `creation_counter`, `use_in_migrations`, `auto_created`, and database routing hints
33
+ - Any reverse accessors (typically `<related_model>_set` or defined by `related_name`) will now return a manager class for the additional `add()`, `remove()`, `clear()`, etc. methods and the regular queryset methods will be available via `.query` (e.g., `user.articles.first()` becomes `user.articles.query.first()`)
34
+
3
35
  ## [0.41.1](https://github.com/dropseed/plain/releases/plain-models@0.41.1) (2025-09-09)
4
36
 
5
37
  ### What's changed
plain/models/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  - [Fields](#fields)
10
10
  - [Validation](#validation)
11
11
  - [Indexes and constraints](#indexes-and-constraints)
12
- - [Managers](#managers)
12
+ - [Custom QuerySets](#custom-querysets)
13
13
  - [Forms](#forms)
14
14
  - [Sharing fields across models](#sharing-fields-across-models)
15
15
  - [Installation](#installation)
@@ -43,7 +43,7 @@ from .models import User
43
43
 
44
44
 
45
45
  # Create a new user
46
- user = User.objects.create(
46
+ user = User.query.create(
47
47
  email="test@example.com",
48
48
  password="password",
49
49
  )
@@ -56,7 +56,7 @@ user.save()
56
56
  user.delete()
57
57
 
58
58
  # Query for users
59
- admin_users = User.objects.filter(is_admin=True)
59
+ admin_users = User.query.filter(is_admin=True)
60
60
  ```
61
61
 
62
62
  ## Database connection
@@ -85,30 +85,30 @@ Multiple backends are supported, including Postgres, MySQL, and SQLite.
85
85
 
86
86
  ## Querying
87
87
 
88
- Models come with a powerful query API through their [`Manager`](./manager.py#Manager) interface:
88
+ Models come with a powerful query API through their [`QuerySet`](./query.py#QuerySet) interface:
89
89
 
90
90
  ```python
91
91
  # Get all users
92
- all_users = User.objects.all()
92
+ all_users = User.query.all()
93
93
 
94
94
  # Filter users
95
- admin_users = User.objects.filter(is_admin=True)
96
- recent_users = User.objects.filter(created_at__gte=datetime.now() - timedelta(days=7))
95
+ admin_users = User.query.filter(is_admin=True)
96
+ recent_users = User.query.filter(created_at__gte=datetime.now() - timedelta(days=7))
97
97
 
98
98
  # Get a single user
99
- user = User.objects.get(email="test@example.com")
99
+ user = User.query.get(email="test@example.com")
100
100
 
101
101
  # Complex queries with Q objects
102
102
  from plain.models import Q
103
- users = User.objects.filter(
103
+ users = User.query.filter(
104
104
  Q(is_admin=True) | Q(email__endswith="@example.com")
105
105
  )
106
106
 
107
107
  # Ordering
108
- users = User.objects.order_by("-created_at")
108
+ users = User.query.order_by("-created_at")
109
109
 
110
110
  # Limiting results
111
- first_10_users = User.objects.all()[:10]
111
+ first_10_users = User.query.all()[:10]
112
112
  ```
113
113
 
114
114
  For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) class.
@@ -211,28 +211,71 @@ class User(models.Model):
211
211
  ]
212
212
  ```
213
213
 
214
- ## Managers
214
+ ## Custom QuerySets
215
215
 
216
- [`Manager`](./manager.py#Manager) objects provide the interface for querying models:
216
+ With the Manager functionality now merged into QuerySet, you can customize [`QuerySet`](./query.py#QuerySet) classes to provide specialized query methods. There are several ways to use custom QuerySets:
217
+
218
+ ### Setting a default QuerySet for a model
219
+
220
+ Use `Meta.queryset_class` to set a custom QuerySet that will be used by `Model.query`:
221
+
222
+ ```python
223
+ class PublishedQuerySet(models.QuerySet):
224
+ def published_only(self):
225
+ return self.filter(status="published")
226
+
227
+ def draft_only(self):
228
+ return self.filter(status="draft")
229
+
230
+ @models.register_model
231
+ class Article(models.Model):
232
+ title = models.CharField(max_length=200)
233
+ status = models.CharField(max_length=20)
234
+
235
+ class Meta:
236
+ queryset_class = PublishedQuerySet
237
+
238
+ # Usage - all methods available on Article.objects
239
+ all_articles = Article.query.all()
240
+ published_articles = Article.query.published_only()
241
+ draft_articles = Article.query.draft_only()
242
+ ```
243
+
244
+ ### Using custom QuerySets without formal attachment
245
+
246
+ You can also use custom QuerySets manually without setting them as the default:
217
247
 
218
248
  ```python
219
- class PublishedManager(models.Manager):
220
- def get_queryset(self):
221
- return super().get_queryset().filter(status="published")
249
+ class SpecialQuerySet(models.QuerySet):
250
+ def special_filter(self):
251
+ return self.filter(special=True)
222
252
 
253
+ # Create and use the QuerySet manually
254
+ special_qs = SpecialQuerySet(model=Article)
255
+ special_articles = special_qs.special_filter()
256
+ ```
257
+
258
+ ### Using classmethods for convenience
259
+
260
+ For even cleaner API, add classmethods to your model:
261
+
262
+ ```python
263
+ @models.register_model
223
264
  class Article(models.Model):
224
265
  title = models.CharField(max_length=200)
225
266
  status = models.CharField(max_length=20)
226
267
 
227
- # Default manager
228
- objects = models.Manager()
268
+ @classmethod
269
+ def published(cls):
270
+ return PublishedQuerySet(model=cls).published_only()
229
271
 
230
- # Custom manager
231
- published = PublishedManager()
272
+ @classmethod
273
+ def drafts(cls):
274
+ return PublishedQuerySet(model=cls).draft_only()
232
275
 
233
276
  # Usage
234
- all_articles = Article.objects.all()
235
- published_articles = Article.published.all()
277
+ published_articles = Article.published()
278
+ draft_articles = Article.drafts()
236
279
  ```
237
280
 
238
281
  ## Forms
plain/models/__init__.py CHANGED
@@ -59,7 +59,6 @@ from .fields.json import JSONField
59
59
  from .indexes import * # NOQA
60
60
  from .indexes import __all__ as indexes_all
61
61
  from .lookups import Lookup, Transform
62
- from .manager import Manager
63
62
  from .query import Prefetch, QuerySet, prefetch_related_objects
64
63
  from .query_utils import FilteredRelation, Q
65
64
  from .registry import models_registry, register_model
@@ -108,7 +107,6 @@ __all__ += [
108
107
  "JSONField",
109
108
  "Lookup",
110
109
  "Transform",
111
- "Manager",
112
110
  "Prefetch",
113
111
  "Q",
114
112
  "QuerySet",
plain/models/base.py CHANGED
@@ -24,9 +24,8 @@ from plain.models.deletion import Collector
24
24
  from plain.models.expressions import RawSQL, Value
25
25
  from plain.models.fields import NOT_PROVIDED
26
26
  from plain.models.fields.reverse_related import ForeignObjectRel
27
- from plain.models.manager import Manager
28
27
  from plain.models.options import Options
29
- from plain.models.query import F, Q
28
+ from plain.models.query import F, Q, QuerySet
30
29
  from plain.packages import packages_registry
31
30
  from plain.utils.encoding import force_str
32
31
  from plain.utils.hashable import make_hashable
@@ -75,7 +74,7 @@ class ModelBase(type):
75
74
  new_class._add_exceptions()
76
75
 
77
76
  # Now go back over all the attrs on this class see if they have a contribute_to_class() method.
78
- # Attributes with contribute_to_class are fields, meta options, and managers.
77
+ # Attributes with contribute_to_class are fields and meta options.
79
78
  for attr_name, attr_value in inspect.getmembers(new_class):
80
79
  if attr_name.startswith("_"):
81
80
  continue
@@ -161,16 +160,6 @@ class ModelBase(type):
161
160
  ", ".join(f.name for f in opts.fields),
162
161
  )
163
162
 
164
- if not opts.managers:
165
- if any(f.name == "objects" for f in opts.fields):
166
- raise ValueError(
167
- f"Model {cls.__name__} must specify a custom Manager, because it has a "
168
- "field named 'objects'."
169
- )
170
- manager = Manager()
171
- manager.auto_created = True
172
- cls.add_to_class("objects", manager)
173
-
174
163
  # Set the name of _meta.indexes. This can't be done in
175
164
  # Options.contribute_to_class() because fields haven't been added to
176
165
  # the model at that point.
@@ -179,12 +168,9 @@ class ModelBase(type):
179
168
  index.set_name_with_model(cls)
180
169
 
181
170
  @property
182
- def _base_manager(cls):
183
- return cls._meta.base_manager
184
-
185
- @property
186
- def _default_manager(cls):
187
- return cls._meta.default_manager
171
+ def query(cls) -> QuerySet:
172
+ """Create a new QuerySet for this model."""
173
+ return cls._meta.queryset
188
174
 
189
175
 
190
176
  class ModelStateFieldsCacheDescriptor:
@@ -207,6 +193,9 @@ class ModelState:
207
193
 
208
194
 
209
195
  class Model(metaclass=ModelBase):
196
+ DoesNotExist: type[ObjectDoesNotExist]
197
+ MultipleObjectsReturned: type[MultipleObjectsReturned]
198
+
210
199
  def __init__(self, *args, **kwargs):
211
200
  # Alias some things as locals to avoid repeat global lookups
212
201
  cls = self.__class__
@@ -434,7 +423,7 @@ class Model(metaclass=ModelBase):
434
423
  "are not allowed in fields."
435
424
  )
436
425
 
437
- db_instance_qs = self.__class__._base_manager.get_queryset().filter(id=self.id)
426
+ db_instance_qs = self.__class__._meta.base_queryset.filter(id=self.id)
438
427
 
439
428
  # Use provided fields, if not set then reload all non-deferred fields.
440
429
  deferred_fields = self.get_deferred_fields()
@@ -617,7 +606,7 @@ class Model(metaclass=ModelBase):
617
606
  force_insert = True
618
607
  # If possible, try an UPDATE. If that doesn't update anything, do an INSERT.
619
608
  if id_set and not force_insert:
620
- base_qs = cls._base_manager
609
+ base_qs = meta.base_queryset
621
610
  values = [
622
611
  (
623
612
  f,
@@ -641,7 +630,7 @@ class Model(metaclass=ModelBase):
641
630
  fields = [f for f in fields if f is not id_field]
642
631
 
643
632
  returning_fields = meta.db_returning_fields
644
- results = self._do_insert(cls._base_manager, fields, returning_fields, raw)
633
+ results = self._do_insert(meta.base_queryset, fields, returning_fields, raw)
645
634
  if results:
646
635
  for value, field in zip(results[0], returning_fields):
647
636
  setattr(self, field.attname, value)
@@ -738,7 +727,7 @@ class Model(metaclass=ModelBase):
738
727
  q = Q.create([(field.name, param), (f"id__{op}", self.id)], connector=Q.AND)
739
728
  q = Q.create([q, (f"{field.name}__{op}", param)], connector=Q.OR)
740
729
  qs = (
741
- self.__class__._default_manager.filter(**kwargs)
730
+ self.__class__.query.filter(**kwargs)
742
731
  .filter(q)
743
732
  .order_by(f"{order}{field.name}", f"{order}id")
744
733
  )
@@ -836,7 +825,7 @@ class Model(metaclass=ModelBase):
836
825
  if len(unique_check) != len(lookup_kwargs):
837
826
  continue
838
827
 
839
- qs = model_class._default_manager.filter(**lookup_kwargs)
828
+ qs = model_class.query.filter(**lookup_kwargs)
840
829
 
841
830
  # Exclude the current object from the query if we are editing an
842
831
  # instance (as opposed to creating a new one)
@@ -995,9 +984,7 @@ class Model(metaclass=ModelBase):
995
984
 
996
985
  @classmethod
997
986
  def check(cls, **kwargs):
998
- errors = [
999
- *cls._check_managers(**kwargs),
1000
- ]
987
+ errors = []
1001
988
 
1002
989
  database = kwargs.get("database", False)
1003
990
  errors += [
@@ -1045,14 +1032,6 @@ class Model(metaclass=ModelBase):
1045
1032
  )
1046
1033
  return errors
1047
1034
 
1048
- @classmethod
1049
- def _check_managers(cls, **kwargs):
1050
- """Perform all manager checks."""
1051
- errors = []
1052
- for manager in cls._meta.managers:
1053
- errors.extend(manager.check(**kwargs))
1054
- return errors
1055
-
1056
1035
  @classmethod
1057
1036
  def _check_fields(cls, **kwargs):
1058
1037
  """Perform all field checks."""
plain/models/cli.py CHANGED
@@ -479,7 +479,7 @@ def migrate(
479
479
  "Migrations can be pruned only when a package is specified."
480
480
  )
481
481
  if verbosity > 0:
482
- click.echo("Pruning migrations:", color="cyan")
482
+ click.secho("Pruning migrations:", fg="cyan")
483
483
  to_prune = set(executor.loader.applied_migrations) - set(
484
484
  executor.loader.disk_migrations
485
485
  )
@@ -529,16 +529,16 @@ def migrate(
529
529
  migration_plan = executor.migration_plan(targets)
530
530
 
531
531
  if plan:
532
- click.echo("Planned operations:", color="cyan")
532
+ click.secho("Planned operations:", fg="cyan")
533
533
  if not migration_plan:
534
534
  click.echo(" No planned migration operations.")
535
535
  else:
536
536
  for migration in migration_plan:
537
- click.echo(str(migration), color="cyan")
537
+ click.secho(str(migration), fg="cyan")
538
538
  for operation in migration.operations:
539
539
  message, is_error = describe_operation(operation)
540
540
  if is_error:
541
- click.echo(" " + message, fg="yellow")
541
+ click.secho(" " + message, fg="yellow")
542
542
  else:
543
543
  click.echo(" " + message)
544
544
  if check_unapplied:
@@ -555,18 +555,18 @@ def migrate(
555
555
 
556
556
  # Print some useful info
557
557
  if verbosity >= 1:
558
- click.echo("Operations to perform:", color="cyan")
558
+ click.secho("Operations to perform:", fg="cyan")
559
559
 
560
560
  if target_package_labels_only:
561
- click.echo(
561
+ click.secho(
562
562
  " Apply all migrations: "
563
563
  + (", ".join(sorted({a for a, n in targets})) or "(none)"),
564
- color="yellow",
564
+ fg="yellow",
565
565
  )
566
566
  else:
567
- click.echo(
567
+ click.secho(
568
568
  f" Target specific migration: {targets[0][1]}, from {targets[0][0]}",
569
- color="yellow",
569
+ fg="yellow",
570
570
  )
571
571
 
572
572
  pre_migrate_state = executor._create_project_state(with_applied_migrations=True)
@@ -575,30 +575,24 @@ def migrate(
575
575
  # pprint(sql)
576
576
 
577
577
  if migration_plan:
578
- if backup or (
579
- backup is None
580
- and settings.DEBUG
581
- and (
582
- no_input
583
- or click.confirm(
584
- "\nYou are in DEBUG mode. Would you like to make a database backup before running migrations?",
585
- default=True,
586
- )
587
- )
588
- ):
578
+ if backup or (backup is None and settings.DEBUG):
589
579
  backup_name = f"migrate_{time.strftime('%Y%m%d_%H%M%S')}"
580
+ click.secho(
581
+ f"Creating backup before applying migrations: {backup_name}",
582
+ bold=True,
583
+ )
590
584
  # Can't use ctx.invoke because this is called by the test db creation currently,
591
585
  # which doesn't have a context.
592
586
  create_backup.callback(
593
587
  backup_name=backup_name,
594
588
  pg_dump=os.environ.get(
595
589
  "PG_DUMP", "pg_dump"
596
- ), # Have to this again manually
590
+ ), # Have to pass this in manually
597
591
  )
598
592
  print()
599
593
 
600
594
  if verbosity >= 1:
601
- click.echo("Running migrations:", color="cyan")
595
+ click.secho("Running migrations:", fg="cyan")
602
596
 
603
597
  post_migrate_state = executor.migrate(
604
598
  targets,
@@ -347,7 +347,7 @@ class UniqueConstraint(BaseConstraint):
347
347
  return path, self.expressions, kwargs
348
348
 
349
349
  def validate(self, model, instance, exclude=None):
350
- queryset = model._default_manager
350
+ queryset = model.query
351
351
  if self.fields:
352
352
  lookup_kwargs = {}
353
353
  for field_name in self.fields:
plain/models/deletion.py CHANGED
@@ -352,7 +352,7 @@ class Collector:
352
352
  [(f"{related_field.name}__in", objs) for related_field in related_fields],
353
353
  connector=query_utils.Q.OR,
354
354
  )
355
- return related_model._base_manager.filter(predicate)
355
+ return related_model._meta.base_queryset.filter(predicate)
356
356
 
357
357
  def sort(self):
358
358
  sorted_models = []
@@ -901,7 +901,7 @@ class Field(RegisterLookupMixin):
901
901
  if hasattr(self.remote_field, "get_related_field")
902
902
  else "id"
903
903
  )
904
- qs = rel_model._default_manager.complex_filter(limit_choices_to)
904
+ qs = rel_model.query.complex_filter(limit_choices_to)
905
905
  if ordering:
906
906
  qs = qs.order_by(*ordering)
907
907
  return (blank_choice if include_blank else []) + [
@@ -12,8 +12,9 @@ from . import Field
12
12
  from .mixins import FieldCacheMixin
13
13
  from .related_descriptors import (
14
14
  ForeignKeyDeferredAttribute,
15
+ ForwardManyToManyDescriptor,
15
16
  ForwardManyToOneDescriptor,
16
- ManyToManyDescriptor,
17
+ ReverseManyToManyDescriptor,
17
18
  ReverseManyToOneDescriptor,
18
19
  )
19
20
  from .related_lookups import (
@@ -123,14 +124,11 @@ class RelatedField(FieldCacheMixin, Field):
123
124
  is_valid_id = (
124
125
  not keyword.iskeyword(related_name) and related_name.isidentifier()
125
126
  )
126
- if not (is_valid_id or related_name.endswith("+")):
127
+ if not is_valid_id:
127
128
  return [
128
129
  preflight.Error(
129
130
  f"The name '{self.remote_field.related_name}' is invalid related_name for field {self.model._meta.object_name}.{self.name}",
130
- hint=(
131
- "Related name must be a valid Python identifier or end with a "
132
- "'+'"
133
- ),
131
+ hint="Related name must be a valid Python identifier.",
134
132
  obj=self,
135
133
  id="fields.E306",
136
134
  )
@@ -310,9 +308,6 @@ class RelatedField(FieldCacheMixin, Field):
310
308
 
311
309
  if self.remote_field.related_name:
312
310
  related_name = self.remote_field.related_name
313
- else:
314
- related_name = self.opts.default_related_name
315
- if related_name:
316
311
  related_name %= {
317
312
  "class": cls.__name__.lower(),
318
313
  "model_name": cls._meta.model_name.lower(),
@@ -672,7 +667,7 @@ class ForeignKey(RelatedField):
672
667
  if value is None:
673
668
  return
674
669
 
675
- qs = self.remote_field.model._base_manager.filter(
670
+ qs = self.remote_field.model._meta.base_queryset.filter(
676
671
  **{self.remote_field.field_name: value}
677
672
  )
678
673
  qs = qs.complex_filter(self.get_limit_choices_to())
@@ -1239,26 +1234,6 @@ class ManyToManyField(RelatedField):
1239
1234
  return getattr(self, cache_attr)
1240
1235
 
1241
1236
  def contribute_to_class(self, cls, name, **kwargs):
1242
- # To support multiple relations to self, it's useful to have a non-None
1243
- # related name on symmetrical relations for internal reasons. The
1244
- # concept doesn't make a lot of sense externally ("you want me to
1245
- # specify *what* on my non-reversible relation?!"), so we set it up
1246
- # automatically. The funky name reduces the chance of an accidental
1247
- # clash.
1248
- if self.remote_field.symmetrical and (
1249
- self.remote_field.model == RECURSIVE_RELATIONSHIP_CONSTANT
1250
- or self.remote_field.model == cls._meta.object_name
1251
- ):
1252
- self.remote_field.related_name = f"{name}_rel_+"
1253
- elif self.remote_field.is_hidden():
1254
- # If the backwards relation is disabled, replace the original
1255
- # related_name with one generated from the m2m field name. Plain
1256
- # still uses backwards relations internally and we need to avoid
1257
- # clashes between multiple m2m fields with related_name == '+'.
1258
- self.remote_field.related_name = (
1259
- f"_{cls._meta.package_label}_{cls.__name__.lower()}_{name}_+"
1260
- )
1261
-
1262
1237
  super().contribute_to_class(cls, name, **kwargs)
1263
1238
 
1264
1239
  def resolve_through_model(_, model, field):
@@ -1269,7 +1244,7 @@ class ManyToManyField(RelatedField):
1269
1244
  )
1270
1245
 
1271
1246
  # Add the descriptor for the m2m relation.
1272
- setattr(cls, self.name, ManyToManyDescriptor(self.remote_field, reverse=False))
1247
+ setattr(cls, self.name, ForwardManyToManyDescriptor(self.remote_field))
1273
1248
 
1274
1249
  # Set up the accessor for the m2m table name for the relation.
1275
1250
  self.m2m_db_table = self._get_m2m_db_table
@@ -1281,7 +1256,7 @@ class ManyToManyField(RelatedField):
1281
1256
  setattr(
1282
1257
  cls,
1283
1258
  related.get_accessor_name(),
1284
- ManyToManyDescriptor(self.remote_field, reverse=True),
1259
+ ReverseManyToManyDescriptor(self.remote_field),
1285
1260
  )
1286
1261
 
1287
1262
  # Set up the accessors for the column names on the m2m table.