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.
- django_ticketing-0.8.0.dist-info/METADATA +201 -0
- django_ticketing-0.8.0.dist-info/RECORD +17 -0
- django_ticketing-0.8.0.dist-info/WHEEL +5 -0
- django_ticketing-0.8.0.dist-info/licenses/LICENSE +7 -0
- django_ticketing-0.8.0.dist-info/top_level.txt +1 -0
- ticketing/__init__.py +7 -0
- ticketing/conf.py +29 -0
- ticketing/exceptions.py +6 -0
- ticketing/fields.py +86 -0
- ticketing/managers.py +49 -0
- ticketing/models.py +89 -0
- ticketing/tests/__init__.py +0 -0
- ticketing/tests/settings.py +28 -0
- ticketing/tests/test_fields.py +88 -0
- ticketing/tests/test_managers.py +102 -0
- ticketing/tests/test_ticketing.py +57 -0
- ticketing/views.py +0 -0
|
@@ -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,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
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)
|
ticketing/exceptions.py
ADDED
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
|