django-naija 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_naija-0.1.0/.gitignore +42 -0
- django_naija-0.1.0/LICENSE +21 -0
- django_naija-0.1.0/PKG-INFO +114 -0
- django_naija-0.1.0/README.md +86 -0
- django_naija-0.1.0/example/example_site/__init__.py +0 -0
- django_naija-0.1.0/example/example_site/settings.py +24 -0
- django_naija-0.1.0/example/example_site/urls.py +9 -0
- django_naija-0.1.0/example/manage.py +9 -0
- django_naija-0.1.0/example/shop/__init__.py +0 -0
- django_naija-0.1.0/example/shop/apps.py +5 -0
- django_naija-0.1.0/example/shop/forms.py +13 -0
- django_naija-0.1.0/example/shop/migrations/0001_initial.py +28 -0
- django_naija-0.1.0/example/shop/migrations/__init__.py +0 -0
- django_naija-0.1.0/example/shop/models.py +16 -0
- django_naija-0.1.0/example/shop/serializers.py +19 -0
- django_naija-0.1.0/example/shop/views.py +30 -0
- django_naija-0.1.0/pyproject.toml +43 -0
- django_naija-0.1.0/src/django_naija/__init__.py +10 -0
- django_naija-0.1.0/src/django_naija/choices.py +33 -0
- django_naija-0.1.0/src/django_naija/fields.py +149 -0
- django_naija-0.1.0/src/django_naija/forms.py +135 -0
- django_naija-0.1.0/src/django_naija/messages.py +35 -0
- django_naija-0.1.0/src/django_naija/rest_framework.py +140 -0
- django_naija-0.1.0/src/django_naija/validators.py +104 -0
- django_naija-0.1.0/tests/test_django_naija.py +161 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Secrets — never commit
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
.dev.vars
|
|
6
|
+
*.pem
|
|
7
|
+
|
|
8
|
+
# Python
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.py[cod]
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
*.egg-info/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
.ruff_cache/
|
|
17
|
+
dist/
|
|
18
|
+
/build/
|
|
19
|
+
packages/*/build/
|
|
20
|
+
|
|
21
|
+
# Node
|
|
22
|
+
node_modules/
|
|
23
|
+
.npm/
|
|
24
|
+
*.tsbuildinfo
|
|
25
|
+
.wrangler/
|
|
26
|
+
.astro/
|
|
27
|
+
|
|
28
|
+
# Go
|
|
29
|
+
go/coverage.out
|
|
30
|
+
|
|
31
|
+
# Local scratch
|
|
32
|
+
.cache/
|
|
33
|
+
raw/
|
|
34
|
+
*.log
|
|
35
|
+
.DS_Store
|
|
36
|
+
Thumbs.db
|
|
37
|
+
|
|
38
|
+
# Local databases (Django example project)
|
|
39
|
+
*.sqlite3
|
|
40
|
+
|
|
41
|
+
# Local editor/agent settings
|
|
42
|
+
.claude/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Akanji Oluwaseun Gabriel
|
|
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.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-naija
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Django and DRF fields for Nigerian phone numbers, NUBAN, plate numbers, states and LGAs.
|
|
5
|
+
Project-URL: Homepage, https://github.com/asgatcreation/naija-data/tree/main/packages/django
|
|
6
|
+
Project-URL: Source, https://github.com/asgatcreation/naija-data
|
|
7
|
+
Project-URL: Issues, https://github.com/asgatcreation/naija-data/issues
|
|
8
|
+
Author: Akanji Oluwaseun Gabriel
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: django,drf,lga,nigeria,nuban,phone,states,validators
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
15
|
+
Classifier: Framework :: Django :: 6.0
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: django>=5.2
|
|
20
|
+
Requires-Dist: naija-data<2,>=0.1.0
|
|
21
|
+
Provides-Extra: rest
|
|
22
|
+
Requires-Dist: djangorestframework>=3.15; extra == 'rest'
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: djangorestframework>=3.15; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest-django==4.14.0; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest==8.4.2; extra == 'test'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# django-naija
|
|
30
|
+
|
|
31
|
+
Django and Django REST framework fields for Nigerian data, built on
|
|
32
|
+
[naija-data](https://github.com/asgatcreation/naija-data): phone numbers (strict NCC validation),
|
|
33
|
+
NUBAN account numbers (CBN check digit), vehicle plate numbers, states and LGAs.
|
|
34
|
+
|
|
35
|
+
Website and playground: https://naija-data.asgatcreation.workers.dev · REST API: https://naija-data-api.asgatcreation.workers.dev/v1
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install django-naija # Django fields, forms and validators
|
|
39
|
+
pip install "django-naija[rest]" # plus Django REST framework fields
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires Python 3.10+ and Django 5.2+.
|
|
43
|
+
|
|
44
|
+
## Models
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from django.db import models
|
|
48
|
+
from django_naija.fields import LGAField, NigerianPhoneField, NUBANField, PlateNumberField, StateField
|
|
49
|
+
|
|
50
|
+
class Customer(models.Model):
|
|
51
|
+
phone = NigerianPhoneField(types=["mobile"]) # stored as "+2348031234567"
|
|
52
|
+
state = StateField() # stored as "LA", 37 choices
|
|
53
|
+
lga = LGAField() # stored as "NG025011", 774 choices grouped by state
|
|
54
|
+
plate = PlateNumberField(blank=True) # stored as "KJA123AB"
|
|
55
|
+
account_number = NUBANField(blank=True) # stored as "0123456789"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Choices are loaded lazily (Django's callable choices), so migrations stay small.
|
|
59
|
+
|
|
60
|
+
## Forms
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from django import forms
|
|
64
|
+
from django_naija import forms as ngforms
|
|
65
|
+
|
|
66
|
+
class PayoutForm(ngforms.StateLGAFormMixin, ngforms.NUBANFormMixin, forms.Form):
|
|
67
|
+
phone = ngforms.NigerianPhoneField(types=["mobile"])
|
|
68
|
+
state = ngforms.StateField()
|
|
69
|
+
lga = ngforms.LGAField() # limited to the chosen state; must belong to it
|
|
70
|
+
bank = ngforms.BankField() # banks with a CBN code
|
|
71
|
+
account_number = ngforms.NUBANField() # checked against the chosen bank
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
For a dependent state → LGA menu, `django_naija.choices.lga_choices("LA")` returns the LGAs of one
|
|
75
|
+
state; the example project serves it as JSON at `/lgas/?state=Lagos`.
|
|
76
|
+
|
|
77
|
+
## Django REST framework
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from rest_framework import serializers
|
|
81
|
+
from django_naija import rest_framework as ngdrf
|
|
82
|
+
|
|
83
|
+
class PayoutSerializer(serializers.Serializer):
|
|
84
|
+
phone = ngdrf.NigerianPhoneField(types=["mobile"])
|
|
85
|
+
state = ngdrf.StateField() # also accepts "Lagos", "Abuja", "FCT"
|
|
86
|
+
lga = ngdrf.LGAField() # also accepts INEC codes and unambiguous names
|
|
87
|
+
bank = ngdrf.BankField() # also accepts "058" or "GTB"
|
|
88
|
+
account_number = ngdrf.NUBANField()
|
|
89
|
+
|
|
90
|
+
class Meta:
|
|
91
|
+
validators = [ngdrf.LGAInStateValidator(), ngdrf.NUBANBankValidator()]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Error messages
|
|
95
|
+
|
|
96
|
+
Every failure has a clear, translatable message and a machine-readable code, e.g.
|
|
97
|
+
`reserved` → "This number range is reserved and not in service.",
|
|
98
|
+
`check_digit` → "This account number is not valid for the selected bank.".
|
|
99
|
+
See `django_naija/messages.py`.
|
|
100
|
+
|
|
101
|
+
## Example project
|
|
102
|
+
|
|
103
|
+
`example/` is a small Django project using every field (model, form, DRF API, dependent LGA
|
|
104
|
+
endpoint). The tests in `tests/` run against it:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pip install -e "packages/python" -e "packages/django[test]"
|
|
108
|
+
cd packages/django && python -m pytest
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Plate numbers: only the format is checked. FRSC does not publish its list of LGA plate codes, so
|
|
112
|
+
plates are not mapped to LGAs yet.
|
|
113
|
+
|
|
114
|
+
Licence: MIT. Data from naija-data: CC BY 4.0, Akanji Oluwaseun Gabriel.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# django-naija
|
|
2
|
+
|
|
3
|
+
Django and Django REST framework fields for Nigerian data, built on
|
|
4
|
+
[naija-data](https://github.com/asgatcreation/naija-data): phone numbers (strict NCC validation),
|
|
5
|
+
NUBAN account numbers (CBN check digit), vehicle plate numbers, states and LGAs.
|
|
6
|
+
|
|
7
|
+
Website and playground: https://naija-data.asgatcreation.workers.dev · REST API: https://naija-data-api.asgatcreation.workers.dev/v1
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install django-naija # Django fields, forms and validators
|
|
11
|
+
pip install "django-naija[rest]" # plus Django REST framework fields
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Python 3.10+ and Django 5.2+.
|
|
15
|
+
|
|
16
|
+
## Models
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from django.db import models
|
|
20
|
+
from django_naija.fields import LGAField, NigerianPhoneField, NUBANField, PlateNumberField, StateField
|
|
21
|
+
|
|
22
|
+
class Customer(models.Model):
|
|
23
|
+
phone = NigerianPhoneField(types=["mobile"]) # stored as "+2348031234567"
|
|
24
|
+
state = StateField() # stored as "LA", 37 choices
|
|
25
|
+
lga = LGAField() # stored as "NG025011", 774 choices grouped by state
|
|
26
|
+
plate = PlateNumberField(blank=True) # stored as "KJA123AB"
|
|
27
|
+
account_number = NUBANField(blank=True) # stored as "0123456789"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Choices are loaded lazily (Django's callable choices), so migrations stay small.
|
|
31
|
+
|
|
32
|
+
## Forms
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from django import forms
|
|
36
|
+
from django_naija import forms as ngforms
|
|
37
|
+
|
|
38
|
+
class PayoutForm(ngforms.StateLGAFormMixin, ngforms.NUBANFormMixin, forms.Form):
|
|
39
|
+
phone = ngforms.NigerianPhoneField(types=["mobile"])
|
|
40
|
+
state = ngforms.StateField()
|
|
41
|
+
lga = ngforms.LGAField() # limited to the chosen state; must belong to it
|
|
42
|
+
bank = ngforms.BankField() # banks with a CBN code
|
|
43
|
+
account_number = ngforms.NUBANField() # checked against the chosen bank
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
For a dependent state → LGA menu, `django_naija.choices.lga_choices("LA")` returns the LGAs of one
|
|
47
|
+
state; the example project serves it as JSON at `/lgas/?state=Lagos`.
|
|
48
|
+
|
|
49
|
+
## Django REST framework
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from rest_framework import serializers
|
|
53
|
+
from django_naija import rest_framework as ngdrf
|
|
54
|
+
|
|
55
|
+
class PayoutSerializer(serializers.Serializer):
|
|
56
|
+
phone = ngdrf.NigerianPhoneField(types=["mobile"])
|
|
57
|
+
state = ngdrf.StateField() # also accepts "Lagos", "Abuja", "FCT"
|
|
58
|
+
lga = ngdrf.LGAField() # also accepts INEC codes and unambiguous names
|
|
59
|
+
bank = ngdrf.BankField() # also accepts "058" or "GTB"
|
|
60
|
+
account_number = ngdrf.NUBANField()
|
|
61
|
+
|
|
62
|
+
class Meta:
|
|
63
|
+
validators = [ngdrf.LGAInStateValidator(), ngdrf.NUBANBankValidator()]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Error messages
|
|
67
|
+
|
|
68
|
+
Every failure has a clear, translatable message and a machine-readable code, e.g.
|
|
69
|
+
`reserved` → "This number range is reserved and not in service.",
|
|
70
|
+
`check_digit` → "This account number is not valid for the selected bank.".
|
|
71
|
+
See `django_naija/messages.py`.
|
|
72
|
+
|
|
73
|
+
## Example project
|
|
74
|
+
|
|
75
|
+
`example/` is a small Django project using every field (model, form, DRF API, dependent LGA
|
|
76
|
+
endpoint). The tests in `tests/` run against it:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install -e "packages/python" -e "packages/django[test]"
|
|
80
|
+
cd packages/django && python -m pytest
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Plate numbers: only the format is checked. FRSC does not publish its list of LGA plate codes, so
|
|
84
|
+
plates are not mapped to LGAs yet.
|
|
85
|
+
|
|
86
|
+
Licence: MIT. Data from naija-data: CC BY 4.0, Akanji Oluwaseun Gabriel.
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Minimal settings for the django-naija example project (development only)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
6
|
+
|
|
7
|
+
SECRET_KEY = "example-only-not-secret" # noqa: S105 - example project, never deployed
|
|
8
|
+
DEBUG = True
|
|
9
|
+
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "testserver"]
|
|
10
|
+
|
|
11
|
+
INSTALLED_APPS = [
|
|
12
|
+
"django.contrib.contenttypes",
|
|
13
|
+
"django.contrib.auth",
|
|
14
|
+
"rest_framework",
|
|
15
|
+
"shop",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
MIDDLEWARE = ["django.middleware.common.CommonMiddleware"]
|
|
19
|
+
ROOT_URLCONF = "example_site.urls"
|
|
20
|
+
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}
|
|
21
|
+
TEMPLATES = [{"BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [], "APP_DIRS": True}]
|
|
22
|
+
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
23
|
+
USE_TZ = True
|
|
24
|
+
REST_FRAMEWORK = {"UNAUTHENTICATED_USER": None}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from django.urls import path
|
|
2
|
+
|
|
3
|
+
from shop import views
|
|
4
|
+
|
|
5
|
+
urlpatterns = [
|
|
6
|
+
path("signup/", views.signup, name="signup"),
|
|
7
|
+
path("lgas/", views.lgas_for_state, name="lgas"), # for a dependent state -> LGA <select>
|
|
8
|
+
path("api/customers/", views.CustomerCreate.as_view(), name="customer-create"),
|
|
9
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from django import forms
|
|
2
|
+
|
|
3
|
+
from django_naija import forms as ngforms
|
|
4
|
+
|
|
5
|
+
from .models import Customer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CustomerForm(ngforms.StateLGAFormMixin, ngforms.NUBANFormMixin, forms.ModelForm):
|
|
9
|
+
bank = ngforms.BankField(required=False)
|
|
10
|
+
|
|
11
|
+
class Meta:
|
|
12
|
+
model = Customer
|
|
13
|
+
fields = ["name", "phone", "state", "lga", "plate", "bank", "account_number"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Generated by Django 5.2.18 on 2026-10-15 16:52
|
|
2
|
+
|
|
3
|
+
import django_naija.fields
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
initial = True
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.CreateModel(
|
|
16
|
+
name='Customer',
|
|
17
|
+
fields=[
|
|
18
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
19
|
+
('name', models.CharField(max_length=100)),
|
|
20
|
+
('phone', django_naija.fields.NigerianPhoneField(types=['mobile'])),
|
|
21
|
+
('state', django_naija.fields.StateField()),
|
|
22
|
+
('lga', django_naija.fields.LGAField()),
|
|
23
|
+
('plate', django_naija.fields.PlateNumberField(blank=True)),
|
|
24
|
+
('bank', models.CharField(blank=True, max_length=60)),
|
|
25
|
+
('account_number', django_naija.fields.NUBANField(blank=True)),
|
|
26
|
+
],
|
|
27
|
+
),
|
|
28
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
from django_naija.fields import LGAField, NigerianPhoneField, NUBANField, PlateNumberField, StateField
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Customer(models.Model):
|
|
7
|
+
name = models.CharField(max_length=100)
|
|
8
|
+
phone = NigerianPhoneField(types=["mobile"]) # stored as "+2348031234567"
|
|
9
|
+
state = StateField() # stored as "LA"
|
|
10
|
+
lga = LGAField() # stored as "NG025011"
|
|
11
|
+
plate = PlateNumberField(blank=True) # stored as "KJA123AB"
|
|
12
|
+
bank = models.CharField(max_length=60, blank=True) # naija-data bank id, e.g. "gtbank"
|
|
13
|
+
account_number = NUBANField(blank=True) # stored as "0123456789"
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
return self.name
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from rest_framework import serializers
|
|
2
|
+
|
|
3
|
+
from django_naija import rest_framework as ngdrf
|
|
4
|
+
|
|
5
|
+
from .models import Customer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CustomerSerializer(serializers.ModelSerializer):
|
|
9
|
+
phone = ngdrf.NigerianPhoneField(types=["mobile"])
|
|
10
|
+
state = ngdrf.StateField()
|
|
11
|
+
lga = ngdrf.LGAField()
|
|
12
|
+
plate = ngdrf.PlateNumberField(required=False, allow_blank=True)
|
|
13
|
+
bank = ngdrf.BankField(required=False, allow_blank=True)
|
|
14
|
+
account_number = ngdrf.NUBANField(required=False, allow_blank=True)
|
|
15
|
+
|
|
16
|
+
class Meta:
|
|
17
|
+
model = Customer
|
|
18
|
+
fields = ["id", "name", "phone", "state", "lga", "plate", "bank", "account_number"]
|
|
19
|
+
validators = [ngdrf.LGAInStateValidator(), ngdrf.NUBANBankValidator()]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from django.http import HttpResponse, JsonResponse
|
|
2
|
+
from rest_framework import generics
|
|
3
|
+
|
|
4
|
+
import naija_data as ng
|
|
5
|
+
from django_naija.choices import lga_choices
|
|
6
|
+
|
|
7
|
+
from .forms import CustomerForm
|
|
8
|
+
from .serializers import CustomerSerializer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def signup(request):
|
|
12
|
+
form = CustomerForm(request.POST or None)
|
|
13
|
+
if request.method == "POST" and form.is_valid():
|
|
14
|
+
customer = form.save()
|
|
15
|
+
return HttpResponse(f"Saved {customer.name}", status=201)
|
|
16
|
+
return HttpResponse(form.as_div(), status=400 if request.method == "POST" else 200)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def lgas_for_state(request):
|
|
20
|
+
"""GET /lgas/?state=Lagos -> {"lgas": [["NG025001", "Agege"], ...]} for a dependent <select>."""
|
|
21
|
+
state = ng.get_state(request.GET.get("state", ""))
|
|
22
|
+
if state is None:
|
|
23
|
+
return JsonResponse({"error": "Unknown state"}, status=404)
|
|
24
|
+
return JsonResponse({"state": state.code, "lgas": lga_choices(state.code)})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CustomerCreate(generics.CreateAPIView):
|
|
28
|
+
serializer_class = CustomerSerializer
|
|
29
|
+
authentication_classes: list = []
|
|
30
|
+
permission_classes: list = []
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-naija"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Django and DRF fields for Nigerian phone numbers, NUBAN, plate numbers, states and LGAs."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Akanji Oluwaseun Gabriel" }]
|
|
14
|
+
keywords = ["django", "nigeria", "phone", "nuban", "lga", "states", "validators", "drf"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Framework :: Django",
|
|
18
|
+
"Framework :: Django :: 5.2",
|
|
19
|
+
"Framework :: Django :: 6.0",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["naija-data>=0.1.0,<2", "Django>=5.2"]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
rest = ["djangorestframework>=3.15"]
|
|
27
|
+
test = ["djangorestframework>=3.15", "pytest==8.4.2", "pytest-django==4.14.0"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/asgatcreation/naija-data/tree/main/packages/django"
|
|
31
|
+
Source = "https://github.com/asgatcreation/naija-data"
|
|
32
|
+
Issues = "https://github.com/asgatcreation/naija-data/issues"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/django_naija"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist]
|
|
38
|
+
include = ["src", "tests", "example", "README.md", "LICENSE"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
DJANGO_SETTINGS_MODULE = "example_site.settings"
|
|
42
|
+
pythonpath = ["example"]
|
|
43
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""django-naija: Django and Django REST framework fields for Nigerian data, built on naija-data.
|
|
2
|
+
|
|
3
|
+
- django_naija.fields model fields: NigerianPhoneField, NUBANField, PlateNumberField, StateField, LGAField
|
|
4
|
+
- django_naija.forms form fields and the StateLGAFormMixin / NUBANFormMixin helpers
|
|
5
|
+
- django_naija.rest_framework serializer fields and validators (needs djangorestframework)
|
|
6
|
+
- django_naija.validators reusable validators
|
|
7
|
+
- django_naija.choices state, LGA, ward and bank choice lists
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Choice lists for states, LGAs and banks. They are functions, so Django (5.0+) can use them as
|
|
2
|
+
callable choices and migrations stay small."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import List, Optional, Sequence, Tuple
|
|
7
|
+
|
|
8
|
+
import naija_data as ng
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def state_choices() -> List[Tuple[str, str]]:
|
|
12
|
+
"""[("AB", "Abia"), ..., ("FC", "Federal Capital Territory"), ...] stored as ISO 3166-2 codes."""
|
|
13
|
+
return [(s.code, s.name) for s in ng.states()]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def lga_choices(state: Optional[str] = None):
|
|
17
|
+
"""LGAs as (P-code, name) pairs. With `state`, only that state's LGAs; without it, grouped by
|
|
18
|
+
state for <optgroup> menus: [("Abia", [("NG001001", "Aba North"), ...]), ...]."""
|
|
19
|
+
if state:
|
|
20
|
+
return [(l.code, l.name) for l in ng.lgas(state)]
|
|
21
|
+
return [(s.name, [(l.code, l.name) for l in ng.lgas(s)]) for s in ng.states()]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ward_choices(lga: str, state: Optional[str] = None) -> List[Tuple[str, str]]:
|
|
25
|
+
"""Wards of an LGA as (INEC code, name) pairs."""
|
|
26
|
+
return [(w.code, w.name) for w in ng.wards(lga, state)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def bank_choices(types: Optional[Sequence[str]] = None, with_cbn_code_only: bool = False) -> List[Tuple[str, str]]:
|
|
30
|
+
"""Active banks as (id, name) pairs, short names first where available. Use
|
|
31
|
+
with_cbn_code_only=True for account-number forms, so every choice can be NUBAN-checked."""
|
|
32
|
+
rows = [b for b in ng.banks() if (types is None or b.type in types) and (b.cbn_code or not with_cbn_code_only)]
|
|
33
|
+
return sorted(((b.id, b.short_name or b.name) for b in rows), key=lambda c: c[1].lower())
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Model fields.
|
|
2
|
+
|
|
3
|
+
from django_naija.fields import LGAField, NigerianPhoneField, NUBANField, PlateNumberField, StateField
|
|
4
|
+
|
|
5
|
+
class Customer(models.Model):
|
|
6
|
+
phone = NigerianPhoneField(types=["mobile"]) # stored as "+2348031234567"
|
|
7
|
+
state = StateField() # stored as "LA"
|
|
8
|
+
lga = LGAField() # stored as "NG025011"
|
|
9
|
+
plate = PlateNumberField(blank=True) # stored as "KJA123AB"
|
|
10
|
+
account_number = NUBANField(blank=True) # stored as "0123456789"
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Optional, Sequence
|
|
16
|
+
|
|
17
|
+
import naija_data as ng
|
|
18
|
+
from django.db import models
|
|
19
|
+
|
|
20
|
+
from . import choices, forms
|
|
21
|
+
from .validators import NUBANValidator, PhoneValidator, PlateValidator, clean_nuban, validate_lga, validate_state
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NigerianPhoneField(models.CharField):
|
|
25
|
+
"""Stores numbers in E.164 form ("+2348031234567"); accepts any common input format."""
|
|
26
|
+
|
|
27
|
+
description = "Nigerian phone number"
|
|
28
|
+
|
|
29
|
+
def __init__(self, *args, types: Optional[Sequence[str]] = None, **kwargs):
|
|
30
|
+
self.types = tuple(types) if types else None
|
|
31
|
+
kwargs.setdefault("max_length", 16)
|
|
32
|
+
super().__init__(*args, **kwargs)
|
|
33
|
+
self.validators.append(PhoneValidator(self.types))
|
|
34
|
+
|
|
35
|
+
def deconstruct(self):
|
|
36
|
+
name, path, args, kwargs = super().deconstruct()
|
|
37
|
+
if self.types:
|
|
38
|
+
kwargs["types"] = list(self.types)
|
|
39
|
+
if kwargs.get("max_length") == 16:
|
|
40
|
+
del kwargs["max_length"]
|
|
41
|
+
return name, path, args, kwargs
|
|
42
|
+
|
|
43
|
+
def to_python(self, value):
|
|
44
|
+
value = super().to_python(value)
|
|
45
|
+
return (ng.normalize_phone(value) or value) if value else value
|
|
46
|
+
|
|
47
|
+
def get_prep_value(self, value):
|
|
48
|
+
return self.to_python(super().get_prep_value(value))
|
|
49
|
+
|
|
50
|
+
def formfield(self, **kwargs):
|
|
51
|
+
# Input like "+234 (0) 803 123 4567" is longer than the stored form, so don't reuse max_length.
|
|
52
|
+
return super().formfield(**{"form_class": forms.NigerianPhoneField, "types": self.types, "max_length": 32, **kwargs})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NUBANField(models.CharField):
|
|
56
|
+
"""Stores a 10-digit account number without spaces."""
|
|
57
|
+
|
|
58
|
+
description = "NUBAN account number"
|
|
59
|
+
|
|
60
|
+
def __init__(self, *args, **kwargs):
|
|
61
|
+
kwargs.setdefault("max_length", 10)
|
|
62
|
+
super().__init__(*args, **kwargs)
|
|
63
|
+
self.validators.append(NUBANValidator())
|
|
64
|
+
|
|
65
|
+
def deconstruct(self):
|
|
66
|
+
name, path, args, kwargs = super().deconstruct()
|
|
67
|
+
if kwargs.get("max_length") == 10:
|
|
68
|
+
del kwargs["max_length"]
|
|
69
|
+
return name, path, args, kwargs
|
|
70
|
+
|
|
71
|
+
def to_python(self, value):
|
|
72
|
+
value = super().to_python(value)
|
|
73
|
+
return clean_nuban(value) if value else value
|
|
74
|
+
|
|
75
|
+
def get_prep_value(self, value):
|
|
76
|
+
return self.to_python(super().get_prep_value(value))
|
|
77
|
+
|
|
78
|
+
def formfield(self, **kwargs):
|
|
79
|
+
return super().formfield(**{"form_class": forms.NUBANField, "max_length": 13, **kwargs})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PlateNumberField(models.CharField):
|
|
83
|
+
"""Stores plate numbers in compact form ("KJA123AB"); use parse_plate(value).display to show them."""
|
|
84
|
+
|
|
85
|
+
description = "Vehicle plate number"
|
|
86
|
+
|
|
87
|
+
def __init__(self, *args, formats: Optional[Sequence[str]] = None, **kwargs):
|
|
88
|
+
self.formats = tuple(formats) if formats else None
|
|
89
|
+
kwargs.setdefault("max_length", 10)
|
|
90
|
+
super().__init__(*args, **kwargs)
|
|
91
|
+
self.validators.append(PlateValidator(self.formats))
|
|
92
|
+
|
|
93
|
+
def deconstruct(self):
|
|
94
|
+
name, path, args, kwargs = super().deconstruct()
|
|
95
|
+
if self.formats:
|
|
96
|
+
kwargs["formats"] = list(self.formats)
|
|
97
|
+
if kwargs.get("max_length") == 10:
|
|
98
|
+
del kwargs["max_length"]
|
|
99
|
+
return name, path, args, kwargs
|
|
100
|
+
|
|
101
|
+
def to_python(self, value):
|
|
102
|
+
value = super().to_python(value)
|
|
103
|
+
parsed = ng.parse_plate(value) if value else None
|
|
104
|
+
return parsed.compact if parsed else value
|
|
105
|
+
|
|
106
|
+
def get_prep_value(self, value):
|
|
107
|
+
return self.to_python(super().get_prep_value(value))
|
|
108
|
+
|
|
109
|
+
def formfield(self, **kwargs):
|
|
110
|
+
return super().formfield(**{"form_class": forms.PlateNumberField, "formats": self.formats, "max_length": 16,
|
|
111
|
+
**kwargs})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class StateField(models.CharField):
|
|
115
|
+
"""Stores a state's ISO 3166-2 code ("LA"), with the 37 states as choices."""
|
|
116
|
+
|
|
117
|
+
description = "Nigerian state"
|
|
118
|
+
|
|
119
|
+
def __init__(self, *args, **kwargs):
|
|
120
|
+
kwargs.setdefault("max_length", 2)
|
|
121
|
+
kwargs.setdefault("choices", choices.state_choices)
|
|
122
|
+
super().__init__(*args, **kwargs)
|
|
123
|
+
self.validators.append(validate_state)
|
|
124
|
+
|
|
125
|
+
def deconstruct(self):
|
|
126
|
+
name, path, args, kwargs = super().deconstruct()
|
|
127
|
+
kwargs.pop("choices", None)
|
|
128
|
+
if kwargs.get("max_length") == 2:
|
|
129
|
+
del kwargs["max_length"]
|
|
130
|
+
return name, path, args, kwargs
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class LGAField(models.CharField):
|
|
134
|
+
"""Stores an LGA's P-code ("NG025011"), with all 774 LGAs (grouped by state) as choices."""
|
|
135
|
+
|
|
136
|
+
description = "Nigerian local government area"
|
|
137
|
+
|
|
138
|
+
def __init__(self, *args, **kwargs):
|
|
139
|
+
kwargs.setdefault("max_length", 8)
|
|
140
|
+
kwargs.setdefault("choices", choices.lga_choices)
|
|
141
|
+
super().__init__(*args, **kwargs)
|
|
142
|
+
self.validators.append(validate_lga)
|
|
143
|
+
|
|
144
|
+
def deconstruct(self):
|
|
145
|
+
name, path, args, kwargs = super().deconstruct()
|
|
146
|
+
kwargs.pop("choices", None)
|
|
147
|
+
if kwargs.get("max_length") == 8:
|
|
148
|
+
del kwargs["max_length"]
|
|
149
|
+
return name, path, args, kwargs
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Form fields and mixins.
|
|
2
|
+
|
|
3
|
+
from django_naija import forms as ngforms
|
|
4
|
+
|
|
5
|
+
class SignupForm(ngforms.StateLGAFormMixin, ngforms.NUBANFormMixin, forms.Form):
|
|
6
|
+
phone = ngforms.NigerianPhoneField(types=["mobile"])
|
|
7
|
+
state = ngforms.StateField()
|
|
8
|
+
lga = ngforms.LGAField()
|
|
9
|
+
bank = ngforms.BankField()
|
|
10
|
+
account_number = ngforms.NUBANField()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Optional, Sequence
|
|
16
|
+
|
|
17
|
+
import naija_data as ng
|
|
18
|
+
from django import forms
|
|
19
|
+
from django.core.exceptions import ValidationError
|
|
20
|
+
|
|
21
|
+
from . import choices
|
|
22
|
+
from .validators import (NUBANValidator, PhoneValidator, PlateValidator, clean_nuban, validate_lga_in_state,
|
|
23
|
+
validate_nuban_for_bank)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NigerianPhoneField(forms.CharField):
|
|
27
|
+
"""Cleans to E.164, e.g. "+2348031234567". `types` restricts accepted number types."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, *, types: Optional[Sequence[str]] = None, **kwargs):
|
|
30
|
+
kwargs.setdefault("max_length", 32)
|
|
31
|
+
super().__init__(**kwargs)
|
|
32
|
+
self.validators.append(PhoneValidator(types))
|
|
33
|
+
self.widget.attrs.setdefault("inputmode", "tel")
|
|
34
|
+
self.widget.attrs.setdefault("autocomplete", "tel")
|
|
35
|
+
|
|
36
|
+
def clean(self, value):
|
|
37
|
+
value = super().clean(value)
|
|
38
|
+
return ng.normalize_phone(value) if value else value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NUBANField(forms.CharField):
|
|
42
|
+
"""A 10-digit account number; cleans to the digits only."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, *, bank: Optional[str] = None, **kwargs):
|
|
45
|
+
kwargs.setdefault("max_length", 13)
|
|
46
|
+
super().__init__(**kwargs)
|
|
47
|
+
self.validators.append(NUBANValidator(bank))
|
|
48
|
+
self.widget.attrs.setdefault("inputmode", "numeric")
|
|
49
|
+
|
|
50
|
+
def clean(self, value):
|
|
51
|
+
value = super().clean(value)
|
|
52
|
+
return clean_nuban(value) if value else value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PlateNumberField(forms.CharField):
|
|
56
|
+
"""A vehicle plate number; cleans to the display form, e.g. "KJA-123AB"."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, *, formats: Optional[Sequence[str]] = None, **kwargs):
|
|
59
|
+
kwargs.setdefault("max_length", 16)
|
|
60
|
+
super().__init__(**kwargs)
|
|
61
|
+
self.validators.append(PlateValidator(formats))
|
|
62
|
+
|
|
63
|
+
def clean(self, value):
|
|
64
|
+
value = super().clean(value)
|
|
65
|
+
return ng.parse_plate(value).display if value else value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class StateField(forms.ChoiceField):
|
|
69
|
+
"""Choose a state; the value is its ISO 3166-2 code ("LA")."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, **kwargs):
|
|
72
|
+
kwargs.setdefault("choices", choices.state_choices)
|
|
73
|
+
super().__init__(**kwargs)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LGAField(forms.ChoiceField):
|
|
77
|
+
"""Choose an LGA; the value is its P-code ("NG025011"). Use with StateLGAFormMixin to limit the
|
|
78
|
+
choices to the selected state."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, *, state: Optional[str] = None, **kwargs):
|
|
81
|
+
kwargs.setdefault("choices", (lambda: choices.lga_choices(state)) if state else choices.lga_choices)
|
|
82
|
+
super().__init__(**kwargs)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BankField(forms.ChoiceField):
|
|
86
|
+
"""Choose a bank; the value is its naija-data id ("gtbank")."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, *, types: Optional[Sequence[str]] = None, with_cbn_code_only: bool = True, **kwargs):
|
|
89
|
+
kwargs.setdefault("choices", lambda: choices.bank_choices(types, with_cbn_code_only))
|
|
90
|
+
super().__init__(**kwargs)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class StateLGAFormMixin:
|
|
94
|
+
"""Limits the LGA field's choices to the submitted (or initial) state and checks that the LGA
|
|
95
|
+
belongs to it. Set `state_field` / `lga_field` if your fields have other names."""
|
|
96
|
+
|
|
97
|
+
state_field = "state"
|
|
98
|
+
lga_field = "lga"
|
|
99
|
+
|
|
100
|
+
def __init__(self, *args, **kwargs):
|
|
101
|
+
super().__init__(*args, **kwargs)
|
|
102
|
+
state = None
|
|
103
|
+
if self.is_bound:
|
|
104
|
+
state = self.data.get(self.add_prefix(self.state_field))
|
|
105
|
+
state = state or self.initial.get(self.state_field) or self.fields[self.state_field].initial
|
|
106
|
+
if state and ng.get_state(str(state)):
|
|
107
|
+
self.fields[self.lga_field].choices = choices.lga_choices(state)
|
|
108
|
+
|
|
109
|
+
def clean(self):
|
|
110
|
+
data = super().clean()
|
|
111
|
+
state, lga = data.get(self.state_field), data.get(self.lga_field)
|
|
112
|
+
if state and lga:
|
|
113
|
+
try:
|
|
114
|
+
validate_lga_in_state(lga, state)
|
|
115
|
+
except ValidationError as e:
|
|
116
|
+
self.add_error(self.lga_field, e)
|
|
117
|
+
return data
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class NUBANFormMixin:
|
|
121
|
+
"""Checks that the account number is valid for the selected bank (CBN check digit).
|
|
122
|
+
Set `nuban_field` / `bank_field` if your fields have other names."""
|
|
123
|
+
|
|
124
|
+
nuban_field = "account_number"
|
|
125
|
+
bank_field = "bank"
|
|
126
|
+
|
|
127
|
+
def clean(self):
|
|
128
|
+
data = super().clean()
|
|
129
|
+
account, bank = data.get(self.nuban_field), data.get(self.bank_field)
|
|
130
|
+
if account and bank:
|
|
131
|
+
try:
|
|
132
|
+
validate_nuban_for_bank(account, bank)
|
|
133
|
+
except ValidationError as e:
|
|
134
|
+
self.add_error(self.nuban_field if e.code == "check_digit" else self.bank_field, e)
|
|
135
|
+
return data
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Human-readable, translatable error messages for each error code from naija-data."""
|
|
2
|
+
|
|
3
|
+
from django.utils.translation import gettext_lazy as _
|
|
4
|
+
|
|
5
|
+
PHONE_MESSAGES = {
|
|
6
|
+
"empty": _("Enter a phone number."),
|
|
7
|
+
"invalid_characters": _("A phone number can only contain digits, spaces, dashes, brackets and a leading +."),
|
|
8
|
+
"not_nigerian": _("Enter a Nigerian phone number (+234)."),
|
|
9
|
+
"bad_length": _("This phone number has the wrong number of digits."),
|
|
10
|
+
"unknown_prefix": _("This is not a Nigerian mobile prefix."),
|
|
11
|
+
"unallocated": _("This number range is not allocated to any network."),
|
|
12
|
+
"reserved": _("This number range is reserved and not in service."),
|
|
13
|
+
"unknown_area_code": _("This is not a Nigerian landline area code."),
|
|
14
|
+
"wrong_type": _("This type of phone number is not accepted here."),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
PLATE_MESSAGES = {
|
|
18
|
+
"empty": _("Enter a plate number."),
|
|
19
|
+
"invalid_characters": _("A plate number can only contain letters, digits, spaces and dashes."),
|
|
20
|
+
"unknown_format": _("Enter a plate number like ABC-123DE."),
|
|
21
|
+
"unknown_state": _("This dealer plate does not start with D and a valid state code."),
|
|
22
|
+
"wrong_format": _("This type of plate number is not accepted here."),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
NUBAN_MESSAGES = {
|
|
26
|
+
"format": _("Enter a 10-digit account number."),
|
|
27
|
+
"check_digit": _("This account number is not valid for the selected bank."),
|
|
28
|
+
"unknown_bank": _("Select a valid bank."),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
ADMIN_MESSAGES = {
|
|
32
|
+
"unknown_state": _("Select a valid state."),
|
|
33
|
+
"unknown_lga": _("Select a valid local government area."),
|
|
34
|
+
"lga_not_in_state": _("This local government area is not in the selected state."),
|
|
35
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Django REST framework serializer fields and validators.
|
|
2
|
+
|
|
3
|
+
from django_naija import rest_framework as ngdrf
|
|
4
|
+
|
|
5
|
+
class PayoutSerializer(serializers.Serializer):
|
|
6
|
+
phone = ngdrf.NigerianPhoneField(types=["mobile"])
|
|
7
|
+
state = ngdrf.StateField()
|
|
8
|
+
lga = ngdrf.LGAField()
|
|
9
|
+
bank = ngdrf.BankField()
|
|
10
|
+
account_number = ngdrf.NUBANField()
|
|
11
|
+
|
|
12
|
+
class Meta:
|
|
13
|
+
validators = [ngdrf.NUBANBankValidator(), ngdrf.LGAInStateValidator()]
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Optional, Sequence
|
|
19
|
+
|
|
20
|
+
import naija_data as ng
|
|
21
|
+
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
22
|
+
from rest_framework import serializers
|
|
23
|
+
|
|
24
|
+
from . import choices
|
|
25
|
+
from .validators import (NUBANValidator, PhoneValidator, PlateValidator, clean_nuban, validate_lga_in_state,
|
|
26
|
+
validate_nuban_for_bank)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _run(validator, value):
|
|
30
|
+
try:
|
|
31
|
+
validator(value)
|
|
32
|
+
except DjangoValidationError as e:
|
|
33
|
+
raise serializers.ValidationError(e.messages, code=e.code)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NigerianPhoneField(serializers.CharField):
|
|
37
|
+
"""Accepts any common format; returns E.164 ("+2348031234567")."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, *, types: Optional[Sequence[str]] = None, **kwargs):
|
|
40
|
+
self.phone_validator = PhoneValidator(types)
|
|
41
|
+
super().__init__(**kwargs)
|
|
42
|
+
|
|
43
|
+
def to_internal_value(self, data):
|
|
44
|
+
value = super().to_internal_value(data)
|
|
45
|
+
_run(self.phone_validator, value)
|
|
46
|
+
return ng.normalize_phone(value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NUBANField(serializers.CharField):
|
|
50
|
+
"""Returns the 10 digits of an account number."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, *, bank: Optional[str] = None, **kwargs):
|
|
53
|
+
self.nuban_validator = NUBANValidator(bank)
|
|
54
|
+
super().__init__(**kwargs)
|
|
55
|
+
|
|
56
|
+
def to_internal_value(self, data):
|
|
57
|
+
value = super().to_internal_value(data)
|
|
58
|
+
_run(self.nuban_validator, value)
|
|
59
|
+
return clean_nuban(value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PlateNumberField(serializers.CharField):
|
|
63
|
+
"""Returns the plate's display form ("KJA-123AB")."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, *, formats: Optional[Sequence[str]] = None, **kwargs):
|
|
66
|
+
self.plate_validator = PlateValidator(formats)
|
|
67
|
+
super().__init__(**kwargs)
|
|
68
|
+
|
|
69
|
+
def to_internal_value(self, data):
|
|
70
|
+
value = super().to_internal_value(data)
|
|
71
|
+
_run(self.plate_validator, value)
|
|
72
|
+
return ng.parse_plate(value).display
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class StateField(serializers.ChoiceField):
|
|
76
|
+
"""A state's ISO 3166-2 code ("LA"). Also accepts names and aliases ("Lagos", "Abuja")."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, **kwargs):
|
|
79
|
+
super().__init__(choices=choices.state_choices(), **kwargs)
|
|
80
|
+
|
|
81
|
+
def to_internal_value(self, data):
|
|
82
|
+
found = ng.get_state(str(data)) if data not in ("", None) else None
|
|
83
|
+
return super().to_internal_value(found.code if found else data)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class LGAField(serializers.ChoiceField):
|
|
87
|
+
"""An LGA's P-code ("NG025011"). Also accepts INEC codes ("24-11") and unambiguous names."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, **kwargs):
|
|
90
|
+
super().__init__(choices=[c for _, group in choices.lga_choices() for c in group], **kwargs)
|
|
91
|
+
|
|
92
|
+
def to_internal_value(self, data):
|
|
93
|
+
found = ng.get_lga(str(data)) if data not in ("", None) else None
|
|
94
|
+
return super().to_internal_value(found.code if found else data)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class BankField(serializers.ChoiceField):
|
|
98
|
+
"""A bank's naija-data id ("gtbank"). Also accepts CBN codes ("058") and names ("GTB")."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, *, types: Optional[Sequence[str]] = None, with_cbn_code_only: bool = True, **kwargs):
|
|
101
|
+
super().__init__(choices=choices.bank_choices(types, with_cbn_code_only), **kwargs)
|
|
102
|
+
|
|
103
|
+
def to_internal_value(self, data):
|
|
104
|
+
found = ng.get_bank(str(data)) if data not in ("", None) else None
|
|
105
|
+
return super().to_internal_value(found.id if found else data)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class NUBANBankValidator:
|
|
109
|
+
"""Serializer-level validator: the account number must be valid for the chosen bank."""
|
|
110
|
+
|
|
111
|
+
requires_context = False
|
|
112
|
+
|
|
113
|
+
def __init__(self, nuban_field: str = "account_number", bank_field: str = "bank"):
|
|
114
|
+
self.nuban_field, self.bank_field = nuban_field, bank_field
|
|
115
|
+
|
|
116
|
+
def __call__(self, attrs):
|
|
117
|
+
account, bank = attrs.get(self.nuban_field), attrs.get(self.bank_field)
|
|
118
|
+
if account and bank:
|
|
119
|
+
try:
|
|
120
|
+
validate_nuban_for_bank(account, bank)
|
|
121
|
+
except DjangoValidationError as e:
|
|
122
|
+
field = self.nuban_field if e.code == "check_digit" else self.bank_field
|
|
123
|
+
raise serializers.ValidationError({field: e.messages}, code=e.code)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class LGAInStateValidator:
|
|
127
|
+
"""Serializer-level validator: the LGA must be in the chosen state."""
|
|
128
|
+
|
|
129
|
+
requires_context = False
|
|
130
|
+
|
|
131
|
+
def __init__(self, state_field: str = "state", lga_field: str = "lga"):
|
|
132
|
+
self.state_field, self.lga_field = state_field, lga_field
|
|
133
|
+
|
|
134
|
+
def __call__(self, attrs):
|
|
135
|
+
state, lga = attrs.get(self.state_field), attrs.get(self.lga_field)
|
|
136
|
+
if state and lga:
|
|
137
|
+
try:
|
|
138
|
+
validate_lga_in_state(lga, state)
|
|
139
|
+
except DjangoValidationError as e:
|
|
140
|
+
raise serializers.ValidationError({self.lga_field: e.messages}, code=e.code)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Validators for model fields, form fields and serializers. All are deconstructible, so they can be
|
|
2
|
+
used in migrations."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from typing import Optional, Sequence
|
|
8
|
+
|
|
9
|
+
import naija_data as ng
|
|
10
|
+
from django.core.exceptions import ValidationError
|
|
11
|
+
from django.utils.deconstruct import deconstructible
|
|
12
|
+
|
|
13
|
+
from .messages import ADMIN_MESSAGES, NUBAN_MESSAGES, PHONE_MESSAGES, PLATE_MESSAGES
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@deconstructible
|
|
17
|
+
class PhoneValidator:
|
|
18
|
+
"""A valid Nigerian phone number, optionally only of some types (e.g. ("mobile",))."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, types: Optional[Sequence[str]] = None):
|
|
21
|
+
self.types = tuple(types) if types else None
|
|
22
|
+
|
|
23
|
+
def __call__(self, value) -> None:
|
|
24
|
+
error = ng.phone_error(str(value))
|
|
25
|
+
if error:
|
|
26
|
+
raise ValidationError(PHONE_MESSAGES[error], code=error)
|
|
27
|
+
if self.types and ng.parse_phone(str(value)).type not in self.types:
|
|
28
|
+
raise ValidationError(PHONE_MESSAGES["wrong_type"], code="wrong_type")
|
|
29
|
+
|
|
30
|
+
def __eq__(self, other) -> bool:
|
|
31
|
+
return isinstance(other, PhoneValidator) and self.types == other.types
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@deconstructible
|
|
35
|
+
class PlateValidator:
|
|
36
|
+
"""A valid vehicle plate number, optionally only of some formats (e.g. ("standard",))."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, formats: Optional[Sequence[str]] = None):
|
|
39
|
+
self.formats = tuple(formats) if formats else None
|
|
40
|
+
|
|
41
|
+
def __call__(self, value) -> None:
|
|
42
|
+
error = ng.plate_error(str(value))
|
|
43
|
+
if error:
|
|
44
|
+
raise ValidationError(PLATE_MESSAGES[error], code=error)
|
|
45
|
+
if self.formats and ng.parse_plate(str(value)).format not in self.formats:
|
|
46
|
+
raise ValidationError(PLATE_MESSAGES["wrong_format"], code="wrong_format")
|
|
47
|
+
|
|
48
|
+
def __eq__(self, other) -> bool:
|
|
49
|
+
return isinstance(other, PlateValidator) and self.formats == other.formats
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def clean_nuban(value) -> str:
|
|
53
|
+
"""The 10 digits of an account number, without spaces or dashes."""
|
|
54
|
+
return re.sub(r"[\s-]", "", str(value))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@deconstructible
|
|
58
|
+
class NUBANValidator:
|
|
59
|
+
"""A 10-digit NUBAN account number. With `bank` (CBN code, id or name) the CBN check digit is
|
|
60
|
+
checked too; to check against a bank chosen in the same form, use NUBANFormMixin."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, bank: Optional[str] = None):
|
|
63
|
+
self.bank = bank
|
|
64
|
+
|
|
65
|
+
def __call__(self, value) -> None:
|
|
66
|
+
if not re.fullmatch(r"\d{10}", clean_nuban(value)):
|
|
67
|
+
raise ValidationError(NUBAN_MESSAGES["format"], code="format")
|
|
68
|
+
if self.bank and not ng.is_valid_nuban(clean_nuban(value), self.bank):
|
|
69
|
+
raise ValidationError(NUBAN_MESSAGES["check_digit"], code="check_digit")
|
|
70
|
+
|
|
71
|
+
def __eq__(self, other) -> bool:
|
|
72
|
+
return isinstance(other, NUBANValidator) and self.bank == other.bank
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def validate_nuban_for_bank(account, bank) -> None:
|
|
76
|
+
"""Raise ValidationError unless `account` has a valid check digit for `bank`."""
|
|
77
|
+
found = ng.get_bank(str(bank)) if bank else None
|
|
78
|
+
if found is None or not found.cbn_code:
|
|
79
|
+
raise ValidationError(NUBAN_MESSAGES["unknown_bank"], code="unknown_bank")
|
|
80
|
+
NUBANValidator()(account)
|
|
81
|
+
if not ng.is_valid_nuban(clean_nuban(account), found.cbn_code):
|
|
82
|
+
raise ValidationError(NUBAN_MESSAGES["check_digit"], code="check_digit")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_state(value) -> None:
|
|
86
|
+
if ng.get_state(str(value)) is None:
|
|
87
|
+
raise ValidationError(ADMIN_MESSAGES["unknown_state"], code="unknown_state")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_lga(value) -> None:
|
|
91
|
+
if ng.get_lga(str(value)) is None:
|
|
92
|
+
raise ValidationError(ADMIN_MESSAGES["unknown_lga"], code="unknown_lga")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_lga_in_state(lga, state) -> None:
|
|
96
|
+
"""Raise ValidationError unless the LGA (code or name) is in the state."""
|
|
97
|
+
st = ng.get_state(str(state)) if state else None
|
|
98
|
+
if st is None:
|
|
99
|
+
raise ValidationError(ADMIN_MESSAGES["unknown_state"], code="unknown_state")
|
|
100
|
+
found = ng.get_lga(str(lga), st)
|
|
101
|
+
if found is None:
|
|
102
|
+
raise ValidationError(ADMIN_MESSAGES["unknown_lga"], code="unknown_lga")
|
|
103
|
+
if found.state_code != st.code:
|
|
104
|
+
raise ValidationError(ADMIN_MESSAGES["lga_not_in_state"], code="lga_not_in_state")
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.core.exceptions import ValidationError
|
|
3
|
+
from django.core.management import call_command
|
|
4
|
+
from rest_framework.test import APIClient
|
|
5
|
+
|
|
6
|
+
from django_naija import forms as ngforms
|
|
7
|
+
from django_naija.choices import bank_choices, lga_choices, state_choices
|
|
8
|
+
from django_naija.validators import NUBANValidator, PhoneValidator, PlateValidator, validate_nuban_for_bank
|
|
9
|
+
from shop.forms import CustomerForm
|
|
10
|
+
from shop.models import Customer
|
|
11
|
+
|
|
12
|
+
VALID = {"name": "Ada", "phone": "0803 123 4567", "state": "LA", "lga": "NG025011", "plate": "kja 123 ab",
|
|
13
|
+
"bank": "first-bank", "account_number": "000 001 4579"} # CBN example: bank 011, serial 000001457
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---- validators
|
|
17
|
+
|
|
18
|
+
@pytest.mark.parametrize("value, code", [("0900 123 4567", "reserved"), ("0910 123 4567", "unknown_prefix"),
|
|
19
|
+
("+1 202 555 0100", "not_nigerian"), ("01 269 1531", "wrong_type")])
|
|
20
|
+
def test_phone_validator_codes(value, code):
|
|
21
|
+
with pytest.raises(ValidationError) as e:
|
|
22
|
+
PhoneValidator(["mobile"])(value)
|
|
23
|
+
assert e.value.code == code
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_validators_accept_valid_values():
|
|
27
|
+
PhoneValidator()("01 269 1531")
|
|
28
|
+
PlateValidator()("DKN 135 3")
|
|
29
|
+
NUBANValidator(bank="011")("0000014579")
|
|
30
|
+
validate_nuban_for_bank("0000014579", "First Bank")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_nuban_validators_reject():
|
|
34
|
+
with pytest.raises(ValidationError) as e:
|
|
35
|
+
NUBANValidator()("12345")
|
|
36
|
+
assert e.value.code == "format"
|
|
37
|
+
with pytest.raises(ValidationError) as e:
|
|
38
|
+
validate_nuban_for_bank("0000014578", "011")
|
|
39
|
+
assert e.value.code == "check_digit"
|
|
40
|
+
with pytest.raises(ValidationError) as e:
|
|
41
|
+
validate_nuban_for_bank("0000014579", "Atlantis Bank")
|
|
42
|
+
assert e.value.code == "unknown_bank"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_validators_are_deconstructible():
|
|
46
|
+
path, args, kwargs = PhoneValidator(["mobile"]).deconstruct()
|
|
47
|
+
assert path == "django_naija.validators.PhoneValidator"
|
|
48
|
+
assert (args, kwargs) == ((["mobile"],), {})
|
|
49
|
+
assert PhoneValidator(["mobile"]) == PhoneValidator(("mobile",))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---- choices
|
|
53
|
+
|
|
54
|
+
def test_choices():
|
|
55
|
+
assert len(state_choices()) == 37
|
|
56
|
+
assert ("LA", "Lagos") in state_choices()
|
|
57
|
+
assert len(lga_choices("LA")) == 20
|
|
58
|
+
assert sum(len(group) for _, group in lga_choices()) == 774
|
|
59
|
+
assert ("gtbank", "GTBank") in bank_choices(with_cbn_code_only=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---- forms
|
|
63
|
+
|
|
64
|
+
def test_form_cleans_and_normalises():
|
|
65
|
+
form = CustomerForm(VALID)
|
|
66
|
+
assert form.is_valid(), form.errors
|
|
67
|
+
assert form.cleaned_data["phone"] == "+2348031234567"
|
|
68
|
+
assert form.cleaned_data["plate"] == "KJA-123AB"
|
|
69
|
+
assert form.cleaned_data["account_number"] == "0000014579"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_form_limits_lgas_to_state():
|
|
73
|
+
form = CustomerForm({**VALID, "lga": "NG031033"}) # Surulere, Oyo: not in Lagos
|
|
74
|
+
assert not form.is_valid()
|
|
75
|
+
assert "lga" in form.errors
|
|
76
|
+
assert len(form.fields["lga"].choices) == 20
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_form_checks_account_against_bank():
|
|
80
|
+
form = CustomerForm({**VALID, "account_number": "0000014578"})
|
|
81
|
+
assert not form.is_valid()
|
|
82
|
+
assert "not valid for the selected bank" in form.errors["account_number"][0]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_form_error_messages_are_clear():
|
|
86
|
+
form = CustomerForm({**VALID, "phone": "0900 123 4567"})
|
|
87
|
+
assert not form.is_valid()
|
|
88
|
+
assert form.errors["phone"] == ["This number range is reserved and not in service."]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_standalone_form_fields():
|
|
92
|
+
assert ngforms.NigerianPhoneField().clean("+234 (0) 803 123 4567") == "+2348031234567"
|
|
93
|
+
assert ngforms.PlateNumberField(formats=["dealer"]).clean("dla5481") == "DLA 548 1"
|
|
94
|
+
with pytest.raises(ValidationError):
|
|
95
|
+
ngforms.PlateNumberField(formats=["dealer"]).clean("KJA-123AB")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---- models (database)
|
|
99
|
+
|
|
100
|
+
@pytest.mark.django_db
|
|
101
|
+
def test_model_stores_canonical_values():
|
|
102
|
+
c = Customer(name="Ada", phone="0803 123 4567", state="LA", lga="NG025011", plate="kja 123 ab",
|
|
103
|
+
account_number="000 001 4579")
|
|
104
|
+
c.full_clean()
|
|
105
|
+
c.save()
|
|
106
|
+
row = Customer.objects.values("phone", "plate", "account_number").get(pk=c.pk)
|
|
107
|
+
assert row == {"phone": "+2348031234567", "plate": "KJA123AB", "account_number": "0000014579"}
|
|
108
|
+
assert Customer.objects.filter(phone="08031234567").exists() # lookups are normalised too
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.django_db
|
|
112
|
+
def test_model_full_clean_rejects_bad_values():
|
|
113
|
+
c = Customer(name="Bad", phone="01 269 1531", state="ZZ", lga="NG999999")
|
|
114
|
+
with pytest.raises(ValidationError) as e:
|
|
115
|
+
c.full_clean()
|
|
116
|
+
assert set(e.value.message_dict) >= {"phone", "state", "lga"}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.django_db
|
|
120
|
+
def test_migrations_are_up_to_date():
|
|
121
|
+
call_command("makemigrations", "shop", "--check", "--dry-run")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---- views and DRF
|
|
125
|
+
|
|
126
|
+
@pytest.mark.django_db
|
|
127
|
+
def test_dependent_lga_endpoint(client):
|
|
128
|
+
data = client.get("/lgas/", {"state": "Lagos"}).json()
|
|
129
|
+
assert data["state"] == "LA" and len(data["lgas"]) == 20
|
|
130
|
+
assert client.get("/lgas/", {"state": "Atlantis"}).status_code == 404
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.django_db
|
|
134
|
+
def test_signup_view(client):
|
|
135
|
+
assert client.post("/signup/", VALID).status_code == 201
|
|
136
|
+
assert Customer.objects.get().phone == "+2348031234567"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.django_db
|
|
140
|
+
def test_drf_accepts_names_and_normalises():
|
|
141
|
+
api = APIClient()
|
|
142
|
+
payload = {"name": "Ada", "phone": "+234 803 123 4567", "state": "Lagos", "lga": "24-11", "plate": "kja123ab",
|
|
143
|
+
"bank": "First Bank", "account_number": "0000014579"}
|
|
144
|
+
r = api.post("/api/customers/", payload, format="json")
|
|
145
|
+
assert r.status_code == 201, r.json()
|
|
146
|
+
body = r.json()
|
|
147
|
+
assert (body["phone"], body["state"], body["lga"], body["bank"]) == ("+2348031234567", "LA", "NG025011", "first-bank")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@pytest.mark.django_db
|
|
151
|
+
def test_drf_errors_point_at_the_right_field():
|
|
152
|
+
api = APIClient()
|
|
153
|
+
bad_account = {"name": "Ada", "phone": "08031234567", "state": "LA", "lga": "NG025011", "bank": "011",
|
|
154
|
+
"account_number": "0000014578"}
|
|
155
|
+
r = api.post("/api/customers/", bad_account, format="json")
|
|
156
|
+
assert r.status_code == 400 and "account_number" in r.json()
|
|
157
|
+
wrong_state = {**bad_account, "account_number": "0000014579", "lga": "NG031033"}
|
|
158
|
+
r = api.post("/api/customers/", wrong_state, format="json")
|
|
159
|
+
assert r.status_code == 400 and "lga" in r.json()
|
|
160
|
+
r = api.post("/api/customers/", {**wrong_state, "lga": "NG025011", "phone": "0900 123 4567"}, format="json")
|
|
161
|
+
assert r.json()["phone"] == ["This number range is reserved and not in service."]
|