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.
@@ -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
+ ]
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env python
2
+ import os
3
+ import sys
4
+
5
+ if __name__ == "__main__":
6
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_site.settings")
7
+ from django.core.management import execute_from_command_line
8
+
9
+ execute_from_command_line(sys.argv)
File without changes
@@ -0,0 +1,5 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class ShopConfig(AppConfig):
5
+ name = "shop"
@@ -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."]