nkscoder-courier 1.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
courier/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Django zone-based courier shipping calculator by Nitesh Kumar Singh (nkscoder)."""
2
+
3
+ __version__ = "1.1.1"
4
+ __author__ = "Nitesh Kumar Singh"
5
+ __author_handle__ = "nkscoder"
6
+
7
+ __all__ = ["__version__", "calculate_courier_cost", "courier_cost", "CourierCalculationError"]
8
+
9
+
10
+ def __getattr__(name):
11
+ if name in {"calculate_courier_cost", "courier_cost", "CourierCalculationError"}:
12
+ from .calculator import (
13
+ CourierCalculationError,
14
+ calculate_courier_cost,
15
+ courier_cost,
16
+ )
17
+
18
+ return locals()[name]
19
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
courier/admin.py ADDED
@@ -0,0 +1,44 @@
1
+ from django.contrib import admin
2
+
3
+ from .models import CourierCountry, CourierRegion, WeightCost, Zone, ZoneConnection
4
+
5
+ try:
6
+ from import_export.admin import ImportExportModelAdmin
7
+
8
+ AdminBase = ImportExportModelAdmin
9
+ except ImportError:
10
+ AdminBase = admin.ModelAdmin
11
+
12
+
13
+ @admin.register(CourierCountry)
14
+ class CourierCountryAdmin(AdminBase):
15
+ list_display = ("name", "code", "is_domestic")
16
+ list_filter = ("is_domestic",)
17
+ search_fields = ("name", "code")
18
+
19
+
20
+ @admin.register(CourierRegion)
21
+ class CourierRegionAdmin(AdminBase):
22
+ list_display = ("name", "country", "code")
23
+ list_filter = ("country",)
24
+ search_fields = ("name", "code", "country__name")
25
+
26
+
27
+ @admin.register(Zone)
28
+ class ZoneAdmin(AdminBase):
29
+ list_display = ("name",)
30
+ search_fields = ("name",)
31
+
32
+
33
+ @admin.register(ZoneConnection)
34
+ class ZoneConnectionAdmin(AdminBase):
35
+ list_display = ("zone",)
36
+ filter_horizontal = ("countries", "regions")
37
+ search_fields = ("zone__name", "countries__name", "regions__name")
38
+
39
+
40
+ @admin.register(WeightCost)
41
+ class WeightCostAdmin(AdminBase):
42
+ list_display = ("zone", "weight", "amount")
43
+ list_filter = ("zone",)
44
+ search_fields = ("zone__name",)
courier/apps.py ADDED
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class CourierConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "courier"
7
+ verbose_name = "Courier Shipping"
courier/calculator.py ADDED
@@ -0,0 +1,136 @@
1
+ """Generic courier pricing logic — usable from views, checkout, or Celery tasks."""
2
+
3
+ from decimal import Decimal, ROUND_HALF_UP
4
+
5
+ from django.db.models import Q
6
+
7
+ from .conf import get_country_model, get_region_model, get_setting
8
+ from .models import WeightCost, ZoneConnection
9
+
10
+ GRAMS_PER_LB = Decimal("453.592")
11
+ GRAMS_PER_KG = Decimal("1000")
12
+
13
+
14
+ class CourierCalculationError(Exception):
15
+ """Raised when shipping cost cannot be calculated."""
16
+
17
+
18
+ def normalize_weight(weight, weight_unit):
19
+ unit = (weight_unit or get_setting("DEFAULT_WEIGHT_UNIT")).lower()
20
+ supported = get_setting("SUPPORTED_WEIGHT_UNITS")
21
+ if unit not in supported:
22
+ raise CourierCalculationError(
23
+ f"Unknown weight unit {weight_unit!r}. Supported: {', '.join(supported)}"
24
+ )
25
+
26
+ value = Decimal(str(weight))
27
+ if unit == "lbs":
28
+ return value * GRAMS_PER_LB
29
+ if unit == "kgs":
30
+ return value * GRAMS_PER_KG
31
+ return value
32
+
33
+
34
+ def is_domestic_country(country):
35
+ if hasattr(country, "is_domestic"):
36
+ return bool(country.is_domestic)
37
+ return country.name.lower() == get_setting("DOMESTIC_COUNTRY_NAME").lower()
38
+
39
+
40
+ def resolve_zone_connection(country_id, region_id=None):
41
+ custom = get_setting("ZONE_RESOLVER")
42
+ if custom:
43
+ return custom(country_id, region_id)
44
+
45
+ country_model = get_country_model()
46
+ country = country_model.objects.get(pk=country_id)
47
+ domestic = is_domestic_country(country)
48
+
49
+ if domestic and region_id:
50
+ region_model = get_region_model()
51
+ region = region_model.objects.get(pk=region_id)
52
+ connection = ZoneConnection.objects.filter(regions=region).first()
53
+ if connection:
54
+ return connection, False
55
+
56
+ connection = ZoneConnection.objects.filter(countries=country).first()
57
+ if not connection:
58
+ raise CourierCalculationError(
59
+ f"No shipping zone configured for country id={country_id}."
60
+ )
61
+ return connection, not domestic
62
+
63
+
64
+ def billable_weight_kg(total_weight_grams):
65
+ threshold = Decimal(str(get_setting("HEAVY_WEIGHT_THRESHOLD_KG")))
66
+ padding = Decimal(str(get_setting("WEIGHT_PADDING_KG")))
67
+ total_kg = total_weight_grams / GRAMS_PER_KG
68
+
69
+ if total_kg > threshold:
70
+ return total_kg + padding
71
+ return total_kg + padding
72
+
73
+
74
+ def lookup_weight_cost(zone, weight_kg):
75
+ increment = Decimal(str(get_setting("WEIGHT_INCREMENT_KG")))
76
+ qst = Q(weight__lt=weight_kg + increment) & Q(weight__gte=weight_kg)
77
+ return WeightCost.objects.filter(zone=zone).filter(qst).first()
78
+
79
+
80
+ def apply_pricing(weight_kg, cost, *, domestic_pricing, apply_surcharge):
81
+ amount = Decimal("0")
82
+ if not cost:
83
+ return amount
84
+
85
+ if domestic_pricing and get_setting("DOMESTIC_USE_WEIGHT_MULTIPLIER"):
86
+ increment = Decimal(str(get_setting("WEIGHT_INCREMENT_KG")))
87
+ multiplier = Decimal(str(weight_kg)) / increment
88
+ amount = cost.amount * multiplier
89
+ else:
90
+ amount = cost.amount
91
+ if apply_surcharge:
92
+ percent = Decimal(str(get_setting("INTERNATIONAL_SURCHARGE_PERCENT")))
93
+ amount = amount + (amount * percent / Decimal("100"))
94
+ return amount
95
+
96
+
97
+ def calculate_courier_cost(
98
+ *,
99
+ weight,
100
+ quantity=1,
101
+ weight_unit=None,
102
+ country_id,
103
+ region_id=None,
104
+ ):
105
+ """
106
+ Generic shipping cost calculator.
107
+
108
+ Returns integer total (rounded) for the given destination and weight.
109
+ """
110
+ grams = normalize_weight(weight, weight_unit or get_setting("DEFAULT_WEIGHT_UNIT"))
111
+ quantity = int(quantity)
112
+ if quantity < 1:
113
+ raise CourierCalculationError("Quantity must be at least 1.")
114
+
115
+ connection, apply_surcharge = resolve_zone_connection(country_id, region_id)
116
+ domestic_pricing = not apply_surcharge
117
+ weight_kg = billable_weight_kg(grams * quantity)
118
+ cost = lookup_weight_cost(connection.zone, weight_kg)
119
+ total = apply_pricing(
120
+ weight_kg,
121
+ cost,
122
+ domestic_pricing=domestic_pricing,
123
+ apply_surcharge=apply_surcharge,
124
+ )
125
+ return int(total.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
126
+
127
+
128
+ def courier_cost(weight, quantity, weight_unit, country, state=None):
129
+ """Backward-compatible alias used by existing integrations."""
130
+ return calculate_courier_cost(
131
+ weight=weight,
132
+ quantity=quantity,
133
+ weight_unit=weight_unit,
134
+ country_id=country,
135
+ region_id=state,
136
+ )
courier/conf.py ADDED
@@ -0,0 +1,60 @@
1
+ """Configurable settings for the courier Django app — Nitesh Kumar Singh (nkscoder)."""
2
+
3
+ from django.apps import apps
4
+ from django.conf import settings
5
+ from django.core.exceptions import ImproperlyConfigured
6
+
7
+
8
+ DEFAULTS = {
9
+ "AUTHOR_NAME": "Nitesh Kumar Singh",
10
+ "AUTHOR_HANDLE": "nkscoder",
11
+ "GITHUB_URL": "https://github.com/nkscoder/courier",
12
+ "PYPI_URL": "https://pypi.org/project/nkscoder-courier/",
13
+ "SEO_SITE_NAME": "Django Courier Shipping Calculator",
14
+ "SEO_DESCRIPTION": (
15
+ "Generic open-source Django courier and shipping cost calculator with "
16
+ "zone-based weight pricing, domestic region zones, and international "
17
+ "surcharge support. Built by Nitesh Kumar Singh (nkscoder)."
18
+ ),
19
+ "SEO_KEYWORDS": (
20
+ "django courier, shipping cost calculator, zone-based shipping, "
21
+ "weight-based courier pricing, generic django shipping, django shipping app, "
22
+ "nkscoder, nitesh kumar singh, python courier, ecommerce shipping"
23
+ ),
24
+ # Location models (override to plug into your own Country/State tables)
25
+ "COUNTRY_MODEL": "courier.CourierCountry",
26
+ "REGION_MODEL": "courier.CourierRegion",
27
+ # Pricing defaults
28
+ "DOMESTIC_COUNTRY_NAME": "India",
29
+ "INTERNATIONAL_SURCHARGE_PERCENT": 22,
30
+ "HEAVY_WEIGHT_THRESHOLD_KG": 20,
31
+ "WEIGHT_PADDING_KG": "0.500",
32
+ "WEIGHT_INCREMENT_KG": "0.500",
33
+ "DEFAULT_WEIGHT_UNIT": "gram",
34
+ "SUPPORTED_WEIGHT_UNITS": ("gram", "kgs", "lbs"),
35
+ "DOMESTIC_USE_WEIGHT_MULTIPLIER": True,
36
+ # Optional hook: callable(country_id, region_id) -> (zone_connection, apply_surcharge)
37
+ "ZONE_RESOLVER": None,
38
+ }
39
+
40
+
41
+ def get_setting(name):
42
+ key = f"COURIER_{name}"
43
+ if hasattr(settings, key):
44
+ return getattr(settings, key)
45
+ return DEFAULTS[name]
46
+
47
+
48
+ def get_model(label):
49
+ try:
50
+ return apps.get_model(label)
51
+ except (LookupError, ValueError) as exc:
52
+ raise ImproperlyConfigured(f"COURIER model {label!r} is not installed.") from exc
53
+
54
+
55
+ def get_country_model():
56
+ return get_model(get_setting("COUNTRY_MODEL"))
57
+
58
+
59
+ def get_region_model():
60
+ return get_model(get_setting("REGION_MODEL"))
@@ -0,0 +1,174 @@
1
+ # Generated by Django 3.0.8 on 2023-01-13 12:57
2
+
3
+ import django.db.models.deletion
4
+ import django.utils.timezone
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = []
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name="CourierCountry",
17
+ fields=[
18
+ (
19
+ "id",
20
+ models.AutoField(
21
+ auto_created=True,
22
+ primary_key=True,
23
+ serialize=False,
24
+ verbose_name="ID",
25
+ ),
26
+ ),
27
+ ("created_at", models.DateTimeField(default=django.utils.timezone.now)),
28
+ ("updated_at", models.DateTimeField(auto_now=True)),
29
+ ("name", models.CharField(max_length=150, unique=True)),
30
+ ("code", models.CharField(blank=True, max_length=10)),
31
+ (
32
+ "is_domestic",
33
+ models.BooleanField(
34
+ default=False,
35
+ help_text="Domestic destinations can use region-level zones and weight multipliers.",
36
+ ),
37
+ ),
38
+ ],
39
+ options={
40
+ "verbose_name": "Courier Country",
41
+ "verbose_name_plural": "Courier Countries",
42
+ "ordering": ("name",),
43
+ },
44
+ ),
45
+ migrations.CreateModel(
46
+ name="Zone",
47
+ fields=[
48
+ (
49
+ "id",
50
+ models.AutoField(
51
+ auto_created=True,
52
+ primary_key=True,
53
+ serialize=False,
54
+ verbose_name="ID",
55
+ ),
56
+ ),
57
+ ("created_at", models.DateTimeField(default=django.utils.timezone.now)),
58
+ ("updated_at", models.DateTimeField(auto_now=True)),
59
+ ("name", models.CharField(max_length=150)),
60
+ ],
61
+ options={
62
+ "verbose_name": "Zone",
63
+ "verbose_name_plural": "Zones",
64
+ "ordering": ("name",),
65
+ },
66
+ ),
67
+ migrations.CreateModel(
68
+ name="CourierRegion",
69
+ fields=[
70
+ (
71
+ "id",
72
+ models.AutoField(
73
+ auto_created=True,
74
+ primary_key=True,
75
+ serialize=False,
76
+ verbose_name="ID",
77
+ ),
78
+ ),
79
+ ("created_at", models.DateTimeField(default=django.utils.timezone.now)),
80
+ ("updated_at", models.DateTimeField(auto_now=True)),
81
+ ("name", models.CharField(max_length=150)),
82
+ ("code", models.CharField(blank=True, max_length=10)),
83
+ (
84
+ "country",
85
+ models.ForeignKey(
86
+ on_delete=django.db.models.deletion.CASCADE,
87
+ related_name="regions",
88
+ to="courier.couriercountry",
89
+ ),
90
+ ),
91
+ ],
92
+ options={
93
+ "verbose_name": "Courier Region",
94
+ "verbose_name_plural": "Courier Regions",
95
+ "ordering": ("country__name", "name"),
96
+ "unique_together": {("country", "name")},
97
+ },
98
+ ),
99
+ migrations.CreateModel(
100
+ name="ZoneConnection",
101
+ fields=[
102
+ (
103
+ "id",
104
+ models.AutoField(
105
+ auto_created=True,
106
+ primary_key=True,
107
+ serialize=False,
108
+ verbose_name="ID",
109
+ ),
110
+ ),
111
+ ("created_at", models.DateTimeField(default=django.utils.timezone.now)),
112
+ ("updated_at", models.DateTimeField(auto_now=True)),
113
+ (
114
+ "countries",
115
+ models.ManyToManyField(
116
+ blank=True,
117
+ related_name="zone_connections",
118
+ to="courier.couriercountry",
119
+ ),
120
+ ),
121
+ (
122
+ "regions",
123
+ models.ManyToManyField(
124
+ blank=True,
125
+ related_name="zone_connections",
126
+ to="courier.courierregion",
127
+ ),
128
+ ),
129
+ (
130
+ "zone",
131
+ models.ForeignKey(
132
+ on_delete=django.db.models.deletion.CASCADE,
133
+ related_name="connections",
134
+ to="courier.zone",
135
+ ),
136
+ ),
137
+ ],
138
+ options={
139
+ "verbose_name": "Zone Connection",
140
+ "verbose_name_plural": "Zone Connections",
141
+ },
142
+ ),
143
+ migrations.CreateModel(
144
+ name="WeightCost",
145
+ fields=[
146
+ (
147
+ "id",
148
+ models.AutoField(
149
+ auto_created=True,
150
+ primary_key=True,
151
+ serialize=False,
152
+ verbose_name="ID",
153
+ ),
154
+ ),
155
+ ("created_at", models.DateTimeField(default=django.utils.timezone.now)),
156
+ ("updated_at", models.DateTimeField(auto_now=True)),
157
+ ("weight", models.DecimalField(decimal_places=3, max_digits=20)),
158
+ ("amount", models.DecimalField(decimal_places=6, max_digits=20)),
159
+ (
160
+ "zone",
161
+ models.ForeignKey(
162
+ on_delete=django.db.models.deletion.CASCADE,
163
+ related_name="weight_costs",
164
+ to="courier.zone",
165
+ ),
166
+ ),
167
+ ],
168
+ options={
169
+ "verbose_name": "Weight Cost",
170
+ "verbose_name_plural": "Weight Costs",
171
+ "ordering": ("zone__name", "weight"),
172
+ },
173
+ ),
174
+ ]
File without changes
courier/models.py ADDED
@@ -0,0 +1,96 @@
1
+ from django.db import models
2
+ from django.utils import timezone
3
+
4
+ class Base(models.Model):
5
+ created_at = models.DateTimeField(default=timezone.now)
6
+ updated_at = models.DateTimeField(auto_now=True)
7
+
8
+ class Meta:
9
+ abstract = True
10
+
11
+
12
+ class CourierCountry(Base):
13
+ """Generic country/destination used for zone mapping."""
14
+
15
+ name = models.CharField(max_length=150, unique=True)
16
+ code = models.CharField(max_length=10, blank=True)
17
+ is_domestic = models.BooleanField(
18
+ default=False,
19
+ help_text="Domestic destinations can use region-level zones and weight multipliers.",
20
+ )
21
+
22
+ class Meta:
23
+ verbose_name = "Courier Country"
24
+ verbose_name_plural = "Courier Countries"
25
+ ordering = ("name",)
26
+
27
+ def __str__(self):
28
+ return self.name
29
+
30
+
31
+ class CourierRegion(Base):
32
+ """Generic state/province/region under a country."""
33
+
34
+ name = models.CharField(max_length=150)
35
+ code = models.CharField(max_length=10, blank=True)
36
+ country = models.ForeignKey(
37
+ CourierCountry,
38
+ on_delete=models.CASCADE,
39
+ related_name="regions",
40
+ )
41
+
42
+ class Meta:
43
+ verbose_name = "Courier Region"
44
+ verbose_name_plural = "Courier Regions"
45
+ ordering = ("country__name", "name")
46
+ unique_together = ("country", "name")
47
+
48
+ def __str__(self):
49
+ return f"{self.name}, {self.country.name}"
50
+
51
+
52
+ class Zone(Base):
53
+ name = models.CharField(max_length=150)
54
+
55
+ class Meta:
56
+ verbose_name = "Zone"
57
+ verbose_name_plural = "Zones"
58
+ ordering = ("name",)
59
+
60
+ def __str__(self):
61
+ return self.name
62
+
63
+
64
+ class ZoneConnection(Base):
65
+ zone = models.ForeignKey(Zone, on_delete=models.CASCADE, related_name="connections")
66
+ countries = models.ManyToManyField(
67
+ "courier.CourierCountry",
68
+ blank=True,
69
+ related_name="zone_connections",
70
+ )
71
+ regions = models.ManyToManyField(
72
+ "courier.CourierRegion",
73
+ blank=True,
74
+ related_name="zone_connections",
75
+ )
76
+
77
+ class Meta:
78
+ verbose_name = "Zone Connection"
79
+ verbose_name_plural = "Zone Connections"
80
+
81
+ def __str__(self):
82
+ return str(self.zone)
83
+
84
+
85
+ class WeightCost(Base):
86
+ zone = models.ForeignKey(Zone, on_delete=models.CASCADE, related_name="weight_costs")
87
+ weight = models.DecimalField(max_digits=20, decimal_places=3)
88
+ amount = models.DecimalField(max_digits=20, decimal_places=6)
89
+
90
+ class Meta:
91
+ verbose_name = "Weight Cost"
92
+ verbose_name_plural = "Weight Costs"
93
+ ordering = ("zone__name", "weight")
94
+
95
+ def __str__(self):
96
+ return f"{self.zone} @ {self.weight} = {self.amount}"
@@ -0,0 +1,12 @@
1
+ SECRET_KEY = "test-secret-key"
2
+ INSTALLED_APPS = [
3
+ "django.contrib.contenttypes",
4
+ "courier",
5
+ ]
6
+ DATABASES = {
7
+ "default": {
8
+ "ENGINE": "django.db.backends.sqlite3",
9
+ "NAME": ":memory:",
10
+ }
11
+ }
12
+ USE_TZ = True
courier/tests.py ADDED
@@ -0,0 +1,46 @@
1
+ from decimal import Decimal
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ from django.test import SimpleTestCase, override_settings
5
+
6
+ from courier.calculator import apply_pricing, normalize_weight
7
+
8
+
9
+ class NormalizeWeightTests(SimpleTestCase):
10
+ def test_grams(self):
11
+ self.assertEqual(normalize_weight(500, "gram"), Decimal("500"))
12
+
13
+ def test_kgs(self):
14
+ self.assertEqual(normalize_weight(1.5, "kgs"), Decimal("1500"))
15
+
16
+ def test_lbs(self):
17
+ self.assertEqual(normalize_weight(1, "lbs"), Decimal("453.592"))
18
+
19
+
20
+ @override_settings(
21
+ COURIER_DOMESTIC_USE_WEIGHT_MULTIPLIER=True,
22
+ COURIER_WEIGHT_INCREMENT_KG="0.500",
23
+ COURIER_INTERNATIONAL_SURCHARGE_PERCENT=22,
24
+ )
25
+ class ApplyPricingTests(SimpleTestCase):
26
+ def test_domestic_multiplier(self):
27
+ cost = MagicMock()
28
+ cost.amount = Decimal("10")
29
+ amount = apply_pricing(
30
+ Decimal("2.0"),
31
+ cost,
32
+ domestic_pricing=True,
33
+ apply_surcharge=False,
34
+ )
35
+ self.assertEqual(amount, Decimal("40"))
36
+
37
+ def test_international_surcharge(self):
38
+ cost = MagicMock()
39
+ cost.amount = Decimal("100")
40
+ amount = apply_pricing(
41
+ Decimal("1.0"),
42
+ cost,
43
+ domestic_pricing=False,
44
+ apply_surcharge=True,
45
+ )
46
+ self.assertEqual(amount, Decimal("122"))
courier/urls.py ADDED
@@ -0,0 +1,7 @@
1
+ from django.urls import path
2
+
3
+ from .views import courier
4
+
5
+ urlpatterns = [
6
+ path("", courier, name="courier_view"),
7
+ ]
courier/views.py ADDED
@@ -0,0 +1,28 @@
1
+ import json
2
+
3
+ from django.http import HttpResponse, JsonResponse
4
+
5
+ from .calculator import CourierCalculationError, calculate_courier_cost
6
+ from .conf import get_setting
7
+
8
+
9
+ def courier(request):
10
+ """JSON endpoint: calculate shipping cost from query params."""
11
+ try:
12
+ total = calculate_courier_cost(
13
+ weight=request.GET.get("weight", 0),
14
+ quantity=request.GET.get("quantity", 1),
15
+ weight_unit=request.GET.get("weight_unit") or get_setting("DEFAULT_WEIGHT_UNIT"),
16
+ country_id=request.GET.get("country"),
17
+ region_id=request.GET.get("state") or request.GET.get("region"),
18
+ )
19
+ except CourierCalculationError as exc:
20
+ return JsonResponse({"error": str(exc)}, status=400)
21
+ except Exception as exc:
22
+ return JsonResponse({"error": str(exc)}, status=400)
23
+
24
+ return HttpResponse(
25
+ json.dumps({"total": total}),
26
+ content_type="application/json",
27
+ status=200,
28
+ )
@@ -0,0 +1,272 @@
1
+ Metadata-Version: 2.4
2
+ Name: nkscoder-courier
3
+ Version: 1.1.1
4
+ Summary: Django zone-based courier shipping cost calculator — by Nitesh Kumar Singh (nkscoder)
5
+ Author-email: Nitesh Kumar Singh <nkscoder@gmail.com>
6
+ Maintainer-email: Nitesh Kumar Singh <nkscoder@gmail.com>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/nkscoder/courier
9
+ Project-URL: Documentation, https://github.com/nkscoder/courier#readme
10
+ Project-URL: Repository, https://github.com/nkscoder/courier
11
+ Project-URL: Bug Tracker, https://github.com/nkscoder/courier/issues
12
+ Project-URL: Author GitHub, https://github.com/nkscoder
13
+ Project-URL: PyPI, https://pypi.org/project/nkscoder-courier/
14
+ Keywords: django,courier,shipping,shipping-calculator,zone-based-shipping,weight-based-pricing,india-courier,ecommerce,logistics,nkscoder,nitesh-kumar-singh,python,django-courier,shipping-cost,delivery-pricing
15
+ Classifier: Development Status :: 5 - Production/Stable
16
+ Classifier: Environment :: Web Environment
17
+ Classifier: Framework :: Django
18
+ Classifier: Framework :: Django :: 3.2
19
+ Classifier: Framework :: Django :: 4.0
20
+ Classifier: Framework :: Django :: 4.1
21
+ Classifier: Framework :: Django :: 4.2
22
+ Classifier: Framework :: Django :: 5.0
23
+ Classifier: Framework :: Django :: 5.1
24
+ Classifier: Framework :: Django :: 5.2
25
+ Classifier: Intended Audience :: Developers
26
+ Classifier: Operating System :: OS Independent
27
+ Classifier: Programming Language :: Python
28
+ Classifier: Programming Language :: Python :: 3
29
+ Classifier: Programming Language :: Python :: 3.8
30
+ Classifier: Programming Language :: Python :: 3.9
31
+ Classifier: Programming Language :: Python :: 3.10
32
+ Classifier: Programming Language :: Python :: 3.11
33
+ Classifier: Programming Language :: Python :: 3.12
34
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
35
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
36
+ Requires-Python: >=3.8
37
+ Description-Content-Type: text/markdown
38
+ License-File: LICENSE
39
+ Requires-Dist: Django>=3.2
40
+ Provides-Extra: admin
41
+ Requires-Dist: django-import-export>=3.0; extra == "admin"
42
+ Dynamic: license-file
43
+
44
+ # Courier — Generic Django Zone-Based Shipping Cost Calculator
45
+
46
+ [![PyPI version](https://badge.fury.io/py/nkscoder-courier.svg)](https://pypi.org/project/nkscoder-courier/)
47
+ [![Python](https://img.shields.io/pypi/pyversions/nkscoder-courier.svg)](https://pypi.org/project/nkscoder-courier/)
48
+ [![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/)
49
+ [![Django](https://img.shields.io/badge/Django-3.2%2B-green.svg)](https://www.djangoproject.com/)
50
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
51
+
52
+ **Author:** [Nitesh Kumar Singh](https://github.com/nkscoder) · **GitHub:** [@nkscoder](https://github.com/nkscoder)
53
+
54
+ A **generic, reusable Django courier app** for calculating zone-based shipping costs by weight, quantity, and destination. Works out of the box with built-in country/region models, or plugs into your existing location tables. Built by **Nitesh Kumar Singh (nkscoder)**.
55
+
56
+ > **Keywords:** django courier · generic shipping calculator · zone-based shipping · weight pricing · reusable django app · nkscoder · nitesh kumar singh · python shipping
57
+
58
+ ---
59
+
60
+ ## Features
61
+
62
+ - **Generic by design** — built-in `CourierCountry` / `CourierRegion` models, no `core` app required
63
+ - **Configurable pricing** — domestic multiplier, international surcharge %, weight units, thresholds
64
+ - **Zone-based pricing** — map countries and regions to shipping zones
65
+ - **Weight slab lookup** — automatic cost lookup by weight brackets
66
+ - **Multi-unit support** — grams, kilograms, and pounds (`gram`, `kgs`, `lbs`)
67
+ - **JSON API endpoint** — real-time shipping quotes via HTTP GET
68
+ - **Python API** — call `calculate_courier_cost()` from checkout, cart, or background jobs
69
+ - **Custom zone resolver** — plug in your own location models with one settings hook
70
+ - **Django Admin** — manage countries, regions, zones, and weight costs
71
+
72
+ ---
73
+
74
+ ## Requirements
75
+
76
+ - Python 3.8–3.12 (including **3.12**)
77
+ - Django 3.2+
78
+
79
+ ---
80
+
81
+ ## Installation
82
+
83
+ ```bash
84
+ pip install nkscoder-courier
85
+ ```
86
+
87
+ With Django Admin CSV import/export:
88
+
89
+ ```bash
90
+ pip install "nkscoder-courier[admin]"
91
+ ```
92
+
93
+ From GitHub:
94
+
95
+ ```bash
96
+ pip install git+https://github.com/nkscoder/courier.git
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Quick start
102
+
103
+ ```python
104
+ # settings.py
105
+ INSTALLED_APPS = [
106
+ ...
107
+ "courier",
108
+ ]
109
+
110
+ COURIER_DOMESTIC_COUNTRY_NAME = "India"
111
+ COURIER_INTERNATIONAL_SURCHARGE_PERCENT = 22
112
+ ```
113
+
114
+ ```bash
115
+ python manage.py migrate courier
116
+ ```
117
+
118
+ ```python
119
+ # urls.py
120
+ urlpatterns = [
121
+ path("courier/", include("courier.urls")),
122
+ ]
123
+ ```
124
+
125
+ ---
126
+
127
+ ## Usage
128
+
129
+ ### Python API (recommended)
130
+
131
+ ```python
132
+ from courier.calculator import calculate_courier_cost
133
+
134
+ total = calculate_courier_cost(
135
+ weight=1.5,
136
+ quantity=2,
137
+ weight_unit="kgs",
138
+ country_id=country.pk,
139
+ region_id=region.pk, # optional for domestic regions
140
+ )
141
+ ```
142
+
143
+ Backward-compatible alias:
144
+
145
+ ```python
146
+ from courier import courier_cost
147
+
148
+ total = courier_cost(weight=500, quantity=1, weight_unit="gram", country=1, state=3)
149
+ ```
150
+
151
+ ### HTTP API
152
+
153
+ ```
154
+ GET /courier/?weight=500&quantity=2&weight_unit=gram&country=1&region=3
155
+ ```
156
+
157
+ Response:
158
+
159
+ ```json
160
+ {"total": 150}
161
+ ```
162
+
163
+ | Parameter | Description |
164
+ |-----------|-------------|
165
+ | `weight` | Package weight |
166
+ | `quantity` | Number of packages |
167
+ | `weight_unit` | `gram`, `kgs`, or `lbs` |
168
+ | `country` | Country PK |
169
+ | `region` or `state` | Region PK (for domestic destinations) |
170
+
171
+ ---
172
+
173
+ ## Configuration
174
+
175
+ All settings use the `COURIER_` prefix:
176
+
177
+ | Setting | Default | Description |
178
+ |---------|---------|-------------|
179
+ | `COURIER_COUNTRY_MODEL` | `courier.CourierCountry` | Country model label |
180
+ | `COURIER_REGION_MODEL` | `courier.CourierRegion` | Region/state model label |
181
+ | `COURIER_DOMESTIC_COUNTRY_NAME` | `India` | Fallback domestic country name |
182
+ | `COURIER_INTERNATIONAL_SURCHARGE_PERCENT` | `22` | Surcharge for non-domestic destinations |
183
+ | `COURIER_HEAVY_WEIGHT_THRESHOLD_KG` | `20` | Heavy shipment threshold |
184
+ | `COURIER_WEIGHT_PADDING_KG` | `0.500` | Padding added to billable weight |
185
+ | `COURIER_WEIGHT_INCREMENT_KG` | `0.500` | Domestic weight multiplier increment |
186
+ | `COURIER_DEFAULT_WEIGHT_UNIT` | `gram` | Default unit for API requests |
187
+ | `COURIER_DOMESTIC_USE_WEIGHT_MULTIPLIER` | `True` | Per-increment pricing for domestic |
188
+ | `COURIER_ZONE_RESOLVER` | `None` | Custom `(country_id, region_id) -> (connection, surcharge)` |
189
+
190
+ ### Plug into your own location models
191
+
192
+ ```python
193
+ # settings.py
194
+ COURIER_COUNTRY_MODEL = "myapp.Country"
195
+ COURIER_REGION_MODEL = "myapp.State"
196
+
197
+ def resolve_courier_zone(country_id, region_id):
198
+ from courier.models import ZoneConnection
199
+ # your lookup logic
200
+ return connection, apply_surcharge
201
+
202
+ COURIER_ZONE_RESOLVER = resolve_courier_zone
203
+ ```
204
+
205
+ Mark domestic countries with `is_domestic=True` on your model, or rely on `COURIER_DOMESTIC_COUNTRY_NAME`.
206
+
207
+ ---
208
+
209
+ ## Admin setup
210
+
211
+ 1. **Courier Country** — add destinations; mark domestic with `is_domestic`
212
+ 2. **Courier Region** — add states/provinces under domestic countries
213
+ 3. **Zone** — create shipping zones (Zone A, Zone B, …)
214
+ 4. **Zone Connection** — link zones to countries and/or regions
215
+ 5. **Weight Cost** — set price per weight slab for each zone
216
+
217
+ ---
218
+
219
+ ## How pricing works
220
+
221
+ 1. Resolve the destination **zone** from country/region via `ZoneConnection`
222
+ 2. Normalize weight to grams and compute total billable weight
223
+ 3. Look up the matching **WeightCost** slab for that zone
224
+ 4. **Domestic**: multiply slab rate by weight increments (configurable)
225
+ 5. **International**: apply configurable **surcharge %** on the base rate
226
+
227
+ ---
228
+
229
+ ## Development
230
+
231
+ ```bash
232
+ git clone git@github.com:nkscoder/courier.git
233
+ cd courier
234
+ pip install -e ".[admin]"
235
+ python manage.py migrate
236
+ python manage.py test courier
237
+ ```
238
+
239
+ ---
240
+
241
+ ## Changelog
242
+
243
+ ### 1.1.1
244
+ - Renamed PyPI package to `nkscoder-courier` (name `courier` was taken on PyPI)
245
+ - Fixed GitHub Actions publish workflow with `PYPI_API_TOKEN`
246
+
247
+ ### 1.1.0
248
+ - Generic built-in `CourierCountry` / `CourierRegion` models (no `core` dependency)
249
+ - New `calculate_courier_cost()` service API
250
+ - Configurable settings via `COURIER_*` prefix
251
+ - Optional custom `COURIER_ZONE_RESOLVER` hook
252
+ - Improved admin with search and filters
253
+
254
+ ### 1.0.0
255
+ - Initial PyPI release by **Nitesh Kumar Singh (nkscoder)**
256
+
257
+ ---
258
+
259
+ ## Author & Links
260
+
261
+ | | |
262
+ |---|---|
263
+ | **Author** | Nitesh Kumar Singh |
264
+ | **GitHub** | [github.com/nkscoder](https://github.com/nkscoder) |
265
+ | **Repository** | [github.com/nkscoder/courier](https://github.com/nkscoder/courier) |
266
+ | **PyPI** | [pypi.org/project/nkscoder-courier](https://pypi.org/project/nkscoder-courier) |
267
+
268
+ ---
269
+
270
+ ## License
271
+
272
+ MIT License — Copyright (c) 2020-2026 [Nitesh Kumar Singh (nkscoder)](https://github.com/nkscoder)
@@ -0,0 +1,17 @@
1
+ courier/__init__.py,sha256=c4hYpuflDF0dhErloZVwCTmSWHSFQ_DojFHc0Tb-B0M,630
2
+ courier/admin.py,sha256=E0GKoEL5cWOywvt9104tHmdkklnwjcJ44bwozToOhOA,1181
3
+ courier/apps.py,sha256=QfaVOAt60KAzjtwlnwjBORl92oF5LI4rQ2BfyNUrViw,184
4
+ courier/calculator.py,sha256=P07i0NfRf5D49jAfrwFJyaWiUPfOCidkxCtWDVCzgmU,4388
5
+ courier/conf.py,sha256=VE7J_82YLNb8NlLDs0Lu3bwLbVbJbUakdFijABe7mww,2140
6
+ courier/models.py,sha256=b32nFWoUfmJb0GTbVXutH3y2L5ThHmZSel4g5JvvjmY,2645
7
+ courier/test_settings.py,sha256=pmuEqUJb9RHcAoeXCmgGM5M6CWZkfgNq10FzOmSXuEs,231
8
+ courier/tests.py,sha256=4nr2ihsigxGW8SV5At89lzMBHAs0VkBJeue-pX9gyUo,1361
9
+ courier/urls.py,sha256=1wcC6_KI_dWdEfBY7RYr-vB_96ftWLWhLo20b72n01k,120
10
+ courier/views.py,sha256=GIM2p_uLOzDi_VePFH4i9dFUt6tLEx2Vwz2AyVu-GAw,967
11
+ courier/migrations/0001_initial.py,sha256=SsAuCoc9cwNbdgDuuRjeOcWe7k3LZm_TeoskqgvzKJ0,6251
12
+ courier/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ nkscoder_courier-1.1.1.dist-info/licenses/LICENSE,sha256=q8sNgEeghCI1fwxqOLrlqOnNZeVppp_xcwNdwlKu-Qk,1091
14
+ nkscoder_courier-1.1.1.dist-info/METADATA,sha256=6NFF93i-MAOpcNCJ1ZwEAkPM7NolNJ--6x1VGNNb6WA,8750
15
+ nkscoder_courier-1.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
16
+ nkscoder_courier-1.1.1.dist-info/top_level.txt,sha256=JCa2wS13MdVQXTpD3ijfo9rPfbHY7VWTYNBueh2imHU,8
17
+ nkscoder_courier-1.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020-2026 Nitesh Kumar Singh (nkscoder)
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 @@
1
+ courier