diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 59ee48d..63b6a9f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,12 +8,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v6 with: - python-version: '3.8' + python-version: '3.14' architecture: 'x64' - name: Install dependencies diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45f3fc6..ede59e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,32 +2,33 @@ name: Linter and tests on: [push, pull_request] jobs: - build: + build-python: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] - django-version: ['2.2', '3.2'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + django-version: ['5.2', '6.0'] + exclude: + - python-version: '3.10' + django-version: '6.0' + - python-version: '3.11' + django-version: '6.0' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 codecov + pip install pre-commit codecov - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Lint with pre-commit + run: pre-commit run --all-files --show-diff-on-failure - name: Install Django ${{ matrix.django-version }} run: pip install django==${{ matrix.django-version }} @@ -49,4 +50,68 @@ jobs: coverage xml - name: Upload coverage to Codecov + if: ${{ !env.ACT }} + uses: codecov/codecov-action@v1 + + build-django: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - python-version: '3.10' + django-version: '5.2' + - python-version: '3.11' + django-version: '5.2' + - python-version: '3.12' + django-version: '5.2' + - python-version: '3.13' + django-version: '5.2' + - python-version: '3.12' + django-version: '6.0' + - python-version: '3.13' + django-version: '6.0' + - python-version: '3.14' + django-version: '6.0' + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pre-commit codecov + + - name: Lint with pre-commit + run: pre-commit run --all-files --show-diff-on-failure + + - name: Install Django ${{ matrix.django-version }} + run: pip install django==${{ matrix.django-version }} + + - name: Install matching django-userforeignkey dependency + run: | + pip install "django-userforeignkey>=0.6.1" + + - name: Install package and dependencies + run: pip install -e . --no-deps + + - name: Run tests + run: | + # prepare Django application + ln -s ./tests/test_project/test_project test_project + ln -s ./tests/test_project/manage.py manage.py + + # run tests with coverage + coverage run \ + --source='./django_changeset' \ + --omit='./anexia_monitoring/migrations/*' \ + manage.py test + coverage xml + + - name: Upload coverage to Codecov + if: ${{ !env.ACT }} uses: codecov/codecov-action@v1 diff --git a/.gitignore b/.gitignore index 4a860e4..c8eb5a8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ venv/ build/ .idea/ db.sqlite3 +.actrc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..23d1ab9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-merge-conflict + - id: end-of-file-fixer + - id: requirements-txt-fixer + - id: trailing-whitespace + args: ["--markdown-linebreak-ext=md"] + + - repo: https://github.com/asottile/pyupgrade + rev: v3.21.2 + hooks: + - id: pyupgrade + args: ["--py310-plus"] + + - repo: https://github.com/asottile/add-trailing-comma + rev: v4.0.0 + hooks: + - id: add-trailing-comma + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.4 + hooks: + - id: ruff + args: ["--fix"] + - id: ruff-format diff --git a/LICENSE b/LICENSE index 26d2f17..2758dc3 100644 --- a/LICENSE +++ b/LICENSE @@ -24,4 +24,4 @@ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in index d82987c..e65fa5f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,3 @@ include LICENSE include README.rst -recursive-include docs * \ No newline at end of file +recursive-include docs * diff --git a/README.md b/README.md index 3f6d27d..ba6bd3a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -Django ChangeSet -================ +# Django ChangeSet [![Linter and tests](https://github.com/beachmachine/django-changeset/workflows/Linter%20and%20tests/badge.svg)](https://github.com/beachmachine/django-changeset/actions) [![Codecov](https://img.shields.io/codecov/c/gh/beachmachine/django-changeset)](https://codecov.io/gh/beachmachine/django-changeset) @@ -7,12 +6,12 @@ Django ChangeSet Django ChangeSet is a simple Django app that will give your models the possibility to track all changes. It depends on `django_userforeignkey` to determine the current user doing the change(s). -Currently, Django 2.2 and 3.2 are supported and tested via GitHub Actions. +Supported Python versions: 3.10 - 3.14. +Supported Django versions: 5.2 and 6.0. Detailed documentation is in the docs subdirectory. -Quick start ------------ +## Quick start 1. Use `pip` to install and download django-changeset (and `django-userforeignkey`): @@ -43,10 +42,9 @@ MIDDLEWARE = ( **Note**: Make sure to insert the `UserForeignKeyMiddleware` **after** Djangos `AuthenticationMiddleware`. -Example usage -------------- +## Example usage -***Use `RevisionModelMixin` as a mixin class for your models and add the fields you want to track in the meta*** +**_Use `RevisionModelMixin` as a mixin class for your models and add the fields you want to track in the meta_** configuration using `track_fields` and `track_related`. Also add a generic relation to `ChangeSet` using `changesets = ChangeSetRelation()`: @@ -102,19 +100,17 @@ class MyModel(BaseModel, RevisionModelMixin, CreatedModifiedByMixin): changesets = ChangeSetRelation() ``` -Querying ChangeSets via the changesets relation ------------------------------------------------ +## Querying ChangeSets via the changesets relation By inheriting from the `RevisionModelMixin` and `CreatedModifiedByMixin` mixins, and adding an attribute of type `ChangeSetRelation` (a `GenericRelation` for the changeset), the following features are added to your model: -- Properties `created_by`, `created_at`, `last_modified_by`, `last_modified_at` are made available for each object - (`CreatedModifiedByMixin`) -- Relation `changesets` is made available, allowing you to run queries like this one: - `MyModel.objects.filter(changesets__changeset_type='I', changesets__user__username='johndoe')` +- Properties `created_by`, `created_at`, `last_modified_by`, `last_modified_at` are made available for each object + (`CreatedModifiedByMixin`) +- Relation `changesets` is made available, allowing you to run queries like this one: + `MyModel.objects.filter(changesets__changeset_type='I', changesets__user__username='johndoe')` -Access ChangeSets and ChangeRecords ------------------------------------ +## Access ChangeSets and ChangeRecords ToDo @@ -143,17 +139,15 @@ for change_set in somemodel.changesets: print("-----") ``` -Maintainers ------------ +## Maintainers This repository is currently maintained by -- beachmachine -- anx-mpoelzl +- beachmachine +- mikelandzelo173 Pull Requests are welcome. -License -------- +## License Django ChangeSet uses the BSD-3 Clause License, see LICENSE file. diff --git a/django_changeset/__init__.py b/django_changeset/__init__.py index 40a96af..e69de29 100644 --- a/django_changeset/__init__.py +++ b/django_changeset/__init__.py @@ -1 +0,0 @@ -# -*- coding: utf-8 -*- diff --git a/django_changeset/migrations/0001_initial.py b/django_changeset/migrations/0001_initial.py index 8d1fe49..e807781 100644 --- a/django_changeset/migrations/0001_initial.py +++ b/django_changeset/migrations/0001_initial.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.10.1 on 2016-09-28 08:46 -from __future__ import unicode_literals from django.conf import settings from django.db import migrations, models @@ -13,43 +11,131 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), + ("contenttypes", "0002_remove_content_type_name"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='ChangeRecord', + name="ChangeRecord", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='Primary Key as Python UUID4 Field')), - ('field_name', models.CharField(editable=False, max_length=255, verbose_name='Field name')), - ('old_value', models.TextField(blank=True, editable=False, null=True, verbose_name='Old value')), - ('new_value', models.TextField(blank=True, editable=False, null=True, verbose_name='New value')), - ('is_related', models.BooleanField(default=False, editable=False, verbose_name='Is change on related entity')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + verbose_name="Primary Key as Python UUID4 Field", + ), + ), + ( + "field_name", + models.CharField( + editable=False, + max_length=255, + verbose_name="Field name", + ), + ), + ( + "old_value", + models.TextField( + blank=True, + editable=False, + null=True, + verbose_name="Old value", + ), + ), + ( + "new_value", + models.TextField( + blank=True, + editable=False, + null=True, + verbose_name="New value", + ), + ), + ( + "is_related", + models.BooleanField( + default=False, + editable=False, + verbose_name="Is change on related entity", + ), + ), ], options={ - 'get_latest_by': 'change_set__date', - 'ordering': ['-change_set__date', 'field_name'], + "get_latest_by": "change_set__date", + "ordering": ["-change_set__date", "field_name"], }, ), migrations.CreateModel( - name='ChangeSet', + name="ChangeSet", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='Primary Key as Python UUID4 Field')), - ('changeset_type', models.CharField(choices=[('I', 'Insert'), ('U', 'Update'), ('D', 'Delete')], default='I', editable=False, max_length=1, verbose_name='Changeset Type')), - ('date', models.DateTimeField(auto_now_add=True, verbose_name='Date')), - ('object_uuid', models.CharField(editable=False, max_length=255, verbose_name='Object UUID')), - ('object_type', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType', verbose_name='Object type')), - ('user', django_userforeignkey.models.fields.UserForeignKey(blank=True, null=True, editable=False, on_delete=django.db.models.deletion.SET_NULL, related_name='all_changes', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + verbose_name="Primary Key as Python UUID4 Field", + ), + ), + ( + "changeset_type", + models.CharField( + choices=[("I", "Insert"), ("U", "Update"), ("D", "Delete")], + default="I", + editable=False, + max_length=1, + verbose_name="Changeset Type", + ), + ), + ("date", models.DateTimeField(auto_now_add=True, verbose_name="Date")), + ( + "object_uuid", + models.CharField( + editable=False, + max_length=255, + verbose_name="Object UUID", + ), + ), + ( + "object_type", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.ContentType", + verbose_name="Object type", + ), + ), + ( + "user", + django_userforeignkey.models.fields.UserForeignKey( + blank=True, + null=True, + editable=False, + on_delete=django.db.models.deletion.SET_NULL, + related_name="all_changes", + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), ], options={ - 'get_latest_by': 'date', - 'ordering': ['-date'], + "get_latest_by": "date", + "ordering": ["-date"], }, ), migrations.AddField( - model_name='changerecord', - name='change_set', - field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='change_records', to='django_changeset.ChangeSet'), + model_name="changerecord", + name="change_set", + field=models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="change_records", + to="django_changeset.ChangeSet", + ), ), ] diff --git a/django_changeset/migrations/0002_add_index_changesettype.py b/django_changeset/migrations/0002_add_index_changesettype.py index 2d3ef3c..15df3c6 100644 --- a/django_changeset/migrations/0002_add_index_changesettype.py +++ b/django_changeset/migrations/0002_add_index_changesettype.py @@ -1,25 +1,33 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-01-04 13:37 -from __future__ import unicode_literals from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('django_changeset', '0001_initial'), + ("django_changeset", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='changeset', - name='changeset_type', - field=models.CharField(choices=[('I', 'Insert'), ('U', 'Update'), ('D', 'Delete')], db_index=True, default='I', editable=False, max_length=1, verbose_name='Changeset Type'), + model_name="changeset", + name="changeset_type", + field=models.CharField( + choices=[("I", "Insert"), ("U", "Update"), ("D", "Delete")], + db_index=True, + default="I", + editable=False, + max_length=1, + verbose_name="Changeset Type", + ), ), migrations.AlterField( - model_name='changeset', - name='date', - field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Date'), + model_name="changeset", + name="date", + field=models.DateTimeField( + auto_now_add=True, + db_index=True, + verbose_name="Date", + ), ), ] diff --git a/django_changeset/migrations/0003_restore_soft_delete.py b/django_changeset/migrations/0003_restore_soft_delete.py index 7006f5a..2cb5fc0 100644 --- a/django_changeset/migrations/0003_restore_soft_delete.py +++ b/django_changeset/migrations/0003_restore_soft_delete.py @@ -1,20 +1,30 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-18 14:06 -from __future__ import unicode_literals from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('django_changeset', '0002_add_index_changesettype'), + ("django_changeset", "0002_add_index_changesettype"), ] operations = [ migrations.AlterField( - model_name='changeset', - name='changeset_type', - field=models.CharField(choices=[('I', 'Insert'), ('U', 'Update'), ('D', 'Delete'), ('S', 'Soft Delete'), ('R', 'Restore')], db_index=True, default='I', editable=False, max_length=1, verbose_name='Changeset Type'), + model_name="changeset", + name="changeset_type", + field=models.CharField( + choices=[ + ("I", "Insert"), + ("U", "Update"), + ("D", "Delete"), + ("S", "Soft Delete"), + ("R", "Restore"), + ], + db_index=True, + default="I", + editable=False, + max_length=1, + verbose_name="Changeset Type", + ), ), ] diff --git a/django_changeset/migrations/0004_object_references.py b/django_changeset/migrations/0004_object_references.py index fca2e82..5a2365e 100644 --- a/django_changeset/migrations/0004_object_references.py +++ b/django_changeset/migrations/0004_object_references.py @@ -1,25 +1,32 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.7 on 2017-11-12 17:08 -from __future__ import unicode_literals from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('django_changeset', '0003_restore_soft_delete'), + ("django_changeset", "0003_restore_soft_delete"), ] operations = [ migrations.AddField( - model_name='changeset', - name='object_id', - field=models.BigIntegerField(db_index=True, editable=False, null=True, verbose_name='Object ID'), + model_name="changeset", + name="object_id", + field=models.BigIntegerField( + db_index=True, + editable=False, + null=True, + verbose_name="Object ID", + ), ), migrations.AlterField( - model_name='changeset', - name='object_uuid', - field=models.UUIDField(db_index=True, editable=False, null=True, verbose_name='Object UUID'), + model_name="changeset", + name="object_uuid", + field=models.UUIDField( + db_index=True, + editable=False, + null=True, + verbose_name="Object UUID", + ), ), ] diff --git a/django_changeset/models/__init__.py b/django_changeset/models/__init__.py index ae3c160..2b30306 100644 --- a/django_changeset/models/__init__.py +++ b/django_changeset/models/__init__.py @@ -1,3 +1,23 @@ -# -*- coding: utf-8 -*- -from django_changeset.models.models import * -from django_changeset.models.mixins import * +from django_changeset.models.models import ( + AbstractChangeSet, + ChangeRecord, + ChangeSet, + ChangeSetManager, +) +from django_changeset.models.mixins import ( + ChangesetVersionField, + ConcurrentUpdateException, + CreatedModifiedByMixin, + RevisionModelMixin, +) + +__all__ = [ + "AbstractChangeSet", + "ChangeRecord", + "ChangeSet", + "ChangeSetManager", + "ChangesetVersionField", + "ConcurrentUpdateException", + "CreatedModifiedByMixin", + "RevisionModelMixin", +] diff --git a/django_changeset/models/fields.py b/django_changeset/models/fields.py index 150ae7c..39f5c69 100644 --- a/django_changeset/models/fields.py +++ b/django_changeset/models/fields.py @@ -1,21 +1,18 @@ -# -*- coding: utf-8 -*- -from django.db import models from django.contrib.contenttypes.fields import GenericRelation class ChangeSetRelation(GenericRelation): - - def __init__(self, object_id_field='object_id', **kwargs): + def __init__(self, object_id_field="object_id", **kwargs): from django_changeset.models import ChangeSet # ToDo: Add Automatic support for object_id when the related field is an int, and object_uuid when the related # field is a UUIDField - assert(object_id_field in ['object_id', 'object_uuid']) + assert object_id_field in ["object_id", "object_uuid"] - kwargs['content_type_field'] = 'object_type' + kwargs["content_type_field"] = "object_type" - super(ChangeSetRelation, self).__init__( + super().__init__( ChangeSet, object_id_field=object_id_field, - **kwargs + **kwargs, ) diff --git a/django_changeset/models/mixins.py b/django_changeset/models/mixins.py index 9474915..b91975b 100644 --- a/django_changeset/models/mixins.py +++ b/django_changeset/models/mixins.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import logging from functools import reduce from threading import local @@ -27,7 +26,7 @@ def getattr_orm(instance, key): :param key: :return: """ - return reduce(getattr, [instance] + key.split('__')) + return reduce(getattr, [instance] + key.split("__")) logger = logging.getLogger(__name__) @@ -41,10 +40,15 @@ def getattr_orm(instance, key): # be visible in the changes of the parent. `track_related` contains a dict, where the key # is the name of the foreign key field, and the value the used field-name for the ChangeRecord # on the parent (usually the `related_name`). -options.DEFAULT_NAMES = options.DEFAULT_NAMES + \ - ('track_fields', 'track_by', 'track_related', 'track_through', - 'track_soft_delete_by', 'track_related_many', - 'aggregate_changesets_within_seconds') +options.DEFAULT_NAMES = options.DEFAULT_NAMES + ( + "track_fields", + "track_by", + "track_related", + "track_through", + "track_soft_delete_by", + "track_related_many", + "aggregate_changesets_within_seconds", +) class ChangesetVersionField(models.PositiveIntegerField): @@ -56,11 +60,11 @@ class ChangesetVersionField(models.PositiveIntegerField): """ def __init__(self, *args, **kwargs): - kwargs.setdefault('default', 0) - super(ChangesetVersionField, self).__init__(*args, **kwargs) + kwargs.setdefault("default", 0) + super().__init__(*args, **kwargs) def formfield(self, **kwargs): - kwargs['widget'] = forms.HiddenInput + kwargs["widget"] = forms.HiddenInput # widget = kwargs.get('widget') @@ -70,7 +74,7 @@ class ConcurrentUpdateException(Exception): """ def __init__(self, orig_data, latest_version_number, *args, **kwargs): - super(ConcurrentUpdateException, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.orig_data = orig_data self.latest_version_number = latest_version_number @@ -84,14 +88,14 @@ class Meta: abstract = True created_by = UserForeignKey( - verbose_name=_(u"User that created this element"), + verbose_name=_("User that created this element"), auto_user_add=True, # sets the current user when the element is created null=True, - related_name='%(class)s_created' + related_name="%(class)s_created", ) created_at = models.DateTimeField( - verbose_name=_(u"Date when this element was created"), + verbose_name=_("Date when this element was created"), auto_now_add=True, # sets the date when the element is created editable=False, null=True, @@ -99,14 +103,14 @@ class Meta: ) last_modified_by = UserForeignKey( - verbose_name=_(u"User that last modified this element"), + verbose_name=_("User that last modified this element"), auto_user=True, # sets the current user everytime the element is saved null=True, - related_name='%(class)s_modified' + related_name="%(class)s_modified", ) last_modified_at = models.DateTimeField( - verbose_name=_(u"Date when this element was last modified"), + verbose_name=_("Date when this element was last modified"), auto_now=True, # sets the date everytime the element is saved editable=False, null=True, @@ -118,12 +122,12 @@ class Meta: CreatedModifiedByMixIn = CreatedModifiedByMixin -class RevisionModelMixin(object): - """ django_changeset uses the RevisionModelMixin as a mixin class, which enables the changeset on a certain - model """ +class RevisionModelMixin: + """django_changeset uses the RevisionModelMixin as a mixin class, which enables the changeset on a certain + model""" def get_version_field(self): - """ gets the version field by looking in _meta.fields, and checks if it is a ChangesetVersionField """ + """gets the version field by looking in _meta.fields, and checks if it is a ChangesetVersionField""" for field in self._meta.fields: if isinstance(field, ChangesetVersionField): return field @@ -147,7 +151,10 @@ def update_version_number(self, content_type): new_version = version_field.value_from_object(self) if old_version != new_version: - raise ConcurrentUpdateException(orig_data=orig_data, latest_version_number=old_version) + raise ConcurrentUpdateException( + orig_data=orig_data, + latest_version_number=old_version, + ) setattr(self, version_field.attname, new_version + 1) @@ -167,12 +174,12 @@ class MyModel: @property def cs_created_by(self): self.check_for_changesets_attribute() - return self.changesets.filter(changeset_type='I').first().user + return self.changesets.filter(changeset_type="I").first().user @property def cs_created_at(self): self.check_for_changesets_attribute() - return self.changesets.filter(changeset_type='I').first().date + return self.changesets.filter(changeset_type="I").first().date @property def cs_last_modified_by(self): @@ -186,19 +193,19 @@ def cs_last_modified_at(self): @staticmethod def set_enabled(state): - setattr(_thread_locals, '__django_changeset__is_enabled', state) + setattr(_thread_locals, "__django_changeset__is_enabled", state) @staticmethod def get_enabled(): - return getattr(_thread_locals, '__django_changeset__is_enabled', True) + return getattr(_thread_locals, "__django_changeset__is_enabled", True) @staticmethod def set_related_enabled(state): - setattr(_thread_locals, '__django_changeset__is_related_enabled', state) + setattr(_thread_locals, "__django_changeset__is_related_enabled", state) @staticmethod def get_related_enabled(): - return getattr(_thread_locals, '__django_changeset__is_related_enabled', True) + return getattr(_thread_locals, "__django_changeset__is_related_enabled", True) @staticmethod @contextmanager @@ -226,28 +233,34 @@ def related_enabled(state): @property def changed_data(self): - """ Gets a dictionary of changed data + """Gets a dictionary of changed data :returns: a dictionary with the affected field name as key, and the original and new value as content :rtype: dict """ changed_fields = {} - orig_data = getattr(self, '__original_data__', {}) + orig_data = getattr(self, "__original_data__", {}) # compare all fields in track_fields - for field_name in getattr(self._meta, 'track_fields', []): + for field_name in getattr(self._meta, "track_fields", []): orig_value = orig_data.get(field_name) try: # check if is foreign key --> if yes, only get the id (--> not a db lookup) field = self._meta.get_field(field_name) - if hasattr(field, 'remote_field') and field.remote_field: + if hasattr(field, "remote_field") and field.remote_field: # related field, get the id if isinstance(field.remote_field, ManyToManyRel): # many to many related fields are special, we need to fetch the IDs using the manager new_value = ",".join( - [str(item) for item in getattr(self, field_name).all().values_list('id', flat=True)]) + [ + str(item) + for item in getattr(self, field_name) + .all() + .values_list("id", flat=True) + ], + ) else: new_value = getattr(self, field_name + "_id") else: @@ -260,7 +273,7 @@ def changed_data(self): changed_fields[field_name] = (orig_value, new_value) # iterate over all related fields with many relationship that need to be tracked in detail - for relation_entry in getattr(self._meta, 'track_related_many', ()): + for relation_entry in getattr(self._meta, "track_related_many", ()): relation_field_name = relation_entry[0] relation_track_fields = relation_entry[1] @@ -270,14 +283,18 @@ def changed_data(self): # get field field = self._meta.get_field(relation_field_name) - if (hasattr(field, 'remote_field') and field.remote_field) or (hasattr(field, 'field') and field.field.remote_field): + if (hasattr(field, "remote_field") and field.remote_field) or ( + hasattr(field, "field") and field.field.remote_field + ): new_value = serializers.serialize( - 'json', + "json", getattr_orm(self, relation_field_name).filter(), - fields=relation_track_fields + fields=relation_track_fields, ) else: - logger.error("track_related_many field '{}' is not a relation".format(relation_field_name)) + logger.error( + f"track_related_many field '{relation_field_name}' is not a relation", + ) new_value = None except (ObjectDoesNotExist, ValueError): @@ -298,7 +315,7 @@ def _persist_related_change(self, related_name, related_uuid): :param related_name: Name of the related field on the parent entity :param object_uuid: UUID of the child entity """ - object_uuid_field_name = getattr(self._meta, 'track_by', 'id') + object_uuid_field_name = getattr(self._meta, "track_by", "id") object_uuid_field = self._meta.get_field(object_uuid_field_name) object_uuid = getattr(self, object_uuid_field_name) object_type = ContentType.objects.get_for_model(self) @@ -308,11 +325,17 @@ def _persist_related_change(self, related_name, related_uuid): if isinstance(object_uuid_field, models.UUIDField): change_set.object_uuid = object_uuid - existing_changesets = ChangeSet.objects.filter(object_uuid=object_uuid, object_type=object_type) + existing_changesets = ChangeSet.objects.filter( + object_uuid=object_uuid, + object_type=object_type, + ) else: change_set.object_id = object_uuid - existing_changesets = ChangeSet.objects.filter(object_id=object_uuid, object_type=object_type) + existing_changesets = ChangeSet.objects.filter( + object_id=object_uuid, + object_type=object_type, + ) # are there any existing changesets? if existing_changesets.exists(): @@ -329,16 +352,25 @@ def _persist_related_change(self, related_name, related_uuid): @staticmethod def save_related_revision(sender, **kwargs): - if not RevisionModelMixin.get_enabled() or not RevisionModelMixin.get_related_enabled(): + if ( + not RevisionModelMixin.get_enabled() + or not RevisionModelMixin.get_related_enabled() + ): return - new_instance = kwargs['instance'] + new_instance = kwargs["instance"] - object_uuid_field_name = getattr(new_instance._meta, 'track_by', 'id') - object_related = getattr(new_instance._meta, 'track_related', []) # get meta class attribute 'track_related' + object_uuid_field_name = getattr(new_instance._meta, "track_by", "id") + object_related = getattr( + new_instance._meta, + "track_related", + [], + ) # get meta class attribute 'track_related' if isinstance(object_related, dict): - logger.error('You are using track_related with a dictionary, but this version is expecting a list!') + logger.error( + "You are using track_related with a dictionary, but this version is expecting a list!", + ) object_uuid = getattr_orm(new_instance, object_uuid_field_name) @@ -364,25 +396,31 @@ def save_initial_model_revision(sender, **kwargs): return # do not track raw inserts/updates (e.g. fixtures) - if kwargs.get('raw'): + if kwargs.get("raw"): return - new_instance = kwargs['instance'] + new_instance = kwargs["instance"] # check if this is a revision model if not isinstance(new_instance, RevisionModelMixin): return - object_uuid_field_name = getattr(new_instance._meta, 'track_by', 'id') + object_uuid_field_name = getattr(new_instance._meta, "track_by", "id") object_uuid_field = new_instance._meta.get_field(object_uuid_field_name) object_uuid = getattr_orm(new_instance, object_uuid_field_name) content_type = ContentType.objects.get_for_model(new_instance) if isinstance(object_uuid_field, models.UUIDField): - change_set_count = ChangeSet.objects.filter(object_type=content_type, object_uuid=object_uuid).count() + change_set_count = ChangeSet.objects.filter( + object_type=content_type, + object_uuid=object_uuid, + ).count() else: - change_set_count = ChangeSet.objects.filter(object_type=content_type, object_id=object_uuid).count() + change_set_count = ChangeSet.objects.filter( + object_type=content_type, + object_id=object_uuid, + ).count() if change_set_count > 0: return # if there is already an change-set, we do not need to save a new initial one @@ -390,17 +428,23 @@ def save_initial_model_revision(sender, **kwargs): changed_fields = {} # iterate over all fields that need to be tracked - for field_name in getattr(new_instance._meta, 'track_fields', []): + for field_name in getattr(new_instance._meta, "track_fields", []): try: # check if is foreign key --> if yes, only get the id (--> not a db lookup) field = new_instance._meta.get_field(field_name) - if hasattr(field, 'remote_field') and field.remote_field: + if hasattr(field, "remote_field") and field.remote_field: # related field, get the id if isinstance(field.remote_field, ManyToManyRel): # many to many related fields are special, we need to fetch the IDs using the manager - new_value = ",".join([str(item) for item in - getattr(new_instance, field_name).all().values_list('id', flat=True)]) + new_value = ",".join( + [ + str(item) + for item in getattr(new_instance, field_name) + .all() + .values_list("id", flat=True) + ], + ) else: new_value = getattr_orm(new_instance, field_name + "_id") else: @@ -410,7 +454,7 @@ def save_initial_model_revision(sender, **kwargs): changed_fields[field_name] = (None, new_value) # iterate over all related fields with many relationship that need to be tracked in detail - for relation_entry in getattr(new_instance._meta, 'track_related_many', ()): + for relation_entry in getattr(new_instance._meta, "track_related_many", ()): relation_field_name = relation_entry[0] relation_track_fields = relation_entry[1] @@ -418,15 +462,18 @@ def save_initial_model_revision(sender, **kwargs): # get field field = new_instance._meta.get_field(relation_field_name) - if (hasattr(field, 'remote_field') and field.remote_field) or \ - (hasattr(field, 'field') and field.field.remote_field): + if (hasattr(field, "remote_field") and field.remote_field) or ( + hasattr(field, "field") and field.field.remote_field + ): new_value = serializers.serialize( - 'json', + "json", getattr_orm(new_instance, relation_field_name).filter(), - fields=relation_track_fields + fields=relation_track_fields, ) else: - logger.error("track_related_many field '{}' is not a relation".format(relation_field_name)) + logger.error( + f"track_related_many field '{relation_field_name}' is not a relation", + ) new_value = None except (ObjectDoesNotExist, ValueError): @@ -440,12 +487,18 @@ def save_initial_model_revision(sender, **kwargs): if isinstance(object_uuid_field, models.UUIDField): change_set.object_uuid = object_uuid # are there any existing changesets? - existing_changesets = ChangeSet.objects.filter(object_uuid=object_uuid, object_type=content_type) + existing_changesets = ChangeSet.objects.filter( + object_uuid=object_uuid, + object_type=content_type, + ) else: change_set.object_id = object_uuid # are there any existing changesets? - existing_changesets = ChangeSet.objects.filter(object_id=object_uuid, object_type=content_type) + existing_changesets = ChangeSet.objects.filter( + object_id=object_uuid, + object_type=content_type, + ) if existing_changesets.exists(): change_set.changeset_type = change_set.UPDATE_TYPE @@ -457,8 +510,10 @@ def save_initial_model_revision(sender, **kwargs): # collect change records for changed_field, changed_value in changed_fields.items(): change_record = ChangeRecord( - change_set=change_set, field_name=changed_field, - old_value=changed_value[0], new_value=changed_value[1] + change_set=change_set, + field_name=changed_field, + old_value=changed_value[0], + new_value=changed_value[1], ) change_records.append(change_record) @@ -474,32 +529,32 @@ def m2m_changed(sender, **kwargs): if not RevisionModelMixin.get_enabled(): return - action = kwargs['action'] + action = kwargs["action"] # only react on post_add and post_remove (this is also checked 30 lines below) - if action not in ['post_add', 'post_remove']: + if action not in ["post_add", "post_remove"]: return # get instance, primary key set and the action - instance = kwargs['instance'] - pk_set = kwargs['pk_set'] + instance = kwargs["instance"] + pk_set = kwargs["pk_set"] - track_through_fields = getattr(instance._meta, 'track_through', []) + track_through_fields = getattr(instance._meta, "track_through", []) for field_name in track_through_fields: field = getattr(instance, field_name) if field.through == sender: # track change on field_name - print('Action ', action, ' on field ', field_name, ': ', pk_set) + print("Action ", action, " on field ", field_name, ": ", pk_set) # check if changeset exists - if hasattr(instance, '__m2m_change_set__'): + if hasattr(instance, "__m2m_change_set__"): # use existing change set - change_set = getattr(instance, '__m2m_change_set__') + change_set = getattr(instance, "__m2m_change_set__") else: # create a new change set content_type = ContentType.objects.get_for_model(instance) - object_uuid_field_name = getattr(instance._meta, 'track_by', 'id') + object_uuid_field_name = getattr(instance._meta, "track_by", "id") object_uuid_field = instance._meta.get_field(object_uuid_field_name) change_set = ChangeSet() @@ -507,16 +562,22 @@ def m2m_changed(sender, **kwargs): change_set.object_type = content_type if isinstance(object_uuid_field, models.UUIDField): - change_set.object_uuid = getattr_orm(instance, object_uuid_field_name) + change_set.object_uuid = getattr_orm( + instance, + object_uuid_field_name, + ) else: - change_set.object_id = getattr_orm(instance, object_uuid_field_name) + change_set.object_id = getattr_orm( + instance, + object_uuid_field_name, + ) change_set.changeset_type = change_set.UPDATE_TYPE change_set.save() # store this changeset in instance, in case we get another update soon - setattr(instance, '__m2m_change_set__', change_set) + setattr(instance, "__m2m_change_set__", change_set) # iterate over the list of primary keys for pk in pk_set: @@ -526,10 +587,10 @@ def m2m_changed(sender, **kwargs): change_record.change_set = change_set change_record.field_name = field_name - if action == 'post_add': + if action == "post_add": # in case of an add, we store the new value (old value is None by default) change_record.new_value = pk - elif action == 'post_remove': + elif action == "post_remove": # in case of a delete, we store the old value (new value is None by default) change_record.old_value = pk @@ -545,10 +606,10 @@ def update_model_version_number(sender, **kwargs): return # do not track raw inserts/updates (e.g. fixtures) - if kwargs.get('raw'): + if kwargs.get("raw"): return - new_instance = kwargs['instance'] + new_instance = kwargs["instance"] # check if this is a revision model if not new_instance.pk or not isinstance(new_instance, RevisionModelMixin): @@ -560,17 +621,23 @@ def update_model_version_number(sender, **kwargs): if not changed_fields: return - object_uuid_field_name = getattr(new_instance._meta, 'track_by', 'id') + object_uuid_field_name = getattr(new_instance._meta, "track_by", "id") object_uuid_field = new_instance._meta.get_field(object_uuid_field_name) content_type = ContentType.objects.get_for_model(new_instance) object_uuid = getattr_orm(new_instance, object_uuid_field_name) # are there any existing changesets? if isinstance(object_uuid_field, models.UUIDField): - existing_changesets = ChangeSet.objects.filter(object_uuid=object_uuid, object_type=content_type) + existing_changesets = ChangeSet.objects.filter( + object_uuid=object_uuid, + object_type=content_type, + ) else: - existing_changesets = ChangeSet.objects.filter(object_id=object_uuid, object_type=content_type) + existing_changesets = ChangeSet.objects.filter( + object_id=object_uuid, + object_type=content_type, + ) if existing_changesets.exists(): new_instance.update_version_number(content_type) @@ -581,10 +648,10 @@ def save_model_revision(sender, **kwargs): return # do not track raw inserts/updates (e.g. fixtures) - if kwargs.get('raw'): + if kwargs.get("raw"): return - new_instance = kwargs['instance'] + new_instance = kwargs["instance"] # check if this is a revision model if not new_instance.pk or not isinstance(new_instance, RevisionModelMixin): @@ -601,7 +668,7 @@ def save_model_revision(sender, **kwargs): is_restore = False # get track_soft_deleted_by from the current model - track_soft_delete_by = getattr(new_instance._meta, 'track_soft_delete_by', None) + track_soft_delete_by = getattr(new_instance._meta, "track_soft_delete_by", None) if track_soft_delete_by and track_soft_delete_by in changed_fields: # if len(changed_fields) > 1: # raise Exception("""Can not modify more than one field if track_soft_delete_by is changed""") @@ -615,7 +682,7 @@ def save_model_revision(sender, **kwargs): else: is_restore = True - object_uuid_field_name = getattr(new_instance._meta, 'track_by', 'id') + object_uuid_field_name = getattr(new_instance._meta, "track_by", "id") object_uuid_field = new_instance._meta.get_field(object_uuid_field_name) content_type = ContentType.objects.get_for_model(new_instance) @@ -632,9 +699,9 @@ def save_model_revision(sender, **kwargs): # are there any existing changesets (without restore/soft_delete)? existing_changesets = ChangeSet.objects.filter( object_uuid=change_set.object_uuid, - object_type=content_type + object_type=content_type, ).exclude( - changeset_type__in=[ChangeSet.RESTORE_TYPE, ChangeSet.SOFT_DELETE_TYPE] + changeset_type__in=[ChangeSet.RESTORE_TYPE, ChangeSet.SOFT_DELETE_TYPE], ) last_changeset = None @@ -655,10 +722,18 @@ def save_model_revision(sender, **kwargs): last_changeset = existing_changesets.latest() # check if last changeset was created by the current user within the last couple of seconds - if last_changeset \ - and last_changeset.user == get_current_user() \ - and last_changeset.date > timezone.now() - timezone.timedelta( - seconds=getattr(new_instance._meta, 'aggregate_changesets_within_seconds', 0) + if ( + last_changeset + and last_changeset.user == get_current_user() + and last_changeset.date + > timezone.now() + - timezone.timedelta( + seconds=getattr( + new_instance._meta, + "aggregate_changesets_within_seconds", + 0, + ), + ) ): # overwrite the new_changeset logger.debug("Re-using last changeset") @@ -675,8 +750,12 @@ def save_model_revision(sender, **kwargs): for changed_field, changed_value in changed_fields.items(): # if the changerecord for a change_set and a field already exists, it needs to be updated change_record, created = ChangeRecord.objects.get_or_create( - change_set=change_set, field_name=changed_field, - defaults={'old_value': changed_value[0], 'new_value': changed_value[1]}, + change_set=change_set, + field_name=changed_field, + defaults={ + "old_value": changed_value[0], + "new_value": changed_value[1], + }, ) if not created: @@ -705,8 +784,10 @@ def save_model_revision(sender, **kwargs): # iterate over all changed fields and create a change record for them for changed_field, changed_value in changed_fields.items(): change_record = ChangeRecord( - change_set=change_set, field_name=changed_field, - old_value=changed_value[0], new_value=changed_value[1] + change_set=change_set, + field_name=changed_field, + old_value=changed_value[0], + new_value=changed_value[1], ) change_records.append(change_record) @@ -720,7 +801,7 @@ def save_model_original_data(sender, **kwargs): if not RevisionModelMixin.get_enabled(): return - instance = kwargs['instance'] + instance = kwargs["instance"] original_data = {} # do not save original data if model is not a RevisionModel @@ -728,17 +809,23 @@ def save_model_original_data(sender, **kwargs): return # iterate over all fields that need to be tracked - for field_name in getattr(instance._meta, 'track_fields', []): + for field_name in getattr(instance._meta, "track_fields", []): try: # check if is foreign key --> if yes, get id field = instance._meta.get_field(field_name) - if hasattr(field, 'remote_field') and field.remote_field: + if hasattr(field, "remote_field") and field.remote_field: # related field, get the id if isinstance(field.remote_field, ManyToManyRel): # many to many related fields are special, we need to fetch the IDs using the manager value = ",".join( - [str(item) for item in getattr(instance, field_name).all().values_list('id', flat=True)]) + [ + str(item) + for item in getattr(instance, field_name) + .all() + .values_list("id", flat=True) + ], + ) else: value = getattr_orm(instance, field_name + "_id") else: @@ -749,7 +836,7 @@ def save_model_original_data(sender, **kwargs): original_data[field_name] = value # iterate over all related fields with many relationship that need to be tracked in detail - for relation_entry in getattr(instance._meta, 'track_related_many', ()): + for relation_entry in getattr(instance._meta, "track_related_many", ()): relation_field_name = relation_entry[0] relation_track_fields = relation_entry[1] @@ -757,15 +844,18 @@ def save_model_original_data(sender, **kwargs): # get field field = instance._meta.get_field(relation_field_name) - if (hasattr(field, 'remote_field') and field.remote_field) or \ - (hasattr(field, 'field') and field.field.remote_field): + if (hasattr(field, "remote_field") and field.remote_field) or ( + hasattr(field, "field") and field.field.remote_field + ): value = serializers.serialize( - 'json', + "json", getattr_orm(instance, relation_field_name).filter(), - fields=relation_track_fields + fields=relation_track_fields, ) else: - logger.error("track_related_many field '{}' is not a relation".format(relation_field_name)) + logger.error( + f"track_related_many field '{relation_field_name}' is not a relation", + ) value = None except (ObjectDoesNotExist, ValueError): @@ -774,7 +864,7 @@ def save_model_original_data(sender, **kwargs): original_data[relation_field_name] = value # store original data on the instance - setattr(instance, '__original_data__', original_data) + setattr(instance, "__original_data__", original_data) # on post init: store the original data (e.g., when the model is loaded from the database the first time) @@ -785,7 +875,7 @@ def save_model_original_data(sender, **kwargs): # on pre save: update version number pre_save.connect( RevisionModelMixin.update_model_version_number, - dispatch_uid="django_changeset.update_model_version_number.subscriber" + dispatch_uid="django_changeset.update_model_version_number.subscriber", ) # on post save: save model changes (changes are determined based on original model data) diff --git a/django_changeset/models/models.py b/django_changeset/models/models.py index ce4cc14..9e43dbe 100644 --- a/django_changeset/models/models.py +++ b/django_changeset/models/models.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals import logging import uuid @@ -8,98 +6,114 @@ from django.conf import settings from django.utils.translation import gettext_lazy as _ -from django.utils.encoding import force_text + +try: + from django.utils.encoding import force_str as force_text +except ImportError: # pragma: no cover + from django.utils.encoding import force_text from django_userforeignkey.models.fields import UserForeignKey logger = logging.getLogger(__name__) -changeset_related_object = getattr(settings, "DJANGO_CHANGESET_SELECT_RELATED", ["user"]) +changeset_related_object = getattr( + settings, + "DJANGO_CHANGESET_SELECT_RELATED", + ["user"], +) class ChangeSetManager(models.Manager): """ ChangeSet Manager that forces all ChangeSet queries to contain at least the "user" foreign relation """ + def get_queryset(self): - return super(ChangeSetManager, self).get_queryset().select_related( - *changeset_related_object + return ( + super() + .get_queryset() + .select_related( + *changeset_related_object, + ) ) class AbstractChangeSet(models.Model): - """ Basic changeset/revision model which contains the ``user`` that modified the object ``object_type`` """ + """Basic changeset/revision model which contains the ``user`` that modified the object ``object_type``""" + objects = ChangeSetManager() # choices for changeset type (insert, update, delete) - INSERT_TYPE = 'I' - UPDATE_TYPE = 'U' - DELETE_TYPE = 'D' - SOFT_DELETE_TYPE = 'S' - RESTORE_TYPE = 'R' + INSERT_TYPE = "I" + UPDATE_TYPE = "U" + DELETE_TYPE = "D" + SOFT_DELETE_TYPE = "S" + RESTORE_TYPE = "R" CHANGESET_TYPE_CHOICES = ( - (INSERT_TYPE, 'Insert'), - (UPDATE_TYPE, 'Update'), - (DELETE_TYPE, 'Delete'), - (SOFT_DELETE_TYPE, 'Soft Delete'), - (RESTORE_TYPE, 'Restore') + (INSERT_TYPE, "Insert"), + (UPDATE_TYPE, "Update"), + (DELETE_TYPE, "Delete"), + (SOFT_DELETE_TYPE, "Soft Delete"), + (RESTORE_TYPE, "Restore"), ) id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, - verbose_name=_(u"Primary Key as Python UUID4 Field") + verbose_name=_("Primary Key as Python UUID4 Field"), ) changeset_type = models.CharField( max_length=1, - verbose_name=_(u"Changeset Type"), + verbose_name=_("Changeset Type"), choices=CHANGESET_TYPE_CHOICES, default=INSERT_TYPE, editable=False, null=False, - db_index=True + db_index=True, ) date = models.DateTimeField( - verbose_name=_(u"Date"), + verbose_name=_("Date"), auto_now_add=True, editable=False, null=False, - db_index=True + db_index=True, ) # track the user that triggered this change user = UserForeignKey( - verbose_name=_(u"User"), + verbose_name=_("User"), auto_user_add=True, - related_name="all_changes", # allows to access userobj.all_changes + related_name="all_changes", # allows to access userobj.all_changes ) object_type = models.ForeignKey( ContentType, - verbose_name=_(u"Object type"), + verbose_name=_("Object type"), editable=False, null=False, - on_delete=models.CASCADE + on_delete=models.CASCADE, ) class Meta: - app_label = 'django_changeset' - get_latest_by = 'date' - ordering = ['-date', ] + app_label = "django_changeset" + get_latest_by = "date" + ordering = ["-date"] abstract = True def __unicode__(self): - return _(u"%(changeset_type)s on %(app_label)s.%(model)s %(uuid)s at date %(date)s by %(user)s") % { - 'changeset_type': self.get_changeset_type_display(), - 'app_label': self.object_type.app_label, - 'model': self.object_type.model, - 'uuid': self.object_uuid, - 'date': self.date, - 'user': self.user, + return _( + "%(changeset_type)s on %(app_label)s.%(model)s %(uuid)s at date %(date)s by %(user)s", + ) % { + "changeset_type": self.get_changeset_type_display(), + "app_label": self.object_type.app_label, + "model": self.object_type.model, + "uuid": self.object_uuid, + "date": self.date, + "user": self.user, } def __str__(self): @@ -108,14 +122,14 @@ def __str__(self): class ChangeSet(AbstractChangeSet): object_id = models.BigIntegerField( - verbose_name=_(u"Object ID"), + verbose_name=_("Object ID"), editable=False, null=True, db_index=True, ) object_uuid = models.UUIDField( - verbose_name=_(u"Object UUID"), + verbose_name=_("Object UUID"), editable=False, null=True, db_index=True, @@ -123,7 +137,7 @@ class ChangeSet(AbstractChangeSet): class ChangeRecord(models.Model): - """ A change_record represents detailed change information, like which field was changed and what the old aswell as + """A change_record represents detailed change information, like which field was changed and what the old aswell as the new value of the field look like. It is related to a ``change_set``. """ @@ -131,7 +145,7 @@ class ChangeRecord(models.Model): primary_key=True, default=uuid.uuid4, editable=False, - verbose_name=_(u"Primary Key as Python UUID4 Field") + verbose_name=_("Primary Key as Python UUID4 Field"), ) change_set = models.ForeignKey( @@ -139,47 +153,47 @@ class ChangeRecord(models.Model): related_name="change_records", null=False, editable=False, - on_delete=models.CASCADE + on_delete=models.CASCADE, ) field_name = models.CharField( - verbose_name=_(u"Field name"), + verbose_name=_("Field name"), max_length=255, editable=False, null=False, ) old_value = models.TextField( - verbose_name=_(u"Old value"), + verbose_name=_("Old value"), editable=False, null=True, blank=True, ) new_value = models.TextField( - verbose_name=_(u"New value"), + verbose_name=_("New value"), editable=False, null=True, blank=True, ) is_related = models.BooleanField( - verbose_name=_(u"Is change on related entity"), + verbose_name=_("Is change on related entity"), editable=False, null=False, default=False, ) class Meta: - app_label = 'django_changeset' - get_latest_by = 'change_set__date' - ordering = ['-change_set__date', 'field_name', ] + app_label = "django_changeset" + get_latest_by = "change_set__date" + ordering = ["-change_set__date", "field_name"] def __unicode__(self): - return _(u"%(label)s: '%(from)s' to '%(to)s'") % { - 'label': force_text(self.field_verbose_name), - 'from': force_text(self.old_value_display), - 'to': force_text(self.new_value_display), + return _("%(label)s: '%(from)s' to '%(to)s'") % { + "label": force_text(self.field_verbose_name), + "from": force_text(self.old_value_display), + "to": force_text(self.new_value_display), } def __str__(self): @@ -197,10 +211,12 @@ def _get_related_object(self): try: return related_class.objects.get(pk=self.new_value) except related_class.DoesNotExist: - logger.warning(u"Related object of model '%(model)s' with pk '%(pk)s' does not exist." % { - 'model': force_text(related_class), - 'pk': force_text(self.new_value), - }) + logger.warning( + "Related object of model '{model}' with pk '{pk}' does not exist.".format( + model=force_text(related_class), + pk=force_text(self.new_value), + ), + ) return None @@ -214,7 +230,10 @@ def _get_field(self, supress_warning=False): # no field for the field_name found if not supress_warning: - logger.warning(u"Field for this change record does not exist on model '%s'." % force_text(model_class)) + logger.warning( + "Field for this change record does not exist on model '%s'." + % force_text(model_class), + ) return None @@ -227,12 +246,17 @@ def _get_relation(self): return rel # no relation for the field_name found - logger.warning(u"Relation for this change record does not exist on model '%s'." % force_text(model_class)) + logger.warning( + "Relation for this change record does not exist on model '%s'." + % force_text(model_class), + ) return None def _get_related_class(self): - field = self._get_field(supress_warning=True) # get the field, but dont log a warning + field = self._get_field( + supress_warning=True, + ) # get the field, but dont log a warning if field: return field.remote_field.to @@ -250,38 +274,44 @@ def _get_object_or_none(self, model_class, **kwargs): @property def related_object(self): - """ returns the related object (only if the change was on a related entity; check obj.is_related) """ + """returns the related object (only if the change was on a related entity; check obj.is_related)""" return self._get_related_object() @property def field_verbose_name(self): - """ returns the verbose name of the affected field """ + """returns the verbose name of the affected field""" field = self._get_field(supress_warning=True) if field: return field.verbose_name - return self.field_name.capitalize().replace('_', ' ') + return self.field_name.capitalize().replace("_", " ") @property def old_value_display(self): - """ returns the old/original value (display) """ + """returns the old/original value (display)""" field = self._get_field(supress_warning=True) if field and isinstance(field, models.ForeignKey): return self._get_object_or_none(field.remote_field.to, pk=self.old_value) - elif field and hasattr(field, 'flatchoices'): - return force_text(dict(field.flatchoices).get(self.old_value, self.old_value), strings_only=True) + elif field and hasattr(field, "flatchoices"): + return force_text( + dict(field.flatchoices).get(self.old_value, self.old_value), + strings_only=True, + ) return self.old_value @property def new_value_display(self): - """ returns the new value (display) """ + """returns the new value (display)""" field = self._get_field(supress_warning=True) if field and isinstance(field, models.ForeignKey): return self._get_object_or_none(field.remote_field.to, pk=self.new_value) - elif field and hasattr(field, 'flatchoices'): - return force_text(dict(field.flatchoices).get(self.new_value, self.new_value), strings_only=True) + elif field and hasattr(field, "flatchoices"): + return force_text( + dict(field.flatchoices).get(self.new_value, self.new_value), + strings_only=True, + ) return self.new_value diff --git a/django_changeset/models/queryset.py b/django_changeset/models/queryset.py index 6021c29..0cd795e 100644 --- a/django_changeset/models/queryset.py +++ b/django_changeset/models/queryset.py @@ -1,6 +1,5 @@ from django_userforeignkey.request import get_current_user from django.contrib.contenttypes.models import ContentType -from django_changeset.models import ChangeSet def get_content_type_of(model): @@ -12,7 +11,7 @@ def get_content_type_of(model): """ # if we are working on a deferred proxy class, we first need to get # the real model class, so we can save a new instance if we need. - if getattr(model, '_deferred', False): + if getattr(model, "_deferred", False): model = model.__mro__[1] try: @@ -21,32 +20,32 @@ def get_content_type_of(model): return None -class ChangeSetQuerySetMixin(object): - """ This is a mixin for QuerySets which is supposed to return a filter with all objects created, updated or (soft) - deleted by the current user. The (soft) deleted option is only available if you also implemented (soft) delete - (TODO). +class ChangeSetQuerySetMixin: + """This is a mixin for QuerySets which is supposed to return a filter with all objects created, updated or (soft) + deleted by the current user. The (soft) deleted option is only available if you also implemented (soft) delete + (TODO). - To extend an existing queryset with this mixin, use it as follows: + To extend an existing queryset with this mixin, use it as follows: - from django_changeset.models.querysets import ChangeSetQuerySetMixin - from django.db.models import QuerySet + from django_changeset.models.querysets import ChangeSetQuerySetMixin + from django.db.models import QuerySet - class MyModelQuerySet(QuerySet, ChangeSetQuerySetMixin): - pass + class MyModelQuerySet(QuerySet, ChangeSetQuerySetMixin): + pass - You also need to tell your model that you want to use the manager with this new queryset + You also need to tell your model that you want to use the manager with this new queryset - class MyModel: - ... - objects = models.Manager.from_queryset(MyModelQuerySet)() + class MyModel: + ... + objects = models.Manager.from_queryset(MyModelQuerySet)() - You can then use it as follows: + You can then use it as follows: - qs_created = MyModel.objects.created_by_current_user() - qs_updated = MyModel.objects.updated_by_current_user() - qs_deleted = MyModel.objects.deleted_by_current_user() # this last one does not work yet (TODO) + qs_created = MyModel.objects.created_by_current_user() + qs_updated = MyModel.objects.updated_by_current_user() + qs_deleted = MyModel.objects.deleted_by_current_user() # this last one does not work yet (TODO) """ diff --git a/docs/index.rst b/docs/index.rst index 1472c26..0d98016 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,7 +5,8 @@ Django ChangeSet Django ChangeSet is a simple Django app that will give your models the possibility to track all changes. It depends on ``django_userforeignkey`` to determine the current user doing the change(s). -Currently, Django 2.2 and 3.2 are supported. +Supported Python versions: 3.10 - 3.14. +Supported Django versions: 5.2 and 6.0. Getting Started --------------- diff --git a/setup.py b/setup.py index 7a5726e..9a54d04 100644 --- a/setup.py +++ b/setup.py @@ -2,42 +2,44 @@ from setuptools import find_packages, setup -with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme: +with open(os.path.join(os.path.dirname(__file__), "README.md")) as readme: README = readme.read() # allow setup.py to be run from any path os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( - name='django-changeset', - version=os.getenv('PACKAGE_VERSION', '0.0.0').replace('refs/tags/', ''), + name="django-changeset", + version=os.getenv("PACKAGE_VERSION", "0.0.0").replace("refs/tags/", ""), packages=find_packages(), include_package_data=True, - long_description_content_type='text/markdown', - license='BSD License', - description='A simple Django app that will give your models the possibility to track all changes.', + long_description_content_type="text/markdown", + license="BSD License", + description="A simple Django app that will give your models the possibility to track all changes.", long_description=README, - url='https://github.com/beachmachine/django-changeset', - author='Andreas Stocker', - author_email='astocker@anexia-it.com', + url="https://github.com/beachmachine/django-changeset", + author="Andreas Stocker", + author_email="astocker@anexia-it.com", install_requires=[ - 'django-userforeignkey>=0.3', + "django-userforeignkey>=0.3", ], + python_requires=">=3.10", classifiers=[ - 'Environment :: Web Environment', - 'Framework :: Django', - 'Framework :: Django :: 2.2', - 'Framework :: Django :: 3.2', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - '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', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 5.2", + "Framework :: Django :: 6.0", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", ], ) diff --git a/tests/test_project/requirements.txt b/tests/test_project/requirements.txt index 19a58d6..83a2558 100644 --- a/tests/test_project/requirements.txt +++ b/tests/test_project/requirements.txt @@ -1,2 +1,2 @@ -django>=2.2,<3.3 +django>=5.2,<6.1 django-changeset diff --git a/tests/test_project/test_project/polls/admin.py b/tests/test_project/test_project/polls/admin.py index 2ba7c7f..a1bc8e3 100644 --- a/tests/test_project/test_project/polls/admin.py +++ b/tests/test_project/test_project/polls/admin.py @@ -10,10 +10,10 @@ class ChoiceInline(admin.TabularInline): class PollAdmin(admin.ModelAdmin): inlines = [ChoiceInline] - list_display = ('question', 'pub_date', 'was_published_recently', 'created_by') - list_filter = ['pub_date'] - search_fields = ['question'] - date_hierarchy = 'pub_date' + list_display = ("question", "pub_date", "was_published_recently", "created_by") + list_filter = ["pub_date"] + search_fields = ["question"] + date_hierarchy = "pub_date" admin.site.register(Poll, PollAdmin) diff --git a/tests/test_project/test_project/polls/migrations/0001_initial_squashed_0006_auto_20171015_1907.py b/tests/test_project/test_project/polls/migrations/0001_initial_squashed_0006_auto_20171015_1907.py index a81cfcc..761c442 100644 --- a/tests/test_project/test_project/polls/migrations/0001_initial_squashed_0006_auto_20171015_1907.py +++ b/tests/test_project/test_project/polls/migrations/0001_initial_squashed_0006_auto_20171015_1907.py @@ -1,18 +1,21 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2017-10-15 19:07 -from __future__ import unicode_literals import datetime from django.conf import settings from django.db import migrations, models import django.db.models.deletion -from django.utils.timezone import utc import django_userforeignkey.models.fields class Migration(migrations.Migration): - - replaces = [('polls', '0001_initial'), ('polls', '0002_auto_20160414_1310'), ('polls', '0003_auto_20160414_1332'), ('polls', '0004_actualvote'), ('polls', '0005_auto_20160414_1351'), ('polls', '0006_auto_20171015_1907')] + replaces = [ + ("polls", "0001_initial"), + ("polls", "0002_auto_20160414_1310"), + ("polls", "0003_auto_20160414_1332"), + ("polls", "0004_actualvote"), + ("polls", "0005_auto_20160414_1351"), + ("polls", "0006_auto_20171015_1907"), + ] dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), @@ -20,60 +23,160 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Choice', + name="Choice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('choice_text', models.CharField(max_length=200)), - ('votes', models.IntegerField(default=0)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("choice_text", models.CharField(max_length=200)), + ("votes", models.IntegerField(default=0)), ], ), migrations.CreateModel( - name='Poll', + name="Poll", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('question', models.CharField(max_length=200)), - ('pub_date', models.DateTimeField(verbose_name=b'Publication date of poll')), - ('created_by', django_userforeignkey.models.fields.UserForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='polls', to=settings.AUTH_USER_MODEL, verbose_name=b'The user that created the poll')), - ('created_at', models.DateTimeField(auto_now_add=True, default=datetime.datetime(2016, 4, 14, 13, 32, 11, 409531, tzinfo=utc), verbose_name=b'Publication date of poll')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("question", models.CharField(max_length=200)), + ( + "pub_date", + models.DateTimeField(verbose_name=b"Publication date of poll"), + ), + ( + "created_by", + django_userforeignkey.models.fields.UserForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="polls", + to=settings.AUTH_USER_MODEL, + verbose_name=b"The user that created the poll", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + default=datetime.datetime( + 2016, + 4, + 14, + 13, + 32, + 11, + 409531, + tzinfo=datetime.timezone.utc, + ), + verbose_name=b"Publication date of poll", + ), + ), ], ), migrations.AddField( - model_name='choice', - name='poll', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='polls.Poll'), + model_name="choice", + name="poll", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="polls.Poll", + ), ), migrations.CreateModel( - name='ActualVote', + name="ActualVote", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('choice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='polls.Choice', verbose_name='Which choice was chosen?')), - ('poll', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='polls.Poll', verbose_name='Which question has been voted for?')), - ('user', django_userforeignkey.models.fields.UserForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='actual_votes', to=settings.AUTH_USER_MODEL, verbose_name='Which user has voted?')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "choice", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="polls.Choice", + verbose_name="Which choice was chosen?", + ), + ), + ( + "poll", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="polls.Poll", + verbose_name="Which question has been voted for?", + ), + ), + ( + "user", + django_userforeignkey.models.fields.UserForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="actual_votes", + to=settings.AUTH_USER_MODEL, + verbose_name="Which user has voted?", + ), + ), ], ), migrations.AlterField( - model_name='choice', - name='poll', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='polls.Poll', verbose_name=b'Which poll?'), + model_name="choice", + name="poll", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="choices", + to="polls.Poll", + verbose_name=b"Which poll?", + ), ), migrations.AlterField( - model_name='choice', - name='poll', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='polls.Poll', verbose_name='Which poll?'), + model_name="choice", + name="poll", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="choices", + to="polls.Poll", + verbose_name="Which poll?", + ), ), migrations.AlterField( - model_name='poll', - name='created_at', - field=models.DateTimeField(auto_now_add=True, verbose_name='Publication date of poll'), + model_name="poll", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + verbose_name="Publication date of poll", + ), ), migrations.AlterField( - model_name='poll', - name='created_by', - field=django_userforeignkey.models.fields.UserForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='polls', to=settings.AUTH_USER_MODEL, verbose_name='The user that created the poll'), + model_name="poll", + name="created_by", + field=django_userforeignkey.models.fields.UserForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="polls", + to=settings.AUTH_USER_MODEL, + verbose_name="The user that created the poll", + ), ), migrations.AlterField( - model_name='poll', - name='pub_date', - field=models.DateTimeField(verbose_name='Publication date of poll'), + model_name="poll", + name="pub_date", + field=models.DateTimeField(verbose_name="Publication date of poll"), ), ] diff --git a/tests/test_project/test_project/polls/models.py b/tests/test_project/test_project/polls/models.py index c4b7722..c9a90b1 100644 --- a/tests/test_project/test_project/polls/models.py +++ b/tests/test_project/test_project/polls/models.py @@ -8,9 +8,15 @@ class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField(verbose_name="Publication date of poll") - created_by = UserForeignKey(auto_user_add=True, verbose_name="The user that created the poll", - related_name="polls") - created_at = models.DateTimeField(auto_now_add=True, verbose_name="Publication date of poll") + created_by = UserForeignKey( + auto_user_add=True, + verbose_name="The user that created the poll", + related_name="polls", + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="Publication date of poll", + ) def __str__(self): return self.question @@ -22,13 +28,18 @@ def was_published_recently(self): now = timezone.now() return now - datetime.timedelta(days=1) <= self.pub_date < now - was_published_recently.admin_order_field = 'pub_date' + was_published_recently.admin_order_field = "pub_date" was_published_recently.boolean = True - was_published_recently.short_description = 'Published recently?' + was_published_recently.short_description = "Published recently?" class Choice(models.Model): - poll = models.ForeignKey(Poll, verbose_name="Which poll?", related_name="choices", on_delete=models.CASCADE) + poll = models.ForeignKey( + Poll, + verbose_name="Which poll?", + related_name="choices", + on_delete=models.CASCADE, + ) choice_text = models.CharField(max_length=200) votes = models.IntegerField(default=0) @@ -40,6 +51,18 @@ def __unicode__(self): # Python 3: def __str__(self): class ActualVote(models.Model): - poll = models.ForeignKey(Poll, verbose_name="Which question has been voted for?", on_delete=models.CASCADE) - choice = models.ForeignKey(Choice, verbose_name="Which choice was chosen?", on_delete=models.CASCADE) - user = UserForeignKey(auto_user_add=True, verbose_name="Which user has voted?", related_name="actual_votes") + poll = models.ForeignKey( + Poll, + verbose_name="Which question has been voted for?", + on_delete=models.CASCADE, + ) + choice = models.ForeignKey( + Choice, + verbose_name="Which choice was chosen?", + on_delete=models.CASCADE, + ) + user = UserForeignKey( + auto_user_add=True, + verbose_name="Which user has voted?", + related_name="actual_votes", + ) diff --git a/tests/test_project/test_project/polls/templates/polls/detail.html b/tests/test_project/test_project/polls/templates/polls/detail.html index 90c6404..d05ba9c 100644 --- a/tests/test_project/test_project/polls/templates/polls/detail.html +++ b/tests/test_project/test_project/polls/templates/polls/detail.html @@ -21,4 +21,4 @@

{{ poll.question }}

{% endif %} -Back to poll index \ No newline at end of file +Back to poll index diff --git a/tests/test_project/test_project/polls/templates/polls/index.html b/tests/test_project/test_project/polls/templates/polls/index.html index cdb442d..46fb7a9 100644 --- a/tests/test_project/test_project/polls/templates/polls/index.html +++ b/tests/test_project/test_project/polls/templates/polls/index.html @@ -15,4 +15,3 @@

Active Polls

{% else %}

No polls are available.

{% endif %} - diff --git a/tests/test_project/test_project/polls/templates/polls/results.html b/tests/test_project/test_project/polls/templates/polls/results.html index 7024593..34c8f3c 100644 --- a/tests/test_project/test_project/polls/templates/polls/results.html +++ b/tests/test_project/test_project/polls/templates/polls/results.html @@ -10,4 +10,4 @@

{{ poll.question }}

{% endif %} -Back to poll index \ No newline at end of file +Back to poll index diff --git a/tests/test_project/test_project/polls/tests.py b/tests/test_project/test_project/polls/tests.py index af819a5..6600534 100644 --- a/tests/test_project/test_project/polls/tests.py +++ b/tests/test_project/test_project/polls/tests.py @@ -12,9 +12,15 @@ class GeneralAuthTest(TestCase): def test_auth_fail(self): c = Client() - response = c.post(reverse('login'), {'username': 'thisuserdoes', 'password': 'notexist'}) + response = c.post( + reverse("login"), + {"username": "thisuserdoes", "password": "notexist"}, + ) self.assertEqual(response.status_code, 200) # should fail - self.assertContains(response, "Your username and password didn't match. Please try again") + self.assertContains( + response, + "Your username and password didn't match. Please try again", + ) class PollMethodTests(TestCase): @@ -53,7 +59,7 @@ def create_poll(question, days=0, choices=None): p = Poll.objects.create( question=question, - pub_date=timezone.now() + datetime.timedelta(days=days) + pub_date=timezone.now() + datetime.timedelta(days=days), ) if len(choices) > 0: @@ -67,24 +73,27 @@ def create_poll(question, days=0, choices=None): class PollViewTests(TestCase): + def assertPollReprListEqual(self, queryset, expected): + self.assertEqual([repr(obj) for obj in queryset], expected) + def test_index_view_with_no_polls(self): """ If no polls exist, an appropriate message should be displayed. """ - response = self.client.get(reverse('polls:index')) + response = self.client.get(reverse("polls:index")) self.assertEqual(response.status_code, 200) self.assertContains(response, "No polls are available.") - self.assertQuerysetEqual(response.context['latest_poll_list'], []) + self.assertPollReprListEqual(response.context["latest_poll_list"], []) def test_index_view_with_a_past_poll(self): """ Polls with a pub_date in the past should be displayed on the index page. """ create_poll(question="Past poll.", days=-30) - response = self.client.get(reverse('polls:index')) - self.assertQuerysetEqual( - response.context['latest_poll_list'], - [''] + response = self.client.get(reverse("polls:index")) + self.assertPollReprListEqual( + response.context["latest_poll_list"], + [""], ) def test_index_view_with_a_future_poll(self): @@ -93,9 +102,9 @@ def test_index_view_with_a_future_poll(self): index page. """ create_poll(question="Future poll.", days=30) - response = self.client.get(reverse('polls:index')) + response = self.client.get(reverse("polls:index")) self.assertContains(response, "No polls are available.", status_code=200) - self.assertQuerysetEqual(response.context['latest_poll_list'], []) + self.assertPollReprListEqual(response.context["latest_poll_list"], []) def test_index_view_with_future_poll_and_past_poll(self): """ @@ -104,10 +113,10 @@ def test_index_view_with_future_poll_and_past_poll(self): """ create_poll(question="Past poll.", days=-30) create_poll(question="Future poll.", days=30) - response = self.client.get(reverse('polls:index')) - self.assertQuerysetEqual( - response.context['latest_poll_list'], - [''] + response = self.client.get(reverse("polls:index")) + self.assertPollReprListEqual( + response.context["latest_poll_list"], + [""], ) def test_index_view_with_two_past_polls(self): @@ -116,10 +125,10 @@ def test_index_view_with_two_past_polls(self): """ create_poll(question="Past poll 1.", days=-30) create_poll(question="Past poll 2.", days=-5) - response = self.client.get(reverse('polls:index')) - self.assertQuerysetEqual( - response.context['latest_poll_list'], - ['', ''] + response = self.client.get(reverse("polls:index")) + self.assertPollReprListEqual( + response.context["latest_poll_list"], + ["", ""], ) @@ -129,8 +138,8 @@ def test_detail_view_with_a_future_poll(self): The detail view of a poll with a pub_date in the future should return a 404 not found. """ - future_poll = create_poll(question='Future poll.', days=5) - response = self.client.get(reverse('polls:detail', args=(future_poll.id,))) + future_poll = create_poll(question="Future poll.", days=5) + response = self.client.get(reverse("polls:detail", args=(future_poll.id,))) self.assertEqual(response.status_code, 404) def test_detail_view_with_a_past_poll(self): @@ -138,33 +147,45 @@ def test_detail_view_with_a_past_poll(self): The detail view of a poll with a pub_date in the past should display the poll's question. """ - past_poll = create_poll(question='Past Poll.', days=-5) - response = self.client.get(reverse('polls:detail', args=(past_poll.id,))) + past_poll = create_poll(question="Past Poll.", days=-5) + response = self.client.get(reverse("polls:detail", args=(past_poll.id,))) self.assertContains(response, past_poll.question, status_code=200) class PollVoteTests(TestCase): def setUp(self): self.user1 = User.objects.create_user( - username='johndoe', email='johndoe@mail.com', password='top_secret') + username="johndoe", + email="johndoe@mail.com", + password="top_secret", + ) self.user2 = User.objects.create_user( - username='homersimpson', email='h.simpson@springfield.com', password='top_secret') + username="homersimpson", + email="h.simpson@springfield.com", + password="top_secret", + ) self.user3 = User.objects.create_user( - username='mrburns', email='mrburns@aol.com', password='top_secret') + username="mrburns", + email="mrburns@aol.com", + password="top_secret", + ) def test_vote_poll_anonymous(self): """ Should not be allowed to vote when not logged in """ - poll = create_poll(question='What is the question?', choices=['I dont know', 'Whatever', '42']) + poll = create_poll( + question="What is the question?", + choices=["I dont know", "Whatever", "42"], + ) choices = Choice.objects.filter(poll=poll) - site = reverse('polls:vote', args=(poll.id,)) + site = reverse("polls:vote", args=(poll.id,)) - response = self.client.get(site, {'choice': choices[0].id}, ) + response = self.client.get(site, {"choice": choices[0].id}) self.assertEqual(response.status_code, 302) self.assertTrue("/results/" not in response.url) @@ -172,38 +193,50 @@ def test_vote_poll_anonymous(self): def test_vote_poll_authed(self): """ - Should be allowed to vote, and vote should count + Should be allowed to vote, and vote should count """ - poll = create_poll(question='What is the question?', choices=['I dont know', 'Whatever', '42']) + poll = create_poll( + question="What is the question?", + choices=["I dont know", "Whatever", "42"], + ) choices = Choice.objects.filter(poll=poll) - site = reverse('polls:vote', args=(poll.id,)) + site = reverse("polls:vote", args=(poll.id,)) c = Client() - response = c.post(reverse("login"), {'username': 'johndoe', 'password': 'top_secret'}) + response = c.post( + reverse("login"), + {"username": "johndoe", "password": "top_secret"}, + ) self.assertEqual(response.status_code, 302) # should work self.assertTrue("/accounts/profile/" in response.url) - response = c.post(site, {'choice': choices[0].id}, ) + response = c.post(site, {"choice": choices[0].id}) self.assertEqual(response.status_code, 302) # should work self.assertTrue("/results/" in response.url) c = Client() - response = c.post(reverse("login"), {'username': 'homersimpson', 'password': 'top_secret'}) + response = c.post( + reverse("login"), + {"username": "homersimpson", "password": "top_secret"}, + ) self.assertEqual(response.status_code, 302) # should work self.assertTrue("/accounts/profile/" in response.url) - response = c.post(site, {'choice': choices[1].id}, ) + response = c.post(site, {"choice": choices[1].id}) self.assertEqual(response.status_code, 302) # should work self.assertTrue("/results/" in response.url) c = Client() - response = c.post(reverse("login"), {'username': 'mrburns', 'password': 'top_secret'}) + response = c.post( + reverse("login"), + {"username": "mrburns", "password": "top_secret"}, + ) self.assertEqual(response.status_code, 302) # should work self.assertTrue("/accounts/profile/" in response.url) - response = c.post(site, {'choice': choices[0].id}, ) + response = c.post(site, {"choice": choices[0].id}) self.assertEqual(response.status_code, 302) # should work self.assertTrue("/results/" in response.url) @@ -222,33 +255,39 @@ def test_vote_poll_authed(self): def test_vote_poll_multivote(self): """ - Should be allowed to vote, and vote should count + Should be allowed to vote, and vote should count """ - poll = create_poll(question='What is the question?', choices=['I dont know', 'Whatever', '42']) + poll = create_poll( + question="What is the question?", + choices=["I dont know", "Whatever", "42"], + ) choices = Choice.objects.filter(poll=poll) - site = reverse('polls:vote', args=(poll.id,)) + site = reverse("polls:vote", args=(poll.id,)) c = Client() - response = c.post(reverse("login"), {'username': 'johndoe', 'password': 'top_secret'}) + response = c.post( + reverse("login"), + {"username": "johndoe", "password": "top_secret"}, + ) self.assertEqual(response.status_code, 302) # should work self.assertTrue("/accounts/profile/" in response.url) - response = c.post(site, {'choice': choices[0].id}, ) + response = c.post(site, {"choice": choices[0].id}) self.assertEqual(response.status_code, 302) # should work self.assertTrue("/results/" in response.url) # vote again - response = c.post(site, {'choice': choices[1].id}, ) + response = c.post(site, {"choice": choices[1].id}) self.assertContains(response, "You already voted", status_code=200) # vote again - response = c.post(site, {'choice': choices[2].id}, ) + response = c.post(site, {"choice": choices[2].id}) self.assertContains(response, "You already voted", status_code=200) # vote again - response = c.post(site, {'choice': choices[0].id}, ) + response = c.post(site, {"choice": choices[0].id}) self.assertContains(response, "You already voted", status_code=200) # check if ActualVotes is there (should only be 1 vote) diff --git a/tests/test_project/test_project/polls/urls.py b/tests/test_project/test_project/polls/urls.py index a17bc7e..c5b41c9 100644 --- a/tests/test_project/test_project/polls/urls.py +++ b/tests/test_project/test_project/polls/urls.py @@ -1,12 +1,12 @@ -from django.conf.urls import url +from django.urls import re_path from . import views -app_name = 'polls' +app_name = "polls" urlpatterns = [ - url(r'^$', views.IndexView.as_view(), name='index'), - url(r'^(?P\d+)/$', views.DetailView.as_view(), name='detail'), - url(r'^(?P\d+)/results/$', views.ResultsView.as_view(), name='results'), - url(r'^(?P\d+)/vote/$', views.vote, name='vote'), + re_path(r"^$", views.IndexView.as_view(), name="index"), + re_path(r"^(?P\d+)/$", views.DetailView.as_view(), name="detail"), + re_path(r"^(?P\d+)/results/$", views.ResultsView.as_view(), name="results"), + re_path(r"^(?P\d+)/vote/$", views.vote, name="vote"), ] diff --git a/tests/test_project/test_project/polls/views.py b/tests/test_project/test_project/polls/views.py index 7847561..01781d6 100644 --- a/tests/test_project/test_project/polls/views.py +++ b/tests/test_project/test_project/polls/views.py @@ -10,8 +10,8 @@ class IndexView(generic.ListView): - template_name = 'polls/index.html' - context_object_name = 'latest_poll_list' + template_name = "polls/index.html" + context_object_name = "latest_poll_list" def get_queryset(self): """ @@ -19,13 +19,13 @@ def get_queryset(self): published in the future). """ return Poll.objects.filter( - pub_date__lte=timezone.now() - ).order_by('-pub_date')[:5] + pub_date__lte=timezone.now(), + ).order_by("-pub_date")[:5] class DetailView(generic.DetailView): model = Poll - template_name = 'polls/detail.html' + template_name = "polls/detail.html" def get_queryset(self): """ @@ -36,30 +36,38 @@ def get_queryset(self): class ResultsView(generic.DetailView): model = Poll - template_name = 'polls/results.html' + template_name = "polls/results.html" @login_required def vote(request, poll_id): p = get_object_or_404(Poll, pk=poll_id) - if 'choice' not in request.POST: - return HttpResponseRedirect(reverse('polls:detail', args=(p.id,))) + if "choice" not in request.POST: + return HttpResponseRedirect(reverse("polls:detail", args=(p.id,))) else: try: - selected_choice = p.choices.get(pk=request.POST['choice']) + selected_choice = p.choices.get(pk=request.POST["choice"]) except (KeyError, Choice.DoesNotExist): # Redisplay the poll voting form. - return render(request, 'polls/detail.html', { - 'poll': p, - 'error_message': "Error: You didn't select a valid choice.", - }) + return render( + request, + "polls/detail.html", + { + "poll": p, + "error_message": "Error: You didn't select a valid choice.", + }, + ) else: if len(ActualVote.objects.filter(poll=p, user=request.user)) > 0: - return render(request, 'polls/detail.html', { - 'poll': p, - 'error_message': "Error: You already voted!", - }) + return render( + request, + "polls/detail.html", + { + "poll": p, + "error_message": "Error: You already voted!", + }, + ) else: selected_choice.votes += 1 selected_choice.save() @@ -72,28 +80,28 @@ def vote(request, poll_id): # Always return an HttpResponseRedirect after successfully dealing # with POST data. This prevents data from being posted twice if a # user hits the Back button. - return HttpResponseRedirect(reverse('polls:results', args=(p.id,))) + return HttpResponseRedirect(reverse("polls:results", args=(p.id,))) def login_view(request): - if 'username' in request.POST and 'password' in request.POST: - username = request.POST['username'] - password = request.POST['password'] + if "username" in request.POST and "password" in request.POST: + username = request.POST["username"] + password = request.POST["password"] user = authenticate(username=username, password=password) if user is not None: if user.is_active: login(request, user) - return HttpResponseRedirect(reverse('polls:index')) + return HttpResponseRedirect(reverse("polls:index")) else: # Return a 'disabled account' error message - return HttpResponse('Error: Account disabled', status=403) + return HttpResponse("Error: Account disabled", status=403) else: # Return an 'invalid login' error message. - return HttpResponse('Error: Invalid Login', status=403) + return HttpResponse("Error: Invalid Login", status=403) else: - return render(request, 'polls/templates/registration/login.html') + return render(request, "polls/templates/registration/login.html") def logout_view(request): logout(request) - return HttpResponseRedirect(reverse('polls:index')) + return HttpResponseRedirect(reverse("polls:index")) diff --git a/tests/test_project/test_project/settings.py b/tests/test_project/test_project/settings.py index dfc6a95..9088549 100644 --- a/tests/test_project/test_project/settings.py +++ b/tests/test_project/test_project/settings.py @@ -2,23 +2,22 @@ Django settings for test_project project. For more information on this file, see -https://docs.djangoproject.com/en/3.2/topics/settings/ +https://docs.djangoproject.com/en/6.0/topics/settings/ For the full list of settings and their values, see -https://docs.djangoproject.com/en/3.2/ref/settings/ +https://docs.djangoproject.com/en/6.0/ref/settings/ """ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) import os -import django BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ +# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = '$ll30t2vwf1yy7kq=x*j#@fzjz2yqrh8c4(#xv6n=m$e$*95go' +SECRET_KEY = "$ll30t2vwf1yy7kq=x*j#@fzjz2yqrh8c4(#xv6n=m$e$*95go" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -28,66 +27,66 @@ # Application definition INSTALLED_APPS = ( - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django_userforeignkey', - 'test_project.polls', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django_userforeignkey", + "test_project.polls", ) MIDDLEWARE = ( - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", # 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'django.middleware.security.SecurityMiddleware', - 'django_userforeignkey.middleware.UserForeignKeyMiddleware', + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django_userforeignkey.middleware.UserForeignKeyMiddleware", ) -ROOT_URLCONF = 'test_project.urls' +ROOT_URLCONF = "test_project.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'test_project.wsgi.application' +WSGI_APPLICATION = "test_project.wsgi.application" # Database -# https://docs.djangoproject.com/en/3.2/ref/settings/#databases +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + }, } -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Internationalization -# https://docs.djangoproject.com/en/3.2/topics/i18n/ +# https://docs.djangoproject.com/en/6.0/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -96,6 +95,6 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/3.2/howto/static-files/ +# https://docs.djangoproject.com/en/6.0/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" diff --git a/tests/test_project/test_project/urls.py b/tests/test_project/test_project/urls.py index 801f35d..796619b 100644 --- a/tests/test_project/test_project/urls.py +++ b/tests/test_project/test_project/urls.py @@ -1,11 +1,11 @@ -from django.conf.urls import url, include from django.contrib import admin from django.contrib.auth import views as auth_views +from django.urls import include, re_path urlpatterns = [ - url(r'^polls/', include("test_project.polls.urls", namespace="polls")), - url(r'^admin/', admin.site.urls), - url(r'^accounts/login/$', auth_views.LoginView.as_view(), name="login"), - url(r'^accounts/logout/$', auth_views.LogoutView.as_view(), name="logout"), + re_path(r"^polls/", include("test_project.polls.urls", namespace="polls")), + re_path(r"^admin/", admin.site.urls), + re_path(r"^accounts/login/$", auth_views.LoginView.as_view(), name="login"), + re_path(r"^accounts/logout/$", auth_views.LogoutView.as_view(), name="logout"), ] diff --git a/tests/test_project/test_project/wsgi.py b/tests/test_project/test_project/wsgi.py index 959fcae..8a4b4e2 100644 --- a/tests/test_project/test_project/wsgi.py +++ b/tests/test_project/test_project/wsgi.py @@ -4,7 +4,7 @@ It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see -https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ """ import os