From daaf1b7745ddbe0476dd7f6360598795d8765323 Mon Sep 17 00:00:00 2001 From: Daniel Gray Date: Wed, 24 Apr 2024 11:15:49 +0200 Subject: [PATCH] added django-simple-history to project Add the following models to simple history - Project - Institution - Language - Subject - DocumentFile - Users Updated tests Added new Migrations Cleaned up admin code while updating --- README.md | 6 + app/app/settings.py | 2 + app/general/admin.py | 49 +++++-- ...mentfile_historicalinstitution_and_more.py | 128 ++++++++++++++++++ app/general/models.py | 16 +++ app/general/tests/test_document_file.py | 12 ++ app/general/tests/tests_institution.py | 10 +- app/general/tests/tests_language.py | 25 ++-- app/general/tests/tests_projects.py | 9 ++ app/general/tests/tests_subject.py | 4 + app/users/admin.py | 4 +- .../migrations/0003_historicalcustomuser.py | 47 +++++++ app/users/models.py | 4 + requirements.txt | 1 + 14 files changed, 292 insertions(+), 25 deletions(-) create mode 100644 app/general/migrations/0004_historicaldocumentfile_historicalinstitution_and_more.py create mode 100644 app/users/migrations/0003_historicalcustomuser.py diff --git a/README.md b/README.md index 194ea783..d2f4ae1f 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,9 @@ About the project: 2. Run `make build` to build the docker image 3. Run `make run` to run the docker container 4. Run `make stop` to stop the docker container + + +### Plugins installed +#### Django Simple History + +https://django-simple-history.readthedocs.io/en/latest/ diff --git a/app/app/settings.py b/app/app/settings.py index bba54394..3b8df701 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -46,6 +46,7 @@ "django.contrib.staticfiles", "users", "general", + "simple_history", ] if DEBUG: INSTALLED_APPS += [ @@ -63,6 +64,7 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "simple_history.middleware.HistoryRequestMiddleware", ] ROOT_URLCONF = "app.urls" diff --git a/app/general/admin.py b/app/general/admin.py index c88bad8a..f0ecbe72 100644 --- a/app/general/admin.py +++ b/app/general/admin.py @@ -2,15 +2,11 @@ from django.contrib import admin from django.forms import HiddenInput, ModelForm, fields_for_model +from simple_history.admin import SimpleHistoryAdmin from .models import DocumentFile, Institution, Language, Project, Subject -class ProjectAdminInline(admin.TabularInline): - model = Project - extra = 0 - - class DocumentFileForm(ModelForm): class Meta: model = DocumentFile @@ -47,22 +43,45 @@ def clean(self): return cleaned_data -class DocumentFileAdmin(admin.ModelAdmin): +class DocumentFileAdmin(SimpleHistoryAdmin): list_display = ["title", "license", "document_type", "available"] - ordering = [ - "license", - ] + ordering = ["license"] search_fields = ["title", "license", "document_type"] - form = DocumentFileForm + history_list_display = ["title", "license", "document_type", "available"] + + +class SubjectAdmin(SimpleHistoryAdmin): + search_fields = ["name"] + list_display = ["name"] + history_list_display = ["name"] + + +class LanguageAdmin(SimpleHistoryAdmin): + history_list_display = ["name", "iso_code"] + list_display = ["name", "iso_code"] + + +class ProjectAdminInline(admin.TabularInline): + model = Project + extra = 0 + + +class ProjectAdmin(SimpleHistoryAdmin): + search_fields = ["name"] + list_display = ["name"] + history_list_display = ["name"] -class ProjectAdmin(admin.ModelAdmin): +class InstitutionAdmin(SimpleHistoryAdmin): + search_fields = ["name"] + list_display = ["name"] inlines = [ProjectAdminInline] + history_list_display = ["name", "abbreviation"] -admin.site.register(Project) -admin.site.register(Institution, ProjectAdmin) -admin.site.register(Language) -admin.site.register(Subject) +admin.site.register(Project, ProjectAdmin) +admin.site.register(Institution, InstitutionAdmin) +admin.site.register(Language, LanguageAdmin) +admin.site.register(Subject, SubjectAdmin) admin.site.register(DocumentFile, DocumentFileAdmin) diff --git a/app/general/migrations/0004_historicaldocumentfile_historicalinstitution_and_more.py b/app/general/migrations/0004_historicaldocumentfile_historicalinstitution_and_more.py new file mode 100644 index 00000000..a30c129c --- /dev/null +++ b/app/general/migrations/0004_historicaldocumentfile_historicalinstitution_and_more.py @@ -0,0 +1,128 @@ +import django.core.validators +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('general', '0003_rename_institution_documentfile_institution_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalDocumentFile', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('url', models.URLField(blank=True, verbose_name='URL')), + ('uploaded_file', models.TextField(blank=True, help_text='PDF files up to 10MB are allowed.', max_length=100, validators=[django.core.validators.FileExtensionValidator(['pdf'])])), + ('available', models.BooleanField(default=True)), + ('license', models.CharField(choices=[('(c)', 'All rights reserved'), ('CC0', 'No rights reserved'), ('CC BY', 'Creative Commons Attribution'), ('CC BY-SA', 'Creative Commons Attribution-ShareAlike'), ('CC BY-NC', 'Creative Commons Attribution-NonCommercial'), ('CC BY-NC-SA', 'Creative Commons Attribution-NonCommercial-ShareAlike')], default='(c)', help_text='\n \n More information about Creative Commons licenses.\n \'\n ', max_length=200)), + ('mime_type', models.CharField(blank=True, help_text='This input will auto-populate.', max_length=200)), + ('document_type', models.CharField(choices=[('Glossary', 'Glossary'), ('Policy', 'Policy')], max_length=200)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('institution', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='general.institution')), + ], + options={ + 'verbose_name': 'historical document file', + 'verbose_name_plural': 'historical document files', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalInstitution', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(db_index=True, max_length=200)), + ('abbreviation', models.CharField(max_length=200)), + ('url', models.URLField(blank=True, verbose_name='URL')), + ('email', models.EmailField(blank=True, max_length=200)), + ('logo', models.TextField(blank=True, max_length=100)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical institution', + 'verbose_name_plural': 'historical institutions', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalLanguage', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(db_index=True, max_length=150)), + ('iso_code', models.CharField(db_index=True, help_text='The 2 or 3 letter code from ISO 639.', max_length=50, verbose_name='ISO code')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical language', + 'verbose_name_plural': 'historical languages', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalProject', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('url', models.URLField(blank=True, verbose_name='URL')), + ('logo', models.TextField(blank=True, max_length=100)), + ('start_date', models.DateField(blank=True, null=True)), + ('end_date', models.DateField(blank=True, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('institution', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='general.institution', verbose_name='institution')), + ], + options={ + 'verbose_name': 'historical project', + 'verbose_name_plural': 'historical projects', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalSubject', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(db_index=True, max_length=150)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical subject', + 'verbose_name_plural': 'historical subjects', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/app/general/models.py b/app/general/models.py index 53f10ec0..8de06076 100644 --- a/app/general/models.py +++ b/app/general/models.py @@ -1,5 +1,6 @@ from django.core.validators import FileExtensionValidator from django.db import models +from simple_history.models import HistoricalRecords class Project(models.Model): @@ -14,6 +15,9 @@ class Project(models.Model): subjects = models.ManyToManyField("Subject", blank=True) languages = models.ManyToManyField("Language", blank=True) + # added simple historical records to the model + history = HistoricalRecords() + def __str__(self): return self.name @@ -25,6 +29,9 @@ class Institution(models.Model): email = models.EmailField(max_length=200, blank=True) logo = models.FileField(upload_to="logos/", blank=True) + # added simple historical records to the model + history = HistoricalRecords() + def __str__(self): return f"{self.name} ({self.abbreviation})" @@ -38,6 +45,9 @@ class Language(models.Model): verbose_name="ISO code", ) + # added simple historical records to the model + history = HistoricalRecords() + def __str__(self): return self.name @@ -45,6 +55,9 @@ def __str__(self): class Subject(models.Model): name = models.CharField(max_length=150, unique=True) + # added simple historical records to the model + history = HistoricalRecords() + def __str__(self): return self.name @@ -98,5 +111,8 @@ class DocumentFile(models.Model): subjects = models.ManyToManyField("Subject", blank=True) languages = models.ManyToManyField("Language", blank=True) + # added simple historical records to the model + history = HistoricalRecords() + def __str__(self): return self.title diff --git a/app/general/tests/test_document_file.py b/app/general/tests/test_document_file.py index 0a2e5ebe..6eb1a651 100644 --- a/app/general/tests/test_document_file.py +++ b/app/general/tests/test_document_file.py @@ -44,6 +44,18 @@ def test_document_str_representation(self): # Test __str__ method def test_document_available_by_default(self): # Test default value self.assertTrue(self.document.available) + def test_history_records_creation(self): + self.assertEqual(self.document.history.count(), 1) + self.assertEqual(self.document.history.first().title, "Some document") + self.assertEqual(self.document.history.first().url, "https://example.com") + self.assertEqual(self.document.history.first().uploaded_file, "documents/example.pdf") + self.assertEqual(self.document.history.first().license, "MIT") + self.assertEqual(self.document.history.first().mime_type, "pdf") + self.assertEqual(self.document.history.first().document_type, "Glossary") + self.assertEqual(self.document.institution, self.institution) + self.assertIn(self.subject, self.document.subjects.all()) + self.assertIn(self.language, self.document.languages.all()) + def tearDown(self): if self.document.uploaded_file: self.document.uploaded_file.delete() diff --git a/app/general/tests/tests_institution.py b/app/general/tests/tests_institution.py index acef3a3d..198fccd1 100644 --- a/app/general/tests/tests_institution.py +++ b/app/general/tests/tests_institution.py @@ -2,7 +2,7 @@ from django.test import TestCase -from general.models import Institution, Project +from general.models import Institution class TestInstitution(TestCase): @@ -34,6 +34,14 @@ def test_institution_email(self): def test_institution_logo(self): self.assertEqual(self.institution.logo, "testuni.png") + def test_history_records_creation(self): + self.assertEqual(self.institution.history.count(), 1) + self.assertEqual(self.institution.history.first().name, "Test University") + self.assertEqual(self.institution.history.first().abbreviation, "tu") + self.assertEqual(self.institution.history.first().url, "http://www.testuni.com") + self.assertEqual(self.institution.history.first().email, "info@testuni.dev") + self.assertEqual(self.institution.history.first().logo, "testuni.png") + if __name__ == "__main__": unittest.main() diff --git a/app/general/tests/tests_language.py b/app/general/tests/tests_language.py index 2efe19bc..abe839ef 100644 --- a/app/general/tests/tests_language.py +++ b/app/general/tests/tests_language.py @@ -2,21 +2,30 @@ from django.test import TestCase -from general.models import Subject +from general.models import Language -class TestSubject(TestCase): +class TestLanguage(TestCase): def setUp(self): - self.subject = Subject.objects.create(name="Maths") - self.subject2 = Subject.objects.create(name="Science") + self.language = Language.objects.create(name="English", iso_code="EN") + self.language2 = Language.objects.create(name="Afrikaans", iso_code="AF") def test_subject_creation(self): - self.assertEqual(self.subject.name, "Maths") - self.assertEqual(self.subject.__str__(), "Maths") + self.assertEqual(self.language.name, "English") + self.assertEqual(self.language.iso_code, "EN") + + self.assertEqual(self.language2.name, "Afrikaans") + self.assertEqual(self.language2.iso_code, "AF") def test_subject_name_uniqueness(self): - duplicate_subject = Subject(name="Maths") - self.assertRaises(Exception, duplicate_subject.save) + with self.assertRaises(Exception): + Language.objects.create(name="English") + + # + def test_history_records_creation(self): + self.assertEqual(self.language.history.count(), 1) + self.assertEqual(self.language.history.first().name, "English") + self.assertEqual(self.language.history.first().iso_code, "EN") if __name__ == "__main__": diff --git a/app/general/tests/tests_projects.py b/app/general/tests/tests_projects.py index e593ed90..1a0b981e 100644 --- a/app/general/tests/tests_projects.py +++ b/app/general/tests/tests_projects.py @@ -61,6 +61,15 @@ def test_project_language(self): def test_str(self): self.assertEqual(str(self.project), "Test Project") + def test_history_records_creation(self): + self.assertEqual(self.project.history.count(), 1) + self.assertEqual(self.project.history.first().name, "Test Project") + self.assertEqual(self.project.history.first().url, "http://test.com") + self.assertEqual(self.project.history.first().logo, "http://test.com/logo.png") + self.assertEqual(self.project.history.first().start_date.strftime("%Y-%m-%d"), "2023-01-01") + self.assertEqual(self.project.history.first().end_date.strftime("%Y-%m-%d"), "2023-12-31") + self.assertEqual(self.project.history.first().institution, self.institution) + if __name__ == "__main__": unittest.main() diff --git a/app/general/tests/tests_subject.py b/app/general/tests/tests_subject.py index 7c623485..53cc60c0 100644 --- a/app/general/tests/tests_subject.py +++ b/app/general/tests/tests_subject.py @@ -18,6 +18,10 @@ def test_subject_name_uniqueness(self): with self.assertRaises(Exception): Subject.objects.create(name="Mathematics") + def test_history_records_creation(self): + self.assertEqual(self.subject1.history.count(), 1) + self.assertEqual(self.subject1.history.first().name, "Mathematics") + if __name__ == "__main__": unittest.main() diff --git a/app/users/admin.py b/app/users/admin.py index a5d010c8..a7f298a1 100644 --- a/app/users/admin.py +++ b/app/users/admin.py @@ -1,10 +1,11 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from simple_history.admin import SimpleHistoryAdmin from .models import CustomUser -class CustomUserAdmin(UserAdmin): +class CustomUserAdmin(UserAdmin, SimpleHistoryAdmin): model = CustomUser list_display = [ "username", @@ -22,6 +23,7 @@ class CustomUserAdmin(UserAdmin): fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("institution", "languages", "subject")}),) add_fieldsets = UserAdmin.add_fieldsets + history_list_display = ["username", "email", "first_name", "last_name", "is_staff", "is_active"] admin.site.register(CustomUser, CustomUserAdmin) diff --git a/app/users/migrations/0003_historicalcustomuser.py b/app/users/migrations/0003_historicalcustomuser.py new file mode 100644 index 00000000..8f2685d1 --- /dev/null +++ b/app/users/migrations/0003_historicalcustomuser.py @@ -0,0 +1,47 @@ + +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('general', '0004_historicaldocumentfile_historicalinstitution_and_more'), + ('users', '0002_alter_customuser_languages_alter_customuser_subject'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalCustomUser', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(db_index=True, error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('institution', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='general.institution')), + ], + options={ + 'verbose_name': 'historical user', + 'verbose_name_plural': 'historical users', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/app/users/models.py b/app/users/models.py index 84976f18..52adb0d1 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -1,5 +1,6 @@ from django.contrib.auth.models import AbstractUser from django.db import models +from simple_history.models import HistoricalRecords from general.models import Institution, Language, Subject @@ -9,5 +10,8 @@ class CustomUser(AbstractUser): languages = models.ManyToManyField(Language, blank=True) subject = models.ManyToManyField(Subject, blank=True) + # added simple historical records to the model + history = HistoricalRecords() + def __str__(self): return self.username diff --git a/requirements.txt b/requirements.txt index cf164c91..0fc65b4e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ psycopg2-binary gunicorn whitenoise django-environ +django-simple-history