django-ticketing 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-ticketing
3
+ Version: 0.8.0
4
+ Summary: Generate tickets efficiently in a database in Django
5
+ Author-email: Chris Streeter <pypi@chrisstreeter.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/streeter/django-ticketing
8
+ Classifier: Framework :: Django
9
+ Classifier: Framework :: Django :: 5.2
10
+ Classifier: Framework :: Django :: 6.0
11
+ Classifier: Framework :: Django :: 6.1
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Programming Language :: Python :: 3.15
21
+ Classifier: Topic :: Software Development
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: Django>=5.2
26
+ Provides-Extra: test
27
+ Requires-Dist: pytest<10,>=9; extra == "test"
28
+ Requires-Dist: pytest-django<5,>=4.14; extra == "test"
29
+ Provides-Extra: dev
30
+ Requires-Dist: django-ticketing[test]; extra == "dev"
31
+ Requires-Dist: tox<5,>=4.28; extra == "dev"
32
+ Requires-Dist: ruff==0.17.2; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ ## About
36
+
37
+ An implementation of a Django model that returns tickets, as described
38
+ in the [Flickr blog post][flickr]. Requires Python 3.11 or higher and Django 5.2
39
+ or higher. Ticket generation supports MySQL 8.4 or higher and SQLite.
40
+
41
+ ## Installation
42
+
43
+ I uploaded it to [PyPi][pypi], so you can grab it there if you'd like with
44
+
45
+ ```
46
+ pip install django-ticketing
47
+ ```
48
+
49
+ or install it with pip using the git address:
50
+
51
+ ```
52
+ pip install git+git@github.com:streeter/django-ticketing.git
53
+ ```
54
+
55
+ Then add `ticketing` to your `INSTALLED_APPS`.
56
+
57
+ ## Usage
58
+
59
+ To use this, you can either use the model interface, or just the shortcut
60
+ function defined in `ticketing.models`. That usage looks like this:
61
+
62
+ ```python
63
+ # Import the function
64
+ from ticketing.models import get_ticket
65
+
66
+ # Go get yourself a ticket
67
+ ticket = get_ticket()
68
+ # Boom. That just happened
69
+ ```
70
+
71
+ Create the sequence tables before requesting tickets. The app generates its
72
+ models from your settings and does not ship migrations; use Django's
73
+ `migrate --run-syncdb` to create tables for this unmigrated app.
74
+
75
+ ### Multiple Sequences
76
+
77
+ `django-ticketing` also supports multiple sequences, which allows to have
78
+ sequences of tickets that are independent. This means you could have a sequence
79
+ for users, a sequence for posts and a sequence for widgets. This is configured
80
+ through your Django settings configuration.
81
+
82
+ Simply define a setting called `TICKETING_SEQUENCES` with a tuple of sequence
83
+ names that have to be valid table names. This defaults to the tuple `('default',)`.
84
+ In addition, you can define the default sequence from which new tickets are
85
+ taken from with the setting `TICKETING_DEFAULT_SEQUENCE`, which defaults to
86
+ `'default'`.
87
+
88
+ Note that `TICKETING_DEFAULT_SEQUENCE` has to be a sequence name that is defined
89
+ inside of `TICKETING_SEQUENCES`, otherwise an exception will be raised
90
+ during setup.
91
+
92
+ So to have sequences for the above example, put the following lines in your
93
+ `settings.py`:
94
+
95
+ ``` python
96
+ TICKETING_DEFAULT_SEQUENCE = "users"
97
+ TICKETING_SEQUENCES = (
98
+ "users",
99
+ "posts",
100
+ "widgets",
101
+ )
102
+ ```
103
+
104
+ Then, to get a ticket from a specific sequence, pass in the sequence name to
105
+ `get_ticket()`:
106
+
107
+ ``` python
108
+ # Get yourself a user ticket
109
+ user_ticket = get_ticket("users")
110
+ # Get yourself another user ticket
111
+ user_ticket = get_ticket()
112
+ # Get yourself a posts ticket
113
+ post_ticket = get_ticket("posts")
114
+ ```
115
+
116
+ Notice that the default sequence for `get_ticket()` is the value of the
117
+ `TICKETING_DEFAULT_SEQUENCE` configuration variable.
118
+
119
+ Also, after you change the value of `TICKETING_SEQUENCES`, be sure to re-run
120
+ `python manage.py migrate --run-syncdb` to create any new sequence tables.
121
+
122
+ ### Other Configuration Options
123
+
124
+ `TICKETING_APP_LABEL`: This is used to specify the prefix for all the DB
125
+ tablenames. The default value is `'ticketing'`. Be sure you know what you are
126
+ doing when you change this.
127
+
128
+
129
+ ## Testing
130
+
131
+ Install the development tools in a virtual environment:
132
+
133
+ ```bash
134
+ python -m venv .venv
135
+ source .venv/bin/activate
136
+ python -m pip install -e '.[dev]'
137
+ ```
138
+
139
+ Run the pytest suite against the current environment, or use Tox for the full
140
+ compatibility matrix and Ruff checks:
141
+
142
+ ```bash
143
+ pytest
144
+ tox
145
+ ```
146
+
147
+ Tox requires each Python interpreter in the matrix to be installed. Missing
148
+ interpreters fail the run so coverage cannot silently disappear. To run just
149
+ one environment or one test:
150
+
151
+ ```bash
152
+ tox -e py315-django61
153
+ tox -e py315-django61 -- -k test_get_ticket
154
+ ```
155
+
156
+ The matrix uses the latest patch release in each Django series. List the
157
+ configured environments with `tox list -d --no-desc`.
158
+
159
+ Tests use an in-memory SQLite database and configure multiple ticket sequences
160
+ in `ticketing/tests/settings.py`. `tox.ini` defines the test matrix; GitHub
161
+ Actions discovers it with `tox list -d --no-desc` and runs every combination
162
+ and the Ruff checks on pushes and pull requests. Add or remove environments
163
+ in `tox.ini` to update both local and CI runs. CI infers the Python version
164
+ from each environment's `py<major><minor>` prefix (for example, `py315`).
165
+
166
+ The MySQL environments cover MySQL 8.4 (the minimum supported version) and
167
+ 26.10 with each supported Django series. CI derives the MySQL service image
168
+ from the `-mysql-<version>` suffix in `tox.ini`.
169
+
170
+ For a local MySQL run, provide a disposable database server and credentials
171
+ for a user that can create and drop test databases:
172
+
173
+ ```bash
174
+ export TICKETING_TEST_DB_USER=root
175
+ export TICKETING_TEST_DB_PASSWORD=ticketing-tests
176
+ export TICKETING_TEST_DB_PORT=3384
177
+ tox -e py315-django61-mysql-8.4
178
+ ```
179
+
180
+ The host defaults to `127.0.0.1`; override it with `TICKETING_TEST_DB_HOST`.
181
+ Django creates and drops `test_ticketing`; set `TICKETING_TEST_DB_NAME` to
182
+ change the base database name. To run the complete matrix against multiple
183
+ local MySQL servers, use `TICKETING_TEST_MYSQL_8_4_PORT` and
184
+ `TICKETING_TEST_MYSQL_26_10_PORT` to select a port for each version.
185
+
186
+ Format all Python code and check lint and formatting locally with:
187
+
188
+ ```bash
189
+ tox -e format
190
+ tox -e lint
191
+ ```
192
+
193
+
194
+ ## License
195
+
196
+ Uses the [MIT][mit] license.
197
+
198
+
199
+ [flickr]: http://code.flickr.com/blog/2010/02/08/ticket-servers-distributed-unique-primary-keys-on-the-cheap/
200
+ [pypi]: http://pypi.python.org/pypi/django-ticketing/
201
+ [mit]: http://opensource.org/licenses/MIT
@@ -0,0 +1,17 @@
1
+ django_ticketing-0.8.0.dist-info/licenses/LICENSE,sha256=g97jvK9pbiXQ8HzjNig4vx6NtcNAmGikEt6LuXEnQWU,1058
2
+ ticketing/__init__.py,sha256=pflwNqxPr6gz_EspcGGPtTqXfWUDpqCd2Q1nQXXH_Sc,91
3
+ ticketing/conf.py,sha256=7napO1wRCRtqk34XbQz3OiM3W8TNOP5kT8XLv5l3j2E,1099
4
+ ticketing/exceptions.py,sha256=pOT2fI_xSwCSon_PlL3mRIGlky-Ehvyj-J3wV52iKmA,93
5
+ ticketing/fields.py,sha256=8-UEGmtVrLrkNotPzGXeQ72SWO-jJpqW-1ofcNEO01s,2641
6
+ ticketing/managers.py,sha256=hiK-YeX5i3QbDu5s4jk4lNLn-pJ4_3JUD7LXYtb6Bjw,1867
7
+ ticketing/models.py,sha256=NZ6YhynovM2sDRFhE9QgOMSgiEZGTHp6LAKbzG7mydg,2445
8
+ ticketing/views.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ ticketing/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ ticketing/tests/settings.py,sha256=vNmNcyJLQ3Fx3iMJH2h8nP0_UBCV3NyADmLSE89I7_o,1093
11
+ ticketing/tests/test_fields.py,sha256=b9tVTw877sgzdToFjsM8WYxw_VXQAtDUd1hDLulVQUI,2582
12
+ ticketing/tests/test_managers.py,sha256=2a0yNg64elC2bVjBu2O1z-nkT4eSswho3M5aZ_IUN70,3287
13
+ ticketing/tests/test_ticketing.py,sha256=g9jtlUcLdrIVKRqvpllet0aBTX1fT2vRwqfxxqRKwQA,1446
14
+ django_ticketing-0.8.0.dist-info/METADATA,sha256=Uln760_2lyzhZ3sG21xV_PMFgzWNhEHqnmSZ74A6Gl0,6549
15
+ django_ticketing-0.8.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
+ django_ticketing-0.8.0.dist-info/top_level.txt,sha256=eAH4TzD_rYMydICCzwBU0Wg6iJwC4BuOkof_f1Btzic,10
17
+ django_ticketing-0.8.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2013 Chris Streeter
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ ticketing
ticketing/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ Ticketing
3
+ ~~~~~~~~~
4
+ """
5
+
6
+ VERSION = (0, 8, 0)
7
+ __version__ = ".".join(map(str, VERSION))
ticketing/conf.py ADDED
@@ -0,0 +1,29 @@
1
+ from django.conf import settings
2
+
3
+ # Testing and Debug settings
4
+ TESTING = getattr(settings, "TICKETING_TESTING", False)
5
+ DEBUG = getattr(settings, "DEBUG", False) and not TESTING
6
+
7
+ # Let the user redefine the APP_LABEL, which is used for the table
8
+ # prefixes.
9
+ APP_LABEL = getattr(settings, "TICKETING_APP_LABEL", "ticketing")
10
+
11
+ # A list of names that define all the different ticketing tables. Every
12
+ # sequence is a sequence of tickets that can be retrieved.
13
+ SEQUENCES = getattr(settings, "TICKETING_SEQUENCES", ("default",))
14
+
15
+ # When testing, add a second sequence so we can test specifying sequences
16
+ if TESTING:
17
+ SEQUENCES = SEQUENCES + ("test",)
18
+
19
+ # The name of the default sequence that is queried when creating tickets.
20
+ DEFAULT_SEQUENCE = getattr(settings, "TICKETING_DEFAULT_SEQUENCE", "default")
21
+
22
+ if DEFAULT_SEQUENCE not in SEQUENCES:
23
+ msg = (
24
+ "Cannot specify a default sequence '%s' that "
25
+ + "is not in the list of allowed sequences %s"
26
+ ) % (DEFAULT_SEQUENCE, str(SEQUENCES))
27
+ from ticketing.exceptions import ImproperlyConfigured
28
+
29
+ raise ImproperlyConfigured(msg)
@@ -0,0 +1,6 @@
1
+ class BadSequenceName(Exception):
2
+ pass
3
+
4
+
5
+ class ImproperlyConfigured(Exception):
6
+ pass
ticketing/fields.py ADDED
@@ -0,0 +1,86 @@
1
+ from django.db import models
2
+
3
+ from ticketing import conf
4
+
5
+
6
+ def _conn_is_mysql(connection):
7
+ return (
8
+ bool(connection)
9
+ and connection.settings_dict["ENGINE"] == "django.db.backends.mysql"
10
+ )
11
+
12
+
13
+ class BigAutoField(models.AutoField):
14
+ def db_type(self, connection):
15
+ if _conn_is_mysql(connection):
16
+ return "bigint UNSIGNED AUTO_INCREMENT"
17
+ else:
18
+ return super().db_type(connection)
19
+
20
+
21
+ class TicketField(models.BigIntegerField):
22
+ description = "A big integer pre-populated from a ticket"
23
+
24
+ def __init__(self, sequence=None, **kwargs):
25
+ """
26
+ Set up this field with the name of the sequence to use
27
+ """
28
+ self.sequence = sequence or conf.DEFAULT_SEQUENCE
29
+ kwargs["editable"] = False
30
+ super().__init__(**kwargs)
31
+
32
+ def deconstruct(self):
33
+ name, path, args, kwargs = super().deconstruct()
34
+ kwargs["sequence"] = self.sequence
35
+ return name, path, args, kwargs
36
+
37
+ def pre_save(self, model_instance, add):
38
+ """
39
+ When saving the DB, if we are adding this instance, get a ticket
40
+ value for this field. Otherwise pull it from the model instance.
41
+ If the field on the instance already has a value, we won't
42
+ a new ticketing value.
43
+ """
44
+ if add and not getattr(model_instance, self.attname):
45
+ from ticketing.models import get_ticket
46
+
47
+ value = get_ticket(self.sequence)
48
+ setattr(model_instance, self.attname, value)
49
+ return value
50
+ else:
51
+ return super().pre_save(model_instance, add)
52
+
53
+ def db_type(self, connection):
54
+ if _conn_is_mysql(connection):
55
+ return "bigint UNSIGNED"
56
+ else:
57
+ return super().db_type(connection)
58
+
59
+ def south_field_triple(self):
60
+ "Returns a suitable description of this field for South."
61
+ # We'll just introspect the _actual_ field.
62
+ from south.modelsinspector import introspector
63
+
64
+ field_class = "ticketing.fields.TicketField"
65
+ args, kwargs = introspector(self)
66
+ # That's our definition!
67
+ return (field_class, args, kwargs)
68
+
69
+
70
+ try:
71
+ from south.modelsinspector import add_introspection_rules
72
+
73
+ add_introspection_rules(
74
+ rules=[
75
+ (
76
+ [TicketField], # Class(es) these apply to
77
+ [], # Positional arguments (not used)
78
+ { # Keyword argument
79
+ "sequence": ["sequence", {"default": None}],
80
+ },
81
+ ),
82
+ ],
83
+ patterns=[r"^ticketing\.fields\.TicketField"],
84
+ )
85
+ except ImportError:
86
+ pass
ticketing/managers.py ADDED
@@ -0,0 +1,49 @@
1
+ from django.db import OperationalError, connections, models, transaction
2
+
3
+ exceptions = (OperationalError,)
4
+ try:
5
+ # Also try to catch a MySQLdb-specific OperationalError
6
+ from MySQLdb import OperationalError as MySQLOperationalError
7
+
8
+ exceptions += (MySQLOperationalError,)
9
+ except ImportError:
10
+ pass
11
+
12
+
13
+ class TicketingManager(models.Manager):
14
+ def create(self):
15
+ ticket_id = self.get_ticket()
16
+ return self.model(id=ticket_id, stub=self.model.STUB_DEFAULT)
17
+
18
+ def get_queryset(self):
19
+ raise NotImplementedError()
20
+
21
+ def _internal_get_ticket(self):
22
+ sql = f"REPLACE INTO `{self.model._meta.db_table}` "
23
+ sql += "(`stub`) VALUES (%s)"
24
+
25
+ with transaction.atomic(using=self.db):
26
+ cursor = connections[self.db].cursor()
27
+ cursor.execute(sql, [self.model.STUB_DEFAULT])
28
+
29
+ lastrow_id = getattr(cursor, "lastrowid", None)
30
+ if lastrow_id is None:
31
+ # Hack. Select the row and get the id manually
32
+ result = super().get_queryset().filter(stub=True)[0]
33
+ lastrow_id = result.id
34
+
35
+ return lastrow_id
36
+
37
+ def get_ticket(self):
38
+ try:
39
+ return self._internal_get_ticket()
40
+ except exceptions:
41
+ connection = connections[self.db]
42
+ # If the transaction is in an `atomic` block, and needs a rollback,
43
+ # we should mark the database as rolled back. This is because the
44
+ # database backend may not prevent running SQL queries in broken
45
+ # transactions, in which case we need to say that we have handled
46
+ # the rollback so we can try one more time.
47
+ if connection.in_atomic_block and transaction.get_rollback(using=self.db):
48
+ transaction.set_rollback(False, using=self.db)
49
+ return self._internal_get_ticket()
ticketing/models.py ADDED
@@ -0,0 +1,89 @@
1
+ from django.db import models
2
+
3
+ from ticketing import conf
4
+ from ticketing.fields import BigAutoField
5
+ from ticketing.managers import TicketingManager
6
+
7
+ # Container for all the ticketing models
8
+ ticketing_models = {}
9
+
10
+
11
+ class BaseTicketing(models.Model):
12
+ """
13
+ The base class used by all Ticket generator models.
14
+
15
+ Define the fields, methods and manager used by all
16
+ classes that will inherit from this abstract model.
17
+ All ticketing models that are created for each
18
+ sequence defined in the configuration will inherit
19
+ these properties.
20
+
21
+ """
22
+
23
+ # Explicitly set the id of the model, even though it is the same
24
+ # as the one Django gives it.
25
+ id = BigAutoField(null=False, primary_key=True)
26
+
27
+ # This is just the smallest placeholder we can create that we can
28
+ # replace into to generate a new id.
29
+ STUB_DEFAULT = True
30
+ stub = models.BooleanField(null=False, default=STUB_DEFAULT, unique=True)
31
+
32
+ # Override the default manager
33
+ objects = TicketingManager()
34
+
35
+ @property
36
+ def ticket(self):
37
+ """Alias for id"""
38
+ return self.id
39
+
40
+ def __str__(self):
41
+ return f"Ticket {self.ticket}"
42
+
43
+ class Meta:
44
+ abstract = True
45
+
46
+
47
+ # Create each model for the ones defined in the configuration
48
+ for seq in conf.SEQUENCES:
49
+
50
+ class Meta:
51
+ pass
52
+
53
+ # Set the application label
54
+ setattr(Meta, "app_label", conf.APP_LABEL)
55
+
56
+ # __module__ is used with app_label to create the name of the table.
57
+ # In addition, we want to have a Meta class so that the Django code
58
+ # will work properly.
59
+ attrs = {
60
+ "__module__": seq,
61
+ "Meta": Meta,
62
+ }
63
+
64
+ # Create the model definition
65
+ model = type(seq, (BaseTicketing,), attrs)
66
+
67
+ # Save the definition in a variable that we can use later. This will
68
+ # also let Django find this model with some python dict traversal
69
+ # goodness.
70
+ ticketing_models[seq] = model
71
+
72
+
73
+ def get_ticket(sequence=conf.DEFAULT_SEQUENCE):
74
+ """
75
+ Get a ticket for the specified sequence.
76
+
77
+ `sequence` defaults to the configuration parameter DEFAULT_SEQUENCE
78
+
79
+ """
80
+ if sequence not in ticketing_models:
81
+ msg = (
82
+ f"Sequence '{sequence}' is not one of the configured sequences "
83
+ f"{conf.SEQUENCES}"
84
+ )
85
+ from ticketing.exceptions import BadSequenceName
86
+
87
+ raise BadSequenceName(msg)
88
+ ticket = ticketing_models[sequence].objects.create()
89
+ return ticket.ticket
File without changes
@@ -0,0 +1,28 @@
1
+ import os
2
+
3
+ SECRET_KEY = "django-ticketing-tests"
4
+ DATABASES = {
5
+ "default": {
6
+ "ENGINE": "django.db.backends.sqlite3",
7
+ "NAME": ":memory:",
8
+ }
9
+ }
10
+ if os.environ.get("TICKETING_TEST_MYSQL"):
11
+ DATABASES["default"] = {
12
+ "ENGINE": "django.db.backends.mysql",
13
+ "NAME": os.environ.get("TICKETING_TEST_DB_NAME", "ticketing"),
14
+ "USER": os.environ.get("TICKETING_TEST_DB_USER", "root"),
15
+ "PASSWORD": os.environ.get("TICKETING_TEST_DB_PASSWORD", ""),
16
+ "HOST": os.environ.get("TICKETING_TEST_DB_HOST", "127.0.0.1"),
17
+ "PORT": os.environ.get("TICKETING_TEST_DB_PORT", "3306"),
18
+ "OPTIONS": {"charset": "utf8mb4"},
19
+ }
20
+ mysql_version = os.environ.get("TOX_ENV_NAME", "").partition("-mysql-")[2]
21
+ if mysql_version:
22
+ port_variable = f"TICKETING_TEST_MYSQL_{mysql_version.replace('.', '_')}_PORT"
23
+ DATABASES["default"]["PORT"] = os.environ.get(
24
+ port_variable, DATABASES["default"]["PORT"]
25
+ )
26
+ INSTALLED_APPS = ["ticketing"]
27
+ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
28
+ TICKETING_TESTING = True
@@ -0,0 +1,88 @@
1
+ import pytest
2
+ from django.db import connection, models
3
+ from django.db.migrations.state import ModelState
4
+ from django.test.utils import isolate_apps
5
+
6
+ from ticketing import conf
7
+ from ticketing.fields import BigAutoField, TicketField
8
+ from ticketing.models import get_ticket
9
+
10
+
11
+ @pytest.fixture
12
+ def ticketed_model(django_db_reset_sequences):
13
+ with isolate_apps():
14
+
15
+ class Ticketed(models.Model):
16
+ ticket = TicketField(sequence="test")
17
+
18
+ class Meta:
19
+ app_label = "ticketing"
20
+
21
+ with connection.schema_editor() as editor:
22
+ editor.create_model(Ticketed)
23
+ try:
24
+ yield Ticketed
25
+ finally:
26
+ with connection.schema_editor() as editor:
27
+ editor.delete_model(Ticketed)
28
+
29
+
30
+ def test_ticket_field_generates_from_requested_sequence(ticketed_model):
31
+ instance = ticketed_model.objects.create()
32
+
33
+ instance.refresh_from_db()
34
+ assert instance.ticket > 0
35
+ assert get_ticket("test") == instance.ticket + 1
36
+ assert get_ticket(conf.DEFAULT_SEQUENCE) == 1
37
+
38
+
39
+ def test_ticket_field_preserves_explicit_value(ticketed_model):
40
+ instance = ticketed_model.objects.create(ticket=123)
41
+
42
+ instance.refresh_from_db()
43
+ assert instance.ticket == 123
44
+ assert get_ticket("test") == 1
45
+
46
+
47
+ def test_ticket_field_preserves_value_when_saved_again(ticketed_model):
48
+ instance = ticketed_model.objects.create()
49
+ ticket = instance.ticket
50
+
51
+ instance.save()
52
+ instance.refresh_from_db()
53
+
54
+ assert instance.ticket == ticket
55
+ assert get_ticket("test") == ticket + 1
56
+
57
+
58
+ @pytest.mark.parametrize("sequence", conf.SEQUENCES)
59
+ def test_migration_state_preserves_ticket_field_sequence(sequence):
60
+ with isolate_apps():
61
+
62
+ class Ticketed(models.Model):
63
+ ticket = TicketField(sequence=sequence)
64
+
65
+ class Meta:
66
+ app_label = "ticketing"
67
+
68
+ state = ModelState.from_model(Ticketed)
69
+
70
+ assert state.fields["ticket"].sequence == sequence
71
+ assert state.fields["ticket"].editable is False
72
+
73
+
74
+ @pytest.mark.django_db(transaction=True, reset_sequences=True)
75
+ def test_big_auto_field_exceeds_32_bits():
76
+ from ticketing.models import ticketing_models
77
+
78
+ model = ticketing_models[conf.DEFAULT_SEQUENCE]
79
+ assert isinstance(model._meta.pk, BigAutoField)
80
+ large_id = 2**32
81
+ with connection.cursor() as cursor:
82
+ cursor.execute(
83
+ f"INSERT INTO {connection.ops.quote_name(model._meta.db_table)} "
84
+ "(id, stub) VALUES (%s, %s)",
85
+ [large_id, model.STUB_DEFAULT],
86
+ )
87
+
88
+ assert get_ticket() > large_id
@@ -0,0 +1,102 @@
1
+ import os
2
+ from unittest.mock import Mock
3
+
4
+ import pytest
5
+ from django.db import IntegrityError, OperationalError, connection, transaction
6
+
7
+ from ticketing import conf
8
+ from ticketing.models import get_ticket, ticketing_models
9
+
10
+
11
+ @pytest.fixture
12
+ def manager():
13
+ return ticketing_models[conf.DEFAULT_SEQUENCE].objects
14
+
15
+
16
+ @pytest.mark.django_db
17
+ def test_retries_once_after_operational_error(manager, monkeypatch):
18
+ allocate = Mock(side_effect=[OperationalError("deadlock"), 42])
19
+ monkeypatch.setattr(manager, "_internal_get_ticket", allocate)
20
+
21
+ assert manager.get_ticket() == 42
22
+ assert allocate.call_count == 2
23
+
24
+
25
+ @pytest.mark.django_db
26
+ def test_propagates_repeated_operational_error(manager, monkeypatch):
27
+ allocate = Mock(side_effect=OperationalError("deadlock"))
28
+ monkeypatch.setattr(manager, "_internal_get_ticket", allocate)
29
+
30
+ with pytest.raises(OperationalError, match="deadlock"):
31
+ manager.get_ticket()
32
+ assert allocate.call_count == 2
33
+
34
+
35
+ @pytest.mark.django_db
36
+ def test_does_not_retry_unrelated_error(manager, monkeypatch):
37
+ allocate = Mock(side_effect=IntegrityError("constraint violation"))
38
+ monkeypatch.setattr(manager, "_internal_get_ticket", allocate)
39
+
40
+ with pytest.raises(IntegrityError, match="constraint violation"):
41
+ manager.get_ticket()
42
+ assert allocate.call_count == 1
43
+
44
+
45
+ @pytest.mark.django_db(transaction=True, reset_sequences=True)
46
+ def test_retry_after_failed_sql_preserves_outer_transaction(manager):
47
+ attempts = 0
48
+
49
+ def fail_once(execute, sql, params, many, context):
50
+ nonlocal attempts
51
+ if sql.startswith("REPLACE INTO"):
52
+ attempts += 1
53
+ if attempts == 1:
54
+ raise OperationalError("deadlock")
55
+ return execute(sql, params, many, context)
56
+
57
+ with transaction.atomic(), connection.execute_wrapper(fail_once):
58
+ assert manager.get_ticket() > 0
59
+ assert get_ticket() > 0
60
+
61
+ assert attempts == 3
62
+ assert get_ticket() > 0
63
+
64
+
65
+ @pytest.mark.django_db(transaction=True, reset_sequences=True)
66
+ def test_retry_works_without_outer_transaction(manager):
67
+ attempts = 0
68
+
69
+ def fail_once(execute, sql, params, many, context):
70
+ nonlocal attempts
71
+ if sql.startswith("REPLACE INTO"):
72
+ attempts += 1
73
+ if attempts == 1:
74
+ raise OperationalError("deadlock")
75
+ return execute(sql, params, many, context)
76
+
77
+ with connection.execute_wrapper(fail_once):
78
+ assert manager.get_ticket() > 0
79
+
80
+ assert attempts == 2
81
+
82
+
83
+ @pytest.mark.django_db
84
+ def test_only_latest_ticket_row_is_retained():
85
+ tickets = [get_ticket() for _ in range(10)]
86
+ model = ticketing_models[conf.DEFAULT_SEQUENCE]
87
+ with connection.cursor() as cursor:
88
+ cursor.execute(
89
+ f"SELECT id FROM {connection.ops.quote_name(model._meta.db_table)}"
90
+ )
91
+ rows = cursor.fetchall()
92
+
93
+ assert list(rows) == [(tickets[-1],)]
94
+
95
+
96
+ @pytest.mark.django_db
97
+ @pytest.mark.skipif(connection.vendor != "mysql", reason="Requires a MySQL server")
98
+ def test_mysql_version_matches_tox_environment():
99
+ assert connection.mysql_version >= (8, 4)
100
+ requested = os.environ.get("TOX_ENV_NAME", "").partition("-mysql-")[2]
101
+ if requested:
102
+ assert connection.mysql_version[:2] == tuple(map(int, requested.split(".")))
@@ -0,0 +1,57 @@
1
+ import pytest
2
+
3
+ from ticketing import conf
4
+ from ticketing.exceptions import BadSequenceName
5
+ from ticketing.models import get_ticket, ticketing_models
6
+
7
+ pytestmark = pytest.mark.django_db(transaction=True, reset_sequences=True)
8
+
9
+
10
+ def test_get_ticket():
11
+ assert get_ticket() > 0
12
+
13
+
14
+ def test_get_tickets():
15
+ first = get_ticket()
16
+ second = get_ticket()
17
+
18
+ assert 0 < first < second
19
+
20
+
21
+ def test_get_n_tickets():
22
+ tickets = [get_ticket() for _ in range(200)]
23
+
24
+ assert tickets[0] > 0
25
+ assert all(first < second for first, second in zip(tickets, tickets[1:]))
26
+
27
+
28
+ @pytest.mark.parametrize("sequence", conf.SEQUENCES)
29
+ def test_get_from_sequence(sequence):
30
+ tickets = [get_ticket(sequence) for _ in range(10)]
31
+
32
+ assert tickets[0] > 0
33
+ assert all(first < second for first, second in zip(tickets, tickets[1:]))
34
+
35
+
36
+ def test_get_sequences_in_order():
37
+ assert len(conf.SEQUENCES) > 1
38
+ for expected in range(1, 10):
39
+ for sequence in conf.SEQUENCES:
40
+ assert get_ticket(sequence) == expected
41
+
42
+
43
+ def test_bad_sequence():
44
+ with pytest.raises(BadSequenceName, match="bad_sequence"):
45
+ get_ticket("bad_sequence")
46
+
47
+
48
+ def test_default_sequence():
49
+ assert get_ticket() == 1
50
+ assert get_ticket(conf.DEFAULT_SEQUENCE) == 2
51
+
52
+
53
+ def test_ticket_model_string():
54
+ ticket = ticketing_models[conf.DEFAULT_SEQUENCE].objects.create()
55
+
56
+ assert ticket.ticket == ticket.pk
57
+ assert str(ticket) == f"Ticket {ticket.pk}"
ticketing/views.py ADDED
File without changes