New Upstream Release - python-django-simple-history

Ready changes

Summary

Merged new upstream version: 3.3.0 (was: 3.2.0).

Resulting package

Built on 2023-07-31T09:09 (took 6m0s)

The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:

apt install -t fresh-releases python3-django-simple-history

Lintian Result

Diff

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 61e31b7..d4cf101 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -11,16 +11,27 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: ['3.7', '3.8', '3.9', '3.10']
-        django-version: ['3.2', '4.0', 'main']
+        python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
+        django-version: ['3.2', '4.0', '4.1', 'main']
 
         exclude:
           - python-version: '3.7'
             django-version: '4.0'
           - python-version: '3.7'
+            django-version: '4.1'
+          - python-version: '3.7'
+            django-version: 'main'
+
+          - python-version: '3.8'
             django-version: 'main'
-          - python-version: '3.10'
-            django-version: '3.1'
+
+          - python-version: '3.9'
+            django-version: 'main'
+
+          - python-version: '3.11'
+            django-version: '3.2'
+          - python-version: '3.11'
+            django-version: '4.0'
 
     services:
 
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d69852a..896bc32 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -8,25 +8,25 @@ repos:
           - "-x *test*.py"
 
   - repo: https://github.com/psf/black
-    rev: 22.3.0
+    rev: 23.1.0
     hooks:
       - id: black
         language_version: python3.8
 
   - repo: https://github.com/pycqa/flake8
-    rev: 4.0.1
+    rev: 6.0.0
     hooks:
       - id: flake8
         args:
           - "--config=tox.ini"
 
   - repo: https://github.com/PyCQA/isort
-    rev: 5.10.1
+    rev: 5.12.0
     hooks:
       - id: isort
 
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.2.0
+    rev: v4.4.0
     hooks:
       - id: requirements-txt-fixer
         files: requirements/.*\.txt$
@@ -40,8 +40,14 @@ repos:
       - id: detect-private-key
 
   - repo: https://github.com/adrienverge/yamllint
-    rev: v1.26.3
+    rev: v1.29.0
     hooks:
       - id: yamllint
         args:
           - "--strict"
+
+  - repo: https://github.com/asottile/pyupgrade
+    rev: v3.3.1
+    hooks:
+      - id: pyupgrade
+        args: [--py37-plus]
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 31fa0b6..ad3086a 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -18,13 +18,16 @@ Authors
 - Amartis Gladius (`Amartis <https://github.com/amartis>`_)
 - Ben Lawson (`blawson <https://github.com/blawson>`_)
 - Benjamin Mampaey (`bmampaey <https://github.com/bmampaey>`_)
+- Bheesham Persaud (`bheesham <https://github.com/bheesham>`_)
 - `bradford281 <https://github.com/bradford281>`_
 - Brian Armstrong (`barm <https://github.com/barm>`_)
-- Buddy Lindsey, Jr.
 - Brian Dixon
+- Brian Mesick (`bmedx <https://github.com/bmedx>`_)
+- Buddy Lindsey, Jr.
 - Carlos San Emeterio (`Carlos-San-Emeterio <https://github.com/Carlos-San-Emeterio>`_)
 - Christopher Broderick (`uhurusurfa <https://github.com/uhurusurfa>`_)
 - Christopher Johns (`tyrantwave <https://github.com/tyrantwave>`_)
+- Conrad (`creyD <https://github.com/creyD>`_)
 - Corey Bertram
 - Craig Maloney (`craigmaloney <https://github.com/craigmaloney>`_)
 - Damien Nozay
@@ -35,10 +38,12 @@ Authors
 - David Grochowski (`ThePumpingLemma <https://github.com/ThePumpingLemma>`_)
 - David Hite
 - David Smith
+- `ddabble <https://github.com/ddabble>`_
 - Dmytro Shyshov (`xahgmah <https://github.com/xahgmah>`_)
 - Edouard Richard (`vied12 <https://github.com/vied12>` _)
 - Eduardo Cuducos
 - Erik van Widenfelt (`erikvw <https://github.com/erikvw>`_)
+- Fábio Capuano (`fabiocapsouza <https://github.com/fabiocapsouza`_)
 - Filipe Pina (@fopina)
 - Florian Eßer
 - François Martin (`martinfrancois <https://github.com/martinfrancois>`_)
@@ -71,11 +76,13 @@ Authors
 - Jonathan Zvesper (`zvesp <https://github.com/zvesp>`_)
 - Jordon Wing  (`jordonwii <https://github.com/jordonwii`_)
 - Josh Fyne
+- Josh Thomas (`joshuadavidthomas <https://github.com/joshuadavidthomas>`_)
 - Keith Hackbarth
 - Kevin Foster
 - Klaas van Schelven
 - Kris Neuharth
 - Kyle Seever (`kseever <https://github.com/kseever>`_)
+- Léni Gauffier (`legau <https://github.com/legau>`_)
 - Leticia Portella
 - Lucas Wiman
 - Maciej "RooTer" Urbański
@@ -111,7 +118,9 @@ Authors
 - Stefan Borer (`sbor23 <https://github.com/sbor23>`_)
 - Steven Buss (`sbuss <https://github.com/sbuss>`_)
 - Steven Klass
+- Thijs Kramer (`thijskramer <https://github.com/thijskramer>`_)
 - Tim Schilling (`tim-schilling <https://github.com/tim-schilling>`_)
+- Todd Wolfson (`twolfson <https://github.com/twolfson>`_)
 - Tommy Beadle (`tbeadle <https://github.com/tbeadle>`_)
 - Trey Hunner (`treyhunner <https://github.com/treyhunner>`_)
 - Ulysses Vilela
diff --git a/CHANGES.rst b/CHANGES.rst
index 850ea40..68aacc9 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,27 @@ Unreleased
 ----------
 
 
+3.3.0 (2023-03-08)
+------------------
+
+- Made it possible to use the new ``m2m_fields`` with model inheritance (gh-1042)
+- Added two signals: ``pre_create_historical_m2m_records`` and ``post_create_historical_m2m_records`` (gh-1042)
+- Added ``tracked_fields`` attribute to historical models (gh-1038)
+- Fixed ``KeyError`` when running ``clean_duplicate_history`` on models with ``excluded_fields`` (gh-1038)
+- Added support for Python 3.11 (gh-1053)
+- Added Arabic translations (gh-1056)
+- Fixed a code example under "Tracking many to many relationships" (gh-1069)
+- Added a ``--base-manager`` option to the ``clean_duplicate_history`` management command (gh-1115)
+
+3.2.0 (2022-09-28)
+------------------
+
+- Fixed typos in the docs
+- Removed n+1 query from ``bulk_create_with_history`` utility (gh-975)
+- Started using ``exists`` query instead of ``count`` in ``populate_history`` command (gh-982)
+- Add basic support for many-to-many fields (gh-399)
+- Added support for Django 4.1 (gh-1021)
+
 3.1.1 (2022-04-23)
 ------------------
 
diff --git a/README.rst b/README.rst
index 0d942fa..495cabb 100644
--- a/README.rst
+++ b/README.rst
@@ -1,8 +1,8 @@
 django-simple-history
 =====================
 
-.. image:: https://github.com/jazzband/django-simple-history/workflows/build/badge.svg?branch=master
-   :target: https://github.com/jazzband/django-simple-history/actions?workflow=build
+.. image:: https://github.com/jazzband/django-simple-history/actions/workflows/test.yml/badge.svg
+   :target: https://github.com/jazzband/django-simple-history/actions/workflows/test.yml
    :alt: Build Status
 
 .. image:: https://readthedocs.org/projects/django-simple-history/badge/?version=latest
@@ -43,6 +43,7 @@ This app supports the following combinations of Django and Python:
 ==========  ========================
 3.2         3.7, 3.8, 3.9, 3.10
 4.0         3.8, 3.9, 3.10
+4.1         3.8, 3.9, 3.10, 3.11
 ==========  ========================
 
 Getting Help
diff --git a/debian/changelog b/debian/changelog
index cb3671b..9e7147d 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,12 +1,14 @@
-python-django-simple-history (3.1.1-2) UNRELEASED; urgency=medium
+python-django-simple-history (3.3.0-1) UNRELEASED; urgency=medium
 
   * Set field Upstream-Contact in debian/copyright.
   * Set upstream metadata fields: Bug-Database, Bug-Submit, Repository-Browse.
   * Remove obsolete field Contact from debian/upstream/metadata (already present
     in machine-readable debian/copyright).
   * Update standards version to 4.6.2, no changes needed.
+  * New upstream release.
+  * New upstream release.
 
- -- Debian Janitor <janitor@jelmer.uk>  Wed, 11 Jan 2023 16:20:34 -0000
+ -- Debian Janitor <janitor@jelmer.uk>  Mon, 31 Jul 2023 09:03:50 -0000
 
 python-django-simple-history (3.1.1-1) unstable; urgency=medium
 
diff --git a/docs/common_issues.rst b/docs/common_issues.rst
index 57dbdee..1e932fe 100644
--- a/docs/common_issues.rst
+++ b/docs/common_issues.rst
@@ -253,16 +253,14 @@ Working with BitBucket Pipelines
 --------------------------------
 
 When using BitBucket Pipelines to test your Django project with the
-django-simple-history middleware, you will run into an error relating to missing migrations relating to the historic User model from the auth app. This is because the migration file is not held within either your project or django-simple-history.  In order to pypass the error you need to add a ```python manage.py makemigrations auth``` step into your YML file prior to running the tests.
+django-simple-history middleware, you will run into an error relating to missing migrations relating to the historic User model from the auth app. This is because the migration file is not held within either your project or django-simple-history.  In order to bypass the error you need to add a ```python manage.py makemigrations auth``` step into your YML file prior to running the tests.
 
 
 Using custom OneToOneFields
 ---------------------------
 
 If you are using a custom OneToOneField that has additional arguments and receiving
-the the following ``TypeError``::
-
-.. code=block:: python
+the following ``TypeError``::
 
     TypeError: __init__() got an unexpected keyword argument
 
diff --git a/docs/historical_model.rst b/docs/historical_model.rst
index cf23579..84b65f9 100644
--- a/docs/historical_model.rst
+++ b/docs/historical_model.rst
@@ -52,7 +52,7 @@ You're able to set a custom ``history_date`` attribute for the historical
 record, by defining the property ``_history_date`` in your model. That's
 helpful if you want to add versions to your model, which happened before the
 current model version, e.g. when batch importing historical data. The content
-of the property ``_history_date`` has to be a datetime-object, but setting the
+of the property ``_history_date`` has to be a ``datetime``-object, but setting the
 value of the property to a ``DateTimeField``, which is already defined in the
 model, will work too.
 
@@ -447,3 +447,46 @@ And you don't want to create database index for ``question``, it is necessary to
 
 By default, django-simple-history keeps all indices. and even forces them on unique fields and relations.
 WARNING: This will drop performance on historical lookups
+
+Tracking many to many relationships
+-----------------------------------
+By default, many to many fields are ignored when tracking changes.
+If you want to track many to many relationships, you need to define them explicitly:
+
+.. code-block:: python
+
+    class Category(models.Model):
+        name = models.CharField(max_length=200)
+
+    class Poll(models.Model):
+        question = models.CharField(max_length=200)
+        categories = models.ManyToManyField(Category)
+        history = HistoricalRecords(m2m_fields=[categories])
+
+This will create a historical intermediate model that tracks each relational change
+between `Poll` and `Category`.
+
+You may also define these fields in a model attribute (by default on `_history_m2m_fields`).
+This is mainly used for inherited models. You can override the attribute name by setting
+your own `m2m_fields_model_field_name` argument on the `HistoricalRecord` instance.
+
+You will see the many to many changes when diffing between two historical records:
+
+.. code-block:: python
+
+    informal = Category.objects.create(name="informal questions")
+    official = Category.objects.create(name="official questions")
+    p = Poll.objects.create(question="what's up?")
+    p.save()
+    p.categories.add(informal, official)
+    p.categories.remove(informal)
+
+    last_record = p.history.latest()
+    previous_record = last_record.prev_record
+    delta = last_record.diff_against(previous_record)
+
+    for change in delta.changes:
+        print("{} changed from {} to {}".format(change.field, change.old, change.new))
+
+    # Output:
+    # categories changed from [{'poll': 1, 'category': 1}, { 'poll': 1, 'category': 2}] to [{'poll': 1, 'category': 2}]
diff --git a/docs/index.rst b/docs/index.rst
index 2081b5e..3dee8f3 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -41,8 +41,9 @@ This app supports the following combinations of Django and Python:
 ==========  =======================
   Django      Python
 ==========  =======================
-3.2         3.7, 3.8, 3.9
+3.2         3.7, 3.8, 3.9, 3.10
 4.0         3.8, 3.9, 3.10
+4.1         3.8, 3.9, 3.10, 3.11
 ==========  =======================
 
 Contribute
diff --git a/docs/querying_history.rst b/docs/querying_history.rst
index 725ec2b..23b30f7 100644
--- a/docs/querying_history.rst
+++ b/docs/querying_history.rst
@@ -144,7 +144,7 @@ If you use `as_of` to query history, the resulting instance will have an
 attribute named `_history` added to it.  This property will contain the
 historical model record that the instance was derived from.  Calling
 is_historic is an easy way to check if an instance was derived from a
-historic timepoint (even if it is the most recent version).
+historic point in time (even if it is the most recent version).
 
 You can use `to_historic` to return the historical model that was used
 to furnish the instance at hand, if it is actually historic.
@@ -155,7 +155,7 @@ HistoricForeignKey
 
 If you have two historic tables linked by foreign key, you can change it
 to use a HistoricForeignKey so that chasing relations from an `as_of`
-acquired instance (at a specific timepoint) will honor that timepoint
+acquired instance (at a specific point in time) will honor that point in time
 when accessing the related object(s).  This works for both forward and
 reverse relationships.
 
@@ -219,7 +219,7 @@ To filter changes to the data, a relationship to the history can be established.
 
     Poll.objects.filter(history__history_user=4)
 
-You can also prefetch the objects with this relationship using somthing like this for example to prefetch order by history_date descending:
+You can also prefetch the objects with this relationship using something like this for example to prefetch order by history_date descending:
 
 .. code-block:: python
 
diff --git a/docs/quick_start.rst b/docs/quick_start.rst
index ffdb84b..dde7e9a 100644
--- a/docs/quick_start.rst
+++ b/docs/quick_start.rst
@@ -141,14 +141,14 @@ tables in your database:
 
 .. _above: `Track History`_
 
-The two extra tables with ``historical`` prepended to their names are tables created
+The two extra tables with ``historical`` prepend to their names are tables created
 by ``django-simple-history``. These tables store every change that you make to their
 respective base tables. Every time a create, update, or delete occurs on ``Choice`` or
 ``Poll`` a new row is created in the historical table for that model including all of
 the fields in the instance of the base model, as well as other metadata:
 
 - ``history_user``: the user that made the create/update/delete
-- ``history_date``: the datetime at which the create/update/delete occurred
+- ``history_date``: the ``datetime`` at which the create/update/delete occurred
 - ``history_change_reason``: the reason the create/update/delete occurred (null by default)
 - ``history_id``: the primary key for the historical table (note the base table's
   primary key is not unique on the historical table since there are multiple versions of it
diff --git a/docs/signals.rst b/docs/signals.rst
index ee3cc66..5f168de 100644
--- a/docs/signals.rst
+++ b/docs/signals.rst
@@ -22,6 +22,24 @@ saving a historical record. Arguments passed to the signals include the followin
     using
         The database alias being used
 
+For Many To Many signals you've got the following :
+
+.. glossary::
+    instance
+        The source model instance being saved
+
+    history_instance
+        The corresponding history record
+
+    rows (for pre_create)
+        The elements to be bulk inserted into the m2m table
+
+    created_rows (for post_create)
+        The created elements into the m2m table
+
+    field
+        The recorded field object
+
 To connect the signals to your callbacks, you can use the ``@receiver`` decorator:
 
 .. code-block:: python
@@ -30,6 +48,8 @@ To connect the signals to your callbacks, you can use the ``@receiver`` decorato
     from simple_history.signals import (
         pre_create_historical_record,
         post_create_historical_record
+        pre_create_historical_m2m_records,
+        post_create_historical_m2m_records,
     )
 
     @receiver(pre_create_historical_record)
@@ -39,3 +59,11 @@ To connect the signals to your callbacks, you can use the ``@receiver`` decorato
     @receiver(post_create_historical_record)
     def post_create_historical_record_callback(sender, **kwargs):
         print("Sent after saving historical record")
+
+    @receiver(pre_create_historical_m2m_records)
+    def pre_create_historical_m2m_records_callback(sender, **kwargs):
+        print("Sent before saving many to many field on historical record")
+
+    @receiver(post_create_historical_m2m_records)
+    def post_create_historical_m2m_records_callback(sender, **kwargs):
+        print("Sent after saving many to many field on historical record")
diff --git a/docs/user_tracking.rst b/docs/user_tracking.rst
index 9553287..653b25e 100644
--- a/docs/user_tracking.rst
+++ b/docs/user_tracking.rst
@@ -58,7 +58,7 @@ Admin integration requires that you use a ``_history_user.setter`` attribute wit
 your custom ``_history_user`` property (see :doc:`/admin`).
 
 Another option for identifying the change user is by providing a function via ``get_user``.
-If provided it will be called everytime that the ``history_user`` needs to be
+If provided it will be called every time that the ``history_user`` needs to be
 identified with the following key word arguments:
 
 * ``instance``:  The current instance being modified
diff --git a/docs/utils.rst b/docs/utils.rst
index 1c7bd42..c030514 100644
--- a/docs/utils.rst
+++ b/docs/utils.rst
@@ -31,6 +31,14 @@ from the duplicate check
 
     $ python manage.py clean_duplicate_history --auto --excluded_fields field1 field2
 
+You can use Django's base manager to perform the cleanup over all records,
+including those that would otherwise be filtered or modified by a
+custom manager, by using the ``--base-manager`` flag.
+
+.. code-block:: bash
+
+    $ python manage.py clean_duplicate_history --auto --base-manager
+
 clean_old_history
 -----------------------
 
@@ -43,7 +51,7 @@ If you find yourself with a lot of old history you can schedule the
 
     $ python manage.py clean_old_history --auto
 
-You can use ``--auto`` to remove old historial entries
+You can use ``--auto`` to remove old historical entries
 with ``HistoricalRecords`` or enumerate specific models as args.
 You may also specify a  ``--days`` parameter, which indicates how many
 days of records you want to keep. The default it 30 days, meaning that
diff --git a/requirements/coverage.txt b/requirements/coverage.txt
index 9f63dd3..294bca1 100644
--- a/requirements/coverage.txt
+++ b/requirements/coverage.txt
@@ -1,2 +1,2 @@
-coverage==6.3.2
+coverage==7.2.1
 toml==0.10.2
diff --git a/requirements/docs.txt b/requirements/docs.txt
index c8cc7c4..dd87066 100644
--- a/requirements/docs.txt
+++ b/requirements/docs.txt
@@ -1 +1 @@
-Sphinx==4.5.0
+Sphinx==5.3.0
diff --git a/requirements/lint.txt b/requirements/lint.txt
index ce1e046..5b14314 100644
--- a/requirements/lint.txt
+++ b/requirements/lint.txt
@@ -1,3 +1,3 @@
-black==22.3.0
-flake8==4.0.1
-isort==5.10.1
+black==23.1.0
+flake8==6.0.0
+isort==5.12.0
diff --git a/requirements/mysql.txt b/requirements/mysql.txt
index 9bfaeca..485d75f 100644
--- a/requirements/mysql.txt
+++ b/requirements/mysql.txt
@@ -1 +1 @@
-mysqlclient==2.1.0
+mysqlclient==2.1.1
diff --git a/requirements/postgres.txt b/requirements/postgres.txt
index 4881093..f2dc80c 100644
--- a/requirements/postgres.txt
+++ b/requirements/postgres.txt
@@ -1 +1 @@
-psycopg2-binary==2.9.3
+psycopg2-binary==2.9.5
diff --git a/requirements/tox.txt b/requirements/tox.txt
index 8d3edff..7a92d7e 100644
--- a/requirements/tox.txt
+++ b/requirements/tox.txt
@@ -1,3 +1,3 @@
 -r ./coverage.txt
-tox==3.25.0
-tox-gh-actions==2.9.1
+tox==4.4.6
+tox-gh-actions==3.0.0
diff --git a/runtests.py b/runtests.py
index dbed2a0..f0e7aa7 100755
--- a/runtests.py
+++ b/runtests.py
@@ -135,6 +135,7 @@ DEFAULT_SETTINGS = dict(  # nosec
         }
     ],
     DEFAULT_AUTO_FIELD="django.db.models.AutoField",
+    USE_TZ=False,
 )
 MIDDLEWARE = [
     "django.contrib.sessions.middleware.SessionMiddleware",
diff --git a/setup.py b/setup.py
index 55b68ac..8b3d66d 100644
--- a/setup.py
+++ b/setup.py
@@ -8,6 +8,7 @@ with open("README.rst") as readme, open("CHANGES.rst") as changes:
             "local_scheme": "node-and-date",
             "relative_to": __file__,
             "root": ".",
+            "fallback_version": "0.0.0",
         },
         setup_requires=["setuptools_scm"],
         description="Store model history and view/revert changes from admin site.",
@@ -31,12 +32,14 @@ with open("README.rst") as readme, open("CHANGES.rst") as changes:
             "Framework :: Django",
             "Framework :: Django :: 3.2",
             "Framework :: Django :: 4.0",
+            "Framework :: Django :: 4.1",
             "Programming Language :: Python",
             "Programming Language :: Python :: 3",
             "Programming Language :: Python :: 3.7",
             "Programming Language :: Python :: 3.8",
             "Programming Language :: Python :: 3.9",
             "Programming Language :: Python :: 3.10",
+            "Programming Language :: Python :: 3.11",
             "License :: OSI Approved :: BSD License",
         ],
         python_requires=">=3.7",
diff --git a/simple_history/locale/ar/LC_MESSAGES/django.mo b/simple_history/locale/ar/LC_MESSAGES/django.mo
new file mode 100644
index 0000000..0ac474b
Binary files /dev/null and b/simple_history/locale/ar/LC_MESSAGES/django.mo differ
diff --git a/simple_history/locale/ar/LC_MESSAGES/django.po b/simple_history/locale/ar/LC_MESSAGES/django.po
new file mode 100644
index 0000000..5b9bcb4
--- /dev/null
+++ b/simple_history/locale/ar/LC_MESSAGES/django.po
@@ -0,0 +1,134 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2022-11-08 11:30+0300\n"
+"PO-Revision-Date: 2022-11-08 13:54+0300\n"
+"Language: ar\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
+"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"X-Generator: Poedit 2.4.2\n"
+
+#: simple_history/admin.py:102
+#, python-format
+msgid "View history: %s"
+msgstr "عرض سجل تغيرات: %s"
+
+#: simple_history/admin.py:104
+#, python-format
+msgid "Change history: %s"
+msgstr "تعديل سجل تغيرات: %s"
+
+#: simple_history/admin.py:110
+#, python-format
+msgid "The %(name)s \"%(obj)s\" was changed successfully."
+msgstr "تم تعديل %(name)s \"%(obj)s\" بنجاح."
+
+#: simple_history/admin.py:116
+msgid "You may edit it again below"
+msgstr "يمكنك تعديله مجددا ادناه"
+
+#: simple_history/admin.py:216
+#, python-format
+msgid "View %s"
+msgstr "عرض %s"
+
+#: simple_history/admin.py:218
+#, python-format
+msgid "Revert %s"
+msgstr "استرجاع %s"
+
+#: simple_history/models.py:552
+msgid "Created"
+msgstr "تم انشاءه"
+
+#: simple_history/models.py:552
+msgid "Changed"
+msgstr "تغيير"
+
+#: simple_history/models.py:552
+msgid "Deleted"
+msgstr "تمت إزالته"
+
+#: simple_history/templates/simple_history/_object_history_list.html:9
+msgid "Object"
+msgstr "عنصر"
+
+#: simple_history/templates/simple_history/_object_history_list.html:13
+msgid "Date/time"
+msgstr "التاريخ/الوقت"
+
+#: simple_history/templates/simple_history/_object_history_list.html:14
+msgid "Comment"
+msgstr "تعليق"
+
+#: simple_history/templates/simple_history/_object_history_list.html:15
+msgid "Changed by"
+msgstr "تغير من قبل"
+
+#: simple_history/templates/simple_history/_object_history_list.html:16
+msgid "Change reason"
+msgstr "سبب التغير"
+
+#: simple_history/templates/simple_history/_object_history_list.html:37
+msgid "None"
+msgstr "فارغ"
+
+#: simple_history/templates/simple_history/object_history.html:11
+msgid ""
+"Choose a date from the list below to revert to a previous version of this "
+"object."
+msgstr "إختر تاريخ من القائمة ادناه."
+
+#: simple_history/templates/simple_history/object_history.html:16
+msgid "This object doesn't have a change history."
+msgstr "هذا العنصر لا يملك سجل تغييرات."
+
+#: simple_history/templates/simple_history/object_history_form.html:7
+msgid "Home"
+msgstr "الرئيسية"
+
+#: simple_history/templates/simple_history/object_history_form.html:11
+msgid "History"
+msgstr "سجل التغيرات"
+
+#: simple_history/templates/simple_history/object_history_form.html:12
+#, python-format
+msgid "View %(verbose_name)s"
+msgstr "عرض %(verbose_name)s"
+
+#: simple_history/templates/simple_history/object_history_form.html:12
+#, python-format
+msgid "Revert %(verbose_name)s"
+msgstr "استرجاع %(verbose_name)s"
+
+#: simple_history/templates/simple_history/object_history_form.html:25
+msgid ""
+"Press the 'Revert' button below to revert to this version of the object. "
+msgstr "اضغط على زر 'استرجاع' ادناه للاسترجاع لهذه النسخة من العنصر. "
+
+#: simple_history/templates/simple_history/object_history_form.html:25
+msgid "Press the 'Change History' button below to edit the history."
+msgstr "اضغط على زر 'تعديل سجل التغيرات' ادناه لتعديل التاريخ."
+
+#: simple_history/templates/simple_history/submit_line.html:4
+msgid "Revert"
+msgstr "استرجاع"
+
+#: simple_history/templates/simple_history/submit_line.html:6
+msgid "Change History"
+msgstr "تعديل سجل التغيرات"
+
+#: simple_history/templates/simple_history/submit_line.html:7
+msgid "Close"
+msgstr "إغلاق"
diff --git a/simple_history/locale/de/LC_MESSAGES/django.po b/simple_history/locale/de/LC_MESSAGES/django.po
index 83097a6..ea47c08 100644
--- a/simple_history/locale/de/LC_MESSAGES/django.po
+++ b/simple_history/locale/de/LC_MESSAGES/django.po
@@ -38,7 +38,7 @@ msgstr "%s wiederherstellen"
 
 #: simple_history/models.py:314
 msgid "Created"
-msgstr "Angelegt"
+msgstr "Erstellt"
 
 #: simple_history/models.py:314
 msgid "Changed"
diff --git a/simple_history/management/commands/clean_duplicate_history.py b/simple_history/management/commands/clean_duplicate_history.py
index 298238d..bbbd17e 100644
--- a/simple_history/management/commands/clean_duplicate_history.py
+++ b/simple_history/management/commands/clean_duplicate_history.py
@@ -1,8 +1,7 @@
 from django.db import transaction
 from django.utils import timezone
 
-from ... import models, utils
-from ...exceptions import NotHistoricalModelError
+from ... import utils
 from . import populate_history
 
 
@@ -36,10 +35,19 @@ class Command(populate_history.Command):
             nargs="+",
             help="List of fields to be excluded from the diff_against check",
         )
+        parser.add_argument(
+            "--base-manager",
+            action="store_true",
+            default=False,
+            help="Use Django's base manager to handle all records stored in the"
+            " database, including those that would otherwise be filtered or modified"
+            " by a custom manager.",
+        )
 
     def handle(self, *args, **options):
         self.verbosity = options["verbosity"]
         self.excluded_fields = options.get("excluded_fields")
+        self.base_manager = options.get("base_manager")
 
         to_process = set()
         model_strings = options.get("models", []) or args
@@ -72,7 +80,10 @@ class Command(populate_history.Command):
                 continue
 
             # Break apart the query so we can add additional filtering
-            model_query = model.objects.all()
+            if self.base_manager:
+                model_query = model._base_manager.all()
+            else:
+                model_query = model._default_manager.all()
 
             # If we're provided a stop date take the initial hit of getting the
             # filtered records to iterate over
diff --git a/simple_history/management/commands/clean_old_history.py b/simple_history/management/commands/clean_old_history.py
index 41e69fd..e5a93e8 100644
--- a/simple_history/management/commands/clean_old_history.py
+++ b/simple_history/management/commands/clean_old_history.py
@@ -53,7 +53,6 @@ class Command(populate_history.Command):
         self._process(to_process, days_back=options["days"], dry_run=options["dry"])
 
     def _process(self, to_process, days_back=None, dry_run=True):
-
         start_date = timezone.now() - timezone.timedelta(days=days_back)
         for model, history_model in to_process:
             history_model_manager = history_model.objects
diff --git a/simple_history/management/commands/populate_history.py b/simple_history/management/commands/populate_history.py
index d0eabee..b078f55 100644
--- a/simple_history/management/commands/populate_history.py
+++ b/simple_history/management/commands/populate_history.py
@@ -135,7 +135,6 @@ class Command(BaseCommand):
             # creating them. So we only keep batch_size worth of models in
             # historical_instances and clear them after we hit batch_size
             if index % batch_size == 0:
-
                 history.bulk_history_create(instances, batch_size=batch_size)
 
                 instances = []
@@ -156,7 +155,7 @@ class Command(BaseCommand):
 
     def _process(self, to_process, batch_size):
         for model, history_model in to_process:
-            if history_model.objects.count():
+            if history_model.objects.exists():
                 self.stderr.write(
                     "{msg} {model}\n".format(
                         msg=self.EXISTING_HISTORY_FOUND, model=model
diff --git a/simple_history/manager.py b/simple_history/manager.py
index afef78f..e91b249 100644
--- a/simple_history/manager.py
+++ b/simple_history/manager.py
@@ -155,11 +155,8 @@ class HistoryManager(models.Manager):
                 )
             )
         tmp = []
-        excluded_fields = getattr(self.model, "_history_excluded_fields", [])
 
-        for field in self.instance._meta.fields:
-            if field.name in excluded_fields:
-                continue
+        for field in self.model.tracked_fields:
             if isinstance(field, models.ForeignKey):
                 tmp.append(field.name + "_id")
             else:
@@ -263,8 +260,7 @@ class HistoryManager(models.Manager):
                 history_type=history_type,
                 **{
                     field.attname: getattr(instance, field.attname)
-                    for field in instance._meta.fields
-                    if field.name not in self.model._history_excluded_fields
+                    for field in self.model.tracked_fields
                 },
             )
             if hasattr(self.model, "history_relation"):
diff --git a/simple_history/models.py b/simple_history/models.py
index 97289c8..db19c66 100644
--- a/simple_history/models.py
+++ b/simple_history/models.py
@@ -2,6 +2,7 @@ import copy
 import importlib
 import uuid
 import warnings
+from functools import partial
 
 from django.apps import apps
 from django.conf import settings
@@ -18,6 +19,7 @@ from django.db.models.fields.related_descriptors import (
     create_reverse_many_to_one_manager,
 )
 from django.db.models.query import QuerySet
+from django.db.models.signals import m2m_changed
 from django.forms.models import model_to_dict
 from django.urls import reverse
 from django.utils import timezone
@@ -30,7 +32,12 @@ from simple_history import utils
 
 from . import exceptions
 from .manager import SIMPLE_HISTORY_REVERSE_ATTR_NAME, HistoryDescriptor
-from .signals import post_create_historical_record, pre_create_historical_record
+from .signals import (
+    post_create_historical_m2m_records,
+    post_create_historical_record,
+    pre_create_historical_m2m_records,
+    pre_create_historical_record,
+)
 from .utils import get_change_reason_from_object
 
 try:
@@ -64,7 +71,10 @@ def _history_user_setter(historical_instance, user):
 
 
 class HistoricalRecords:
+    DEFAULT_MODEL_NAME_PREFIX = "Historical"
+
     thread = context = LocalContext()  # retain thread for backwards compatibility
+    m2m_models = {}
 
     def __init__(
         self,
@@ -90,6 +100,9 @@ class HistoricalRecords:
         user_db_constraint=True,
         no_db_index=list(),
         excluded_field_kwargs=None,
+        m2m_fields=(),
+        m2m_fields_model_field_name="_history_m2m_fields",
+        m2m_bases=(models.Model,),
     ):
         self.user_set_verbose_name = verbose_name
         self.user_set_verbose_name_plural = verbose_name_plural
@@ -109,6 +122,8 @@ class HistoricalRecords:
         self.user_setter = history_user_setter
         self.related_name = related_name
         self.use_base_model_db = use_base_model_db
+        self.m2m_fields = m2m_fields
+        self.m2m_fields_model_field_name = m2m_fields_model_field_name
 
         if isinstance(no_db_index, str):
             no_db_index = [no_db_index]
@@ -127,6 +142,12 @@ class HistoricalRecords:
             self.bases = (HistoricalChanges,) + tuple(bases)
         except TypeError:
             raise TypeError("The `bases` option must be a list or a tuple.")
+        try:
+            if isinstance(m2m_bases, str):
+                raise TypeError
+            self.m2m_bases = (HistoricalChanges,) + tuple(m2m_bases)
+        except TypeError:
+            raise TypeError("The `m2m_bases` option must be a list or a tuple.")
 
     def contribute_to_class(self, cls, name):
         self.manager_name = name
@@ -172,6 +193,7 @@ class HistoricalRecords:
                 )
             )
         history_model = self.create_history_model(sender, inherited)
+
         if inherited:
             # Make sure history model is in same module as concrete model
             module = importlib.import_module(history_model.__module__)
@@ -184,13 +206,33 @@ class HistoricalRecords:
         models.signals.post_save.connect(self.post_save, sender=sender, weak=False)
         models.signals.post_delete.connect(self.post_delete, sender=sender, weak=False)
 
+        m2m_fields = self.get_m2m_fields_from_model(sender)
+
+        for field in m2m_fields:
+            m2m_changed.connect(
+                partial(self.m2m_changed, attr=field.name),
+                sender=field.remote_field.through,
+                weak=False,
+            )
+
         descriptor = HistoryDescriptor(history_model)
         setattr(sender, self.manager_name, descriptor)
         sender._meta.simple_history_manager_attribute = self.manager_name
 
+        for field in m2m_fields:
+            m2m_model = self.create_history_m2m_model(
+                history_model, field.remote_field.through
+            )
+            self.m2m_models[field] = m2m_model
+
+            setattr(module, m2m_model.__name__, m2m_model)
+
+            m2m_descriptor = HistoryDescriptor(m2m_model)
+            setattr(history_model, field.name, m2m_descriptor)
+
     def get_history_model_name(self, model):
         if not self.custom_model_name:
-            return f"Historical{model._meta.object_name}"
+            return f"{self.DEFAULT_MODEL_NAME_PREFIX}{model._meta.object_name}"
         # Must be trying to use a custom history model name
         if callable(self.custom_model_name):
             name = self.custom_model_name(model._meta.object_name)
@@ -210,6 +252,22 @@ class HistoricalRecords:
             )
         )
 
+    def create_history_m2m_model(self, model, through_model):
+        attrs = {}
+
+        fields = self.copy_fields(through_model)
+        attrs.update(fields)
+        attrs.update(self.get_extra_fields_m2m(model, through_model, fields))
+
+        name = self.get_history_model_name(through_model)
+        registered_models[through_model._meta.db_table] = through_model
+
+        attrs.update(Meta=type("Meta", (), self.get_meta_options_m2m(through_model)))
+
+        m2m_history_model = type(str(name), self.m2m_bases, attrs)
+
+        return m2m_history_model
+
     def create_history_model(self, model, inherited):
         """
         Creates a historical model to associate with the model provided.
@@ -217,6 +275,8 @@ class HistoricalRecords:
         attrs = {
             "__module__": self.module,
             "_history_excluded_fields": self.excluded_fields,
+            "_history_m2m_fields": self.get_m2m_fields_from_model(model),
+            "tracked_fields": self.fields_included(model),
         }
 
         app_module = "%s.models" % model._meta.app_label
@@ -343,7 +403,7 @@ class HistoricalRecords:
 
     def _get_history_id_field(self):
         if self.history_id_field:
-            history_id_field = self.history_id_field
+            history_id_field = self.history_id_field.clone()
             history_id_field.primary_key = True
             history_id_field.editable = False
         elif getattr(settings, "SIMPLE_HISTORY_HISTORY_ID_USE_UUID", False):
@@ -396,6 +456,25 @@ class HistoricalRecords:
         else:
             return {}
 
+    def get_extra_fields_m2m(self, model, through_model, fields):
+        """Return dict of extra fields added to the m2m historical record model"""
+
+        extra_fields = {
+            "__module__": model.__module__,
+            "__str__": lambda self: "{} as of {}".format(
+                self._meta.verbose_name, self.history.history_date
+            ),
+            "history": models.ForeignKey(
+                model,
+                db_constraint=False,
+                on_delete=models.DO_NOTHING,
+            ),
+            "instance_type": through_model,
+            "m2m_history_id": self._get_history_id_field(),
+        }
+
+        return extra_fields
+
     def get_extra_fields(self, model, fields):
         """Return dict of extra fields added to the historical record model"""
 
@@ -508,6 +587,20 @@ class HistoricalRecords:
             )
         return result
 
+    def get_meta_options_m2m(self, through_model):
+        """
+        Returns a dictionary of fields that will be added to
+        the Meta inner class of the m2m historical record model.
+        """
+        name = self.get_history_model_name(through_model)
+
+        meta_fields = {"verbose_name": name}
+
+        if self.app:
+            meta_fields["app_label"] = self.app
+
+        return meta_fields
+
     def get_meta_options(self, model):
         """
         Returns a dictionary of fields that will be added to
@@ -559,6 +652,51 @@ class HistoricalRecords:
         """
         return get_change_reason_from_object(instance)
 
+    def m2m_changed(self, instance, action, attr, pk_set, reverse, **_):
+        if hasattr(instance, "skip_history_when_saving"):
+            return
+
+        if action in ("post_add", "post_remove", "post_clear"):
+            # It should be safe to ~ this since the row must exist to modify m2m on it
+            self.create_historical_record(instance, "~")
+
+    def create_historical_record_m2ms(self, history_instance, instance):
+        for field in history_instance._history_m2m_fields:
+            m2m_history_model = self.m2m_models[field]
+            original_instance = history_instance.instance
+            through_model = getattr(original_instance, field.name).through
+
+            insert_rows = []
+
+            through_field_name = type(original_instance).__name__.lower()
+
+            rows = through_model.objects.filter(**{through_field_name: instance})
+
+            for row in rows:
+                insert_row = {"history": history_instance}
+
+                for through_model_field in through_model._meta.fields:
+                    insert_row[through_model_field.name] = getattr(
+                        row, through_model_field.name
+                    )
+                insert_rows.append(m2m_history_model(**insert_row))
+
+            pre_create_historical_m2m_records.send(
+                sender=m2m_history_model,
+                rows=insert_rows,
+                history_instance=history_instance,
+                instance=instance,
+                field=field,
+            )
+            created_rows = m2m_history_model.objects.bulk_create(insert_rows)
+            post_create_historical_m2m_records.send(
+                sender=m2m_history_model,
+                created_rows=created_rows,
+                history_instance=history_instance,
+                instance=instance,
+                field=field,
+            )
+
     def create_historical_record(self, instance, history_type, using=None):
         using = using if self.use_base_model_db else None
         history_date = getattr(instance, "_history_date", timezone.now())
@@ -595,6 +733,7 @@ class HistoricalRecords:
         )
 
         history_instance.save(using=using)
+        self.create_historical_record_m2ms(history_instance, instance)
 
         post_create_historical_record.send(
             sender=manager.model,
@@ -620,6 +759,14 @@ class HistoricalRecords:
 
         return self.get_user(instance=instance, request=request)
 
+    def get_m2m_fields_from_model(self, model):
+        m2m_fields = set(self.m2m_fields)
+        try:
+            m2m_fields.update(getattr(model, self.m2m_fields_model_field_name))
+        except AttributeError:
+            pass
+        return [getattr(model, field.name).field for field in m2m_fields]
+
 
 def transform_field(field):
     """Customize field appropriately for use in historical model"""
@@ -673,7 +820,7 @@ class HistoricForwardManyToOneDescriptor(ForwardManyToOneDescriptor):
                 None,
             )
             if history and histmgr:
-                return histmgr.as_of(history._as_of)
+                return histmgr.as_of(getattr(history, "_as_of", history.history_date))
         return super().get_queryset(**hints)
 
 
@@ -708,7 +855,9 @@ class HistoricReverseManyToOneDescriptor(ReverseManyToOneDescriptor):
                         None,
                     )
                     if history and histmgr:
-                        queryset = histmgr.as_of(history._as_of)
+                        queryset = histmgr.as_of(
+                            getattr(history, "_as_of", history.history_date)
+                        )
                     else:
                         queryset = super().get_queryset()
                     return self._apply_rel_filters(queryset)
@@ -777,12 +926,18 @@ class HistoricalChanges:
         if excluded_fields is None:
             excluded_fields = set()
 
+        included_m2m_fields = {field.name for field in old_history._history_m2m_fields}
         if included_fields is None:
-            included_fields = {
-                f.name for f in old_history.instance_type._meta.fields if f.editable
-            }
+            included_fields = {f.name for f in old_history.tracked_fields if f.editable}
+        else:
+            included_m2m_fields = included_m2m_fields.intersection(included_fields)
 
-        fields = set(included_fields).difference(excluded_fields)
+        fields = (
+            set(included_fields)
+            .difference(included_m2m_fields)
+            .difference(excluded_fields)
+        )
+        m2m_fields = set(included_m2m_fields).difference(excluded_fields)
 
         changes = []
         changed_fields = []
@@ -798,6 +953,32 @@ class HistoricalChanges:
                 changes.append(ModelChange(field, old_value, current_value))
                 changed_fields.append(field)
 
+        # Separately compare m2m fields:
+        for field in m2m_fields:
+            # First retrieve a single item to get the field names from:
+            reference_history_m2m_item = (
+                getattr(old_history, field).first() or getattr(self, field).first()
+            )
+            history_field_names = []
+            if reference_history_m2m_item:
+                # Create a list of field names to compare against.
+                # The list is generated without the primary key of the intermediate
+                # table, the foreign key to the history record, and the actual 'history'
+                # field, to avoid false positives while diffing.
+                history_field_names = [
+                    f.name
+                    for f in reference_history_m2m_item._meta.fields
+                    if f.editable and f.name not in ["id", "m2m_history_id", "history"]
+                ]
+
+            old_rows = list(getattr(old_history, field).values(*history_field_names))
+            new_rows = list(getattr(self, field).values(*history_field_names))
+
+            if old_rows != new_rows:
+                change = ModelChange(field, old_rows, new_rows)
+                changes.append(change)
+                changed_fields.append(field)
+
         return ModelDelta(changes, changed_fields, old_history, self)
 
 
diff --git a/simple_history/registry_tests/migration_test_app/migrations/0001_initial.py b/simple_history/registry_tests/migration_test_app/migrations/0001_initial.py
index 294409b..f21f55b 100644
--- a/simple_history/registry_tests/migration_test_app/migrations/0001_initial.py
+++ b/simple_history/registry_tests/migration_test_app/migrations/0001_initial.py
@@ -5,7 +5,6 @@ from django.db import migrations, models
 
 
 class Migration(migrations.Migration):
-
     initial = True
 
     dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
diff --git a/simple_history/registry_tests/migration_test_app/migrations/0002_historicalmodelwithcustomattrforeignkey_modelwithcustomattrforeignkey.py b/simple_history/registry_tests/migration_test_app/migrations/0002_historicalmodelwithcustomattrforeignkey_modelwithcustomattrforeignkey.py
index 03e45a9..4cc2bfd 100644
--- a/simple_history/registry_tests/migration_test_app/migrations/0002_historicalmodelwithcustomattrforeignkey_modelwithcustomattrforeignkey.py
+++ b/simple_history/registry_tests/migration_test_app/migrations/0002_historicalmodelwithcustomattrforeignkey_modelwithcustomattrforeignkey.py
@@ -10,7 +10,6 @@ from .. import models as my_models
 
 
 class Migration(migrations.Migration):
-
     dependencies = [
         migrations.swappable_dependency(settings.AUTH_USER_MODEL),
         ("migration_test_app", "0001_initial"),
diff --git a/simple_history/registry_tests/migration_test_app/migrations/0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py b/simple_history/registry_tests/migration_test_app/migrations/0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py
index 0f40470..0bb1268 100644
--- a/simple_history/registry_tests/migration_test_app/migrations/0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py
+++ b/simple_history/registry_tests/migration_test_app/migrations/0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py
@@ -4,7 +4,6 @@ from django.db import migrations
 
 
 class Migration(migrations.Migration):
-
     dependencies = [
         (
             "migration_test_app",
diff --git a/simple_history/registry_tests/migration_test_app/migrations/0004_history_date_indexing.py b/simple_history/registry_tests/migration_test_app/migrations/0004_history_date_indexing.py
index fbe0f04..c8c8390 100644
--- a/simple_history/registry_tests/migration_test_app/migrations/0004_history_date_indexing.py
+++ b/simple_history/registry_tests/migration_test_app/migrations/0004_history_date_indexing.py
@@ -4,7 +4,6 @@ from django.db import migrations, models
 
 
 class Migration(migrations.Migration):
-
     dependencies = [
         (
             "migration_test_app",
diff --git a/simple_history/registry_tests/migration_test_app/migrations/0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield.py b/simple_history/registry_tests/migration_test_app/migrations/0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield.py
index 85859b1..668fc5e 100644
--- a/simple_history/registry_tests/migration_test_app/migrations/0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield.py
+++ b/simple_history/registry_tests/migration_test_app/migrations/0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield.py
@@ -9,7 +9,6 @@ import simple_history.registry_tests.migration_test_app.models
 
 
 class Migration(migrations.Migration):
-
     dependencies = [
         migrations.swappable_dependency(settings.AUTH_USER_MODEL),
         (
diff --git a/simple_history/registry_tests/migration_test_app/migrations/0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more.py b/simple_history/registry_tests/migration_test_app/migrations/0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more.py
index 5c16f6a..2baafe0 100644
--- a/simple_history/registry_tests/migration_test_app/migrations/0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more.py
+++ b/simple_history/registry_tests/migration_test_app/migrations/0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more.py
@@ -4,7 +4,6 @@ from django.db import migrations, models
 
 
 class Migration(migrations.Migration):
-
     dependencies = [
         (
             "migration_test_app",
diff --git a/simple_history/registry_tests/migration_test_app/migrations/0007_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py b/simple_history/registry_tests/migration_test_app/migrations/0007_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py
index d93f5a8..00c5901 100644
--- a/simple_history/registry_tests/migration_test_app/migrations/0007_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py
+++ b/simple_history/registry_tests/migration_test_app/migrations/0007_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py
@@ -4,7 +4,6 @@ from django.db import migrations
 
 
 class Migration(migrations.Migration):
-
     dependencies = [
         (
             "migration_test_app",
diff --git a/simple_history/signals.py b/simple_history/signals.py
index 0900080..270dc38 100644
--- a/simple_history/signals.py
+++ b/simple_history/signals.py
@@ -7,3 +7,11 @@ pre_create_historical_record = django.dispatch.Signal()
 # Arguments: "instance",  "history_instance", "history_date",
 #            "history_user", "history_change_reason", "using"
 post_create_historical_record = django.dispatch.Signal()
+
+# Arguments: "sender",  "rows", "history_instance", "instance",
+#             "field"
+pre_create_historical_m2m_records = django.dispatch.Signal()
+
+# Arguments: "sender",  "created_rows", "history_instance",
+#            "instance", "field"
+post_create_historical_m2m_records = django.dispatch.Signal()
diff --git a/simple_history/tests/models.py b/simple_history/tests/models.py
index 623e67a..a9e86ed 100644
--- a/simple_history/tests/models.py
+++ b/simple_history/tests/models.py
@@ -92,6 +92,22 @@ class PollWithAlternativeManager(models.Model):
     history = HistoricalRecords()
 
 
+class CustomPollManager(models.Manager):
+    def get_queryset(self):
+        return super().get_queryset().exclude(hidden=True)
+
+
+class PollWithCustomManager(models.Model):
+    some_objects = CustomPollManager()
+    all_objects = models.Manager()
+
+    question = models.CharField(max_length=200)
+    pub_date = models.DateTimeField("date published")
+    hidden = models.BooleanField(default=False)
+
+    history = HistoricalRecords()
+
+
 class IPAddressHistoricalModel(models.Model):
     ip_address = models.GenericIPAddressField()
 
@@ -109,6 +125,81 @@ class PollWithHistoricalIPAddress(models.Model):
         return reverse("poll-detail", kwargs={"pk": self.pk})
 
 
+class PollWithManyToMany(models.Model):
+    question = models.CharField(max_length=200)
+    pub_date = models.DateTimeField("date published")
+    places = models.ManyToManyField("Place")
+
+    history = HistoricalRecords(m2m_fields=[places])
+
+
+class PollWithManyToManyCustomHistoryID(models.Model):
+    question = models.CharField(max_length=200)
+    pub_date = models.DateTimeField("date published")
+    places = models.ManyToManyField("Place")
+
+    history = HistoricalRecords(
+        m2m_fields=[places], history_id_field=models.UUIDField(default=uuid.uuid4)
+    )
+
+
+class HistoricalRecordsWithExtraFieldM2M(HistoricalRecords):
+    def get_extra_fields_m2m(self, model, through_model, fields):
+        extra_fields = super().get_extra_fields_m2m(model, through_model, fields)
+
+        def get_class_name(self):
+            return self.__class__.__name__
+
+        extra_fields["get_class_name"] = get_class_name
+        return extra_fields
+
+
+class PollWithManyToManyWithIPAddress(models.Model):
+    question = models.CharField(max_length=200)
+    pub_date = models.DateTimeField("date published")
+    places = models.ManyToManyField("Place")
+
+    history = HistoricalRecordsWithExtraFieldM2M(
+        m2m_fields=[places], m2m_bases=[IPAddressHistoricalModel]
+    )
+
+
+class PollWithSeveralManyToMany(models.Model):
+    question = models.CharField(max_length=200)
+    pub_date = models.DateTimeField("date published")
+    places = models.ManyToManyField("Place", related_name="places_poll")
+    restaurants = models.ManyToManyField("Restaurant", related_name="restaurants_poll")
+    books = models.ManyToManyField("Book", related_name="books_poll")
+
+    history = HistoricalRecords(m2m_fields=[places, restaurants, books])
+
+
+class PollParentWithManyToMany(models.Model):
+    question = models.CharField(max_length=200)
+    pub_date = models.DateTimeField("date published")
+    places = models.ManyToManyField("Place")
+
+    history = HistoricalRecords(
+        m2m_fields=[places],
+        inherit=True,
+    )
+
+    class Meta:
+        abstract = True
+
+
+class PollChildBookWithManyToMany(PollParentWithManyToMany):
+    books = models.ManyToManyField("Book", related_name="books_poll_child")
+    _history_m2m_fields = [books]
+
+
+class PollChildRestaurantWithManyToMany(PollParentWithManyToMany):
+    restaurants = models.ManyToManyField(
+        "Restaurant", related_name="restaurants_poll_child"
+    )
+    _history_m2m_fields = [restaurants]
+
+
 class CustomAttrNameForeignKey(models.ForeignKey):
     def __init__(self, *args, **kwargs):
         self.attr_name = kwargs.pop("attr_name", None)
diff --git a/simple_history/tests/tests/test_commands.py b/simple_history/tests/tests/test_commands.py
index 4994c16..f2f1665 100644
--- a/simple_history/tests/tests/test_commands.py
+++ b/simple_history/tests/tests/test_commands.py
@@ -17,6 +17,7 @@ from ..models import (
     CustomManagerNameModel,
     Place,
     Poll,
+    PollWithCustomManager,
     PollWithExcludeFields,
     Restaurant,
 )
@@ -283,6 +284,55 @@ class TestCleanDuplicateHistory(TestCase):
         )
         self.assertEqual(Poll.history.all().count(), 2)
 
+    def _prepare_cleanup_manager(self):
+        one = PollWithCustomManager._default_manager.create(
+            question="This is hidden in default manager",
+            pub_date=datetime.now(),
+            hidden=True,
+        )
+        one.save()
+
+        two = PollWithCustomManager._default_manager.create(
+            question="This is visible in default manager", pub_date=datetime.now()
+        )
+        two.save()
+
+        self.assertEqual(PollWithCustomManager.history.count(), 4)
+
+    def test_auto_cleanup_defaultmanager(self):
+        self._prepare_cleanup_manager()
+
+        out = StringIO()
+        management.call_command(
+            self.command_name, auto=True, stdout=out, stderr=StringIO()
+        )
+        self.assertEqual(
+            out.getvalue(),
+            "Removed 1 historical records for "
+            "<class 'simple_history.tests.models.PollWithCustomManager'>\n",
+        )
+        self.assertEqual(PollWithCustomManager.history.count(), 3)
+
+    def test_auto_cleanup_basemanage(self):
+        self._prepare_cleanup_manager()
+
+        out = StringIO()
+        management.call_command(
+            self.command_name,
+            auto=True,
+            base_manager=True,
+            stdout=out,
+            stderr=StringIO(),
+        )
+        self.assertEqual(
+            out.getvalue(),
+            "Removed 1 historical records for "
+            "<class 'simple_history.tests.models.PollWithCustomManager'>\n"
+            "Removed 1 historical records for "
+            "<class 'simple_history.tests.models.PollWithCustomManager'>\n",
+        )
+        self.assertEqual(PollWithCustomManager.history.count(), 2)
+
     def test_auto_cleanup_verbose(self):
         p = Poll.objects.create(
             question="Will this be deleted?", pub_date=datetime.now()
@@ -410,6 +460,25 @@ class TestCleanDuplicateHistory(TestCase):
         )
         self.assertEqual(Poll.history.all().count(), 1)
 
+    def test_auto_cleanup_for_model_with_excluded_fields(self):
+        p = PollWithExcludeFields.objects.create(
+            question="Will this be deleted?", pub_date=datetime.now()
+        )
+        self.assertEqual(PollWithExcludeFields.history.all().count(), 1)
+        p.pub_date = p.pub_date + timedelta(days=1)
+        p.save()
+        self.assertEqual(PollWithExcludeFields.history.all().count(), 2)
+        out = StringIO()
+        management.call_command(
+            self.command_name, auto=True, stdout=out, stderr=StringIO()
+        )
+        self.assertEqual(
+            out.getvalue(),
+            "Removed 1 historical records for "
+            "<class 'simple_history.tests.models.PollWithExcludeFields'>\n",
+        )
+        self.assertEqual(PollWithExcludeFields.history.all().count(), 1)
+
 
 class TestCleanOldHistory(TestCase):
     command_name = "clean_old_history"
diff --git a/simple_history/tests/tests/test_models.py b/simple_history/tests/tests/test_models.py
index 8862d86..5dd87ae 100644
--- a/simple_history/tests/tests/test_models.py
+++ b/simple_history/tests/tests/test_models.py
@@ -24,7 +24,10 @@ from simple_history.models import (
     is_historic,
     to_historic,
 )
-from simple_history.signals import pre_create_historical_record
+from simple_history.signals import (
+    pre_create_historical_m2m_records,
+    pre_create_historical_record,
+)
 from simple_history.tests.custom_user.models import CustomUser
 from simple_history.tests.tests.utils import (
     database_router_override_settings,
@@ -69,10 +72,12 @@ from ..models import (
     HistoricalCustomFKError,
     HistoricalPoll,
     HistoricalPollWithHistoricalIPAddress,
+    HistoricalPollWithManyToMany_places,
     HistoricalState,
     InheritedRestaurant,
     Library,
     ManyToManyModelOther,
+    ModelWithCustomAttrOneToOneField,
     ModelWithExcludedManyToMany,
     ModelWithFkToModelWithHistoryUsingBaseModelDb,
     ModelWithHistoryInDifferentDb,
@@ -86,12 +91,19 @@ from ..models import (
     Person,
     Place,
     Poll,
+    PollChildBookWithManyToMany,
+    PollChildRestaurantWithManyToMany,
     PollInfo,
+    PollWithAlternativeManager,
     PollWithExcludedFieldsWithDefaults,
     PollWithExcludedFKField,
     PollWithExcludeFields,
     PollWithHistoricalIPAddress,
+    PollWithManyToMany,
+    PollWithManyToManyCustomHistoryID,
+    PollWithManyToManyWithIPAddress,
     PollWithNonEditableField,
+    PollWithSeveralManyToMany,
     Province,
     Restaurant,
     SelfFK,
@@ -884,11 +896,65 @@ class GetPrevRecordAndNextRecordTestCase(TestCase):
 
 
 class CreateHistoryModelTests(unittest.TestCase):
+    @staticmethod
+    def create_history_model(model, inherited):
+        custom_model_name_prefix = f"Mock{HistoricalRecords.DEFAULT_MODEL_NAME_PREFIX}"
+        records = HistoricalRecords(
+            # Provide a custom history model name, to prevent name collisions
+            # with existing historical models
+            custom_model_name=lambda name: f"{custom_model_name_prefix}{name}",
+        )
+        records.module = model.__module__
+        return records.create_history_model(model, inherited)
+
+    def test_create_history_model_has_expected_tracked_files_attr(self):
+        def assert_tracked_fields_equal(model, expected_field_names):
+            from .. import models
+
+            history_model = getattr(
+                models, f"{HistoricalRecords.DEFAULT_MODEL_NAME_PREFIX}{model.__name__}"
+            )
+            self.assertListEqual(
+                [field.name for field in history_model.tracked_fields],
+                expected_field_names,
+            )
+
+        assert_tracked_fields_equal(
+            Poll,
+            ["id", "question", "pub_date"],
+        )
+        assert_tracked_fields_equal(
+            PollWithNonEditableField,
+            ["id", "question", "pub_date", "modified"],
+        )
+        assert_tracked_fields_equal(
+            PollWithExcludeFields,
+            ["id", "question", "place"],
+        )
+        assert_tracked_fields_equal(
+            PollWithExcludedFieldsWithDefaults,
+            ["id", "question"],
+        )
+        assert_tracked_fields_equal(
+            PollWithExcludedFKField,
+            ["id", "question", "pub_date"],
+        )
+        assert_tracked_fields_equal(
+            PollWithAlternativeManager,
+            ["id", "question", "pub_date"],
+        )
+        assert_tracked_fields_equal(
+            PollWithHistoricalIPAddress,
+            ["id", "question", "pub_date"],
+        )
+        assert_tracked_fields_equal(
+            ModelWithCustomAttrOneToOneField,
+            ["id", "poll"],
+        )
+
     def test_create_history_model_with_one_to_one_field_to_integer_field(self):
-        records = HistoricalRecords()
-        records.module = AdminProfile.__module__
         try:
-            records.create_history_model(AdminProfile, False)
+            self.create_history_model(AdminProfile, False)
         except Exception:
             self.fail(
                 "SimpleHistory should handle foreign keys to one to one"
@@ -896,10 +962,8 @@ class CreateHistoryModelTests(unittest.TestCase):
             )
 
     def test_create_history_model_with_one_to_one_field_to_char_field(self):
-        records = HistoricalRecords()
-        records.module = Bookcase.__module__
         try:
-            records.create_history_model(Bookcase, False)
+            self.create_history_model(Bookcase, False)
         except Exception:
             self.fail(
                 "SimpleHistory should handle foreign keys to one to one"
@@ -907,10 +971,8 @@ class CreateHistoryModelTests(unittest.TestCase):
             )
 
     def test_create_history_model_with_multiple_one_to_ones(self):
-        records = HistoricalRecords()
-        records.module = MultiOneToOne.__module__
         try:
-            records.create_history_model(MultiOneToOne, False)
+            self.create_history_model(MultiOneToOne, False)
         except Exception:
             self.fail(
                 "SimpleHistory should handle foreign keys to one to one"
@@ -1324,14 +1386,8 @@ class TestOrderWrtField(TestCase):
 
         model_state = state.ModelState.from_model(SeriesWork.history.model)
         found = False
-        # `fields` is a dict in Django 3.1
-        fields = None
-        if isinstance(model_state.fields, dict):
-            fields = model_state.fields.items()
-        else:
-            fields = model_state.fields
-
-        for name, field in fields:
+
+        for name, field in model_state.fields.items():
             if name == "_order":
                 found = True
                 self.assertEqual(type(field), models.IntegerField)
@@ -1488,6 +1544,11 @@ def add_static_history_ip_address(sender, **kwargs):
     history_instance.ip_address = "192.168.0.1"
 
 
+def add_static_history_ip_address_on_m2m(sender, rows, **kwargs):
+    for row in rows:
+        row.ip_address = "192.168.0.1"
+
+
 class ExtraFieldsStaticIPAddressTestCase(TestCase):
     def setUp(self):
         pre_create_historical_record.connect(
@@ -1699,6 +1760,465 @@ class ForeignKeyToSelfTest(TestCase):
         )
 
 
+class SeveralManyToManyTest(TestCase):
+    def setUp(self):
+        self.model = PollWithSeveralManyToMany
+        self.history_model = self.model.history.model
+        self.place = Place.objects.create(name="Home")
+        self.book = Book.objects.create(isbn="1234")
+        self.restaurant = Restaurant.objects.create(rating=1)
+        self.poll = PollWithSeveralManyToMany.objects.create(
+            question="what's up?", pub_date=today
+        )
+
+    def test_separation(self):
+        self.assertEqual(self.poll.history.all().count(), 1)
+        self.poll.places.add(self.place)
+        self.poll.books.add(self.book)
+        self.poll.restaurants.add(self.restaurant)
+        self.assertEqual(self.poll.history.all().count(), 4)
+
+        restaurant, book, place, add = self.poll.history.all()
+
+        self.assertEqual(restaurant.restaurants.all().count(), 1)
+        self.assertEqual(restaurant.books.all().count(), 1)
+        self.assertEqual(restaurant.places.all().count(), 1)
+        self.assertEqual(restaurant.restaurants.first().restaurant, self.restaurant)
+
+        self.assertEqual(book.restaurants.all().count(), 0)
+        self.assertEqual(book.books.all().count(), 1)
+        self.assertEqual(book.places.all().count(), 1)
+        self.assertEqual(book.books.first().book, self.book)
+
+        self.assertEqual(place.restaurants.all().count(), 0)
+        self.assertEqual(place.books.all().count(), 0)
+        self.assertEqual(place.places.all().count(), 1)
+        self.assertEqual(place.places.first().place, self.place)
+
+        self.assertEqual(add.restaurants.all().count(), 0)
+        self.assertEqual(add.books.all().count(), 0)
+        self.assertEqual(add.places.all().count(), 0)
+
+
+class InheritedManyToManyTest(TestCase):
+    def setUp(self):
+        self.model_book = PollChildBookWithManyToMany
+        self.model_rstr = PollChildRestaurantWithManyToMany
+        self.place = Place.objects.create(name="Home")
+        self.book = Book.objects.create(isbn="1234")
+        self.restaurant = Restaurant.objects.create(rating=1)
+        self.poll_book = self.model_book.objects.create(
+            question="what's up?", pub_date=today
+        )
+        self.poll_rstr = self.model_rstr.objects.create(
+            question="what's up?", pub_date=today
+        )
+
+    def test_separation(self):
+        self.assertEqual(self.poll_book.history.all().count(), 1)
+        self.poll_book.places.add(self.place)
+        self.poll_book.books.add(self.book)
+        self.assertEqual(self.poll_book.history.all().count(), 3)
+
+        self.assertEqual(self.poll_rstr.history.all().count(), 1)
+        self.poll_rstr.places.add(self.place)
+        self.poll_rstr.restaurants.add(self.restaurant)
+        self.assertEqual(self.poll_rstr.history.all().count(), 3)
+
+        book, place, add = self.poll_book.history.all()
+
+        self.assertEqual(book.books.all().count(), 1)
+        self.assertEqual(book.places.all().count(), 1)
+        self.assertEqual(book.books.first().book, self.book)
+
+        self.assertEqual(place.books.all().count(), 0)
+        self.assertEqual(place.places.all().count(), 1)
+        self.assertEqual(place.places.first().place, self.place)
+
+        self.assertEqual(add.books.all().count(), 0)
+        self.assertEqual(add.places.all().count(), 0)
+
+        restaurant, place, add = self.poll_rstr.history.all()
+
+        self.assertEqual(restaurant.restaurants.all().count(), 1)
+        self.assertEqual(restaurant.places.all().count(), 1)
+        self.assertEqual(restaurant.restaurants.first().restaurant, self.restaurant)
+
+        self.assertEqual(place.restaurants.all().count(), 0)
+        self.assertEqual(place.places.all().count(), 1)
+        self.assertEqual(place.places.first().place, self.place)
+
+        self.assertEqual(add.restaurants.all().count(), 0)
+        self.assertEqual(add.places.all().count(), 0)
+
+
+class ManyToManyWithSignalsTest(TestCase):
+    def setUp(self):
+        self.model = PollWithManyToManyWithIPAddress
+        # self.historical_through_model = self.model.history.
+        self.places = (
+            Place.objects.create(name="London"),
+            Place.objects.create(name="Paris"),
+        )
+        self.poll = self.model.objects.create(question="what's up?", pub_date=today)
+        pre_create_historical_m2m_records.connect(
+            add_static_history_ip_address_on_m2m,
+            dispatch_uid="add_static_history_ip_address_on_m2m",
+        )
+
+    def tearDown(self):
+        pre_create_historical_m2m_records.disconnect(
+            add_static_history_ip_address_on_m2m,
+            dispatch_uid="add_static_history_ip_address_on_m2m",
+        )
+
+    def test_ip_address_added(self):
+        self.poll.places.add(*self.places)
+
+        places = self.poll.history.first().places
+        self.assertEqual(2, places.count())
+        for place in places.all():
+            self.assertEqual("192.168.0.1", place.ip_address)
+
+    def test_extra_field(self):
+        self.poll.places.add(*self.places)
+        m2m_record = self.poll.history.first().places.first()
+        self.assertEqual(
+            m2m_record.get_class_name(),
+            "HistoricalPollWithManyToManyWithIPAddress_places",
+        )
+
+    def test_diff(self):
+        self.poll.places.clear()
+        self.poll.places.add(*self.places)
+
+        new = self.poll.history.first()
+        old = new.prev_record
+
+        delta = new.diff_against(old)
+
+        self.assertEqual("places", delta.changes[0].field)
+        self.assertEqual(2, len(delta.changes[0].new))
+
+
+class ManyToManyCustomIDTest(TestCase):
+    def setUp(self):
+        self.model = PollWithManyToManyCustomHistoryID
+        self.history_model = self.model.history.model
+        self.place = Place.objects.create(name="Home")
+        self.poll = self.model.objects.create(question="what's up?", pub_date=today)
+
+
+class ManyToManyTest(TestCase):
+    def setUp(self):
+        self.model = PollWithManyToMany
+        self.history_model = self.model.history.model
+        self.place = Place.objects.create(name="Home")
+        self.poll = PollWithManyToMany.objects.create(
+            question="what's up?", pub_date=today
+        )
+
+    def assertDatetimesEqual(self, time1, time2):
+        self.assertAlmostEqual(time1, time2, delta=timedelta(seconds=2))
+
+    def assertRecordValues(self, record, klass, values_dict):
+        for key, value in values_dict.items():
+            self.assertEqual(getattr(record, key), value)
+        self.assertEqual(record.history_object.__class__, klass)
+        for key, value in values_dict.items():
+            if key not in ["history_type", "history_change_reason"]:
+                self.assertEqual(getattr(record.history_object, key), value)
+
+    def test_create(self):
+        # There should be 1 history record for our poll, the create from setUp
+        self.assertEqual(self.poll.history.all().count(), 1)
+
+        # The created history row should be normal and correct
+        (record,) = self.poll.history.all()
+        self.assertRecordValues(
+            record,
+            self.model,
+            {
+                "question": "what's up?",
+                "pub_date": today,
+                "id": self.poll.id,
+                "history_type": "+",
+            },
+        )
+        self.assertDatetimesEqual(record.history_date, datetime.now())
+
+        historical_poll = self.poll.history.all()[0]
+
+        # There should be no places associated with the current poll yet
+        self.assertEqual(historical_poll.places.count(), 0)
+
+        # Add a many-to-many child
+        self.poll.places.add(self.place)
+
+        # A new history row has been created by adding the M2M
+        self.assertEqual(self.poll.history.all().count(), 2)
+
+        # The new row has a place attached to it
+        m2m_record = self.poll.history.all()[0]
+        self.assertEqual(m2m_record.places.count(), 1)
+
+        # And the historical place is the correct one
+        historical_place = m2m_record.places.first()
+        self.assertEqual(historical_place.place, self.place)
+
+    def test_remove(self):
+        # Add and remove a many-to-many child
+        self.poll.places.add(self.place)
+        self.poll.places.remove(self.place)
+
+        # Two new history exist for the place add & remove
+        self.assertEqual(self.poll.history.all().count(), 3)
+
+        # The newest row has no place attached to it
+        m2m_record = self.poll.history.all()[0]
+        self.assertEqual(m2m_record.places.count(), 0)
+
+        # The previous one should have one place
+        previous_m2m_record = m2m_record.prev_record
+        self.assertEqual(previous_m2m_record.places.count(), 1)
+
+        # And the previous row still has the correct one
+        historical_place = previous_m2m_record.places.first()
+        self.assertEqual(historical_place.place, self.place)
+
+    def test_clear(self):
+        # Add some places
+        place_2 = Place.objects.create(name="Place 2")
+        place_3 = Place.objects.create(name="Place 3")
+        place_4 = Place.objects.create(name="Place 4")
+        self.poll.places.add(self.place)
+        self.poll.places.add(place_2)
+        self.poll.places.add(place_3)
+        self.poll.places.add(place_4)
+
+        # Should be 5 history rows, one for the create, one from each add
+        self.assertEqual(self.poll.history.all().count(), 5)
+
+        # Most recent should have 4 places
+        m2m_record = self.poll.history.all()[0]
+        self.assertEqual(m2m_record.places.all().count(), 4)
+
+        # Previous one should have 3
+        prev_record = m2m_record.prev_record
+        self.assertEqual(prev_record.places.all().count(), 3)
+
+        # Clear all places
+        self.poll.places.clear()
+
+        # Clearing M2M should create a new history entry
+        self.assertEqual(self.poll.history.all().count(), 6)
+
+        # Most recent should have no places
+        m2m_record = self.poll.history.all()[0]
+        self.assertEqual(m2m_record.places.all().count(), 0)
+
+    def test_delete_child(self):
+        # Add a place
+        original_place_id = self.place.id
+        self.poll.places.add(self.place)
+        self.assertEqual(self.poll.history.all().count(), 2)
+
+        # Delete the place instance
+        self.place.delete()
+
+        # No new history row is created when the Place is deleted
+        self.assertEqual(self.poll.history.all().count(), 2)
+
+        # The newest row still has a place attached to it
+        m2m_record = self.poll.history.all()[0]
+        self.assertEqual(m2m_record.places.count(), 1)
+
+        # Place instance cannot be created...
+        historical_place = m2m_record.places.first()
+        with self.assertRaises(ObjectDoesNotExist):
+            historical_place.place.id
+
+        # But the values persist
+        historical_place_values = m2m_record.places.all().values()[0]
+        self.assertEqual(historical_place_values["history_id"], m2m_record.history_id)
+        self.assertEqual(historical_place_values["place_id"], original_place_id)
+        self.assertEqual(historical_place_values["pollwithmanytomany_id"], self.poll.id)
+
+    def test_delete_parent(self):
+        # Add a place
+        self.poll.places.add(self.place)
+        self.assertEqual(self.poll.history.all().count(), 2)
+
+        # Delete the poll instance
+        self.poll.delete()
+
+        # History row is created when the Poll is deleted, but all m2m relations have
+        # been deleted
+        self.assertEqual(self.model.history.all().count(), 3)
+
+        # Confirm the newest row (the delete) has no relations
+        m2m_record = self.model.history.all()[0]
+        self.assertEqual(m2m_record.places.count(), 0)
+
+        # Confirm the previous row still has one
+        prev_record = m2m_record.prev_record
+        self.assertEqual(prev_record.places.count(), 1)
+
+        # And it is the correct one
+        historical_place = prev_record.places.first()
+        self.assertEqual(historical_place.place, self.place)
+
+    def test_update_child(self):
+        self.poll.places.add(self.place)
+
+        # Only two history rows, one for create and one for the M2M add
+        self.assertEqual(self.poll.history.all().count(), 2)
+
+        self.place.name = "Updated"
+        self.place.save()
+
+        # Updating the referenced M2M does not add history
+        self.assertEqual(self.poll.history.all().count(), 2)
+
+        # The newest row has the updated place
+        m2m_record = self.poll.history.all()[0]
+        self.assertEqual(m2m_record.places.count(), 1)
+        historical_place = m2m_record.places.first()
+        self.assertEqual(historical_place.place.name, "Updated")
+
+    def test_update_parent(self):
+        self.poll.places.add(self.place)
+
+        # Only two history rows, one for create and one for the M2M add
+        self.assertEqual(self.poll.history.all().count(), 2)
+
+        self.poll.question = "Updated?"
+        self.poll.save()
+
+        # Updating the model with the M2M on it creates new history
+        self.assertEqual(self.poll.history.all().count(), 3)
+
+        # The newest row still has the associated Place
+        m2m_record = self.poll.history.all()[0]
+        self.assertEqual(m2m_record.places.count(), 1)
+        historical_place = m2m_record.places.first()
+        self.assertEqual(historical_place.place, self.place)
+
+    def test_bulk_add_remove(self):
+        # Add some places
+        Place.objects.create(name="Place 2")
+        Place.objects.create(name="Place 3")
+        Place.objects.create(name="Place 4")
+
+        # Bulk add all of the places
+        self.poll.places.add(*Place.objects.all())
+
+        # Should be 2 history rows, one for the create, one from the bulk add
+        self.assertEqual(self.poll.history.all().count(), 2)
+
+        # Most recent should have 4 places
+        m2m_record = self.poll.history.all()[0]
+        self.assertEqual(m2m_record.places.all().count(), 4)
+
+        # Previous one should have 0
+        prev_record = m2m_record.prev_record
+        self.assertEqual(prev_record.places.all().count(), 0)
+
+        # Remove all places but the first
+        self.poll.places.remove(*Place.objects.exclude(pk=self.place.pk))
+
+        self.assertEqual(self.poll.history.all().count(), 3)
+
+        # Most recent should only have the first Place remaining
+        m2m_record = self.poll.history.all()[0]
+        self.assertEqual(m2m_record.places.all().count(), 1)
+
+        historical_place = m2m_record.places.first()
+        self.assertEqual(historical_place.place, self.place)
+
+    def test_m2m_relation(self):
+        # Ensure only the correct M2Ms are saved and returned for history objects
+        poll_2 = PollWithManyToMany.objects.create(question="Why", pub_date=today)
+        place_2 = Place.objects.create(name="Place 2")
+
+        poll_2.places.add(self.place)
+        poll_2.places.add(place_2)
+
+        self.assertEqual(self.poll.history.all()[0].places.count(), 0)
+        self.assertEqual(poll_2.history.all()[0].places.count(), 2)
+
+    def test_skip_history(self):
+        skip_poll = PollWithManyToMany.objects.create(
+            question="skip history?", pub_date=today
+        )
+        self.assertEqual(self.poll.history.all().count(), 1)
+        self.assertEqual(self.poll.history.all()[0].places.count(), 0)
+
+        skip_poll.skip_history_when_saving = True
+
+        skip_poll.question = "huh?"
+        skip_poll.save()
+        skip_poll.places.add(self.place)
+
+        self.assertEqual(self.poll.history.all().count(), 1)
+        self.assertEqual(self.poll.history.all()[0].places.count(), 0)
+
+        del skip_poll.skip_history_when_saving
+        place_2 = Place.objects.create(name="Place 2")
+
+        skip_poll.places.add(place_2)
+
+        self.assertEqual(skip_poll.history.all().count(), 2)
+        self.assertEqual(skip_poll.history.all()[0].places.count(), 2)
+
+    def test_diff_against(self):
+        self.poll.places.add(self.place)
+        add_record, create_record = self.poll.history.all()
+
+        delta = add_record.diff_against(create_record)
+        expected_change = ModelChange(
+            "places", [], [{"pollwithmanytomany": self.poll.pk, "place": self.place.pk}]
+        )
+        self.assertEqual(delta.changed_fields, ["places"])
+        self.assertEqual(delta.old_record, create_record)
+        self.assertEqual(delta.new_record, add_record)
+        self.assertEqual(expected_change.field, delta.changes[0].field)
+
+        self.assertListEqual(expected_change.new, delta.changes[0].new)
+        self.assertListEqual(expected_change.old, delta.changes[0].old)
+
+        delta = add_record.diff_against(create_record, included_fields=["places"])
+        self.assertEqual(delta.changed_fields, ["places"])
+        self.assertEqual(delta.old_record, create_record)
+        self.assertEqual(delta.new_record, add_record)
+        self.assertEqual(expected_change.field, delta.changes[0].field)
+
+        delta = add_record.diff_against(create_record, excluded_fields=["places"])
+        self.assertEqual(delta.changed_fields, [])
+        self.assertEqual(delta.old_record, create_record)
+        self.assertEqual(delta.new_record, add_record)
+
+        self.poll.places.clear()
+
+        # First and third records are effectively the same.
+        del_record, add_record, create_record = self.poll.history.all()
+        delta = del_record.diff_against(create_record)
+        self.assertNotIn("places", delta.changed_fields)
+
+        # Second and third should have the same diffs as first and second, but with
+        # old and new reversed
+        expected_change = ModelChange(
+            "places", [{"place": self.place.pk, "pollwithmanytomany": self.poll.pk}], []
+        )
+        delta = del_record.diff_against(add_record)
+        self.assertEqual(delta.changed_fields, ["places"])
+        self.assertEqual(delta.old_record, add_record)
+        self.assertEqual(delta.new_record, del_record)
+        self.assertEqual(expected_change.field, delta.changes[0].field)
+        self.assertListEqual(expected_change.new, delta.changes[0].new)
+        self.assertListEqual(expected_change.old, delta.changes[0].old)
+
+
 @override_settings(**database_router_override_settings)
 class MultiDBExplicitHistoryUserIDTest(TestCase):
     databases = {"default", "other"}
@@ -2012,3 +2532,16 @@ class HistoricForeignKeyTest(TestCase):
             to_historic(ot1), TestOrganizationWithHistory.history.model
         )
         self.assertIsNone(to_historic(org))
+
+        # test querying directly from the history table and converting
+        # to an instance, it should chase the foreign key properly
+        # in this case if _as_of is not present we use the history_date
+        # https://github.com/jazzband/django-simple-history/issues/983
+        pt1h = TestHistoricParticipanToHistoricOrganization.history.all()[0]
+        pt1i = pt1h.instance
+        self.assertEqual(pt1i.organization.name, "modified")
+        pt1h = TestHistoricParticipanToHistoricOrganization.history.all().order_by(
+            "history_date"
+        )[0]
+        pt1i = pt1h.instance
+        self.assertEqual(pt1i.organization.name, "original")
diff --git a/simple_history/tests/tests/test_signals.py b/simple_history/tests/tests/test_signals.py
index fc7a0f0..fe0b9c9 100644
--- a/simple_history/tests/tests/test_signals.py
+++ b/simple_history/tests/tests/test_signals.py
@@ -3,11 +3,13 @@ from datetime import datetime
 from django.test import TestCase
 
 from simple_history.signals import (
+    post_create_historical_m2m_records,
     post_create_historical_record,
+    pre_create_historical_m2m_records,
     pre_create_historical_record,
 )
 
-from ..models import Poll
+from ..models import Place, Poll, PollWithManyToMany
 
 today = datetime(2021, 1, 1, 10, 0)
 
@@ -18,6 +20,8 @@ class PrePostCreateHistoricalRecordSignalTest(TestCase):
         self.signal_instance = None
         self.signal_history_instance = None
         self.signal_sender = None
+        self.field = None
+        self.rows = None
 
     def test_pre_create_historical_record_signal(self):
         def handler(sender, instance, **kwargs):
@@ -52,3 +56,59 @@ class PrePostCreateHistoricalRecordSignalTest(TestCase):
         self.assertEqual(self.signal_instance, p)
         self.assertIsNotNone(self.signal_history_instance)
         self.assertEqual(self.signal_sender, p.history.first().__class__)
+
+    def test_pre_create_historical_m2m_records_signal(self):
+        def handler(sender, rows, history_instance, instance, field, **kwargs):
+            self.signal_was_called = True
+            self.signal_instance = instance
+            self.signal_history_instance = history_instance
+            self.signal_sender = sender
+            self.rows = rows
+            self.field = field
+
+        pre_create_historical_m2m_records.connect(handler)
+
+        p = PollWithManyToMany(
+            question="what's up?",
+            pub_date=today,
+        )
+        p.save()
+        self.setUp()
+        p.places.add(
+            Place.objects.create(name="London"), Place.objects.create(name="Paris")
+        )
+
+        self.assertTrue(self.signal_was_called)
+        self.assertEqual(self.signal_instance, p)
+        self.assertIsNotNone(self.signal_history_instance)
+        self.assertEqual(self.signal_sender, p.history.first().places.model)
+        self.assertEqual(self.field, PollWithManyToMany._meta.many_to_many[0])
+        self.assertEqual(len(self.rows), 2)
+
+    def test_post_create_historical_m2m_records_signal(self):
+        def handler(sender, created_rows, history_instance, instance, field, **kwargs):
+            self.signal_was_called = True
+            self.signal_instance = instance
+            self.signal_history_instance = history_instance
+            self.signal_sender = sender
+            self.rows = created_rows
+            self.field = field
+
+        post_create_historical_m2m_records.connect(handler)
+
+        p = PollWithManyToMany(
+            question="what's up?",
+            pub_date=today,
+        )
+        p.save()
+        self.setUp()
+        p.places.add(
+            Place.objects.create(name="London"), Place.objects.create(name="Paris")
+        )
+
+        self.assertTrue(self.signal_was_called)
+        self.assertEqual(self.signal_instance, p)
+        self.assertIsNotNone(self.signal_history_instance)
+        self.assertEqual(self.signal_sender, p.history.first().places.model)
+        self.assertEqual(self.field, PollWithManyToMany._meta.many_to_many[0])
+        self.assertEqual(len(self.rows), 2)
diff --git a/simple_history/tests/tests/test_utils.py b/simple_history/tests/tests/test_utils.py
index b88a6fc..0b629a4 100644
--- a/simple_history/tests/tests/test_utils.py
+++ b/simple_history/tests/tests/test_utils.py
@@ -186,6 +186,38 @@ class BulkCreateWithHistoryTestCase(TestCase):
         self.assertEqual(PollWithUniqueQuestion.objects.count(), 2)
         self.assertEqual(PollWithUniqueQuestion.history.count(), 2)
 
+    def test_bulk_create_history_with_no_ids_return(self):
+        pub_date = timezone.now()
+        objects = [
+            Poll(question="Question 1", pub_date=pub_date),
+            Poll(question="Question 2", pub_date=pub_date),
+            Poll(question="Question 3", pub_date=pub_date),
+            Poll(question="Question 4", pub_date=pub_date),
+            Poll(question="Question 5", pub_date=pub_date),
+        ]
+
+        _bulk_create = Poll._default_manager.bulk_create
+
+        def mock_bulk_create(*args, **kwargs):
+            _bulk_create(*args, **kwargs)
+            return [
+                Poll(question="Question 1", pub_date=pub_date),
+                Poll(question="Question 2", pub_date=pub_date),
+                Poll(question="Question 3", pub_date=pub_date),
+                Poll(question="Question 4", pub_date=pub_date),
+                Poll(question="Question 5", pub_date=pub_date),
+            ]
+
+        with patch.object(
+            Poll._default_manager, "bulk_create", side_effect=mock_bulk_create
+        ):
+            with self.assertNumQueries(3):
+                result = bulk_create_with_history(objects, Poll)
+            self.assertEqual(
+                [poll.question for poll in result], [poll.question for poll in objects]
+            )
+            self.assertNotEqual(result[0].id, None)
+
 
 class BulkCreateWithHistoryTransactionTestCase(TransactionTestCase):
     def setUp(self):
@@ -242,7 +274,7 @@ class BulkCreateWithHistoryTransactionTestCase(TransactionTestCase):
         model = Mock(
             _default_manager=Mock(
                 bulk_create=Mock(return_value=[Place(name="Place 1")]),
-                filter=Mock(return_value=objects),
+                filter=Mock(return_value=Mock(order_by=Mock(return_value=objects))),
             ),
             _meta=Mock(get_fields=Mock(return_value=[])),
         )
diff --git a/simple_history/utils.py b/simple_history/utils.py
index e1ec61d..a3b405b 100644
--- a/simple_history/utils.py
+++ b/simple_history/utils.py
@@ -1,8 +1,5 @@
-import warnings
-
-import django
 from django.db import transaction
-from django.db.models import ForeignKey, ManyToManyField
+from django.db.models import Case, ForeignKey, ManyToManyField, Q, When
 from django.forms.models import model_to_dict
 
 from simple_history.exceptions import AlternativeManagerError, NotHistoricalModelError
@@ -111,16 +108,35 @@ def bulk_create_with_history(
                 default_date=default_date,
             )
     if second_transaction_required:
-        obj_list = []
         with transaction.atomic(savepoint=False):
-            for obj in objs_with_id:
+            # Generate a common query to avoid n+1 selections
+            #   https://github.com/jazzband/django-simple-history/issues/974
+            cumulative_filter = None
+            obj_when_list = []
+            for i, obj in enumerate(objs_with_id):
                 attributes = dict(
                     filter(
                         lambda x: x[1] is not None,
                         model_to_dict(obj, exclude=exclude_fields).items(),
                     )
                 )
-                obj_list += model_manager.filter(**attributes)
+                q = Q(**attributes)
+                cumulative_filter = (cumulative_filter | q) if cumulative_filter else q
+                # https://stackoverflow.com/a/49625179/1960509
+                # DEV: If an attribute has `then` as a key
+                #   then they'll also run into issues with `bulk_update`
+                #   due to shared implementation
+                #   https://github.com/django/django/blob/4.0.4/django/db/models/query.py#L624-L638
+                obj_when_list.append(When(**attributes, then=i))
+            obj_list = (
+                list(
+                    model_manager.filter(cumulative_filter).order_by(
+                        Case(*obj_when_list)
+                    )
+                )
+                if objs_with_id
+                else []
+            )
             history_manager.bulk_history_create(
                 obj_list,
                 batch_size=batch_size,
diff --git a/tox.ini b/tox.ini
index a33b8c7..1d57f75 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,7 +1,9 @@
 [tox]
 envlist =
     py{37,38,39,310}-dj32-{sqlite3,postgres,mysql,mariadb},
-    py{38,39,310}-dj{40,main}-{sqlite3,postgres,mysql,mariadb},
+    py{38,39}-dj{40,41}-{sqlite3,postgres,mysql,mariadb},
+    py310-dj{40,41,main}-{sqlite3,postgres,mysql,mariadb},
+    py311-dj{41,main}-{sqlite3,postgres,mysql,mariadb},
     docs,
     lint
 
@@ -11,11 +13,13 @@ python =
     3.8: py38, docs, lint
     3.9: py39
     3.10: py310
+    3.11: py311
 
 [gh-actions:env]
 DJANGO =
     3.2: dj32
     4.0: dj40
+    4.1: dj41
     main: djmain
 
 [flake8]
@@ -29,6 +33,7 @@ deps =
     -rrequirements/test.txt
     dj32: Django>=3.2,<3.3
     dj40: Django>=4.0,<4.1
+    dj41: Django>=4.1,<4.2
     djmain: https://github.com/django/django/tarball/main
     postgres: -rrequirements/postgres.txt
     mysql: -rrequirements/mysql.txt

Debdiff

[The following lists of changes regard files as different if they have different names, permissions or owners.]

Files in second set of .debs but not in first

-rw-r--r--  root/root   /usr/lib/python3/dist-packages/django_simple_history-3.3.0.egg-info/PKG-INFO
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/django_simple_history-3.3.0.egg-info/dependency_links.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/django_simple_history-3.3.0.egg-info/top_level.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/simple_history/locale/ar/LC_MESSAGES/django.mo
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/simple_history/locale/ar/LC_MESSAGES/django.po

Files in first set of .debs but not in second

-rw-r--r--  root/root   /usr/lib/python3/dist-packages/django_simple_history-3.1.1.egg-info/PKG-INFO
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/django_simple_history-3.1.1.egg-info/dependency_links.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/django_simple_history-3.1.1.egg-info/top_level.txt

No differences were encountered in the control files

More details

Full run details