diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ca15ef1 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: python +python: + - 3.8 + - 3.6 + - 2.7 +install: pip install tox-travis +script: tox diff --git a/readonly/cursor.py b/readonly/cursor.py index c9700ae..b3e54ba 100644 --- a/readonly/cursor.py +++ b/readonly/cursor.py @@ -105,8 +105,8 @@ def _last_executed(self): class PatchedCursorWrapper(utils.CursorWrapper): - def __init__(self, cursor, db): - self.cursor = ReadOnlyCursorWrapper(cursor, db) + def __init__(self, cursor, db,read_only=None): + self.cursor = ReadOnlyCursorWrapper(cursor, db, read_only=read_only) self.db = db diff --git a/readonly/decorators.py b/readonly/decorators.py new file mode 100644 index 0000000..7e57191 --- /dev/null +++ b/readonly/decorators.py @@ -0,0 +1,49 @@ +from contextlib import contextmanager + +from django.db.backends import utils + +from readonly.cursor import ( + PatchedCursorWrapper, + PatchedCursorDebugWrapper, +) + +_orig_CursorWrapper = utils.CursorWrapper +_orig_CursorDebugWrapper = utils.CursorDebugWrapper + + +class ForcedPatchedCursorWrapper(PatchedCursorWrapper): + def __init__(self, cursor, db): + super(ForcedPatchedCursorWrapper, self).__init__(cursor, db, read_only=True) + + +class ForcedPatchedCursorDebugWrapper(PatchedCursorDebugWrapper): + def __init__(self, cursor, db): + super(ForcedPatchedCursorDebugWrapper, self).__init__( + cursor, db, read_only=True + ) + + +@contextmanager +def readonly(): + old_CursorWrapper = utils.CursorWrapper + old_CursorDebugWrapper = utils.CursorDebugWrapper + utils.CursorWrapper = ForcedPatchedCursorWrapper + utils.CursorDebugWrapper = ForcedPatchedCursorDebugWrapper + try: + yield + finally: + utils.CursorWrapper = old_CursorWrapper + utils.CursorDebugWrapper = old_CursorDebugWrapper + + +@contextmanager +def dangerously_enabled(): + old_CursorWrapper = utils.CursorWrapper + old_CursorDebugWrapper = utils.CursorDebugWrapper + utils.CursorWrapper = _orig_CursorWrapper + utils.CursorDebugWrapper = _orig_CursorDebugWrapper + try: + yield + finally: + utils.CursorWrapper = old_CursorWrapper + utils.CursorDebugWrapper = old_CursorDebugWrapper diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 0000000..f97b787 --- /dev/null +++ b/tests/models.py @@ -0,0 +1,5 @@ +from django.db import models + + +class Widget(models.Model): + name = models.CharField(max_length=100) diff --git a/tests/settings.py b/tests/settings.py index 470f21d..eb2825b 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -2,18 +2,18 @@ DB_READ_ONLY_MIDDLEWARE_MESSAGE = False SITE_READ_ONLY = False -DB_READ_ONLY_DATABASES = False +DB_READ_ONLY_DATABASES = [] -DATABASE_ENGINE = "sqlite3" - -# Uncomment below to run tests with mysql -# DATABASE_ENGINE = "django.db.backends.mysql" -# DATABASE_NAME = "readonly_test" -# DATABASE_USER = "readonly_test" -# DATABASE_HOST = "/var/mysql/mysql.sock" +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + }, +} INSTALLED_APPS = [ "readonly", + "tests", ] MIDDLEWARE = [ diff --git a/tests/test_context_manager.py b/tests/test_context_manager.py new file mode 100644 index 0000000..a3ed9b8 --- /dev/null +++ b/tests/test_context_manager.py @@ -0,0 +1,91 @@ +from django.db import transaction +from django.db.transaction import TransactionManagementError + +from django.test import TestCase + +from readonly.decorators import ( + readonly, + dangerously_enabled, +) +from readonly.exceptions import DatabaseWriteDenied + +from tests.models import Widget + + +class ContextManagerTestCase(TestCase): + def _create_obj(self): + with transaction.atomic(): + obj = Widget.objects.create() + obj.save() + + def test_normal(self): + Widget.objects.count() + obj = Widget.objects.create() + obj.save() + + def test_readonly_transaction(self): + before = Widget.objects.count() + + with readonly(): + with self.assertRaises(DatabaseWriteDenied): + with transaction.atomic(): + obj = Widget.objects.create() + obj.save() + + after = Widget.objects.count() + assert after == before + + obj = Widget.objects.create() + obj.save() + + after = Widget.objects.count() + assert after == before + 1 + + def test_readonly(self): + Widget.objects.count() + + with readonly(): + with self.assertRaises(DatabaseWriteDenied): + obj = Widget.objects.create() + obj.save() + + # TODO: Automatic cancellation of the transaction would simplify + # developer use of readonly & DatabaseWriteDenied with foreign code + with self.assertRaises(TransactionManagementError): + Widget.objects.count() + + def test_nested_readonly_disabled(self): + with readonly(): + with self.assertRaises(DatabaseWriteDenied): + self._create_obj() + with readonly(): + with self.assertRaises(DatabaseWriteDenied): + self._create_obj() + with readonly(): + with self.assertRaises(DatabaseWriteDenied): + self._create_obj() + + Widget.objects.create() + + def test_readonly_enabled(self): + with readonly(): + with dangerously_enabled(): + self._create_obj() + + def test_nested_readonly_enabled(self): + with readonly(): + with readonly(): + with dangerously_enabled(): + with readonly(): + with dangerously_enabled(): + with readonly(): + with self.assertRaises(DatabaseWriteDenied): + self._create_obj() + + with self.assertRaises(DatabaseWriteDenied): + self._create_obj() + + with self.assertRaises(DatabaseWriteDenied): + self._create_obj() + + self._create_obj() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..9463a00 --- /dev/null +++ b/tox.ini @@ -0,0 +1,18 @@ +[tox] +envlist = py27-django{18,19,110,111}, py{36,37,38}-django{111,20,21,22,30,31} +skip_missing_interpreters = True + +[testenv] +commands = python -m django test {posargs} +setenv = + DJANGO_SETTINGS_MODULE = tests.settings +deps = + django18: Django>=1.8,<1.9 + django19: Django>=1.9,<1.10 + django110: Django>=1.10,<1.11 + django111: Django>=1.11,<1.12 + django20: Django>=2.0,<2.1 + django21: Django>=2.1,<2.2 + django22: Django>=2.2,<2.3 + django30: Django>=3.0,<3.1 + django31: Django>=3.1,<3.2