django-namedid 0.1.0__tar.gz
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_namedid-0.1.0/LICENSE +22 -0
- django_namedid-0.1.0/PKG-INFO +109 -0
- django_namedid-0.1.0/README.md +72 -0
- django_namedid-0.1.0/pyproject.toml +81 -0
- django_namedid-0.1.0/setup.cfg +4 -0
- django_namedid-0.1.0/src/django_namedid.egg-info/PKG-INFO +109 -0
- django_namedid-0.1.0/src/django_namedid.egg-info/SOURCES.txt +11 -0
- django_namedid-0.1.0/src/django_namedid.egg-info/dependency_links.txt +1 -0
- django_namedid-0.1.0/src/django_namedid.egg-info/requires.txt +8 -0
- django_namedid-0.1.0/src/django_namedid.egg-info/top_level.txt +1 -0
- django_namedid-0.1.0/src/namedid/__init__.py +11 -0
- django_namedid-0.1.0/src/namedid/decorators.py +22 -0
- django_namedid-0.1.0/src/namedid/fields.py +88 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Octolo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-namedid
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Django field that combines multiple fields into a unique identifier with collision handling
|
|
5
|
+
Author-email: Octolo <dev@octolo.tech>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/octolo/django-namedid
|
|
8
|
+
Project-URL: Repository, https://github.com/octolo/django-namedid
|
|
9
|
+
Project-URL: Documentation, https://github.com/octolo/django-namedid#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/octolo/django-namedid/issues
|
|
11
|
+
Keywords: django,django-field,unique-identifier,composite-field,named-id,slug-generation,django-orm,python
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 3.2
|
|
16
|
+
Classifier: Framework :: Django :: 4.0
|
|
17
|
+
Classifier: Framework :: Django :: 4.1
|
|
18
|
+
Classifier: Framework :: Django :: 4.2
|
|
19
|
+
Classifier: Framework :: Django :: 5.0
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: Django>=3.2
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-django>=4.5; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
34
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
35
|
+
Requires-Dist: django-stubs>=5.0.0; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# django-namedid
|
|
39
|
+
|
|
40
|
+
Django field that automatically combines multiple fields into a unique identifier with collision handling.
|
|
41
|
+
|
|
42
|
+
## Purpose
|
|
43
|
+
|
|
44
|
+
Create unique identifiers by combining multiple model fields (e.g., name, id, date) with automatic collision resolution.
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install django-namedid
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
### Using NamedIDField directly
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from django.db import models
|
|
58
|
+
from namedid import NamedIDField
|
|
59
|
+
from datetime import date
|
|
60
|
+
|
|
61
|
+
class Product(models.Model):
|
|
62
|
+
name = models.CharField(max_length=100)
|
|
63
|
+
code = models.IntegerField()
|
|
64
|
+
created_date = models.DateField()
|
|
65
|
+
|
|
66
|
+
named_id = NamedIDField(
|
|
67
|
+
source_fields=["name", "code", "created_date"],
|
|
68
|
+
max_length=200,
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Using the decorator
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from django.db import models
|
|
76
|
+
from namedid import add_namedid
|
|
77
|
+
|
|
78
|
+
@add_namedid(named_id=["title", "id"], slug=["title", "category"])
|
|
79
|
+
class Article(models.Model):
|
|
80
|
+
title = models.CharField(max_length=200)
|
|
81
|
+
category = models.CharField(max_length=50)
|
|
82
|
+
content = models.TextField()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Features
|
|
86
|
+
|
|
87
|
+
- **Automatic generation**: Values are generated automatically before saving
|
|
88
|
+
- **Collision handling**: Automatically appends numeric suffix if value exists (e.g., `name-1-2023`, `name-1-2023-1`, `name-1-2023-2`)
|
|
89
|
+
- **Type formatting**: Automatically formats dates, numbers, booleans
|
|
90
|
+
- **Unique and required**: Fields are always unique and cannot be empty
|
|
91
|
+
- **Read-only**: Fields are automatically set to read-only
|
|
92
|
+
|
|
93
|
+
## Field Properties
|
|
94
|
+
|
|
95
|
+
- `unique=True`: Always unique
|
|
96
|
+
- `editable=False`: Always read-only
|
|
97
|
+
- `blank=False`: Cannot be empty
|
|
98
|
+
- `null=False`: Cannot be NULL
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
./service.py dev install-dev
|
|
104
|
+
./service.py dev test
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# django-namedid
|
|
2
|
+
|
|
3
|
+
Django field that automatically combines multiple fields into a unique identifier with collision handling.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
Create unique identifiers by combining multiple model fields (e.g., name, id, date) with automatic collision resolution.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install django-namedid
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
### Using NamedIDField directly
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from django.db import models
|
|
21
|
+
from namedid import NamedIDField
|
|
22
|
+
from datetime import date
|
|
23
|
+
|
|
24
|
+
class Product(models.Model):
|
|
25
|
+
name = models.CharField(max_length=100)
|
|
26
|
+
code = models.IntegerField()
|
|
27
|
+
created_date = models.DateField()
|
|
28
|
+
|
|
29
|
+
named_id = NamedIDField(
|
|
30
|
+
source_fields=["name", "code", "created_date"],
|
|
31
|
+
max_length=200,
|
|
32
|
+
)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Using the decorator
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from django.db import models
|
|
39
|
+
from namedid import add_namedid
|
|
40
|
+
|
|
41
|
+
@add_namedid(named_id=["title", "id"], slug=["title", "category"])
|
|
42
|
+
class Article(models.Model):
|
|
43
|
+
title = models.CharField(max_length=200)
|
|
44
|
+
category = models.CharField(max_length=50)
|
|
45
|
+
content = models.TextField()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- **Automatic generation**: Values are generated automatically before saving
|
|
51
|
+
- **Collision handling**: Automatically appends numeric suffix if value exists (e.g., `name-1-2023`, `name-1-2023-1`, `name-1-2023-2`)
|
|
52
|
+
- **Type formatting**: Automatically formats dates, numbers, booleans
|
|
53
|
+
- **Unique and required**: Fields are always unique and cannot be empty
|
|
54
|
+
- **Read-only**: Fields are automatically set to read-only
|
|
55
|
+
|
|
56
|
+
## Field Properties
|
|
57
|
+
|
|
58
|
+
- `unique=True`: Always unique
|
|
59
|
+
- `editable=False`: Always read-only
|
|
60
|
+
- `blank=False`: Cannot be empty
|
|
61
|
+
- `null=False`: Cannot be NULL
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
./service.py dev install-dev
|
|
67
|
+
./service.py dev test
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-namedid"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Django field that combines multiple fields into a unique identifier with collision handling"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "Octolo", email = "dev@octolo.tech" }]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
keywords = [
|
|
15
|
+
"django",
|
|
16
|
+
"django-field",
|
|
17
|
+
"unique-identifier",
|
|
18
|
+
"composite-field",
|
|
19
|
+
"named-id",
|
|
20
|
+
"slug-generation",
|
|
21
|
+
"django-orm",
|
|
22
|
+
"python",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"Framework :: Django",
|
|
28
|
+
"Framework :: Django :: 3.2",
|
|
29
|
+
"Framework :: Django :: 4.0",
|
|
30
|
+
"Framework :: Django :: 4.1",
|
|
31
|
+
"Framework :: Django :: 4.2",
|
|
32
|
+
"Framework :: Django :: 5.0",
|
|
33
|
+
"Programming Language :: Python :: 3",
|
|
34
|
+
"Programming Language :: Python :: 3.10",
|
|
35
|
+
"Programming Language :: Python :: 3.11",
|
|
36
|
+
"Programming Language :: Python :: 3.12",
|
|
37
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
38
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
39
|
+
]
|
|
40
|
+
dependencies = [
|
|
41
|
+
"Django>=3.2",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/octolo/django-namedid"
|
|
46
|
+
Repository = "https://github.com/octolo/django-namedid"
|
|
47
|
+
Documentation = "https://github.com/octolo/django-namedid#readme"
|
|
48
|
+
Issues = "https://github.com/octolo/django-namedid/issues"
|
|
49
|
+
|
|
50
|
+
[project.optional-dependencies]
|
|
51
|
+
dev = [
|
|
52
|
+
"pytest>=8.3",
|
|
53
|
+
"pytest-django>=4.5",
|
|
54
|
+
"ruff>=0.5",
|
|
55
|
+
"mypy>=1.0",
|
|
56
|
+
"django-stubs>=5.0.0",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.packages.find]
|
|
60
|
+
where = ["src"]
|
|
61
|
+
include = ["namedid*"]
|
|
62
|
+
exclude = ["tests*"]
|
|
63
|
+
|
|
64
|
+
[tool.pytest.ini_options]
|
|
65
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
pythonpath = ["src"]
|
|
68
|
+
|
|
69
|
+
[tool.mypy]
|
|
70
|
+
python_version = "3.10"
|
|
71
|
+
warn_return_any = true
|
|
72
|
+
plugins = ["mypy_django_plugin.main"]
|
|
73
|
+
mypy_path = "src"
|
|
74
|
+
|
|
75
|
+
[tool.django-stubs]
|
|
76
|
+
django_settings_module = "tests.settings"
|
|
77
|
+
|
|
78
|
+
[tool.ruff]
|
|
79
|
+
line-length = 100
|
|
80
|
+
target-version = "py310"
|
|
81
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-namedid
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Django field that combines multiple fields into a unique identifier with collision handling
|
|
5
|
+
Author-email: Octolo <dev@octolo.tech>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/octolo/django-namedid
|
|
8
|
+
Project-URL: Repository, https://github.com/octolo/django-namedid
|
|
9
|
+
Project-URL: Documentation, https://github.com/octolo/django-namedid#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/octolo/django-namedid/issues
|
|
11
|
+
Keywords: django,django-field,unique-identifier,composite-field,named-id,slug-generation,django-orm,python
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 3.2
|
|
16
|
+
Classifier: Framework :: Django :: 4.0
|
|
17
|
+
Classifier: Framework :: Django :: 4.1
|
|
18
|
+
Classifier: Framework :: Django :: 4.2
|
|
19
|
+
Classifier: Framework :: Django :: 5.0
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: Django>=3.2
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-django>=4.5; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
34
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
35
|
+
Requires-Dist: django-stubs>=5.0.0; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# django-namedid
|
|
39
|
+
|
|
40
|
+
Django field that automatically combines multiple fields into a unique identifier with collision handling.
|
|
41
|
+
|
|
42
|
+
## Purpose
|
|
43
|
+
|
|
44
|
+
Create unique identifiers by combining multiple model fields (e.g., name, id, date) with automatic collision resolution.
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install django-namedid
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
### Using NamedIDField directly
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from django.db import models
|
|
58
|
+
from namedid import NamedIDField
|
|
59
|
+
from datetime import date
|
|
60
|
+
|
|
61
|
+
class Product(models.Model):
|
|
62
|
+
name = models.CharField(max_length=100)
|
|
63
|
+
code = models.IntegerField()
|
|
64
|
+
created_date = models.DateField()
|
|
65
|
+
|
|
66
|
+
named_id = NamedIDField(
|
|
67
|
+
source_fields=["name", "code", "created_date"],
|
|
68
|
+
max_length=200,
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Using the decorator
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from django.db import models
|
|
76
|
+
from namedid import add_namedid
|
|
77
|
+
|
|
78
|
+
@add_namedid(named_id=["title", "id"], slug=["title", "category"])
|
|
79
|
+
class Article(models.Model):
|
|
80
|
+
title = models.CharField(max_length=200)
|
|
81
|
+
category = models.CharField(max_length=50)
|
|
82
|
+
content = models.TextField()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Features
|
|
86
|
+
|
|
87
|
+
- **Automatic generation**: Values are generated automatically before saving
|
|
88
|
+
- **Collision handling**: Automatically appends numeric suffix if value exists (e.g., `name-1-2023`, `name-1-2023-1`, `name-1-2023-2`)
|
|
89
|
+
- **Type formatting**: Automatically formats dates, numbers, booleans
|
|
90
|
+
- **Unique and required**: Fields are always unique and cannot be empty
|
|
91
|
+
- **Read-only**: Fields are automatically set to read-only
|
|
92
|
+
|
|
93
|
+
## Field Properties
|
|
94
|
+
|
|
95
|
+
- `unique=True`: Always unique
|
|
96
|
+
- `editable=False`: Always read-only
|
|
97
|
+
- `blank=False`: Cannot be empty
|
|
98
|
+
- `null=False`: Cannot be NULL
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
./service.py dev install-dev
|
|
104
|
+
./service.py dev test
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/django_namedid.egg-info/PKG-INFO
|
|
5
|
+
src/django_namedid.egg-info/SOURCES.txt
|
|
6
|
+
src/django_namedid.egg-info/dependency_links.txt
|
|
7
|
+
src/django_namedid.egg-info/requires.txt
|
|
8
|
+
src/django_namedid.egg-info/top_level.txt
|
|
9
|
+
src/namedid/__init__.py
|
|
10
|
+
src/namedid/decorators.py
|
|
11
|
+
src/namedid/fields.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
namedid
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, TypeVar
|
|
4
|
+
|
|
5
|
+
from .fields import NamedIDField
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_namedid(**fields_config: list[str]) -> Callable[[type[T]], type[T]]:
|
|
11
|
+
"""Decorator to automatically add NamedIDField fields to a Django model."""
|
|
12
|
+
|
|
13
|
+
def decorator(cls: type[T]) -> type[T]:
|
|
14
|
+
for field_name, source_fields in fields_config.items():
|
|
15
|
+
field = NamedIDField(
|
|
16
|
+
source_fields=source_fields,
|
|
17
|
+
max_length=255,
|
|
18
|
+
)
|
|
19
|
+
cls.add_to_class(field_name, field) # type: ignore[attr-defined]
|
|
20
|
+
return cls
|
|
21
|
+
|
|
22
|
+
return decorator
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.db import models
|
|
8
|
+
from django.utils.translation import gettext_lazy as _
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NamedIDField(models.CharField):
|
|
12
|
+
"""Field that combines multiple fields into a unique identifier with collision handling."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
source_fields: list[str],
|
|
17
|
+
separator: str = "-",
|
|
18
|
+
*args: Any,
|
|
19
|
+
**kwargs: Any,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.source_fields = source_fields
|
|
22
|
+
self.separator = separator
|
|
23
|
+
kwargs["editable"] = False
|
|
24
|
+
kwargs.setdefault("blank", False)
|
|
25
|
+
kwargs.setdefault("null", False)
|
|
26
|
+
kwargs.setdefault("unique", True)
|
|
27
|
+
super().__init__(*args, **kwargs)
|
|
28
|
+
|
|
29
|
+
def pre_save(self, model_instance: Any, add: bool) -> str:
|
|
30
|
+
base_value = self._generate_base_value(model_instance)
|
|
31
|
+
if not base_value:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
_("Cannot generate named_id: all source fields %(fields)s are None or empty")
|
|
34
|
+
% {"fields": self.source_fields}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
field_name = self.attname
|
|
38
|
+
model_class = model_instance.__class__
|
|
39
|
+
|
|
40
|
+
query = model_class.objects.filter(**{field_name: base_value})
|
|
41
|
+
if not add and hasattr(model_instance, "pk") and model_instance.pk:
|
|
42
|
+
query = query.exclude(pk=model_instance.pk)
|
|
43
|
+
|
|
44
|
+
if not query.exists():
|
|
45
|
+
return base_value
|
|
46
|
+
|
|
47
|
+
counter = 1
|
|
48
|
+
while True:
|
|
49
|
+
candidate = f"{base_value}{self.separator}{counter}"
|
|
50
|
+
query = model_class.objects.filter(**{field_name: candidate})
|
|
51
|
+
if not add and hasattr(model_instance, "pk") and model_instance.pk:
|
|
52
|
+
query = query.exclude(pk=model_instance.pk)
|
|
53
|
+
if not query.exists():
|
|
54
|
+
return candidate
|
|
55
|
+
counter += 1
|
|
56
|
+
|
|
57
|
+
def _generate_base_value(self, model_instance: Any) -> str:
|
|
58
|
+
values = []
|
|
59
|
+
for field_name in self.source_fields:
|
|
60
|
+
value = getattr(model_instance, field_name, None)
|
|
61
|
+
if value is not None:
|
|
62
|
+
formatted_value = self._format_value(value)
|
|
63
|
+
values.append(str(formatted_value))
|
|
64
|
+
return self.separator.join(values)
|
|
65
|
+
|
|
66
|
+
def _format_value(self, value: Any) -> str:
|
|
67
|
+
if isinstance(value, (date, datetime)):
|
|
68
|
+
return value.strftime("%Y%m%d")
|
|
69
|
+
if isinstance(value, (int, float)):
|
|
70
|
+
return str(value)
|
|
71
|
+
if isinstance(value, bool):
|
|
72
|
+
return "1" if value else "0"
|
|
73
|
+
string_value = str(value)
|
|
74
|
+
string_value = string_value.lower()
|
|
75
|
+
string_value = string_value.replace(" ", self.separator)
|
|
76
|
+
string_value = re.sub(r'[^\w\-]', '', string_value)
|
|
77
|
+
string_value = re.sub(rf'{re.escape(self.separator)}+', self.separator, string_value)
|
|
78
|
+
string_value = string_value.strip(self.separator)
|
|
79
|
+
return string_value
|
|
80
|
+
|
|
81
|
+
def deconstruct(self):
|
|
82
|
+
name, path, args, kwargs = super().deconstruct()
|
|
83
|
+
kwargs['source_fields'] = self.source_fields
|
|
84
|
+
if self.separator != "-": # Only include if non-default
|
|
85
|
+
kwargs['separator'] = self.separator
|
|
86
|
+
# Remove editable since it's always False
|
|
87
|
+
kwargs.pop('editable', None)
|
|
88
|
+
return name, path, args, kwargs
|