diff --git a/.coveragerc b/.coveragerc
index 4edd7b1..3334c71 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,2 +1,4 @@
[run]
relative_files = True
+omit = admin_confirm/tests/*
+branch = True
diff --git a/.gitignore b/.gitignore
index a695b50..0228584 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,5 @@ docs/_build/
# Database
db.sqlite3
+
+tmp/
diff --git a/Makefile b/Makefile
index fb6b564..b358d1a 100644
--- a/Makefile
+++ b/Makefile
@@ -3,6 +3,7 @@ run:
test:
coverage run --source admin_confirm --branch -m pytest
+ coverage report -m
check-readme:
python -m readme_renderer README.md -o /tmp/README.html
diff --git a/README.md b/README.md
index a9231c8..d378c5f 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,12 @@ Typical Usage:
confirmation_fields = ['field1', 'field2']
```
+## Disclaimer
+
+Be aware that not all possible combinations of ModelAdmin have been tested, even if test coverage is high.
+
+See [testing readme](admin_confirm/tests/README.md) for more details
+
## Installation
Install django-admin-confirm by running:
diff --git a/admin_confirm/admin.py b/admin_confirm/admin.py
index 6c8ee56..9286bec 100644
--- a/admin_confirm/admin.py
+++ b/admin_confirm/admin.py
@@ -6,11 +6,23 @@ from django.template.response import TemplateResponse
from django.contrib.admin.options import TO_FIELD_VAR
from django.utils.translation import gettext as _
from django.contrib.admin import helpers
-from django.db.models import Model, ManyToManyField
+from django.db.models import Model, ManyToManyField, FileField, ImageField
from django.forms import ModelForm
-from admin_confirm.utils import snake_to_title_case
-
-SAVE_ACTIONS = ["_save", "_saveasnew", "_addanother", "_continue"]
+from admin_confirm.utils import get_admin_change_url, snake_to_title_case
+from django.core.cache import cache
+from django.views.decorators.cache import cache_control
+from django.forms.formsets import all_valid
+from admin_confirm.constants import (
+ CACHE_TIMEOUT,
+ CONFIRMATION_RECEIVED,
+ CONFIRM_ADD,
+ CONFIRM_CHANGE,
+ SAVE,
+ SAVE_ACTIONS,
+ CACHE_KEYS,
+ SAVE_AND_CONTINUE,
+ SAVE_AS_NEW,
+)
class AdminConfirmMixin:
@@ -34,7 +46,9 @@ class AdminConfirmMixin:
if self.confirmation_fields is not None:
return self.confirmation_fields
- return flatten_fieldsets(self.get_fieldsets(request, obj))
+ model_fields = set([field.name for field in self.model._meta.fields])
+ admin_fields = set(flatten_fieldsets(self.get_fieldsets(request, obj)))
+ return list(model_fields & admin_fields)
def render_change_confirmation(self, request, context):
opts = self.model._meta
@@ -81,14 +95,22 @@ class AdminConfirmMixin:
context,
)
+ @cache_control(private=True)
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
if request.method == "POST":
- if (not object_id and "_confirm_add" in request.POST) or (
- object_id and "_confirm_change" in request.POST
+ if (not object_id and CONFIRM_ADD in request.POST) or (
+ object_id and CONFIRM_CHANGE in request.POST
):
+ cache.delete_many(CACHE_KEYS.values())
return self._change_confirmation_view(
request, object_id, form_url, extra_context
)
+ elif CONFIRMATION_RECEIVED in request.POST:
+ return self._confirmation_received_view(
+ request, object_id, form_url, extra_context
+ )
+ else:
+ cache.delete_many(CACHE_KEYS.values())
extra_context = {
**(extra_context or {}),
@@ -111,32 +133,160 @@ class AdminConfirmMixin:
Returns a dictionary of the fields and their changed values if any
"""
- changed_data = {}
- if form.is_valid():
- if add:
- for name, new_value in form.cleaned_data.items():
- # Don't consider default values as changed for adding
- default_value = model._meta.get_field(name).get_default()
- if new_value is not None and new_value != default_value:
- # Show what the default value is
- changed_data[name] = [default_value, new_value]
- else:
- # Parse the changed data - Note that using form.changed_data would not work because initial is not set
- for name, new_value in form.cleaned_data.items():
- # Since the form considers initial as the value first shown in the form
- # It could be incorrect when user hits save, and then hits "No, go back to edit"
- obj.refresh_from_db()
- # Note: getattr does not work on ManyToManyFields
- field_object = model._meta.get_field(name)
- initial_value = getattr(obj, name)
- if isinstance(field_object, ManyToManyField):
- initial_value = field_object.value_from_object(obj)
- if initial_value != new_value:
- changed_data[name] = [initial_value, new_value]
+ def _display_for_changed_data(field, initial_value, new_value):
+ if not (isinstance(field, FileField) or isinstance(field, ImageField)):
+ return [initial_value, new_value]
+
+ if initial_value:
+ if new_value == False:
+ # Clear has been selected
+ return [initial_value.name, None]
+ elif new_value:
+ return [initial_value.name, new_value.name]
+ else:
+ # No cover: Technically doesn't get called in current code because
+ # This function is only called if there was a difference in the data
+ return [initial_value.name, initial_value.name] # pragma: no cover
+
+ if new_value:
+ return [None, new_value.name]
+
+ return [None, None]
+
+ changed_data = {}
+ if add:
+ for name, new_value in form.cleaned_data.items():
+ # Don't consider default values as changed for adding
+ field_object = model._meta.get_field(name)
+ default_value = field_object.get_default()
+ if new_value is not None and new_value != default_value:
+ # Show what the default value is
+ changed_data[name] = _display_for_changed_data(
+ field_object, default_value, new_value
+ )
+ else:
+ # Parse the changed data - Note that using form.changed_data would not work because initial is not set
+ for name, new_value in form.cleaned_data.items():
+
+ # Since the form considers initial as the value first shown in the form
+ # It could be incorrect when user hits save, and then hits "No, go back to edit"
+ obj.refresh_from_db()
+
+ field_object = model._meta.get_field(name)
+ initial_value = getattr(obj, name)
+
+ # Note: getattr does not work on ManyToManyFields
+ if isinstance(field_object, ManyToManyField):
+ initial_value = field_object.value_from_object(obj)
+
+ if initial_value != new_value:
+ changed_data[name] = _display_for_changed_data(
+ field_object, initial_value, new_value
+ )
return changed_data
+ def _confirmation_received_view(self, request, object_id, form_url, extra_context):
+ """
+ When the form is a multipart form, the object and POST are cached
+ This is required because file(s) cannot be programmically uploaded
+ ie. There is no way to set a file on the html form
+
+ If the form isn't multipart, this function would not be called.
+ If there are no file changes, do nothing to the request and send to Django.
+
+ If there are files uploaded, save the files from cached object to either:
+ - the object instance if already exists
+ - or save the new object and modify the request from `add` to `change`
+ and pass the request to Django
+ """
+
+ def _reconstruct_request_files():
+ """
+ Reconstruct the file(s) from the cached object (if any).
+ Returns a dictionary of field name to cached file
+ """
+ reconstructed_files = {}
+
+ cached_object = cache.get(CACHE_KEYS["object"])
+ query_dict = cache.get(CACHE_KEYS["post"])
+ # Reconstruct the files from cached object
+ if not cached_object:
+ return
+
+ if not query_dict:
+ # Use the current POST, since it should mirror cached POST
+ query_dict = request.POST
+
+ if type(cached_object) != self.model:
+ # Do not use cache if the model doesn't match this model
+ return
+
+ for field in self.model._meta.get_fields():
+ if not (isinstance(field, FileField) or isinstance(field, ImageField)):
+ continue
+
+ cached_file = getattr(cached_object, field.name)
+ # If a file was uploaded, the field is omitted from the POST since it's in request.FILES
+ if not query_dict.get(field.name) and cached_file:
+ reconstructed_files[field.name] = cached_file
+
+ return reconstructed_files
+
+ reconstructed_files = _reconstruct_request_files()
+ if reconstructed_files:
+ obj = None
+
+ # remove the _confirm_add and _confirm_change from post
+ modified_post = request.POST.copy()
+ cached_post = cache.get(CACHE_KEYS["post"])
+ # No cover: __reconstruct_request_files currently checks for cached post so cached_post won't be None
+ if cached_post: # pragma: no cover
+ modified_post = cached_post.copy()
+ if CONFIRM_ADD in modified_post:
+ del modified_post[CONFIRM_ADD]
+ if CONFIRM_CHANGE in modified_post:
+ del modified_post[CONFIRM_CHANGE]
+
+ if object_id and not SAVE_AS_NEW in request.POST:
+ # Update the obj with the new uploaded files
+ # then pass rest of changes to Django
+ obj = self.model.objects.filter(id=object_id).first()
+ else:
+ # Create the obj and pass the rest as changes to Django
+ # (Since we are not handling the formsets/inlines)
+ # Note that this results in the "Yes, I'm Sure" submission
+ # act as a `change` not an `add`
+ obj = cache.get(CACHE_KEYS["object"])
+
+ # No cover: __reconstruct_request_files currently checks for cached obj so obj won't be None
+ if obj: # pragma: no cover
+ for field, file in reconstructed_files.items():
+ setattr(obj, field, file)
+ obj.save()
+ object_id = str(obj.id)
+ # Update the request path, used in the message to user and redirect
+ # Used in `self.response_change`
+ request.path = get_admin_change_url(obj)
+
+ if SAVE_AS_NEW in request.POST:
+ # We have already saved the new object
+ # So change action to _continue
+ del modified_post[SAVE_AS_NEW]
+ if self.save_as_continue:
+ modified_post[SAVE_AND_CONTINUE] = True
+ else:
+ modified_post[SAVE] = True
+ if "id" in modified_post:
+ del modified_post["id"]
+ modified_post["id"] = object_id
+
+ request.POST = modified_post
+
+ cache.delete_many(CACHE_KEYS.values())
+ return super()._changeform_view(request, object_id, form_url, extra_context)
+
def _change_confirmation_view(self, request, object_id, form_url, extra_context):
# This code is taken from super()._changeform_view
# https://github.com/django/django/blob/master/django/contrib/admin/options.py#L1575-L1592
@@ -169,13 +319,19 @@ class AdminConfirmMixin:
)
form = ModelForm(request.POST, request.FILES, obj)
- # Note to self: For inline instances see:
- # https://github.com/django/django/blob/master/django/contrib/admin/options.py#L1582
-
+ form_validated = form.is_valid()
+ if form_validated:
+ new_object = self.save_form(request, form, change=not add)
+ else:
+ new_object = form.instance
+ formsets, inline_instances = self._create_formsets(
+ request, new_object, change=not add
+ )
# End code from super()._changeform_view
+ add_or_new = add or SAVE_AS_NEW in request.POST
# Get changed data to show on confirmation
- changed_data = self._get_changed_data(form, model, obj, add)
+ changed_data = self._get_changed_data(form, model, obj, add_or_new)
changed_confirmation_fields = set(
self.get_confirmation_fields(request, obj)
@@ -186,13 +342,17 @@ class AdminConfirmMixin:
# Parse the original save action from request
save_action = None
- for key in request.POST.keys():
+ # No cover: There would not be a case of not request.POST.keys() and form is valid
+ for key in request.POST.keys(): # pragma: no cover
if key in SAVE_ACTIONS:
save_action = key
break
- title_action = _("adding") if add else _("changing")
+ if form.is_multipart():
+ cache.set(CACHE_KEYS["post"], request.POST, timeout=CACHE_TIMEOUT)
+ cache.set(CACHE_KEYS["object"], new_object, timeout=CACHE_TIMEOUT)
+ title_action = _("adding") if add_or_new else _("changing")
context = {
**self.admin_site.each_context(request),
"preserved_filters": self.get_preserved_filters(request),
@@ -205,8 +365,10 @@ class AdminConfirmMixin:
"opts": opts,
"changed_data": changed_data,
"add": add,
+ "save_as_new": SAVE_AS_NEW in request.POST,
"submit_name": save_action,
"form": form,
+ "formsets": formsets,
**(extra_context or {}),
}
return self.render_change_confirmation(request, context)
diff --git a/admin_confirm/constants.py b/admin_confirm/constants.py
new file mode 100644
index 0000000..b062cd4
--- /dev/null
+++ b/admin_confirm/constants.py
@@ -0,0 +1,17 @@
+from django.conf import settings
+
+SAVE = "_save"
+SAVE_AS_NEW = "_saveasnew"
+ADD_ANOTHER = "_addanother"
+SAVE_AND_CONTINUE = "_continue"
+SAVE_ACTIONS = [SAVE, SAVE_AS_NEW, ADD_ANOTHER, SAVE_AND_CONTINUE]
+
+CONFIRM_ADD = "_confirm_add"
+CONFIRM_CHANGE = "_confirm_change"
+CONFIRMATION_RECEIVED = "_confirmation_received"
+
+CACHE_TIMEOUT = getattr(settings, "ADMIN_CONFIRM_CACHE_TIMEOUT", 10)
+CACHE_KEYS = {
+ "object": "admin_confirm__confirmation_object",
+ "post": "admin_confirm__confirmation_request_post",
+}
diff --git a/admin_confirm/templates/admin/change_confirmation.html b/admin_confirm/templates/admin/change_confirmation.html
index 6f123f3..da06731 100644
--- a/admin_confirm/templates/admin/change_confirmation.html
+++ b/admin_confirm/templates/admin/change_confirmation.html
@@ -31,27 +31,29 @@
{% block content %}
- {% if add %}
-
{% blocktrans with escaped_object=object %}Are you sure you want to add the {{ model_name }}?{% endblocktrans %}
- {% include "admin/change_data.html" %}
-
+
+
{% endblock %}
diff --git a/admin_confirm/tests/README.md b/admin_confirm/tests/README.md
new file mode 100644
index 0000000..bb7626e
--- /dev/null
+++ b/admin_confirm/tests/README.md
@@ -0,0 +1,100 @@
+# Testing Documentation/Notes
+
+[](https://coveralls.io/github/TrangPham/django-admin-confirm)
+
+Hello, friend! You have found the list of test cases that this package can benefit from.
+
+You seem concerned about the stability and reliability of this package. You're probably wondering if you should include it in your production codebase. Well, although I have tried very hard to get 100% code coverage, there are so many permutations of ModelAdmins in the wild. And I'm only one person.
+
+So if you want to include this package in your production codebase, be aware that AdminConfirmMixin works best with simple unmodified ModelAdmins.
+
+## Save Options
+
+- [x] Save
+- [x] Conitnue
+- [x] Save As New
+- [x] Add another
+
+### Field types
+
+- [x] CharField
+- [x] PositiveIntegerField
+- [x] DecimalField
+- [x] TextField
+- [x] ImageField
+- [x] FileField
+- [x] ManyToManyField
+- [x] OneToOneField
+- [x] ForeignKey
+
+- [x] Custom Readonly fields
+
+### Options
+
+- [x] .exclude
+- [x] .fields
+- [x] .readonly_fields
+- [x] Actions
+
+### Options to test
+
+- [x] ModelAdmin.fieldsets
+- [ ] ModelAdmin.form
+- [ ] ModelAdmin.raw_id_fields
+- [ ] ModelAdmin.radio_fields
+- [ ] ModelAdmin.autocomplete_fields
+- [ ] ModelAdmin.prepopulated_fields
+
+## ModelAdmin form template overrides?
+
+https://docs.djangoproject.com/en/3.1/ref/contrib/admin/#custom-template-options
+(Maybe??? IDK this is esoteric)
+
+## Function overrides to test
+
+- [ ] .save_model()
+- [ ] .get_readonly_fields()
+- [ ] .get_fields()
+- [ ] .get_excludes()
+- [ ] .get_form()
+- [ ] .get_autocomplete_fields()
+- [ ] .get_prepopulated_fields()
+- [x] .get_fieldsets()
+- [ ] ModelAdmin.formfield_for_manytomany()
+- [ ] ModelAdmin.formfield_for_foreignkey()
+- [ ] ModelAdmin.formfield_for_choice_field()
+- [ ] ModelAdmin.get_changeform_initial_data()
+
+## Inline instance support??
+
+Confirmation on inline changes is not a current feature of this project.
+
+Confirmation on add/change of ModelAdmin that includes inlines needs to be tested. Use AdminConfirmMixin with ModelAdmin containing inlines at your own risk.
+
+- [ ] .inlines
+- [ ] .get_inline_instances()
+- [ ] .get_inlines() (New in Django 3.0)
+- [ ] .get_formsets_with_inlines()
+
+#### Options for inlines
+
+- [ ] classes of inlines: Tabular, Stacked, etc
+- [ ] extra
+- [ ] action on the inline: add or change
+- [ ] clicking add another on the inline
+
+## IDK if we want to support these
+
+- [ ] .get_changelist_form()
+- [ ] ModelAdmin.list_editable
+- [ ] ModelAdmin.changelist_view()
+
+- [ ] ModelAdmin.add_view(request, form_url='', extra_context=None)
+- [ ] ModelAdmin.change_view(request, object_id, form_url='', extra_context=None)
+
+## More tests for these?
+
+Note: Currently the code always calls super().\_changeform_view(), which would ensure permissions correct as well
+
+- [x] ModelAdmin.has_add_permission
+- [x] ModelAdmin.has_change_permission
diff --git a/admin_confirm/tests/helpers.py b/admin_confirm/tests/helpers.py
index 7dc2f0d..7ec89a9 100644
--- a/admin_confirm/tests/helpers.py
+++ b/admin_confirm/tests/helpers.py
@@ -1,8 +1,13 @@
+from django.core.cache import cache
from django.test import TestCase, RequestFactory
from django.contrib.auth.models import User
-class ConfirmAdminTestCase(TestCase):
+class AdminConfirmTestCase(TestCase):
+ """
+ Helper TestCase class and common associated assertions
+ """
+
@classmethod
def setUpTestData(cls):
cls.superuser = User.objects.create_superuser(
@@ -10,6 +15,7 @@ class ConfirmAdminTestCase(TestCase):
)
def setUp(self):
+ cache.clear()
self.client.force_login(self.superuser)
self.factory = RequestFactory()
@@ -24,7 +30,9 @@ class ConfirmAdminTestCase(TestCase):
# ManyToManyField should be embedded
self.assertIn("related-widget-wrapper", rendered_content)
- def _assertSubmitHtml(self, rendered_content, save_action="_save"):
+ def _assertSubmitHtml(
+ self, rendered_content, save_action="_save", multipart_form=False
+ ):
# Submit should conserve the save action
self.assertIn(
f'',
@@ -34,6 +42,16 @@ class ConfirmAdminTestCase(TestCase):
self.assertNotIn("_confirm_add", rendered_content)
self.assertNotIn("_confirm_change", rendered_content)
+ confirmation_received_html = (
+ ''
+ )
+
+ if multipart_form:
+ # Should have _confirmation_received as a hidden field
+ self.assertIn(confirmation_received_html, rendered_content)
+ else:
+ self.assertNotIn(confirmation_received_html, rendered_content)
+
def _assertSimpleFieldFormHtml(self, rendered_content, fields):
for k, v in fields.items():
self.assertIn(f'name="{k}"', rendered_content)
diff --git a/admin_confirm/tests/unit/test_admin_options.py b/admin_confirm/tests/unit/test_admin_options.py
new file mode 100644
index 0000000..ff9b808
--- /dev/null
+++ b/admin_confirm/tests/unit/test_admin_options.py
@@ -0,0 +1,307 @@
+from unittest import mock
+from django.core.cache import cache
+
+from admin_confirm.tests.helpers import AdminConfirmTestCase
+from tests.market.admin import ShoppingMallAdmin
+from tests.market.models import GeneralManager, ShoppingMall, Town
+from tests.factories import ShopFactory
+
+from admin_confirm.constants import CACHE_KEYS, CONFIRMATION_RECEIVED
+
+
+@mock.patch.object(ShoppingMallAdmin, "inlines", [])
+class TestAdminOptions(AdminConfirmTestCase):
+ @mock.patch.object(ShoppingMallAdmin, "confirmation_fields", ["name"])
+ @mock.patch.object(ShoppingMallAdmin, "fields", ["name", "town"])
+ def test_change_model_with_m2m_field_without_input_for_m2m_field_should_work(self):
+ gm = GeneralManager.objects.create(name="gm")
+ shops = [ShopFactory() for i in range(3)]
+ town = Town.objects.create(name="town")
+ mall = ShoppingMall.objects.create(name="mall", general_manager=gm, town=town)
+ mall.shops.set(shops)
+
+ # new values
+ gm2 = GeneralManager.objects.create(name="gm2")
+ shops2 = [ShopFactory() for i in range(3)]
+ town2 = Town.objects.create(name="town2")
+
+ data = {
+ "id": mall.id,
+ "name": "name",
+ "town": town2.id,
+ "_confirm_change": True,
+ "_continue": True,
+ }
+ response = self.client.post(
+ f"/admin/market/shoppingmall/{mall.id}/change/", data=data
+ )
+
+ # Should be shown confirmation page
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content, save_action="_continue"
+ )
+
+ # Should not have cached the unsaved obj
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNone(cached_item)
+
+ # Should not have saved changes yet
+ self.assertEqual(ShoppingMall.objects.count(), 1)
+ mall.refresh_from_db()
+ self.assertEqual(mall.name, "mall")
+ self.assertEqual(mall.general_manager, gm)
+ self.assertEqual(mall.town, town)
+ for shop in mall.shops.all():
+ self.assertIn(shop, shops)
+
+ # Click "Yes, I'm Sure"
+ confirmation_received_data = data
+ del confirmation_received_data["_confirm_change"]
+
+ response = self.client.post(
+ f"/admin/market/shoppingmall/{mall.id}/change/",
+ data=confirmation_received_data,
+ )
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/shoppingmall/{mall.id}/change/")
+
+ # Should have saved obj
+ self.assertEqual(ShoppingMall.objects.count(), 1)
+ saved_item = ShoppingMall.objects.all().first()
+ # should have updated fields that were in form
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.town, town2)
+ # should have presevered the fields that are not in form
+ self.assertEqual(saved_item.general_manager, gm)
+ for shop in saved_item.shops.all():
+ self.assertIn(shop, shops)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ @mock.patch.object(ShoppingMallAdmin, "confirmation_fields", ["name"])
+ @mock.patch.object(ShoppingMallAdmin, "exclude", ["shops"])
+ def test_when_m2m_field_in_exclude_changes_to_field_should_not_be_saved(self):
+ gm = GeneralManager.objects.create(name="gm")
+ shops = [ShopFactory() for i in range(3)]
+ town = Town.objects.create(name="town")
+ mall = ShoppingMall.objects.create(name="mall", general_manager=gm, town=town)
+ mall.shops.set(shops)
+
+ # new values
+ gm2 = GeneralManager.objects.create(name="gm2")
+ shops2 = [ShopFactory() for i in range(3)]
+ town2 = Town.objects.create(name="town2")
+
+ data = {
+ "id": mall.id,
+ "name": "name",
+ "general_manager": gm2.id,
+ "shops": [1],
+ "town": town2.id,
+ "_confirm_change": True,
+ "_continue": True,
+ }
+ response = self.client.post(
+ f"/admin/market/shoppingmall/{mall.id}/change/", data=data
+ )
+ # Should be shown confirmation page
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content, save_action="_continue"
+ )
+
+ # Should not have cached the unsaved obj
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNone(cached_item)
+
+ # Should not have saved changes yet
+ self.assertEqual(ShoppingMall.objects.count(), 1)
+ mall.refresh_from_db()
+ self.assertEqual(mall.name, "mall")
+ self.assertEqual(mall.general_manager, gm)
+ self.assertEqual(mall.town, town)
+ for shop in mall.shops.all():
+ self.assertIn(shop, shops)
+
+ # Click "Yes, I'm Sure"
+ confirmation_received_data = data
+ del confirmation_received_data["_confirm_change"]
+
+ response = self.client.post(
+ f"/admin/market/shoppingmall/{mall.id}/change/",
+ data=confirmation_received_data,
+ )
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/shoppingmall/{mall.id}/change/")
+
+ # Should have saved obj
+ self.assertEqual(ShoppingMall.objects.count(), 1)
+ saved_item = ShoppingMall.objects.all().first()
+ # should have updated fields that were in form
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.town, town2)
+ self.assertEqual(saved_item.general_manager, gm2)
+ # should have presevered the fields that are not in form (exclude)
+ for shop in saved_item.shops.all():
+ self.assertIn(shop, shops)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ @mock.patch.object(ShoppingMallAdmin, "confirmation_fields", ["name"])
+ @mock.patch.object(ShoppingMallAdmin, "exclude", ["shops", "name"])
+ @mock.patch.object(ShoppingMallAdmin, "inlines", [])
+ def test_if_confirmation_fields_in_exclude_should_not_trigger_confirmation(self):
+ gm = GeneralManager.objects.create(name="gm")
+ shops = [ShopFactory() for i in range(3)]
+ town = Town.objects.create(name="town")
+ mall = ShoppingMall.objects.create(name="mall", general_manager=gm, town=town)
+ mall.shops.set(shops)
+
+ # new values
+ gm2 = GeneralManager.objects.create(name="gm2")
+ shops2 = [ShopFactory() for i in range(3)]
+ town2 = Town.objects.create(name="town2")
+
+ data = {
+ "id": mall.id,
+ "name": "name",
+ "general_manager": gm2.id,
+ "shops": [1],
+ "town": town2.id,
+ "_confirm_change": True,
+ "_continue": True,
+ }
+ response = self.client.post(
+ f"/admin/market/shoppingmall/{mall.id}/change/", data=data
+ )
+ # Should not be shown confirmation page
+ # SInce we used "Save and Continue", should show change page
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, f"/admin/market/shoppingmall/{mall.id}/change/")
+
+ # Should have saved the non excluded fields
+ mall.refresh_from_db()
+ for shop in shops:
+ self.assertIn(shop, mall.shops.all())
+ self.assertEqual(mall.name, "mall")
+ # Should have saved other fields
+ self.assertEqual(mall.town, town2)
+ self.assertEqual(mall.general_manager, gm2)
+
+ @mock.patch.object(ShoppingMallAdmin, "confirmation_fields", ["name"])
+ @mock.patch.object(ShoppingMallAdmin, "readonly_fields", ["shops", "name"])
+ @mock.patch.object(ShoppingMallAdmin, "inlines", [])
+ def test_if_confirmation_fields_in_readonly_should_not_trigger_confirmation(self):
+ gm = GeneralManager.objects.create(name="gm")
+ shops = [ShopFactory() for i in range(3)]
+ town = Town.objects.create(name="town")
+ mall = ShoppingMall.objects.create(name="mall", general_manager=gm, town=town)
+ mall.shops.set(shops)
+
+ # new values
+ gm2 = GeneralManager.objects.create(name="gm2")
+ shops2 = [ShopFactory() for i in range(3)]
+ town2 = Town.objects.create(name="town2")
+
+ data = {
+ "id": mall.id,
+ "name": "name",
+ "general_manager": gm2.id,
+ "shops": [1],
+ "town": town2.id,
+ "_confirm_change": True,
+ "_continue": True,
+ }
+ response = self.client.post(
+ f"/admin/market/shoppingmall/{mall.id}/change/", data=data
+ )
+ # Should not be shown confirmation page
+ # SInce we used "Save and Continue", should show change page
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, f"/admin/market/shoppingmall/{mall.id}/change/")
+
+ # Should have saved the non excluded fields
+ mall.refresh_from_db()
+ for shop in shops:
+ self.assertIn(shop, mall.shops.all())
+ self.assertEqual(mall.name, "mall")
+ # Should have saved other fields
+ self.assertEqual(mall.town, town2)
+ self.assertEqual(mall.general_manager, gm2)
+
+ @mock.patch.object(ShoppingMallAdmin, "confirmation_fields", ["name"])
+ @mock.patch.object(ShoppingMallAdmin, "readonly_fields", ["shops"])
+ @mock.patch.object(ShoppingMallAdmin, "inlines", [])
+ def test_readonly_fields_should_not_change(self):
+ gm = GeneralManager.objects.create(name="gm")
+ shops = [ShopFactory() for i in range(3)]
+ town = Town.objects.create(name="town")
+ mall = ShoppingMall.objects.create(name="mall", general_manager=gm, town=town)
+ mall.shops.set(shops)
+
+ # new values
+ gm2 = GeneralManager.objects.create(name="gm2")
+ shops2 = [ShopFactory() for i in range(3)]
+ town2 = Town.objects.create(name="town2")
+
+ data = {
+ "id": mall.id,
+ "name": "name",
+ "general_manager": gm2.id,
+ "shops": [1],
+ "town": town2.id,
+ "_confirm_change": True,
+ "_continue": True,
+ }
+ response = self.client.post(
+ f"/admin/market/shoppingmall/{mall.id}/change/", data=data
+ )
+ # Should be shown confirmation page
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content, save_action="_continue"
+ )
+
+ # Should not have cached the unsaved obj
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNone(cached_item)
+
+ # Should not have saved changes yet
+ self.assertEqual(ShoppingMall.objects.count(), 1)
+ mall.refresh_from_db()
+ self.assertEqual(mall.name, "mall")
+ self.assertEqual(mall.general_manager, gm)
+ self.assertEqual(mall.town, town)
+ for shop in mall.shops.all():
+ self.assertIn(shop, shops)
+
+ # Click "Yes, I'm Sure"
+ confirmation_received_data = data
+ del confirmation_received_data["_confirm_change"]
+
+ response = self.client.post(
+ f"/admin/market/shoppingmall/{mall.id}/change/",
+ data=confirmation_received_data,
+ )
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/shoppingmall/{mall.id}/change/")
+
+ # Should have saved obj
+ self.assertEqual(ShoppingMall.objects.count(), 1)
+ saved_item = ShoppingMall.objects.all().first()
+ # should have updated fields that were in form
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.town, town2)
+ self.assertEqual(saved_item.general_manager, gm2)
+ # should have presevered the fields that are not in form (exclude)
+ for shop in saved_item.shops.all():
+ self.assertIn(shop, shops)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
diff --git a/admin_confirm/tests/test_confirm_actions.py b/admin_confirm/tests/unit/test_confirm_actions.py
similarity index 100%
rename from admin_confirm/tests/test_confirm_actions.py
rename to admin_confirm/tests/unit/test_confirm_actions.py
diff --git a/admin_confirm/tests/test_confirm_change_and_add.py b/admin_confirm/tests/unit/test_confirm_change_and_add.py
similarity index 84%
rename from admin_confirm/tests/test_confirm_change_and_add.py
rename to admin_confirm/tests/unit/test_confirm_change_and_add.py
index 46498fe..53e153b 100644
--- a/admin_confirm/tests/test_confirm_change_and_add.py
+++ b/admin_confirm/tests/unit/test_confirm_change_and_add.py
@@ -1,17 +1,20 @@
+from unittest import mock
from django.contrib.auth.models import User
from django.contrib.admin.sites import AdminSite
from django.contrib.admin.options import TO_FIELD_VAR
from django.http import HttpResponseForbidden, HttpResponseBadRequest
from django.urls import reverse
-from admin_confirm.tests.helpers import ConfirmAdminTestCase
-from tests.market.admin import ItemAdmin, InventoryAdmin
-from tests.market.models import Item, Inventory
+from admin_confirm.tests.helpers import AdminConfirmTestCase
+from tests.market.admin import ItemAdmin, InventoryAdmin, ShoppingMallAdmin
+from tests.market.models import Item, Inventory, ShoppingMall
from tests.factories import ItemFactory, ShopFactory, InventoryFactory
-class TestConfirmChangeAndAdd(ConfirmAdminTestCase):
+@mock.patch.object(ShoppingMallAdmin, "inlines", [])
+class TestConfirmChangeAndAdd(AdminConfirmTestCase):
def test_get_add_without_confirm_add(self):
+ ItemAdmin.confirm_add = False
response = self.client.get(reverse("admin:market_item_add"))
self.assertFalse(response.context_data.get("confirm_add"))
self.assertNotIn("_confirm_add", response.rendered_content)
@@ -70,6 +73,36 @@ class TestConfirmChangeAndAdd(ConfirmAdminTestCase):
# Should not have been added yet
self.assertEqual(Inventory.objects.count(), 0)
+ def test_post_change_with_confirm_change_shoppingmall_name(self):
+ # When testing found that even though name was in confirmation_fields
+ # When only name changed, `form.is_valid` = False, and thus didn't trigger
+ # confirmation page previously, even though it should have
+
+ mall = ShoppingMall.objects.create(name="name")
+ data = {
+ "id": mall.id,
+ "name": "new name",
+ "_confirm_change": True,
+ "csrfmiddlewaretoken": "fake token",
+ "_save": True,
+ }
+ response = self.client.post(
+ f"/admin/market/shoppingmall/{mall.id}/change/", data
+ )
+ # Ensure not redirected (confirmation page does not redirect)
+ self.assertEqual(response.status_code, 200)
+ expected_templates = [
+ "admin/market/shoppingmall/change_confirmation.html",
+ "admin/market/change_confirmation.html",
+ "admin/change_confirmation.html",
+ ]
+ self.assertEqual(response.template_name, expected_templates)
+ self._assertSubmitHtml(rendered_content=response.rendered_content)
+
+ # Hasn't changed item yet
+ mall.refresh_from_db()
+ self.assertEqual(mall.name, "name")
+
def test_post_change_with_confirm_change(self):
item = ItemFactory(name="item")
data = {
@@ -100,7 +133,9 @@ class TestConfirmChangeAndAdd(ConfirmAdminTestCase):
self._assertSimpleFieldFormHtml(
rendered_content=response.rendered_content, fields=form_data
)
- self._assertSubmitHtml(rendered_content=response.rendered_content)
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content, multipart_form=True
+ )
# Hasn't changed item yet
item.refresh_from_db()
@@ -120,9 +155,22 @@ class TestConfirmChangeAndAdd(ConfirmAdminTestCase):
def test_get_confirmation_fields_should_default_if_not_set(self):
expected_fields = [f.name for f in Item._meta.fields if f.name != "id"]
ItemAdmin.confirmation_fields = None
+ ItemAdmin.fields = expected_fields
admin = ItemAdmin(Item, AdminSite())
actual_fields = admin.get_confirmation_fields(self.factory.request())
- self.assertEqual(expected_fields, actual_fields)
+ for field in expected_fields:
+ self.assertIn(field, actual_fields)
+
+ def test_get_confirmation_fields_default_should_only_include_fields_shown_on_admin(
+ self,
+ ):
+ admin_fields = ["name", "price"]
+ ItemAdmin.confirmation_fields = None
+ ItemAdmin.fields = admin_fields
+ admin = ItemAdmin(Item, AdminSite())
+ actual_fields = admin.get_confirmation_fields(self.factory.request())
+ for field in admin_fields:
+ self.assertIn(field, actual_fields)
def test_get_confirmation_fields_if_set(self):
expected_fields = ["name", "currency"]
diff --git a/admin_confirm/tests/test_confirm_change_and_add_m2m_field.py b/admin_confirm/tests/unit/test_confirm_change_and_add_m2m_field.py
similarity index 95%
rename from admin_confirm/tests/test_confirm_change_and_add_m2m_field.py
rename to admin_confirm/tests/unit/test_confirm_change_and_add_m2m_field.py
index 6387162..e564c94 100644
--- a/admin_confirm/tests/test_confirm_change_and_add_m2m_field.py
+++ b/admin_confirm/tests/unit/test_confirm_change_and_add_m2m_field.py
@@ -1,12 +1,15 @@
+from unittest import mock
+from admin_confirm.admin import AdminConfirmMixin
from django.urls import reverse
-from admin_confirm.tests.helpers import ConfirmAdminTestCase
+from admin_confirm.tests.helpers import AdminConfirmTestCase
from tests.market.admin import ShoppingMallAdmin
from tests.market.models import ShoppingMall
from tests.factories import ShopFactory
-class TestConfirmChangeAndAddM2MField(ConfirmAdminTestCase):
+@mock.patch.object(ShoppingMallAdmin, "inlines", [])
+class TestConfirmChangeAndAddM2MField(AdminConfirmTestCase):
def test_post_add_without_confirm_add_m2m(self):
shops = [ShopFactory() for i in range(3)]
@@ -84,6 +87,9 @@ class TestConfirmChangeAndAddM2MField(ConfirmAdminTestCase):
]
self.assertEqual(response.template_name, expected_templates)
+ # Should show two lists for the m2m current and modified values
+ self.assertEqual(response.rendered_content.count("
"), 2)
+
self._assertManyToManyFormHtml(
rendered_content=response.rendered_content,
options=shops,
diff --git a/admin_confirm/tests/unit/test_confirm_save_actions.py b/admin_confirm/tests/unit/test_confirm_save_actions.py
new file mode 100644
index 0000000..f50d99a
--- /dev/null
+++ b/admin_confirm/tests/unit/test_confirm_save_actions.py
@@ -0,0 +1,477 @@
+from unittest import mock
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.core.cache import cache
+from django.urls import reverse
+
+from admin_confirm.tests.helpers import AdminConfirmTestCase
+from tests.market.admin import ItemAdmin, ShoppingMallAdmin
+from tests.market.models import GeneralManager, Item, ShoppingMall, Town
+from tests.factories import ItemFactory, ShopFactory
+
+from admin_confirm.constants import CACHE_KEYS, CONFIRMATION_RECEIVED
+
+
+@mock.patch.object(ShoppingMallAdmin, "inlines", [])
+class TestConfirmSaveActions(AdminConfirmTestCase):
+ def test_simple_add_with_save(self):
+ # Load the Add Item Page
+ ItemAdmin.confirm_add = True
+ response = self.client.get(reverse("admin:market_item_add"))
+
+ # Should be asked for confirmation
+ self.assertTrue(response.context_data.get("confirm_add"))
+ self.assertIn("_confirm_add", response.rendered_content)
+
+ # Click "Save"
+ data = {
+ "name": "name",
+ "price": 2.0,
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_add": True,
+ "_save": True,
+ }
+ response = self.client.post(reverse("admin:market_item_add"), data=data)
+
+ # Should be shown confirmation page
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content,
+ save_action="_save",
+ multipart_form=True,
+ )
+
+ # Should have cached the unsaved item
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNotNone(cached_item)
+ self.assertIsNone(cached_item.id)
+ self.assertEqual(cached_item.name, data["name"])
+ self.assertEqual(cached_item.price, data["price"])
+ self.assertEqual(cached_item.currency, data["currency"])
+
+ # Should not have saved the item yet
+ self.assertEqual(Item.objects.count(), 0)
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_add"]
+ data[CONFIRMATION_RECEIVED] = True
+ response = self.client.post(reverse("admin:market_item_add"), data=data)
+
+ # Should have redirected to changelist
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "/admin/market/item/")
+
+ # Should have saved item
+ self.assertEqual(Item.objects.count(), 1)
+ saved_item = Item.objects.all().first()
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.price, data["price"])
+ self.assertEqual(saved_item.currency, data["currency"])
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_simple_change_with_continue(self):
+ item = ItemFactory(name="Not name")
+
+ # Load the Change Item Page
+ ItemAdmin.confirm_change = True
+ response = self.client.get(f"/admin/market/item/{item.id}/change/")
+
+ # Should be asked for confirmation
+ self.assertTrue(response.context_data.get("confirm_change"))
+ self.assertIn("_confirm_change", response.rendered_content)
+
+ # Click "Save And Continue"
+ data = {
+ "id": item.id,
+ "name": "name",
+ "price": 2.0,
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_change": True,
+ "_continue": True,
+ }
+ response = self.client.post(f"/admin/market/item/{item.id}/change/", data=data)
+
+ # Should be shown confirmation page
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content,
+ save_action="_continue",
+ multipart_form=True,
+ )
+
+ # Should have cached the unsaved item
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNotNone(cached_item)
+ self.assertIsNone(cached_item.id)
+ self.assertEqual(cached_item.name, data["name"])
+ self.assertEqual(cached_item.price, data["price"])
+ self.assertEqual(cached_item.currency, data["currency"])
+
+ # Should not have saved the changes yet
+ self.assertEqual(Item.objects.count(), 1)
+ item.refresh_from_db()
+ self.assertEqual(item.name, "Not name")
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_change"]
+ data[CONFIRMATION_RECEIVED] = True
+ response = self.client.post(f"/admin/market/item/{item.id}/change/", data=data)
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/{item.id}/change/")
+
+ # Should have saved item
+ self.assertEqual(Item.objects.count(), 1)
+ saved_item = Item.objects.all().first()
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.price, data["price"])
+ self.assertEqual(saved_item.currency, data["currency"])
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_file_and_image_add_addanother(self):
+ # Load the Add Item Page
+ ItemAdmin.confirm_add = True
+ response = self.client.get(reverse("admin:market_item_add"))
+
+ # Should be asked for confirmation
+ self.assertTrue(response.context_data.get("confirm_add"))
+ self.assertIn("_confirm_add", response.rendered_content)
+
+ # Select files
+ image_path = "screenshot.png"
+ f = SimpleUploadedFile(
+ name="test_file.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ i = SimpleUploadedFile(
+ name="test_image.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Click "Save"
+ data = {
+ "name": "name",
+ "price": 2.0,
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "file": f,
+ "image": i,
+ "_confirm_add": True,
+ "_addanother": True,
+ }
+ response = self.client.post(reverse("admin:market_item_add"), data=data)
+
+ # Should be shown confirmation page
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content,
+ save_action="_addanother",
+ multipart_form=True,
+ )
+
+ # Should have cached the unsaved item
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNotNone(cached_item)
+ self.assertIsNone(cached_item.id)
+ self.assertEqual(cached_item.name, data["name"])
+ self.assertEqual(cached_item.price, data["price"])
+ self.assertEqual(cached_item.currency, data["currency"])
+ self.assertEqual(cached_item.file, data["file"])
+ self.assertEqual(cached_item.image, data["image"])
+
+ # Should not have saved the item yet
+ self.assertEqual(Item.objects.count(), 0)
+
+ # Click "Yes, I'm Sure"
+ confirmation_data = data.copy()
+ del confirmation_data["_confirm_add"]
+ del confirmation_data["image"]
+ del confirmation_data["file"]
+ confirmation_data[CONFIRMATION_RECEIVED] = True
+ response = self.client.post(
+ reverse("admin:market_item_add"), data=confirmation_data
+ )
+
+ # Should have redirected to changelist
+ self.assertEqual(response.status_code, 302)
+ # Should show add page since "add another" was selected
+ self.assertEqual(response.url, "/admin/market/item/add/")
+
+ # Should have saved item
+ self.assertEqual(Item.objects.count(), 1)
+ saved_item = Item.objects.all().first()
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.price, data["price"])
+ self.assertEqual(saved_item.currency, data["currency"])
+ self.assertEqual(saved_item.file, data["file"])
+ self.assertEqual(saved_item.image, data["image"])
+
+ self.assertEqual(saved_item.file.name, "test_file.jpg")
+ self.assertEqual(saved_item.image.name, "test_image.jpg")
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_file_and_image_change_with_saveasnew(self):
+ item = ItemFactory(name="Not name")
+ # Select files
+ image_path = "screenshot.png"
+ f = SimpleUploadedFile(
+ name="test_file.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ i = SimpleUploadedFile(
+ name="test_image.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ item.file = f
+ item.image = i
+ item.save()
+
+ # Load the Change Item Page
+ ItemAdmin.confirm_change = True
+ ItemAdmin.fields = ["name", "price", "file", "image", "currency"]
+ ItemAdmin.save_as = True
+ ItemAdmin.save_as_continue = True
+ response = self.client.get(f"/admin/market/item/{item.id}/change/")
+
+ # Should be asked for confirmation
+ self.assertTrue(response.context_data.get("confirm_change"))
+ self.assertIn("_confirm_change", response.rendered_content)
+
+ # Upload new image and remove file
+ i2 = SimpleUploadedFile(
+ name="test_image2.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Click "Save And Continue"
+ data = {
+ "id": item.id,
+ "name": "name",
+ "price": 2.0,
+ "image": i2,
+ "file": "",
+ "file-clear": "on",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_change": True,
+ "_saveasnew": True,
+ }
+ response = self.client.post(f"/admin/market/item/{item.id}/change/", data=data)
+
+ # Should be shown confirmation page
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content,
+ save_action="_saveasnew",
+ multipart_form=True,
+ )
+
+ # Should have cached the unsaved item
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNotNone(cached_item)
+ self.assertIsNone(cached_item.id)
+ self.assertEqual(cached_item.name, data["name"])
+ self.assertEqual(cached_item.price, data["price"])
+ self.assertEqual(cached_item.currency, data["currency"])
+ self.assertFalse(cached_item.file.name)
+ self.assertEqual(cached_item.image, i2)
+
+ # Should not have saved the changes yet
+ self.assertEqual(Item.objects.count(), 1)
+ item.refresh_from_db()
+ self.assertEqual(item.name, "Not name")
+ self.assertIsNotNone(item.file)
+ self.assertIsNotNone(item.image)
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_change"]
+ data["image"] = ""
+ data[CONFIRMATION_RECEIVED] = True
+ response = self.client.post(f"/admin/market/item/{item.id}/change/", data=data)
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/{item.id + 1}/change/")
+
+ # Should not have changed existing item
+ item.refresh_from_db()
+ self.assertEqual(item.name, "Not name")
+ self.assertEqual(item.file.name.count("test_file"), 1)
+ self.assertEqual(item.image.name.count("test_image2"), 0)
+ self.assertEqual(item.image.name.count("test_image"), 1)
+
+ # Should have saved new item
+ self.assertEqual(Item.objects.count(), 2)
+ new_item = Item.objects.filter(id=item.id + 1).first()
+ self.assertIsNotNone(new_item)
+ self.assertEqual(new_item.name, data["name"])
+ self.assertEqual(new_item.price, data["price"])
+ self.assertEqual(new_item.currency, data["currency"])
+ self.assertFalse(new_item.file)
+ self.assertEqual(new_item.image, i2)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_relations_add(self):
+ gm = GeneralManager.objects.create(name="gm")
+ shops = [ShopFactory() for i in range(3)]
+ town = Town.objects.create(name="town")
+
+ # Load the Add ShoppingMall Page
+ ShoppingMallAdmin.confirm_add = True
+ response = self.client.get(reverse("admin:market_shoppingmall_add"))
+
+ # Should be asked for confirmation
+ self.assertTrue(response.context_data.get("confirm_add"))
+ self.assertIn("_confirm_add", response.rendered_content)
+
+ # Click "Save"
+ data = {
+ "name": "name",
+ "shops": [s.id for s in shops],
+ "general_manager": gm.id,
+ "town": town.id,
+ "_confirm_add": True,
+ "_save": True,
+ }
+ response = self.client.post(reverse("admin:market_shoppingmall_add"), data=data)
+
+ # Should be shown confirmation page
+ self._assertManyToManyFormHtml(
+ rendered_content=response.rendered_content,
+ options=shops,
+ selected_ids=data["shops"],
+ )
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content, save_action="_save"
+ )
+
+ # Should not have cached the unsaved object
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNone(cached_item)
+
+ # Click "Yes, I'm Sure"
+ confirmation_received_data = data
+ del confirmation_received_data["_confirm_add"]
+ confirmation_received_data[CONFIRMATION_RECEIVED] = True
+
+ response = self.client.post(
+ reverse("admin:market_shoppingmall_add"), data=confirmation_received_data
+ )
+
+ # Should have redirected to changelist
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "/admin/market/shoppingmall/")
+
+ # Should have saved object
+ self.assertEqual(ShoppingMall.objects.count(), 1)
+ saved_item = ShoppingMall.objects.all().first()
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.general_manager, gm)
+ self.assertEqual(saved_item.town, town)
+ for shop in saved_item.shops.all():
+ self.assertIn(shop, shops)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_relation_change_with_saveasnew(self):
+ gm = GeneralManager.objects.create(name="gm")
+ shops = [ShopFactory() for i in range(3)]
+ town = Town.objects.create(name="town")
+ mall = ShoppingMall.objects.create(name="mall", general_manager=gm, town=town)
+ mall.shops.set(shops)
+
+ # new values
+ gm2 = GeneralManager.objects.create(name="gm2")
+ shops2 = [ShopFactory() for i in range(3)]
+ town2 = Town.objects.create(name="town2")
+
+ # Load the Change ShoppingMall Page
+ ShoppingMallAdmin.confirm_change = True
+ response = self.client.get(f"/admin/market/shoppingmall/{mall.id}/change/")
+
+ # Should be asked for confirmation
+ self.assertTrue(response.context_data.get("confirm_change"))
+ self.assertIn("_confirm_change", response.rendered_content)
+
+ # Click "Save"
+ data = {
+ "id": mall.id,
+ "name": "name",
+ "shops": [s.id for s in shops2],
+ "general_manager": gm2.id,
+ "town": town2.id,
+ "_confirm_change": True,
+ "_saveasnew": True,
+ }
+ response = self.client.post(
+ f"/admin/market/shoppingmall/{mall.id}/change/", data=data
+ )
+
+ # Should be shown confirmation page
+ self._assertManyToManyFormHtml(
+ rendered_content=response.rendered_content,
+ options=shops,
+ selected_ids=data["shops"],
+ )
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content, save_action="_saveasnew"
+ )
+
+ # Should not have cached the unsaved obj
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNone(cached_item)
+
+ # Should not have saved changes yet
+ self.assertEqual(ShoppingMall.objects.count(), 1)
+ mall.refresh_from_db()
+ self.assertEqual(mall.name, "mall")
+ self.assertEqual(mall.general_manager, gm)
+ self.assertEqual(mall.town, town)
+ for shop in mall.shops.all():
+ self.assertIn(shop, shops)
+
+ # Click "Yes, I'm Sure"
+ confirmation_received_data = data.copy()
+ del confirmation_received_data["_confirm_change"]
+
+ response = self.client.post(
+ f"/admin/market/shoppingmall/{mall.id}/change/",
+ data=confirmation_received_data,
+ )
+
+ # Should not have redirected to changelist
+ self.assertEqual(
+ response.url, f"/admin/market/shoppingmall/{mall.id + 1}/change/"
+ )
+
+ # Should have saved obj
+ self.assertEqual(ShoppingMall.objects.count(), 2)
+ # Should not have changed old obj
+ mall.refresh_from_db()
+ self.assertEqual(mall.name, "mall")
+ self.assertEqual(mall.general_manager, gm)
+ self.assertEqual(mall.town, town)
+ for shop in mall.shops.all():
+ self.assertIn(shop, shops)
+
+ # Should have created new obj
+ saved_item = ShoppingMall.objects.filter(id=mall.id + 1).first()
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.general_manager, gm2)
+ self.assertEqual(saved_item.town, town2)
+
+ for shop in saved_item.shops.all():
+ self.assertIn(shop, shops2)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
diff --git a/admin_confirm/tests/unit/test_confirmation_cache.py b/admin_confirm/tests/unit/test_confirmation_cache.py
new file mode 100644
index 0000000..50d8e87
--- /dev/null
+++ b/admin_confirm/tests/unit/test_confirmation_cache.py
@@ -0,0 +1,458 @@
+from unittest import mock
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.core.cache import cache
+from django.urls import reverse
+
+from admin_confirm.tests.helpers import AdminConfirmTestCase
+from tests.market.admin import ItemAdmin, ShoppingMallAdmin
+from tests.market.models import GeneralManager, Item, ShoppingMall, Town
+from tests.factories import ItemFactory, ShopFactory
+
+from admin_confirm.constants import CACHE_KEYS, CONFIRMATION_RECEIVED
+
+
+@mock.patch.object(ShoppingMallAdmin, "inlines", [])
+class TestConfirmationCache(AdminConfirmTestCase):
+ def test_simple_add(self):
+ # Load the Add Item Page
+ ItemAdmin.confirm_add = True
+ response = self.client.get(reverse("admin:market_item_add"))
+
+ # Should be asked for confirmation
+ self.assertTrue(response.context_data.get("confirm_add"))
+ self.assertIn("_confirm_add", response.rendered_content)
+
+ # Click "Save"
+ data = {
+ "name": "name",
+ "price": 2.0,
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_add": True,
+ "_save": True,
+ }
+ response = self.client.post(reverse("admin:market_item_add"), data=data)
+
+ # Should be shown confirmation page
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content,
+ save_action="_save",
+ multipart_form=True,
+ )
+
+ # Should have cached the unsaved item
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNotNone(cached_item)
+ self.assertIsNone(cached_item.id)
+ self.assertEqual(cached_item.name, data["name"])
+ self.assertEqual(cached_item.price, data["price"])
+ self.assertEqual(cached_item.currency, data["currency"])
+
+ # Should not have saved the item yet
+ self.assertEqual(Item.objects.count(), 0)
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_add"]
+ data[CONFIRMATION_RECEIVED] = True
+ response = self.client.post(reverse("admin:market_item_add"), data=data)
+
+ # Should have redirected to changelist
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "/admin/market/item/")
+
+ # Should have saved item
+ self.assertEqual(Item.objects.count(), 1)
+ saved_item = Item.objects.all().first()
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.price, data["price"])
+ self.assertEqual(saved_item.currency, data["currency"])
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_simple_change(self):
+ item = ItemFactory(name="Not name")
+
+ # Load the Change Item Page
+ ItemAdmin.confirm_change = True
+ response = self.client.get(f"/admin/market/item/{item.id}/change/")
+
+ # Should be asked for confirmation
+ self.assertTrue(response.context_data.get("confirm_change"))
+ self.assertIn("_confirm_change", response.rendered_content)
+
+ # Click "Save And Continue"
+ data = {
+ "id": item.id,
+ "name": "name",
+ "price": 2.0,
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_change": True,
+ "_continue": True,
+ }
+ response = self.client.post(f"/admin/market/item/{item.id}/change/", data=data)
+
+ # Should be shown confirmation page
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content,
+ save_action="_continue",
+ multipart_form=True,
+ )
+
+ # Should have cached the unsaved item
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNotNone(cached_item)
+ self.assertIsNone(cached_item.id)
+ self.assertEqual(cached_item.name, data["name"])
+ self.assertEqual(cached_item.price, data["price"])
+ self.assertEqual(cached_item.currency, data["currency"])
+
+ # Should not have saved the changes yet
+ self.assertEqual(Item.objects.count(), 1)
+ item.refresh_from_db()
+ self.assertEqual(item.name, "Not name")
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_change"]
+ data[CONFIRMATION_RECEIVED] = True
+ response = self.client.post(f"/admin/market/item/{item.id}/change/", data=data)
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/{item.id}/change/")
+
+ # Should have saved item
+ self.assertEqual(Item.objects.count(), 1)
+ saved_item = Item.objects.all().first()
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.price, data["price"])
+ self.assertEqual(saved_item.currency, data["currency"])
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_file_and_image_add(self):
+ # Load the Add Item Page
+ ItemAdmin.confirm_add = True
+ response = self.client.get(reverse("admin:market_item_add"))
+
+ # Should be asked for confirmation
+ self.assertTrue(response.context_data.get("confirm_add"))
+ self.assertIn("_confirm_add", response.rendered_content)
+
+ # Select files
+ image_path = "screenshot.png"
+ f = SimpleUploadedFile(
+ name="test_file.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ i = SimpleUploadedFile(
+ name="test_image.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Click "Save"
+ data = {
+ "name": "name",
+ "price": 2.0,
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "file": f,
+ "image": i,
+ "_confirm_add": True,
+ "_save": True,
+ }
+ response = self.client.post(reverse("admin:market_item_add"), data=data)
+
+ # Should be shown confirmation page
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content,
+ save_action="_save",
+ multipart_form=True,
+ )
+
+ # Should have cached the unsaved item
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNotNone(cached_item)
+ self.assertIsNone(cached_item.id)
+ self.assertEqual(cached_item.name, data["name"])
+ self.assertEqual(cached_item.price, data["price"])
+ self.assertEqual(cached_item.currency, data["currency"])
+ self.assertEqual(cached_item.file, data["file"])
+ self.assertEqual(cached_item.image, data["image"])
+
+ # Should not have saved the item yet
+ self.assertEqual(Item.objects.count(), 0)
+
+ # Click "Yes, I'm Sure"
+ confirmation_data = data.copy()
+ del confirmation_data["_confirm_add"]
+ del confirmation_data["image"]
+ del confirmation_data["file"]
+ confirmation_data[CONFIRMATION_RECEIVED] = True
+ response = self.client.post(
+ reverse("admin:market_item_add"), data=confirmation_data
+ )
+
+ # Should have redirected to changelist
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "/admin/market/item/")
+
+ # Should have saved item
+ self.assertEqual(Item.objects.count(), 1)
+ saved_item = Item.objects.all().first()
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.price, data["price"])
+ self.assertEqual(saved_item.currency, data["currency"])
+ self.assertEqual(saved_item.file, data["file"])
+ self.assertEqual(saved_item.image, data["image"])
+
+ self.assertEqual(saved_item.file.name, "test_file.jpg")
+ self.assertEqual(saved_item.image.name, "test_image.jpg")
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_file_and_image_change(self):
+ item = ItemFactory(name="Not name")
+ # Select files
+ image_path = "screenshot.png"
+ f = SimpleUploadedFile(
+ name="test_file.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ i = SimpleUploadedFile(
+ name="test_image.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ item.file = f
+ item.image = i
+ item.save()
+
+ # Load the Change Item Page
+ ItemAdmin.confirm_change = True
+ ItemAdmin.fields = ["name", "price", "file", "image", "currency"]
+ response = self.client.get(f"/admin/market/item/{item.id}/change/")
+
+ # Should be asked for confirmation
+ self.assertTrue(response.context_data.get("confirm_change"))
+ self.assertIn("_confirm_change", response.rendered_content)
+
+ # Upload new image and remove file
+ i2 = SimpleUploadedFile(
+ name="test_image2.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Click "Save And Continue"
+ data = {
+ "id": item.id,
+ "name": "name",
+ "price": 2.0,
+ "image": i2,
+ "file": "",
+ "file-clear": "on",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_change": True,
+ "_continue": True,
+ }
+ response = self.client.post(f"/admin/market/item/{item.id}/change/", data=data)
+
+ # Should be shown confirmation page
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content,
+ save_action="_continue",
+ multipart_form=True,
+ )
+
+ # Should have cached the unsaved item
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNotNone(cached_item)
+ self.assertIsNone(cached_item.id)
+ self.assertEqual(cached_item.name, data["name"])
+ self.assertEqual(cached_item.price, data["price"])
+ self.assertEqual(cached_item.currency, data["currency"])
+ self.assertFalse(cached_item.file.name)
+ self.assertEqual(cached_item.image, i2)
+
+ # Should not have saved the changes yet
+ self.assertEqual(Item.objects.count(), 1)
+ item.refresh_from_db()
+ self.assertEqual(item.name, "Not name")
+ self.assertIsNotNone(item.file)
+ self.assertIsNotNone(item.image)
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_change"]
+ data["image"] = ""
+ data[CONFIRMATION_RECEIVED] = True
+ response = self.client.post(f"/admin/market/item/{item.id}/change/", data=data)
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/{item.id}/change/")
+
+ # Should have saved item
+ self.assertEqual(Item.objects.count(), 1)
+ saved_item = Item.objects.all().first()
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.price, data["price"])
+ self.assertEqual(saved_item.currency, data["currency"])
+ self.assertFalse(saved_item.file)
+ self.assertEqual(saved_item.image, i2)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_relations_add(self):
+ gm = GeneralManager.objects.create(name="gm")
+ shops = [ShopFactory() for i in range(3)]
+ town = Town.objects.create(name="town")
+
+ # Load the Add ShoppingMall Page
+ ShoppingMallAdmin.confirm_add = True
+ response = self.client.get(reverse("admin:market_shoppingmall_add"))
+
+ # Should be asked for confirmation
+ self.assertTrue(response.context_data.get("confirm_add"))
+ self.assertIn("_confirm_add", response.rendered_content)
+
+ # Click "Save"
+ data = {
+ "name": "name",
+ "shops": [s.id for s in shops],
+ "general_manager": gm.id,
+ "town": town.id,
+ "_confirm_add": True,
+ "_save": True,
+ }
+ response = self.client.post(reverse("admin:market_shoppingmall_add"), data=data)
+
+ # Should be shown confirmation page
+ self._assertManyToManyFormHtml(
+ rendered_content=response.rendered_content,
+ options=shops,
+ selected_ids=data["shops"],
+ )
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content, save_action="_save"
+ )
+
+ # Should not have cached the unsaved object
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNone(cached_item)
+
+ # Should not have saved the object yet
+ self.assertEqual(ShoppingMall.objects.count(), 0)
+
+ # Click "Yes, I'm Sure"
+ confirmation_received_data = data
+ del confirmation_received_data["_confirm_add"]
+
+ response = self.client.post(
+ reverse("admin:market_shoppingmall_add"), data=confirmation_received_data
+ )
+
+ # Should have redirected to changelist
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "/admin/market/shoppingmall/")
+
+ # Should have saved object
+ self.assertEqual(ShoppingMall.objects.count(), 1)
+ saved_item = ShoppingMall.objects.all().first()
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.general_manager, gm)
+ self.assertEqual(saved_item.town, town)
+ for shop in saved_item.shops.all():
+ self.assertIn(shop, shops)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_relation_change(self):
+ gm = GeneralManager.objects.create(name="gm")
+ shops = [ShopFactory() for i in range(3)]
+ town = Town.objects.create(name="town")
+ mall = ShoppingMall.objects.create(name="mall", general_manager=gm, town=town)
+ mall.shops.set(shops)
+
+ # new values
+ gm2 = GeneralManager.objects.create(name="gm2")
+ shops2 = [ShopFactory() for i in range(3)]
+ town2 = Town.objects.create(name="town2")
+
+ # Load the Change ShoppingMall Page
+ ShoppingMallAdmin.confirm_change = True
+ response = self.client.get(f"/admin/market/shoppingmall/{mall.id}/change/")
+
+ # Should be asked for confirmation
+ self.assertTrue(response.context_data.get("confirm_change"))
+ self.assertIn("_confirm_change", response.rendered_content)
+
+ # Click "Save"
+ data = {
+ "id": mall.id,
+ "name": "name",
+ "shops": [s.id for s in shops2],
+ "general_manager": gm2.id,
+ "town": town2.id,
+ "_confirm_change": True,
+ "_continue": True,
+ }
+ response = self.client.post(
+ f"/admin/market/shoppingmall/{mall.id}/change/", data=data
+ )
+
+ # Should be shown confirmation page
+ self._assertManyToManyFormHtml(
+ rendered_content=response.rendered_content,
+ options=shops,
+ selected_ids=data["shops"],
+ )
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content, save_action="_continue"
+ )
+
+ # Should not have cached the unsaved obj
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNone(cached_item)
+
+ # Should not have saved changes yet
+ self.assertEqual(ShoppingMall.objects.count(), 1)
+ mall.refresh_from_db()
+ self.assertEqual(mall.name, "mall")
+ self.assertEqual(mall.general_manager, gm)
+ self.assertEqual(mall.town, town)
+ for shop in mall.shops.all():
+ self.assertIn(shop, shops)
+
+ # Click "Yes, I'm Sure"
+ confirmation_received_data = data
+ del confirmation_received_data["_confirm_change"]
+ confirmation_received_data[CONFIRMATION_RECEIVED] = True
+
+ response = self.client.post(
+ f"/admin/market/shoppingmall/{mall.id}/change/",
+ data=confirmation_received_data,
+ )
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/shoppingmall/{mall.id}/change/")
+
+ # Should have saved obj
+ self.assertEqual(ShoppingMall.objects.count(), 1)
+ saved_item = ShoppingMall.objects.all().first()
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.general_manager, gm2)
+ self.assertEqual(saved_item.town, town2)
+
+ for shop in saved_item.shops.all():
+ self.assertIn(shop, shops2)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
diff --git a/admin_confirm/tests/unit/test_fieldsets.py b/admin_confirm/tests/unit/test_fieldsets.py
new file mode 100644
index 0000000..1c86ac8
--- /dev/null
+++ b/admin_confirm/tests/unit/test_fieldsets.py
@@ -0,0 +1,169 @@
+"""
+Tests ModelAdmin with fieldsets custom configured through one of the possible methods
+Ensures that AdminConfirmMixin works correctly when implimenting class alters default fieldsets
+
+Test Matrix
+method: `.fieldsets =`, `def get_fieldsets()`
+action: change, add
+fieldset: simple, with readonly fields, with custom fields
+"""
+import pytest
+from importlib import reload
+from tests.market.admin import item_admin
+
+from django.contrib.auth.models import User
+from django.contrib.admin import AdminSite
+from django.test.client import RequestFactory
+
+
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.core.cache import cache
+
+from admin_confirm.constants import CACHE_KEYS, CONFIRM_CHANGE, CONFIRMATION_RECEIVED
+
+from tests.market.models import Item
+from tests.factories import ItemFactory
+
+
+def fs_simple(admin):
+ return (
+ (None, {"fields": ("name", "price", "image")}),
+ (
+ "Advanced options",
+ {
+ "classes": ("collapse",),
+ "fields": ("currency", "file"),
+ },
+ ),
+ )
+
+
+def fs_w_readonly(admin):
+ admin.readonly_fields = ["description", "image"]
+ return fs_simple(admin)
+
+
+def fs_w_custom(admin):
+ admin.one = lambda self, obj: "ReadOnly"
+ admin.two = lambda self, obj: "ReadOnly"
+ admin.three = lambda self, obj: "ReadOnly"
+ admin.readonly_fields = ["one", "two", "three"]
+ return (
+ (None, {"fields": ("name", "price", "image", "one")}),
+ (
+ "Advanced options",
+ {
+ "classes": ("collapse",),
+ "fields": ("currency", "two", "file"),
+ },
+ ),
+ ("More Info", {"fields": ("three", "description")}),
+ )
+
+
+def set_fieldsets(admin, fieldset):
+ admin.fieldsets = fieldset
+
+
+def override_get_fieldsets(admin, fieldset):
+ admin.get_fieldsets = lambda self, request, obj=None: fieldset
+
+
+methods = [set_fieldsets, override_get_fieldsets]
+actions = ["_confirm_add", "_confirm_change"]
+fieldsets = [fs_simple, fs_w_readonly, fs_w_custom]
+
+param_matrix = []
+for method in methods:
+ for fieldset in fieldsets:
+ for action in actions:
+ param_matrix.append((method, fieldset, action))
+
+
+@pytest.mark.django_db()
+@pytest.mark.parametrize("method,get_fieldset,action", param_matrix)
+def test_fieldsets(client, method, get_fieldset, action):
+ reload(item_admin)
+
+ admin = item_admin.ItemAdmin
+ fs = get_fieldset(admin)
+ # set fieldsets via one of the methods
+ method(admin, fs)
+
+ admin_instance = admin(admin_site=AdminSite(), model=Item)
+ request = RequestFactory().request
+ assert admin_instance.get_fieldsets(request) == fs
+
+ user = User.objects.create_superuser(
+ username="super", email="super@email.org", password="pass"
+ )
+ client.force_login(user)
+
+ url = "/admin/market/item/add/"
+ image_path = "screenshot.png"
+ f2 = SimpleUploadedFile(
+ name="new_file.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ i2 = SimpleUploadedFile(
+ name="new_image.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ data = {
+ "name": "new name",
+ "price": 2,
+ "currency": "USD",
+ "image": i2,
+ "file": f2,
+ action: True,
+ "_save": True,
+ }
+ for f in admin.readonly_fields:
+ if f in data.keys():
+ del data[f]
+ if action == CONFIRM_CHANGE:
+ url = "/admin/market/item/1/change/"
+ f = SimpleUploadedFile(
+ name="old_file.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ i = SimpleUploadedFile(
+ name="old_image.jpg",
+ content=open(image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ item = ItemFactory(name="old name", price=1, currency="CAD", file=f, image=i)
+ data["id"] = item.id
+
+ cache_item = Item()
+ for f in ["name", "price", "currency", "image", "file"]:
+ if f not in admin.readonly_fields:
+ setattr(cache_item, f, data[f])
+
+ cache.set(CACHE_KEYS["object"], cache_item)
+ cache.set(CACHE_KEYS["post"], data)
+
+ # Click "Yes, I'm Sure"
+ del data[action]
+ data[CONFIRMATION_RECEIVED] = True
+ response = client.post(url, data=data)
+
+ # Should have redirected to changelist
+ # assert response.status_code == 302
+ assert response.url == "/admin/market/item/"
+
+ # Should have saved item
+ assert Item.objects.count() == 1
+ saved_item = Item.objects.all().first()
+ for f in ["name", "price", "currency"]:
+ if f not in admin.readonly_fields:
+ assert getattr(saved_item, f) == data[f]
+ if "file" not in admin.readonly_fields:
+ assert "new_file" in saved_item.file.name
+ if "image" not in admin.readonly_fields:
+ assert "new_image" in saved_item.image.name
+
+ reload(item_admin)
diff --git a/admin_confirm/tests/unit/test_file_cache.py b/admin_confirm/tests/unit/test_file_cache.py
new file mode 100644
index 0000000..1418c9d
--- /dev/null
+++ b/admin_confirm/tests/unit/test_file_cache.py
@@ -0,0 +1,967 @@
+"""
+Ensure that files are saved during confirmation
+Without file changes, Django is relied on
+
+With file changes, we cache the object, save it with
+the files if new, or add files to existing obj and save
+
+Then send the rest of the changes to Django to handle
+
+This is arguably the most we fiddle with the Django request
+Thus we should test it extensively
+"""
+import time
+from unittest import mock
+
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.core.cache import cache
+
+from admin_confirm.tests.helpers import AdminConfirmTestCase
+from admin_confirm.constants import CACHE_KEYS, CONFIRMATION_RECEIVED
+
+from tests.market.admin import ItemAdmin
+from tests.market.models import Item, Shop
+from tests.factories import ItemFactory, ShopFactory
+
+
+class TestFileCache(AdminConfirmTestCase):
+ def setUp(self):
+ # Load the Change Item Page
+ ItemAdmin.confirm_change = True
+ ItemAdmin.fields = ["name", "price", "file", "image", "currency"]
+ ItemAdmin.save_as = True
+ ItemAdmin.save_as_continue = True
+
+ self.image_path = "screenshot.png"
+ f = SimpleUploadedFile(
+ name="test_file.jpg",
+ content=open(self.image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ i = SimpleUploadedFile(
+ name="test_image.jpg",
+ content=open(self.image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ self.item = ItemFactory(name="Not name", file=f, image=i)
+
+ return super().setUp()
+
+ def test_save_as_continue_true_should_not_redirect_to_changelist(self):
+ item = self.item
+ # Load the Change Item Page
+ ItemAdmin.save_as_continue = True
+
+ # Upload new image and remove file
+ i2 = SimpleUploadedFile(
+ name="test_image2.jpg",
+ content=open(self.image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Request.POST
+ data = {
+ "id": item.id,
+ "name": "name",
+ "price": 2.0,
+ "file": "",
+ "file-clear": "on",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_change": True,
+ "_saveasnew": True,
+ }
+
+ # Set cache
+ cache_item = Item(
+ name=data["name"],
+ price=data["price"],
+ currency=data["currency"],
+ image=i2,
+ )
+
+ cache.set(CACHE_KEYS["object"], cache_item)
+ cache.set(CACHE_KEYS["post"], data)
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_change"]
+ data[CONFIRMATION_RECEIVED] = True
+
+ with mock.patch.object(ItemAdmin, "message_user") as message_user:
+ response = self.client.post(
+ f"/admin/market/item/{self.item.id}/change/", data=data
+ )
+ # Should show message to user with correct obj and path
+ message_user.assert_called_once()
+ message = message_user.call_args[0][1]
+ self.assertIn("/admin/market/item/2/change/", message)
+ self.assertIn(data["name"], message)
+ self.assertIn("You may edit it again below.", message)
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/{self.item.id + 1}/change/")
+
+ # Should not have changed existing item
+ item.refresh_from_db()
+ self.assertEqual(item.name, "Not name")
+ self.assertEqual(item.file.name.count("test_file"), 1)
+ self.assertEqual(item.image.name.count("test_image2"), 0)
+ self.assertEqual(item.image.name.count("test_image"), 1)
+
+ # Should have saved new item
+ self.assertEqual(Item.objects.count(), 2)
+ new_item = Item.objects.filter(id=item.id + 1).first()
+ self.assertIsNotNone(new_item)
+ self.assertEqual(new_item.name, data["name"])
+ self.assertEqual(new_item.price, data["price"])
+ self.assertEqual(new_item.currency, data["currency"])
+ self.assertFalse(new_item.file)
+ self.assertEqual(new_item.image.name.count("test_image2"), 1)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_save_as_continue_false_should_redirect_to_changelist(self):
+ item = self.item
+ # Load the Change Item Page
+ ItemAdmin.save_as_continue = False
+
+ # Upload new image and remove file
+ i2 = SimpleUploadedFile(
+ name="test_image2.jpg",
+ content=open(self.image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Request.POST
+ data = {
+ "id": item.id,
+ "name": "name",
+ "price": 2.0,
+ "file": "",
+ "file-clear": "on",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_change": True,
+ "_saveasnew": True,
+ }
+
+ # Set cache
+ cache_item = Item(
+ name=data["name"],
+ price=data["price"],
+ currency=data["currency"],
+ image=i2,
+ )
+
+ cache.set(CACHE_KEYS["object"], cache_item)
+ cache.set(CACHE_KEYS["post"], data)
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_change"]
+ data[CONFIRMATION_RECEIVED] = True
+
+ with mock.patch.object(ItemAdmin, "message_user") as message_user:
+ response = self.client.post(
+ f"/admin/market/item/{self.item.id}/change/", data=data
+ )
+ # Should show message to user with correct obj and path
+ message_user.assert_called_once()
+ message = message_user.call_args[0][1]
+ self.assertIn("/admin/market/item/2/change/", message)
+ self.assertIn(data["name"], message)
+ self.assertNotIn("You may edit it again below.", message)
+
+ # Should have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/")
+
+ # Should not have changed existing item
+ item.refresh_from_db()
+ self.assertEqual(item.name, "Not name")
+ self.assertEqual(item.file.name.count("test_file"), 1)
+ self.assertEqual(item.image.name.count("test_image2"), 0)
+ self.assertEqual(item.image.name.count("test_image"), 1)
+
+ # Should have saved new item
+ self.assertEqual(Item.objects.count(), 2)
+ new_item = Item.objects.filter(id=item.id + 1).first()
+ self.assertIsNotNone(new_item)
+ self.assertEqual(new_item.name, data["name"])
+ self.assertEqual(new_item.price, data["price"])
+ self.assertEqual(new_item.currency, data["currency"])
+ self.assertFalse(new_item.file)
+ self.assertEqual(new_item.image.name.count("test_image2"), 1)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_saveasnew_without_any_file_changes_should_save_new_instance_without_files(
+ self,
+ ):
+ item = self.item
+
+ # Request.POST
+ data = {
+ "id": item.id,
+ "name": "name",
+ "price": 2.0,
+ "file": "",
+ "image": "",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_change": True,
+ "_saveasnew": True,
+ }
+
+ # Set cache
+ cache_item = Item(
+ name=data["name"],
+ price=data["price"],
+ currency=data["currency"],
+ )
+
+ cache.set(CACHE_KEYS["object"], cache_item)
+ cache.set(CACHE_KEYS["post"], data)
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_change"]
+ data[CONFIRMATION_RECEIVED] = True
+
+ with mock.patch.object(ItemAdmin, "message_user") as message_user:
+ response = self.client.post(
+ f"/admin/market/item/{self.item.id}/change/", data=data
+ )
+ # Should show message to user with correct obj and path
+ message_user.assert_called_once()
+ message = message_user.call_args[0][1]
+ self.assertIn("/admin/market/item/2/change/", message)
+ self.assertIn(data["name"], message)
+ self.assertIn("You may edit it again below.", message)
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/{self.item.id + 1}/change/")
+
+ # Should not have changed existing item
+ item.refresh_from_db()
+ self.assertEqual(item.name, "Not name")
+ self.assertEqual(item.file.name.count("test_file"), 1)
+ self.assertEqual(item.image.name.count("test_image2"), 0)
+ self.assertEqual(item.image.name.count("test_image"), 1)
+
+ # Should have saved new item
+ self.assertEqual(Item.objects.count(), 2)
+ new_item = Item.objects.filter(id=item.id + 1).first()
+ self.assertIsNotNone(new_item)
+ self.assertEqual(new_item.name, data["name"])
+ self.assertEqual(new_item.price, data["price"])
+ self.assertEqual(new_item.currency, data["currency"])
+ # In Django (by default), the save as new does not transfer over the files
+ self.assertFalse(new_item.file)
+ self.assertFalse(new_item.image)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_add_with_upload_file_should_save_new_instance_with_files(self):
+ # Request.POST
+ data = {
+ "name": "name",
+ "price": 2.0,
+ "image": "",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_add": True,
+ "_save": True,
+ }
+
+ # Upload new file
+ f2 = SimpleUploadedFile(
+ name="test_file2.jpg",
+ content=open(self.image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Set cache
+ cache_item = Item(
+ name=data["name"], price=data["price"], currency=data["currency"], file=f2
+ )
+
+ cache.set(CACHE_KEYS["object"], cache_item)
+ cache.set(CACHE_KEYS["post"], data)
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_add"]
+ data[CONFIRMATION_RECEIVED] = True
+
+ with mock.patch.object(ItemAdmin, "message_user") as message_user:
+ response = self.client.post(f"/admin/market/item/add/", data=data)
+ # Should show message to user with correct obj and path
+ message_user.assert_called_once()
+ message = message_user.call_args[0][1]
+ self.assertIn("/admin/market/item/2/change/", message)
+ self.assertIn(data["name"], message)
+ self.assertNotIn("You may edit it again below.", message)
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/")
+
+ # Should not have changed existing item
+ self.item.refresh_from_db()
+ self.assertEqual(self.item.name, "Not name")
+ self.assertEqual(self.item.file.name.count("test_file"), 1)
+ self.assertEqual(self.item.image.name.count("test_image2"), 0)
+ self.assertEqual(self.item.image.name.count("test_image"), 1)
+
+ # Should have saved new item
+ self.assertEqual(Item.objects.count(), 2)
+ new_item = Item.objects.filter(id=self.item.id + 1).first()
+ self.assertIsNotNone(new_item)
+ self.assertEqual(new_item.name, data["name"])
+ self.assertEqual(new_item.price, data["price"])
+ self.assertEqual(new_item.currency, data["currency"])
+ self.assertIn("test_file2", new_item.file.name)
+ self.assertFalse(new_item.image)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_add_without_cached_post_should_save_new_instance_with_file(self):
+ # Request.POST
+ data = {
+ "name": "name",
+ "price": 2.0,
+ "image": "",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_add": True,
+ "_save": True,
+ }
+
+ # Upload new file
+ f2 = SimpleUploadedFile(
+ name="test_file2.jpg",
+ content=open(self.image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Set cache
+ cache_item = Item(
+ name=data["name"], price=data["price"], currency=data["currency"], file=f2
+ )
+
+ cache.set(CACHE_KEYS["object"], cache_item)
+ # Make sure there's no post cached post
+ cache.delete(CACHE_KEYS["post"])
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_add"]
+ data[CONFIRMATION_RECEIVED] = True
+
+ with mock.patch.object(ItemAdmin, "message_user") as message_user:
+ response = self.client.post(f"/admin/market/item/add/", data=data)
+ # Should show message to user with correct obj and path
+ message_user.assert_called_once()
+ message = message_user.call_args[0][1]
+ self.assertIn("/admin/market/item/2/change/", message)
+ self.assertIn(data["name"], message)
+ self.assertNotIn("You may edit it again below.", message)
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/")
+
+ # Should not have changed existing item
+ self.item.refresh_from_db()
+ self.assertEqual(self.item.name, "Not name")
+ self.assertEqual(self.item.file.name.count("test_file"), 1)
+ self.assertEqual(self.item.image.name.count("test_image2"), 0)
+ self.assertEqual(self.item.image.name.count("test_image"), 1)
+
+ # Should have saved new item
+ self.assertEqual(Item.objects.count(), 2)
+ new_item = Item.objects.filter(id=self.item.id + 1).first()
+ self.assertIsNotNone(new_item)
+ self.assertEqual(new_item.name, data["name"])
+ self.assertEqual(new_item.price, data["price"])
+ self.assertEqual(new_item.currency, data["currency"])
+ self.assertFalse(new_item.image)
+
+ # Able to save the cached file since cached object was there even though cached post was not
+ self.assertIn("test_file2", new_item.file.name)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_add_without_cached_object_should_save_new_instance_but_not_have_file(self):
+ # Request.POST
+ data = {
+ "name": "name",
+ "price": 2.0,
+ "image": "",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_add": True,
+ "_save": True,
+ }
+
+ # Upload new file
+ f2 = SimpleUploadedFile(
+ name="test_file2.jpg",
+ content=open(self.image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Set cache
+ cache_item = Item(
+ name=data["name"], price=data["price"], currency=data["currency"], file=f2
+ )
+
+ # Make sure there's no post cached obj
+ cache.delete(CACHE_KEYS["object"])
+ cache.set(CACHE_KEYS["post"], data)
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_add"]
+ data[CONFIRMATION_RECEIVED] = True
+
+ with mock.patch.object(ItemAdmin, "message_user") as message_user:
+ response = self.client.post(f"/admin/market/item/add/", data=data)
+ # Should show message to user with correct obj and path
+ message_user.assert_called_once()
+ message = message_user.call_args[0][1]
+ self.assertIn("/admin/market/item/2/change/", message)
+ self.assertIn(data["name"], message)
+ self.assertNotIn("You may edit it again below.", message)
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/")
+
+ # Should not have changed existing item
+ self.item.refresh_from_db()
+ self.assertEqual(self.item.name, "Not name")
+ self.assertEqual(self.item.file.name.count("test_file"), 1)
+ self.assertEqual(self.item.image.name.count("test_image2"), 0)
+ self.assertEqual(self.item.image.name.count("test_image"), 1)
+
+ # Should have saved new item
+ self.assertEqual(Item.objects.count(), 2)
+ new_item = Item.objects.filter(id=self.item.id + 1).first()
+ self.assertIsNotNone(new_item)
+ self.assertEqual(new_item.name, data["name"])
+ self.assertEqual(new_item.price, data["price"])
+ self.assertEqual(new_item.currency, data["currency"])
+ self.assertFalse(new_item.image)
+
+ # FAILED to save the file, because cached item was not there
+ self.assertFalse(new_item.file)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_add_without_any_cache_should_save_new_instance_but_not_have_file(self):
+ # Request.POST
+ data = {
+ "name": "name",
+ "price": 2.0,
+ "image": "",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_add": True,
+ "_save": True,
+ }
+
+ # Upload new file
+ f2 = SimpleUploadedFile(
+ name="test_file2.jpg",
+ content=open(self.image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Set cache
+ cache_item = Item(
+ name=data["name"], price=data["price"], currency=data["currency"], file=f2
+ )
+
+ # Make sure there's no cache
+ cache.delete(CACHE_KEYS["object"])
+ cache.delete(CACHE_KEYS["post"])
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_add"]
+ data[CONFIRMATION_RECEIVED] = True
+
+ with mock.patch.object(ItemAdmin, "message_user") as message_user:
+ response = self.client.post(f"/admin/market/item/add/", data=data)
+ # Should show message to user with correct obj and path
+ message_user.assert_called_once()
+ message = message_user.call_args[0][1]
+ self.assertIn("/admin/market/item/2/change/", message)
+ self.assertIn(data["name"], message)
+ self.assertNotIn("You may edit it again below.", message)
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/")
+
+ # Should not have changed existing item
+ self.item.refresh_from_db()
+ self.assertEqual(self.item.name, "Not name")
+ self.assertEqual(self.item.file.name.count("test_file"), 1)
+ self.assertEqual(self.item.image.name.count("test_image2"), 0)
+ self.assertEqual(self.item.image.name.count("test_image"), 1)
+
+ # Should have saved new item
+ self.assertEqual(Item.objects.count(), 2)
+ new_item = Item.objects.filter(id=self.item.id + 1).first()
+ self.assertIsNotNone(new_item)
+ self.assertEqual(new_item.name, data["name"])
+ self.assertEqual(new_item.price, data["price"])
+ self.assertEqual(new_item.currency, data["currency"])
+ self.assertFalse(new_item.image)
+
+ # FAILED to save the file, because cached item was not there
+ self.assertFalse(new_item.file)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_change_without_cached_post_should_save_file_changes(self):
+ item = self.item
+ # Load the Change Item Page
+ ItemAdmin.save_as_continue = False
+
+ # Upload new image and remove file
+ i2 = SimpleUploadedFile(
+ name="test_image2.jpg",
+ content=open(self.image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Request.POST
+ data = {
+ "id": item.id,
+ "name": "name",
+ "price": 2.0,
+ "image": i2,
+ "file": "",
+ "file-clear": "on",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_change": True,
+ "_saveasnew": True,
+ }
+
+ # Set cache
+ cache_item = Item(
+ name=data["name"],
+ price=data["price"],
+ currency=data["currency"],
+ image=i2,
+ )
+
+ cache.set(CACHE_KEYS["object"], cache_item)
+ # Ensure no cached post
+ cache.delete(CACHE_KEYS["post"])
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_change"]
+ # Image would have been in FILES and not in POST
+ del data["image"]
+ data[CONFIRMATION_RECEIVED] = True
+
+ with mock.patch.object(ItemAdmin, "message_user") as message_user:
+ response = self.client.post(
+ f"/admin/market/item/{self.item.id}/change/", data=data
+ )
+ # Should show message to user with correct obj and path
+ message_user.assert_called_once()
+ message = message_user.call_args[0][1]
+ self.assertIn("/admin/market/item/2/change/", message)
+ self.assertIn(data["name"], message)
+ self.assertNotIn("You may edit it again below.", message)
+
+ # Should have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/")
+
+ # Should not have changed existing item
+ item.refresh_from_db()
+ self.assertEqual(item.name, "Not name")
+ self.assertEqual(item.file.name.count("test_file"), 1)
+ self.assertEqual(item.image.name.count("test_image2"), 0)
+ self.assertEqual(item.image.name.count("test_image"), 1)
+
+ # Should have saved new item
+ self.assertEqual(Item.objects.count(), 2)
+ new_item = Item.objects.filter(id=item.id + 1).first()
+ self.assertIsNotNone(new_item)
+ self.assertEqual(new_item.name, data["name"])
+ self.assertEqual(new_item.price, data["price"])
+ self.assertEqual(new_item.currency, data["currency"])
+ # Should have cleared `file` since clear was selected
+ self.assertFalse(new_item.file)
+ # Saved cached file from cached obj even if cached post was missing
+ self.assertIn("test_image2", new_item.image.name)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_change_without_cached_object_should_save_but_without_file_changes(self):
+ item = self.item
+ # Load the Change Item Page
+ ItemAdmin.save_as_continue = False
+
+ # Upload new image and remove file
+ i2 = SimpleUploadedFile(
+ name="test_image2.jpg",
+ content=open(self.image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Request.POST
+ data = {
+ "id": item.id,
+ "name": "name",
+ "price": 2.0,
+ "file": "",
+ "file-clear": "on",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_change": True,
+ "_saveasnew": True,
+ }
+
+ # Set cache
+ cache_item = Item(
+ name=data["name"],
+ price=data["price"],
+ currency=data["currency"],
+ image=i2,
+ )
+
+ # Ensure no cached obj
+ cache.delete(CACHE_KEYS["object"])
+ cache.set(CACHE_KEYS["post"], data)
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_change"]
+ data[CONFIRMATION_RECEIVED] = True
+
+ with mock.patch.object(ItemAdmin, "message_user") as message_user:
+ response = self.client.post(
+ f"/admin/market/item/{self.item.id}/change/", data=data
+ )
+ # Should show message to user with correct obj and path
+ message_user.assert_called_once()
+ message = message_user.call_args[0][1]
+ self.assertIn("/admin/market/item/2/change/", message)
+ self.assertIn(data["name"], message)
+ self.assertNotIn("You may edit it again below.", message)
+
+ # Should have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/")
+
+ # Should not have changed existing item
+ item.refresh_from_db()
+ self.assertEqual(item.name, "Not name")
+ self.assertEqual(item.file.name.count("test_file"), 1)
+ self.assertEqual(item.image.name.count("test_image2"), 0)
+ self.assertEqual(item.image.name.count("test_image"), 1)
+
+ # Should have saved new item
+ self.assertEqual(Item.objects.count(), 2)
+ new_item = Item.objects.filter(id=item.id + 1).first()
+ self.assertIsNotNone(new_item)
+ self.assertEqual(new_item.name, data["name"])
+ self.assertEqual(new_item.price, data["price"])
+ self.assertEqual(new_item.currency, data["currency"])
+ self.assertFalse(new_item.file)
+ # FAILED to save image
+ self.assertFalse(new_item.image)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_change_without_any_cache_should_save_but_not_have_file_changes(self):
+ item = self.item
+ # Load the Change Item Page
+ ItemAdmin.save_as_continue = False
+
+ # Upload new image and remove file
+ i2 = SimpleUploadedFile(
+ name="test_image2.jpg",
+ content=open(self.image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Request.POST
+ data = {
+ "id": item.id,
+ "name": "name",
+ "price": 2.0,
+ "file": "",
+ "file-clear": "on",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_change": True,
+ "_saveasnew": True,
+ }
+
+ # Set cache
+ cache_item = Item(
+ name=data["name"],
+ price=data["price"],
+ currency=data["currency"],
+ image=i2,
+ )
+
+ # Ensure no cache
+ cache.delete(CACHE_KEYS["object"])
+ cache.delete(CACHE_KEYS["post"])
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_change"]
+ data[CONFIRMATION_RECEIVED] = True
+
+ with mock.patch.object(ItemAdmin, "message_user") as message_user:
+ response = self.client.post(
+ f"/admin/market/item/{self.item.id}/change/", data=data
+ )
+ # Should show message to user with correct obj and path
+ message_user.assert_called_once()
+ message = message_user.call_args[0][1]
+ self.assertIn("/admin/market/item/2/change/", message)
+ self.assertIn(data["name"], message)
+ self.assertNotIn("You may edit it again below.", message)
+
+ # Should have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/")
+
+ # Should not have changed existing item
+ item.refresh_from_db()
+ self.assertEqual(item.name, "Not name")
+ self.assertEqual(item.file.name.count("test_file"), 1)
+ self.assertEqual(item.image.name.count("test_image2"), 0)
+ self.assertEqual(item.image.name.count("test_image"), 1)
+
+ # Should have saved new item
+ self.assertEqual(Item.objects.count(), 2)
+ new_item = Item.objects.filter(id=item.id + 1).first()
+ self.assertIsNotNone(new_item)
+ self.assertEqual(new_item.name, data["name"])
+ self.assertEqual(new_item.price, data["price"])
+ self.assertEqual(new_item.currency, data["currency"])
+ self.assertFalse(new_item.file)
+ # FAILED to save image
+ self.assertFalse(new_item.image)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_change_without_changing_file_should_save_changes(self):
+ item = self.item
+ # Load the Change Item Page
+ ItemAdmin.save_as_continue = False
+
+ # Request.POST
+ data = {
+ "id": item.id,
+ "name": "name",
+ "price": 2.0,
+ "file": "",
+ "image": "",
+ "file-clear": "on",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_change": True,
+ "_save": True,
+ }
+
+ # Set cache
+ cache_item = Item(
+ name=data["name"],
+ price=data["price"],
+ currency=data["currency"],
+ )
+
+ cache.get(CACHE_KEYS["object"], cache_item)
+ cache.get(CACHE_KEYS["post"], data)
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_change"]
+ data[CONFIRMATION_RECEIVED] = True
+
+ with mock.patch.object(ItemAdmin, "message_user") as message_user:
+ response = self.client.post(
+ f"/admin/market/item/{self.item.id}/change/", data=data
+ )
+ # Should show message to user with correct obj and path
+ message_user.assert_called_once()
+ message = message_user.call_args[0][1]
+ self.assertIn("/admin/market/item/1/change/", message)
+ self.assertIn(data["name"], message)
+ self.assertNotIn("You may edit it again below.", message)
+
+ # Should have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/")
+
+ # Should have changed existing item
+ self.assertEqual(Item.objects.count(), 1)
+ item.refresh_from_db()
+ self.assertEqual(item.name, "name")
+ # Should have cleared if requested
+ self.assertFalse(item.file.name)
+ self.assertEqual(item.image.name.count("test_image2"), 0)
+ self.assertEqual(item.image.name.count("test_image"), 1)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ @mock.patch("admin_confirm.admin.CACHE_TIMEOUT", 1)
+ def test_old_cache_should_not_be_used(self):
+ item = self.item
+
+ # Upload new image and remove file
+ i2 = SimpleUploadedFile(
+ name="test_image2.jpg",
+ content=open(self.image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Click "Save And Continue"
+ data = {
+ "id": item.id,
+ "name": "name",
+ "price": 2.0,
+ "image": i2,
+ "file": "",
+ "file-clear": "on",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_change": True,
+ "_continue": True,
+ }
+ response = self.client.post(f"/admin/market/item/{item.id}/change/", data=data)
+
+ # Should be shown confirmation page
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content,
+ save_action="_continue",
+ multipart_form=True,
+ )
+
+ # Should have cached the unsaved item
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNotNone(cached_item)
+ self.assertIsNone(cached_item.id)
+ self.assertEqual(cached_item.name, data["name"])
+ self.assertEqual(cached_item.price, data["price"])
+ self.assertEqual(cached_item.currency, data["currency"])
+ self.assertFalse(cached_item.file.name)
+ self.assertEqual(cached_item.image, i2)
+
+ # Should not have saved the changes yet
+ self.assertEqual(Item.objects.count(), 1)
+ item.refresh_from_db()
+ self.assertEqual(item.name, "Not name")
+ self.assertIsNotNone(item.file)
+ self.assertIsNotNone(item.image)
+
+ # Wait for cache to time out
+
+ time.sleep(1)
+
+ # Check that it did time out
+ cached_item = cache.get(CACHE_KEYS["object"])
+ self.assertIsNone(cached_item)
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_change"]
+ data["image"] = ""
+ data[CONFIRMATION_RECEIVED] = True
+ response = self.client.post(f"/admin/market/item/{item.id}/change/", data=data)
+
+ # Should not have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/{item.id}/change/")
+
+ # Should have saved item
+ self.assertEqual(Item.objects.count(), 1)
+ saved_item = Item.objects.all().first()
+ self.assertEqual(saved_item.name, data["name"])
+ self.assertEqual(saved_item.price, data["price"])
+ self.assertEqual(saved_item.currency, data["currency"])
+ self.assertFalse(saved_item.file)
+
+ # SHOULD not have saved image since it was in the old cache
+ self.assertNotIn("test_image2", saved_item.image)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_cache_with_incorrect_model_should_not_be_used(self):
+ item = self.item
+ # Load the Change Item Page
+ ItemAdmin.save_as_continue = False
+
+ # Upload new image and remove file
+ i2 = SimpleUploadedFile(
+ name="test_image2.jpg",
+ content=open(self.image_path, "rb").read(),
+ content_type="image/jpeg",
+ )
+ # Request.POST
+ data = {
+ "id": item.id,
+ "name": "name",
+ "price": 2.0,
+ "file": "",
+ "file-clear": "on",
+ "currency": Item.VALID_CURRENCIES[0][0],
+ "_confirm_change": True,
+ "_save": True,
+ }
+
+ # Set cache to incorrect model
+ cache_obj = Shop(name="ShopName")
+
+ cache.set(CACHE_KEYS["object"], cache_obj)
+ cache.set(CACHE_KEYS["post"], data)
+
+ # Click "Yes, I'm Sure"
+ del data["_confirm_change"]
+ data[CONFIRMATION_RECEIVED] = True
+
+ with mock.patch.object(ItemAdmin, "message_user") as message_user:
+ response = self.client.post(
+ f"/admin/market/item/{self.item.id}/change/", data=data
+ )
+ # Should show message to user with correct obj and path
+ message_user.assert_called_once()
+ message = message_user.call_args[0][1]
+ self.assertIn("/admin/market/item/1/change/", message)
+ self.assertIn(data["name"], message)
+ self.assertNotIn("You may edit it again below.", message)
+
+ # Should have redirected to changelist
+ self.assertEqual(response.url, f"/admin/market/item/")
+
+ # Should have changed existing item
+ self.assertEqual(Item.objects.count(), 1)
+ item.refresh_from_db()
+ self.assertEqual(item.name, "name")
+ # Should have cleared if requested
+ self.assertFalse(item.file.name)
+ self.assertEqual(item.image.name.count("test_image2"), 0)
+ self.assertEqual(item.image.name.count("test_image"), 1)
+
+ # Should have cleared cache
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
+
+ def test_form_without_files_should_not_use_cache(self):
+ cache.delete_many(CACHE_KEYS.values())
+ shop = ShopFactory()
+ # Click "Save And Continue"
+ data = {
+ "id": shop.id,
+ "name": "name",
+ "_confirm_change": True,
+ "_continue": True,
+ }
+ response = self.client.post(f"/admin/market/shop/{shop.id}/change/", data=data)
+
+ # Should be shown confirmation page
+ self._assertSubmitHtml(
+ rendered_content=response.rendered_content, save_action="_continue"
+ )
+
+ # Should not have set cache since not multipart form
+ for key in CACHE_KEYS.values():
+ self.assertIsNone(cache.get(key))
diff --git a/admin_confirm/utils.py b/admin_confirm/utils.py
index 9eed8c8..39f637b 100644
--- a/admin_confirm/utils.py
+++ b/admin_confirm/utils.py
@@ -1,2 +1,12 @@
+from django.urls import reverse
+
+
def snake_to_title_case(string: str) -> str:
return " ".join(string.split("_")).title()
+
+
+def get_admin_change_url(obj):
+ return reverse(
+ "admin:%s_%s_change" % (obj._meta.app_label, obj._meta.model_name),
+ args=(obj.pk,),
+ )
diff --git a/requirements.txt b/requirements.txt
index 8df713b..e056a77 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,3 +7,5 @@ pytest-django~=4.1.0
readme-renderer~=28.0
twine~=3.3.0
coveralls~=3.0.0
+Pillow~=8.1.0 # For ImageField
+selenium~=3.141.0
diff --git a/setup.py b/setup.py
index 26de37e..e2fc88a 100644
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@ README = open(os.path.join(here, "README.md")).read()
setup(
name="django-admin-confirm",
- version="0.2.3.dev5",
+ version="0.2.3.dev9",
packages=["admin_confirm"],
description="Adds confirmation to Django Admin changes, additions and actions",
long_description_content_type="text/markdown",
diff --git a/tests/market/admin.py b/tests/market/admin.py
deleted file mode 100644
index d536409..0000000
--- a/tests/market/admin.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from django.contrib import admin
-
-from admin_confirm.admin import AdminConfirmMixin, confirm_action
-
-from .models import Item, Inventory, Shop, ShoppingMall
-
-
-class ItemAdmin(AdminConfirmMixin, admin.ModelAdmin):
- list_display = ("name", "price", "currency")
- confirm_change = True
-
-
-class InventoryAdmin(AdminConfirmMixin, admin.ModelAdmin):
- list_display = ("shop", "item", "quantity")
- confirm_change = True
- confirm_add = True
- confirmation_fields = ["quantity"]
-
-
-class ShopAdmin(AdminConfirmMixin, admin.ModelAdmin):
- confirmation_fields = ["name"]
- actions = ["show_message", "show_message_no_confirmation"]
-
- @confirm_action
- def show_message(modeladmin, request, queryset):
- shops = ", ".join(shop.name for shop in queryset)
- modeladmin.message_user(request, f"You selected with confirmation: {shops}")
-
- show_message.allowed_permissions = ("delete",)
-
- def show_message_no_confirmation(modeladmin, request, queryset):
- shops = ", ".join(shop.name for shop in queryset)
- modeladmin.message_user(request, f"You selected without confirmation: {shops}")
-
- def has_delete_permission(self, request, obj=None):
- return request.user.is_superuser
-
-
-class ShoppingMallAdmin(AdminConfirmMixin, admin.ModelAdmin):
- confirm_add = True
- confirm_change = True
- confirmation_fields = ["name"]
-
-
-admin.site.register(Item, ItemAdmin)
-admin.site.register(Inventory, InventoryAdmin)
-admin.site.register(Shop, ShopAdmin)
-admin.site.register(ShoppingMall, ShoppingMallAdmin)
diff --git a/tests/market/admin/__init__.py b/tests/market/admin/__init__.py
new file mode 100644
index 0000000..03cb423
--- /dev/null
+++ b/tests/market/admin/__init__.py
@@ -0,0 +1,15 @@
+from django.contrib import admin
+
+from ..models import GeneralManager, Item, Inventory, Shop, ShoppingMall
+
+from .item_admin import ItemAdmin
+from .inventory_admin import InventoryAdmin
+from .shop_admin import ShopAdmin
+from .shoppingmall_admin import ShoppingMallAdmin
+from .generalmanager_admin import GeneralManagerAdmin
+
+admin.site.register(Item, ItemAdmin)
+admin.site.register(Inventory, InventoryAdmin)
+admin.site.register(Shop, ShopAdmin)
+admin.site.register(ShoppingMall, ShoppingMallAdmin)
+admin.site.register(GeneralManager, GeneralManagerAdmin)
diff --git a/tests/market/admin/generalmanager_admin.py b/tests/market/admin/generalmanager_admin.py
new file mode 100644
index 0000000..4c5073e
--- /dev/null
+++ b/tests/market/admin/generalmanager_admin.py
@@ -0,0 +1,5 @@
+from django.contrib.admin import ModelAdmin
+
+
+class GeneralManagerAdmin(ModelAdmin):
+ save_as = True
diff --git a/tests/market/admin/inventory_admin.py b/tests/market/admin/inventory_admin.py
new file mode 100644
index 0000000..6aec345
--- /dev/null
+++ b/tests/market/admin/inventory_admin.py
@@ -0,0 +1,10 @@
+from django.contrib.admin import ModelAdmin
+from admin_confirm.admin import AdminConfirmMixin
+
+
+class InventoryAdmin(AdminConfirmMixin, ModelAdmin):
+ list_display = ("shop", "item", "quantity")
+
+ confirm_change = True
+ confirm_add = True
+ confirmation_fields = ["quantity"]
diff --git a/tests/market/admin/item_admin.py b/tests/market/admin/item_admin.py
new file mode 100644
index 0000000..62df006
--- /dev/null
+++ b/tests/market/admin/item_admin.py
@@ -0,0 +1,28 @@
+from django.contrib.admin import ModelAdmin
+from django.utils.safestring import mark_safe
+from admin_confirm.admin import AdminConfirmMixin
+
+
+class ItemAdmin(AdminConfirmMixin, ModelAdmin):
+ confirm_change = True
+ confirm_add = True
+ confirmation_fields = ["price"]
+
+ list_display = ("name", "price", "currency")
+ readonly_fields = ["image_preview"]
+
+ save_as = True
+ save_as_continue = False
+
+ def image_preview(self, obj):
+ if obj.image:
+ return mark_safe('')
+
+ # def one(self, obj):
+ # return "Read Only"
+
+ # def two(self, obj):
+ # return "Read Only"
+
+ # def three(self, obj):
+ # return "Read Only"
diff --git a/tests/market/admin/shop_admin.py b/tests/market/admin/shop_admin.py
new file mode 100644
index 0000000..e6d492a
--- /dev/null
+++ b/tests/market/admin/shop_admin.py
@@ -0,0 +1,21 @@
+from django.contrib.admin import ModelAdmin
+from admin_confirm.admin import AdminConfirmMixin, confirm_action
+
+
+class ShopAdmin(AdminConfirmMixin, ModelAdmin):
+ confirmation_fields = ["name"]
+ actions = ["show_message", "show_message_no_confirmation"]
+
+ @confirm_action
+ def show_message(modeladmin, request, queryset):
+ shops = ", ".join(shop.name for shop in queryset)
+ modeladmin.message_user(request, f"You selected with confirmation: {shops}")
+
+ show_message.allowed_permissions = ("delete",)
+
+ def show_message_no_confirmation(modeladmin, request, queryset):
+ shops = ", ".join(shop.name for shop in queryset)
+ modeladmin.message_user(request, f"You selected without confirmation: {shops}")
+
+ def has_delete_permission(self, request, obj=None):
+ return request.user.is_superuser
diff --git a/tests/market/admin/shoppingmall_admin.py b/tests/market/admin/shoppingmall_admin.py
new file mode 100644
index 0000000..cabc13a
--- /dev/null
+++ b/tests/market/admin/shoppingmall_admin.py
@@ -0,0 +1,16 @@
+from ..models import ShoppingMall
+from django.contrib.admin import ModelAdmin
+from django.contrib.admin.options import StackedInline
+from admin_confirm.admin import AdminConfirmMixin
+
+
+class ShopInline(StackedInline):
+ model = ShoppingMall.shops.through
+
+
+class ShoppingMallAdmin(AdminConfirmMixin, ModelAdmin):
+ confirm_add = True
+ confirm_change = True
+ confirmation_fields = ["name"]
+
+ inlines = [ShopInline]
diff --git a/tests/market/migrations/0006_auto_20210222_0312.py b/tests/market/migrations/0006_auto_20210222_0312.py
new file mode 100644
index 0000000..484de32
--- /dev/null
+++ b/tests/market/migrations/0006_auto_20210222_0312.py
@@ -0,0 +1,48 @@
+# Generated by Django 3.1.6 on 2021-02-22 03:12
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('market', '0005_shoppingmall'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='GeneralManager',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=120)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Town',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=120)),
+ ],
+ ),
+ migrations.AddField(
+ model_name='item',
+ name='file',
+ field=models.FileField(blank=True, null=True, upload_to='tmp/files'),
+ ),
+ migrations.AddField(
+ model_name='item',
+ name='image',
+ field=models.ImageField(blank=True, null=True, upload_to='tmp/items'),
+ ),
+ migrations.AddField(
+ model_name='shoppingmall',
+ name='general_manager',
+ field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='market.generalmanager'),
+ ),
+ migrations.AddField(
+ model_name='shoppingmall',
+ name='town',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='market.town'),
+ ),
+ ]
diff --git a/tests/market/migrations/0007_generalmanager_headshot.py b/tests/market/migrations/0007_generalmanager_headshot.py
new file mode 100644
index 0000000..645e815
--- /dev/null
+++ b/tests/market/migrations/0007_generalmanager_headshot.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1.6 on 2021-02-24 01:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('market', '0006_auto_20210222_0312'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='generalmanager',
+ name='headshot',
+ field=models.ImageField(blank=True, null=True, upload_to='tmp/gm/headshots'),
+ ),
+ ]
diff --git a/tests/market/migrations/0008_item_description.py b/tests/market/migrations/0008_item_description.py
new file mode 100644
index 0000000..8915db6
--- /dev/null
+++ b/tests/market/migrations/0008_item_description.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1.6 on 2021-02-24 08:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('market', '0007_generalmanager_headshot'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='item',
+ name='description',
+ field=models.TextField(blank=True, null=True),
+ ),
+ ]
diff --git a/tests/market/models.py b/tests/market/models.py
index f09a68e..9658aec 100644
--- a/tests/market/models.py
+++ b/tests/market/models.py
@@ -9,6 +9,9 @@ class Item(models.Model):
name = models.CharField(max_length=120)
price = models.DecimalField(max_digits=5, decimal_places=2)
currency = models.CharField(max_length=3, choices=VALID_CURRENCIES)
+ image = models.ImageField(upload_to="tmp/items", null=True, blank=True)
+ file = models.FileField(upload_to="tmp/files", null=True, blank=True)
+ description = models.TextField(null=True, blank=True)
def __str__(self):
return self.name
@@ -18,7 +21,7 @@ class Shop(models.Model):
name = models.CharField(max_length=120)
def __str__(self):
- return self.name
+ return str(self.name)
class Inventory(models.Model):
@@ -35,9 +38,22 @@ class Inventory(models.Model):
notes = models.TextField(default="This is the default", null=True, blank=True)
+class GeneralManager(models.Model):
+ name = models.CharField(max_length=120)
+ headshot = models.ImageField(upload_to="tmp/gm/headshots", null=True, blank=True)
+
+
+class Town(models.Model):
+ name = models.CharField(max_length=120)
+
+
class ShoppingMall(models.Model):
name = models.CharField(max_length=120)
shops = models.ManyToManyField(Shop)
+ general_manager = models.OneToOneField(
+ GeneralManager, on_delete=models.CASCADE, null=True, blank=True
+ )
+ town = models.ForeignKey(Town, on_delete=models.CASCADE, null=True, blank=True)
def __str__(self):
return self.name