diff --git a/development_notebook/2024-05-05.md b/development_notebook/2024-05-05.md new file mode 100644 index 0000000..64da297 --- /dev/null +++ b/development_notebook/2024-05-05.md @@ -0,0 +1,58 @@ +# State Of Work + +* Project has been fully configured from cookiecutter-django using MySQL. +* Deployed to production at [negligentoctopus.pythonanywhere.com](negligentoctopus.pythonanywhere.com). + +Core models are defined and added to the django-admin. +Account and Transactions have been implemented with balance as prefix-sum. +Transfers are implemented as coupled transactions. + +We have implemented a SimpleTransactionImport model and admin, which processes on save and creates SimpleImportedTransactions. These can be validated by a checkbox and if so, will create a Transaction on save. + +Created a custom admin site to use as prototype, currently in phase preparation for alpha testing. Pages, for example list view, will be slowly redirect to main site as they are implemented. +Sign up on main site is fully functionational, with email confirmation. + +## Update + +# Today + +## Work Log +__InProgress__ +* Add simple import for transactions (for example from edenred csv) + * Once this is defined, rewrite Activo using the extension points + +* Write selenium script to get edenred - use js from browser + +__ToDo__ +* Implement alpha prototype admin login. +* Implement Forms for admin + +* Create homepage with reporting. + +* Define DRF for core +* Write admin tests + + +__Done__ +* Add simple import for transactions (for example from edenred csv) + * Currently in progress to define an API - since this is first version of a general solution doesnt really matter + * Define SimpleImport then define SimpleTransaction + * Add it to admin and test + +* Change existing user mrmurilo73 to use group permissions. (in local) + +__Discarded__ + +# To Do +* Write tests + * For uploading activo file thru admin + * Setting name from filename + * only allowed extensions files + * For Activo transaction + +__Next on road map:__ + Export transactions to file. + v2. -> extended management command to be more modular. Code option to load Montepio export. + v3. -> define budget loadedTransaction as inherited from transaction and give the option to associate as previous transaction, filter by value and not associated, or validate auto create new transaction. + v4. -> create association between certain titles/transactions loaded into categories/ template transactions + Create new admin for end users. diff --git a/development_notebook/2024-05-06.md b/development_notebook/2024-05-06.md new file mode 100644 index 0000000..64da297 --- /dev/null +++ b/development_notebook/2024-05-06.md @@ -0,0 +1,58 @@ +# State Of Work + +* Project has been fully configured from cookiecutter-django using MySQL. +* Deployed to production at [negligentoctopus.pythonanywhere.com](negligentoctopus.pythonanywhere.com). + +Core models are defined and added to the django-admin. +Account and Transactions have been implemented with balance as prefix-sum. +Transfers are implemented as coupled transactions. + +We have implemented a SimpleTransactionImport model and admin, which processes on save and creates SimpleImportedTransactions. These can be validated by a checkbox and if so, will create a Transaction on save. + +Created a custom admin site to use as prototype, currently in phase preparation for alpha testing. Pages, for example list view, will be slowly redirect to main site as they are implemented. +Sign up on main site is fully functionational, with email confirmation. + +## Update + +# Today + +## Work Log +__InProgress__ +* Add simple import for transactions (for example from edenred csv) + * Once this is defined, rewrite Activo using the extension points + +* Write selenium script to get edenred - use js from browser + +__ToDo__ +* Implement alpha prototype admin login. +* Implement Forms for admin + +* Create homepage with reporting. + +* Define DRF for core +* Write admin tests + + +__Done__ +* Add simple import for transactions (for example from edenred csv) + * Currently in progress to define an API - since this is first version of a general solution doesnt really matter + * Define SimpleImport then define SimpleTransaction + * Add it to admin and test + +* Change existing user mrmurilo73 to use group permissions. (in local) + +__Discarded__ + +# To Do +* Write tests + * For uploading activo file thru admin + * Setting name from filename + * only allowed extensions files + * For Activo transaction + +__Next on road map:__ + Export transactions to file. + v2. -> extended management command to be more modular. Code option to load Montepio export. + v3. -> define budget loadedTransaction as inherited from transaction and give the option to associate as previous transaction, filter by value and not associated, or validate auto create new transaction. + v4. -> create association between certain titles/transactions loaded into categories/ template transactions + Create new admin for end users. diff --git a/negligent_octopus/alpha_prototype/admin/__init__.py b/negligent_octopus/alpha_prototype/admin/__init__.py index cd33a9a..05a3f86 100644 --- a/negligent_octopus/alpha_prototype/admin/__init__.py +++ b/negligent_octopus/alpha_prototype/admin/__init__.py @@ -1,6 +1,6 @@ from .models import OpenAccountAdmin # noqa: F401 from .models import OpenCategoryAdmin # noqa: F401 -from .models import OpenImportActivoAdmin # noqa: F401 -from .models import OpenImportedActivoTransactionAdmin # noqa: F401 +from .models import OpenSimpleImportedTransactionAdmin # noqa: F401 +from .models import OpenSimpleTransactionsImportAdmin # noqa: F401 from .models import OpenTransactionAdmin # noqa: F401 from .site import alpha_admin_site # noqa: F401 diff --git a/negligent_octopus/alpha_prototype/admin/models.py b/negligent_octopus/alpha_prototype/admin/models.py index feb12ef..b7e012f 100644 --- a/negligent_octopus/alpha_prototype/admin/models.py +++ b/negligent_octopus/alpha_prototype/admin/models.py @@ -1,7 +1,7 @@ -from negligent_octopus.budget.admin import ImportActivoAdmin -from negligent_octopus.budget.admin import ImportedActivoTransactionAdmin -from negligent_octopus.budget.models import ImportActivo -from negligent_octopus.budget.models import ImportedActivoTransaction +from negligent_octopus.budget.admin import SimpleImportedTransactionAdmin +from negligent_octopus.budget.admin import SimpleTransactionsImportAdmin +from negligent_octopus.budget.models import SimpleImportedTransaction +from negligent_octopus.budget.models import SimpleTransactionsImport from negligent_octopus.core.admin import AccountAdmin from negligent_octopus.core.admin import CategoryAdmin from negligent_octopus.core.admin import TransactionAdmin @@ -20,11 +20,11 @@ def get_queryset(self, request): return qs.filter(**{self.user_relation_field: request.user}) -class OpenImportActivoAdmin(ImportActivoAdmin, BaseOpenAdmin): +class OpenSimpleTransactionsImportAdmin(SimpleTransactionsImportAdmin, BaseOpenAdmin): pass -class OpenImportedActivoTransactionAdmin(ImportedActivoTransactionAdmin, BaseOpenAdmin): +class OpenSimpleImportedTransactionAdmin(SimpleImportedTransactionAdmin, BaseOpenAdmin): user_relation_field = "loaded_from__owner" @@ -40,8 +40,8 @@ class OpenAccountAdmin(AccountAdmin, BaseOpenAdmin): pass -alpha_admin_site.register(ImportActivo, OpenImportActivoAdmin) -alpha_admin_site.register(ImportedActivoTransaction, OpenImportedActivoTransactionAdmin) +alpha_admin_site.register(SimpleTransactionsImport, OpenSimpleTransactionsImportAdmin) +alpha_admin_site.register(SimpleImportedTransaction, OpenSimpleImportedTransactionAdmin) alpha_admin_site.register(Account, OpenAccountAdmin) alpha_admin_site.register(Category, OpenCategoryAdmin) alpha_admin_site.register(Transaction, OpenTransactionAdmin) diff --git a/negligent_octopus/budget/admin.py b/negligent_octopus/budget/admin.py index b60da00..54e1a6a 100644 --- a/negligent_octopus/budget/admin.py +++ b/negligent_octopus/budget/admin.py @@ -3,34 +3,33 @@ from negligent_octopus.utils.admin import LimitedQuerysetInlineAdmin from negligent_octopus.utils.admin import LimitedQuerysetInlineFormset -from .models import ImportActivo -from .models import ImportedActivoTransaction +from .models import SimpleImportedTransaction +from .models import SimpleTransactionsImport -class ImportedActivoTransactionInlineFormset(LimitedQuerysetInlineFormset): +class SimpleImportedTransactionInlineFormset(LimitedQuerysetInlineFormset): def get_queryset(self, *args, **kwargs): return super().get_queryset(*args, **kwargs)[::-1] -class ImportedActivoTransactionInlineAdmin(LimitedQuerysetInlineAdmin): - model = ImportedActivoTransaction +class SimpleImportedTransactionInlineAdmin(LimitedQuerysetInlineAdmin): + model = SimpleImportedTransaction fk_name = "loaded_from" fields = [ "loaded_from", - "date_of_movement", - "date_of_process", - "description", - "value", + "date", + "title", + "amount", "balance", "validated", "transaction", ] extra = 0 - formset = ImportedActivoTransactionInlineFormset + formset = SimpleImportedTransactionInlineFormset -@admin.register(ImportActivo) -class ImportActivoAdmin(admin.ModelAdmin): +@admin.register(SimpleTransactionsImport) +class SimpleTransactionsImportAdmin(admin.ModelAdmin): list_display = ["owner", "name", "account", "processed", "created"] list_display_links = ["name"] search_fields = ["name", "account__name", "owner__name"] @@ -40,7 +39,7 @@ class ImportActivoAdmin(admin.ModelAdmin): "processed", "created", ] - inlines = [ImportedActivoTransactionInlineAdmin] + inlines = [SimpleImportedTransactionInlineAdmin] def get_inlines(self, request, obj): if obj is None: @@ -50,7 +49,7 @@ def get_inlines(self, request, obj): def get_readonly_fields( self, request, - obj: ImportActivo | None = None, + obj=None, ): if obj is None: return self.readonly_fields @@ -60,38 +59,35 @@ def get_readonly_fields( return readonly_fields -@admin.register(ImportedActivoTransaction) -class ImportedActivoTransactionAdmin(admin.ModelAdmin): +@admin.register(SimpleImportedTransaction) +class SimpleImportedTransactionAdmin(admin.ModelAdmin): fields = [ "loaded_from", - "date_of_movement", - "date_of_process", - "description", - "value", + "date", + "title", + "amount", "balance", "validated", "transaction", ] list_display = [ "get_load_owner", - "date_of_movement", - "date_of_process", - "description", - "value", + "date", + "title", + "amount", "balance", "validated", "has_transaction", ] - list_display_links = ["description"] + list_display_links = ["title"] search_fields = [ - "description", - "value", + "title", + "amount", "balance", ] list_filter = [ "loaded_from__owner", - "date_of_movement", - "date_of_process", + "date", "validated", ] readonly_fields = ["loaded_from"] @@ -100,10 +96,9 @@ def get_readonly_fields(self, request, obj=None): if obj is not None and obj.validated and obj.transaction is not None: return [ *self.readonly_fields, - "date_of_movement", - "date_of_process", - "description", - "value", + "date", + "title", + "amount", "balance", "validated", "transaction", diff --git a/negligent_octopus/budget/migrations/0001_initial.py b/negligent_octopus/budget/migrations/0001_initial.py index d6ee012..fdacd17 100644 --- a/negligent_octopus/budget/migrations/0001_initial.py +++ b/negligent_octopus/budget/migrations/0001_initial.py @@ -51,7 +51,7 @@ class Migration(migrations.Migration): ( "load", models.FileField( - upload_to=negligent_octopus.budget.models.upload_activo_import_to, + upload_to=negligent_octopus.budget.models.upload_import_file_to, validators=[ negligent_octopus.utils.validators.FileExtensionValidator( ("csv", "xsls") diff --git a/negligent_octopus/budget/migrations/0003_alter_importactivo_load_and_more.py b/negligent_octopus/budget/migrations/0003_alter_importactivo_load_and_more.py index bc15cef..f8912be 100644 --- a/negligent_octopus/budget/migrations/0003_alter_importactivo_load_and_more.py +++ b/negligent_octopus/budget/migrations/0003_alter_importactivo_load_and_more.py @@ -16,7 +16,7 @@ class Migration(migrations.Migration): model_name="importactivo", name="load", field=models.FileField( - upload_to=negligent_octopus.budget.models.upload_activo_import_to, + upload_to=negligent_octopus.budget.models.upload_import_file_to, validators=[ negligent_octopus.utils.validators.FileExtensionValidator( ( diff --git a/negligent_octopus/budget/migrations/0004_simpleimportedtransaction_simpletransactionsimport_and_more.py b/negligent_octopus/budget/migrations/0004_simpleimportedtransaction_simpletransactionsimport_and_more.py new file mode 100644 index 0000000..37bb428 --- /dev/null +++ b/negligent_octopus/budget/migrations/0004_simpleimportedtransaction_simpletransactionsimport_and_more.py @@ -0,0 +1,156 @@ +# Generated by Django 4.2.10 on 2024-05-06 10:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import negligent_octopus.budget.models +import negligent_octopus.utils.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0006_transaction_destination_account_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("budget", "0003_alter_importactivo_load_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SimpleImportedTransaction", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ("date", models.DateField()), + ("title", models.CharField(max_length=255)), + ("amount", models.FloatField()), + ("balance", models.FloatField()), + ("validated", models.BooleanField(default=False)), + ], + options={ + "verbose_name": "Simple Imported Transaction", + "verbose_name_plural": "Simple Imported Transactions", + "ordering": ["-date", "-created"], + "abstract": False, + }, + ), + migrations.CreateModel( + name="SimpleTransactionsImport", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ("name", models.CharField(blank=True, max_length=255)), + ( + "load", + models.FileField( + upload_to=negligent_octopus.budget.models.upload_import_file_to, + validators=[ + negligent_octopus.utils.validators.FileExtensionValidator( + ("csv", "xsls", "xls", "xlsx", "xlsm", "xlsb") + ) + ], + ), + ), + ("processed", models.BooleanField(default=False)), + ( + "account", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="core.account" + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Simple Transactions Import", + "verbose_name_plural": "Simple Imports", + "ordering": ["-created"], + "abstract": False, + }, + ), + migrations.RemoveField( + model_name="importedactivotransaction", + name="loaded_from", + ), + migrations.RemoveField( + model_name="importedactivotransaction", + name="transaction", + ), + migrations.DeleteModel( + name="ImportActivo", + ), + migrations.DeleteModel( + name="ImportedActivoTransaction", + ), + migrations.AddField( + model_name="simpleimportedtransaction", + name="loaded_from", + field=models.ForeignKey( + on_delete=django.db.models.deletion.RESTRICT, + to="budget.simpletransactionsimport", + ), + ), + migrations.AddField( + model_name="simpleimportedtransaction", + name="transaction", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="core.transaction", + ), + ), + ] diff --git a/negligent_octopus/budget/migrations/0005_alter_simpletransactionsimport_load.py b/negligent_octopus/budget/migrations/0005_alter_simpletransactionsimport_load.py new file mode 100644 index 0000000..0161f94 --- /dev/null +++ b/negligent_octopus/budget/migrations/0005_alter_simpletransactionsimport_load.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.10 on 2024-05-06 10:57 + +from django.db import migrations, models +import negligent_octopus.budget.models +import negligent_octopus.utils.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ("budget", "0004_simpleimportedtransaction_simpletransactionsimport_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="simpletransactionsimport", + name="load", + field=models.FileField( + upload_to=negligent_octopus.budget.models.upload_import_file_to, + validators=[ + negligent_octopus.utils.validators.FileExtensionValidator( + ("csv", "xsls", "xls", "xlsx", "xlsm", "xlsb") + ) + ], + ), + ), + ] diff --git a/negligent_octopus/budget/models.py b/negligent_octopus/budget/models.py index f9c1410..3b8e780 100644 --- a/negligent_octopus/budget/models.py +++ b/negligent_octopus/budget/models.py @@ -16,7 +16,7 @@ from negligent_octopus.utils.validators import FileExtensionValidator -def upload_activo_import_to(instance, filename): +def upload_import_file_to(instance, filename): return ( f"{slugify(instance.owner)}--{instance.owner.pk}/" f"{instance.account}/" @@ -25,26 +25,73 @@ def upload_activo_import_to(instance, filename): ) -class ImportActivo(TimeStampedModel): - # TODO deal with each when importing - for activo we only deal with excel - ALLOWED_EXTENSIONS = ( - "csv", - "xsls", - "xls", - "xlsx", - "xlsm", - "xlsb", - "odf", - "ods", - "odt", +class SimpleImportedTransaction(TimeStampedModel): + loaded_from = models.ForeignKey( + "SimpleTransactionsImport", + on_delete=models.RESTRICT, + ) + date = models.DateField() + title = models.CharField(max_length=255) + amount = models.FloatField() + balance = models.FloatField() + validated = models.BooleanField(default=False) + transaction = models.OneToOneField( + Transaction, + on_delete=models.SET_NULL, + null=True, + blank=True, ) + def get_load_owner(self): + return str(self.loaded_from.owner) + + def has_transaction(self): + return self.transaction is not None + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.validated and not self.transaction: + self.transaction = Transaction.objects.create( + account=self.loaded_from.account, + amount=self.amount, + timestamp=datetime.combine( + self.date, + datetime.min.time(), + tzinfo=timezone.get_current_timezone(), + ), + balance=self.balance, + title=self.title, + ) + kwargs["update_fields"] = {"transaction", "validated"} + super().save(*args, **kwargs) + + def __str__(self): + return str(self.title) + + class Meta(TimeStampedModel.Meta): + verbose_name = "Simple Imported Transaction" + verbose_name_plural = "Simple Imported Transactions" + ordering = ["-date", "-created"] + + +class SimpleTransactionsImport(TimeStampedModel): + READABLE_EXTENSIONS = { + "csv": pd.read_csv, + "xsls": pd.read_excel, + "xls": pd.read_excel, + "xlsx": pd.read_excel, + "xlsm": pd.read_excel, + "xlsb": pd.read_excel, + } + child_transaction_class = SimpleImportedTransaction + child_transaction_related_name = None + owner = models.ForeignKey(User, on_delete=models.CASCADE) name = models.CharField(max_length=255, blank=True) load = models.FileField( - upload_to=upload_activo_import_to, + upload_to=upload_import_file_to, validators=[ - FileExtensionValidator(ALLOWED_EXTENSIONS), + FileExtensionValidator(READABLE_EXTENSIONS.keys()), ], ) account = models.ForeignKey( @@ -53,6 +100,15 @@ class ImportActivo(TimeStampedModel): ) processed = models.BooleanField(default=False) + @property + def child_transaction_manager(self): + if self.child_transaction_related_name is not None: + return getattr(self, self.child_transaction_related_name) + return getattr( + self, + f"{self.child_transaction_class.__name__.lower()}_set", + ) + def _set_name_from_filename(self): try: name = self.load.name.rsplit("/", 1)[1] @@ -60,32 +116,52 @@ def _set_name_from_filename(self): name = self.load.name self.name = get_filename_no_extension(name) - def _create_imported_transactions(self, commit=True): # noqa: FBT002 - transactions = [] - with (Path(settings.MEDIA_ROOT) / Path(self.load.name)).open("rb") as xslx: - xls = pd.read_excel( - xslx, - skiprows=7, - names=[ - "date_of_movement", - "date_of_process", - "description", - "value", + def create_imported_transactions( + self, + read_func_args=None, + read_func_kwargs=None, + *, + row_to_model=lambda **x: x, + commit=True, + ): + if read_func_args is None: + read_func_args = () + if read_func_kwargs is None: + read_func_kwargs = { + "names": [ + "date", + "title", + "amount", "balance", ], + "header": 0, + } + + transactions = [] + with (Path(settings.MEDIA_ROOT) / Path(self.load.name)).open("rb") as data_file: + extension = self.load.name.rsplit(".", 1)[1] + read_file = self.READABLE_EXTENSIONS[extension] + data = read_file( + data_file, + *read_func_args, + **read_func_kwargs, ) - for _i, row in xls.iterrows(): + + for _i, row in data.iterrows(): + values = row_to_model(**row) try: - self.importedactivotransaction_set.get(**row) - except ImportedActivoTransaction.DoesNotExist: + self.child_transaction_manager.get(**values) + except self.child_transaction_class.DoesNotExist: # TODO Pass in relevant args, kwargs - transaction = ImportedActivoTransaction( + transaction = self.child_transaction_class( loaded_from=self, - **row, + **values, ) transactions.append(transaction) + if commit: transaction.save() + return transactions def save(self, *args, **kwargs): @@ -94,59 +170,12 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) if not self.processed: - self._create_imported_transactions(commit=True) + self.create_imported_transactions(commit=True) self.processed = True kwargs["update_fields"] = {"processed"} super().save(*args, **kwargs) class Meta(TimeStampedModel.Meta): - verbose_name = "Activo Import" - verbose_name_plural = "Activo Imports" - ordering = ["created"] - - -class ImportedActivoTransaction(TimeStampedModel): - loaded_from = models.ForeignKey(ImportActivo, on_delete=models.RESTRICT) - date_of_movement = models.DateField() - date_of_process = models.DateField() - description = models.CharField(max_length=255) - value = models.FloatField() - balance = models.FloatField() - validated = models.BooleanField(default=False) - transaction = models.OneToOneField( - Transaction, - on_delete=models.SET_NULL, - null=True, - blank=True, - ) - - def get_load_owner(self): - return str(self.loaded_from.owner) - - def has_transaction(self): - return self.transaction is not None - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - if self.validated and not self.transaction: - self.transaction = Transaction.objects.create( - account=self.loaded_from.account, - amount=self.value, - timestamp=datetime.combine( - self.date_of_process, - datetime.min.time(), - tzinfo=timezone.get_current_timezone(), - ), - balance=self.balance, - title=self.description, - ) - kwargs["update_fields"] = {"transaction", "validated"} - super().save(*args, **kwargs) - - def __str__(self): - return str(self.description) - - class Meta(TimeStampedModel.Meta): - verbose_name = "Imported Activo Transaction" - verbose_name_plural = "Imported Activo Transactions" - ordering = ["-date_of_process", "-created"] + verbose_name = "Simple Transactions Import" + verbose_name_plural = "Simple Imports" + ordering = ["-created"] diff --git a/negligent_octopus/core/migrations/0005_transaction_transfer.py b/negligent_octopus/core/migrations/0005_transaction_transfer.py index aaca0be..d8ae349 100644 --- a/negligent_octopus/core/migrations/0005_transaction_transfer.py +++ b/negligent_octopus/core/migrations/0005_transaction_transfer.py @@ -15,6 +15,7 @@ class Migration(migrations.Migration): model_name="transaction", name="destination_account", field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="transfer_set", diff --git a/negligent_octopus/utils/validators.py b/negligent_octopus/utils/validators.py index b40578d..7cbbdba 100644 --- a/negligent_octopus/utils/validators.py +++ b/negligent_octopus/utils/validators.py @@ -1,3 +1,5 @@ +from collections.abc import Iterable + from django.core.exceptions import ValidationError from django.utils.deconstruct import deconstructible @@ -6,7 +8,7 @@ @deconstructible class FileExtensionValidator: - allowed_extensions: set + allowed_extensions: Iterable def __init__(self, allowed_extensions): self.allowed_extensions = (ext.lower() for ext in allowed_extensions) @@ -14,9 +16,8 @@ def __init__(self, allowed_extensions): def __call__(self, value): ext = get_filename_extension(value.name) if ext.lower() not in self.allowed_extensions: - msg = "Only {} file types are allowed.".format( - ", ".join(self.allowed_extensions), - ) + allowed_ext = ", ".join(self.allowed_extensions) + msg = f"Only {allowed_ext} file types are allowed." raise ValidationError( msg, )