Composable business calendars for Django with holidays, intraday schedules, and timezone-aware operations.
Project description
django-bizcal
django-bizcal is a production-oriented Python library for Django projects that need composable business calendars with official holidays, custom holidays, intraday schedules, timezone-aware arithmetic, and reusable service integration.
It is designed for SLA clocks, operational workflows, due dates, approvals, support desks, tenant-specific calendars, and country-specific business hours.
Highlights
- Pure domain core with no ORM coupling.
- Official holidays via
holidays. - Custom organization or tenant holidays in memory.
- Intraday schedules with multiple windows per weekday.
- Calendar composition with union, intersection, difference, and override.
- Explicit timezone support based on
zoneinfo. - SLA and due-date helpers built on top of business calendars.
- Reusable Django app with namespaced settings and service helpers.
- Optional database-backed holiday closures and per-day schedule overrides for named Django calendars.
- Context-aware Django calendar resolution for tenant, client, or region specific lookups.
- Modern packaging with
pyproject.toml, wheel/sdist builds, pytest, and GitHub Actions.
Installation
pip install django-bizcal
For local development:
pip install -e ".[dev]"
Quickstart
from datetime import datetime
from zoneinfo import ZoneInfo
from django_bizcal import UnionCalendar, WorkingCalendar
cl = WorkingCalendar.from_country(
country="CL",
years=[2026, 2027],
tz="America/Santiago",
weekly_schedule={
0: [("09:00", "13:00"), ("14:00", "18:00")],
1: [("09:00", "13:00"), ("14:00", "18:00")],
2: [("09:00", "13:00"), ("14:00", "18:00")],
3: [("09:00", "13:00"), ("14:00", "18:00")],
4: [("09:00", "13:00"), ("14:00", "17:00")],
},
extra_holidays=["2026-12-24", "2026-12-31"],
)
mx = WorkingCalendar.from_country(
country="MX",
years=[2026, 2027],
tz="America/Mexico_City",
weekly_schedule={
0: [("09:00", "18:00")],
1: [("09:00", "18:00")],
2: [("09:00", "18:00")],
3: [("09:00", "18:00")],
4: [("09:00", "18:00")],
},
)
regional = UnionCalendar([cl, mx], tz="UTC")
start = datetime(2026, 1, 5, 15, 0, tzinfo=ZoneInfo("UTC"))
deadline = regional.add_business_hours(start, 10)
elapsed = regional.business_minutes_between(start, deadline)
Deadline helpers
from datetime import timedelta
from django_bizcal import breach_at, deadline_for, due_on_next_business_day
target = deadline_for(start, timedelta(hours=8), calendar=regional)
breach_time = breach_at(start, timedelta(hours=8), calendar=regional)
next_cutoff = due_on_next_business_day("2026-03-06", calendar=regional, at="closing")
assert target.deadline == breach_time
assert target.is_breached(at=breach_time) is False
The same helpers are also available as calendar instance methods:
target = regional.deadline_for(start, timedelta(hours=8))
breach_time = regional.breach_at(start, timedelta(hours=8))
next_cutoff = regional.due_on_next_business_day("2026-03-06", at="closing")
Django integration
Add the reusable app:
INSTALLED_APPS = [
...,
"django_bizcal",
]
Configure a default calendar:
BIZCAL_DEFAULT_TIMEZONE = "America/Santiago"
BIZCAL_DEFAULT_COUNTRY = "CL"
BIZCAL_PRELOAD_YEARS = [2026, 2027]
BIZCAL_DEFAULT_CALENDAR = {
"type": "working",
"tz": "America/Santiago",
"country": "CL",
"years": [2026, 2027],
"weekly_schedule": {
"0": [["09:00", "13:00"], ["14:00", "18:00"]],
"1": [["09:00", "13:00"], ["14:00", "18:00"]],
"2": [["09:00", "13:00"], ["14:00", "18:00"]],
"3": [["09:00", "13:00"], ["14:00", "18:00"]],
"4": [["09:00", "13:00"], ["14:00", "17:00"]],
},
"extra_holidays": ["2026-12-24", "2026-12-31"],
}
Consume it from application code:
from django_bizcal.services import get_default_calendar
calendar = get_default_calendar()
deadline = calendar.add_business_hours(ticket.created_at, 8)
For projects with more than one operational calendar, define a named registry:
BIZCAL_DEFAULT_TIMEZONE = "UTC"
BIZCAL_DEFAULT_CALENDAR_NAME = "support"
BIZCAL_CALENDARS = {
"support": {
"type": "working",
"tz": "UTC",
"weekly_schedule": {
"0": [["09:00", "18:00"]],
"1": [["09:00", "18:00"]],
"2": [["09:00", "18:00"]],
"3": [["09:00", "18:00"]],
"4": [["09:00", "18:00"]],
},
},
"operations_latam": {
"type": "union",
"tz": "UTC",
"children": [
{
"type": "working",
"country": "CL",
"years": [2026, 2027],
"tz": "America/Santiago",
"weekly_schedule": {
"0": [["09:00", "18:00"]],
"1": [["09:00", "18:00"]],
"2": [["09:00", "18:00"]],
"3": [["09:00", "18:00"]],
"4": [["09:00", "18:00"]],
},
},
{
"type": "working",
"country": "MX",
"years": [2026, 2027],
"tz": "America/Mexico_City",
"weekly_schedule": {
"0": [["09:00", "18:00"]],
"1": [["09:00", "18:00"]],
"2": [["09:00", "18:00"]],
"3": [["09:00", "18:00"]],
"4": [["09:00", "18:00"]],
},
},
],
},
}
Use named calendars from application code:
from django_bizcal.services import get_calendar, get_default_calendar
support = get_default_calendar()
regional_ops = get_calendar("operations_latam")
For tenant-, client-, or region-aware lookups, configure a contextual resolver:
from django_bizcal.django_api import CalendarResolution
def support_calendar_resolver(*, context, bizcal_settings):
region = str(context["region"]).strip().lower()
if region in {"cl", "mx"}:
return f"support_{region}"
tenant = str(context["tenant"]).strip().lower()
return CalendarResolution.for_config(
{
"type": "working",
"tz": "America/Santiago",
"weekly_schedule": {
"0": [["09:00", "18:00"]],
"1": [["09:00", "18:00"]],
"2": [["09:00", "18:00"]],
"3": [["09:00", "18:00"]],
"4": [["09:00", "18:00"]],
},
},
name=f"tenant:{tenant}",
cache_key=f"tenant:{tenant}",
)
BIZCAL_CALENDAR_RESOLVER = support_calendar_resolver
Then resolve calendars from application context:
from django_bizcal.django_api import get_calendar_for
regional_support = get_calendar_for(region="cl")
tenant_calendar = get_calendar_for(tenant="acme", region="cl")
And use the same deadline helpers on top of contextual calendars:
from datetime import timedelta
from django_bizcal.django_api import deadline_for, get_calendar_for, now
calendar = get_calendar_for(tenant="acme", region="cl")
deadline = deadline_for(now(), timedelta(hours=8), calendar=calendar)
Or, more ergonomically:
calendar = get_calendar_for(tenant="acme", region="cl")
deadline = calendar.deadline_for(now(), timedelta(hours=8))
Calendars resolved through get_default_calendar(), get_calendar(name), and get_calendar_for(...) also carry their logical calendar_name, so BusinessDeadline.calendar_name is filled automatically in the common Django flows.
For more complete scenarios, see:
examples/sla_deadlines.pyexamples/helpdesk_sla.py
Resolver return values can be:
- a logical calendar name such as
"support_cl" - a serializable calendar config mapping
CalendarResolution, which can carry both a config and a logicalnameplus an optionalcache_key
When BIZCAL_ENABLE_DB_MODELS = True, a contextual resolution with CalendarResolution(name=..., config=...) also participates in persisted holiday and per-day override application for that logical name.
Persisted holiday closures and per-day overrides can be enabled for named Django calendars:
BIZCAL_ENABLE_DB_MODELS = True
from datetime import date
from django_bizcal.django_api import (
CalendarHoliday,
set_calendar_day_override,
set_calendar_holiday,
)
CalendarHoliday.objects.create(
calendar_name="support",
day=date(2026, 12, 24),
name="Company shutdown",
)
set_calendar_holiday("support", date(2026, 12, 31), name="Year end close")
set_calendar_day_override(
"support",
date(2026, 12, 24),
[("09:00", "13:00")],
name="Christmas Eve half day",
)
Once enabled, get_default_calendar() and get_calendar(name) automatically apply persisted rows that match the logical calendar name:
CalendarHolidaycloses a full dayCalendarDayOverridereplaces the day with one or more explicit intraday windows
If both exist for the same day, the per-day override wins because it is more specific. The affected cached named calendar is invalidated automatically after persisted changes, whether they happen through the Django service helpers, the Django admin, or direct ORM saves and deletes after transaction commit. When the optional models are enabled, the Django admin also includes bulk activate/deactivate actions plus window summaries for one-off intraday schedules.
Preferred Django-specific imports:
from django_bizcal.django_api import get_calendar, set_calendar_holiday
If you need to inspect persisted state without working with ORM relations directly, the Django service layer also exposes helpers such as list_calendar_holiday_days(...), list_calendar_day_override_windows(...), and get_calendar_day_override_windows(...).
Calendar builder
from django_bizcal import CalendarBuilder
calendar = CalendarBuilder.from_dict(
{
"type": "union",
"tz": "UTC",
"children": [
{
"type": "working",
"country": "CL",
"years": [2026, 2027],
"tz": "America/Santiago",
"weekly_schedule": {
"0": [["09:00", "18:00"]],
"1": [["09:00", "18:00"]],
"2": [["09:00", "18:00"]],
"3": [["09:00", "18:00"]],
"4": [["09:00", "18:00"]],
},
},
{
"type": "working",
"country": "MX",
"years": [2026, 2027],
"tz": "America/Mexico_City",
"weekly_schedule": {
"0": [["09:00", "18:00"]],
"1": [["09:00", "18:00"]],
"2": [["09:00", "18:00"]],
"3": [["09:00", "18:00"]],
"4": [["09:00", "18:00"]],
},
},
],
}
)
The builder also supports serialization back to declarative config:
from django_bizcal import CalendarBuilder
config = CalendarBuilder.to_dict(calendar)
restored = CalendarBuilder.from_dict(config)
API ergonomics
Common day- and boundary-level helpers are part of the public API:
iter_business_days(...),list_business_days(...),count_business_days(...)next_business_day(...),previous_business_day(...)opening_for_day(...),closing_for_day(...)next_opening_datetime(...),previous_closing_datetime(...)
Typed declarative config helpers are also exported for IDE and static typing support:
CalendarConfigWorkingCalendarConfigUnionCalendarConfigIntersectionCalendarConfigDifferenceCalendarConfigOverrideCalendarConfig
Architecture
- The domain core lives in
src/django_bizcaland stays framework-light. WorkingCalendarhandles business schedules and holiday lookup.- Composition calendars project child windows into a reference timezone.
- The Django layer wraps settings, AppConfig, service helpers, and optional persistence for named calendar closures and per-day overrides.
- The public core remains framework-light even though Django-specific models are now available behind the reusable app boundary.
See the full documentation in:
Compatibility
- Python 3.11+
- Django 4.2, 5.0, 5.1
Limitations
- Official holiday lookup requires the relevant years to be preloaded.
- Wall-clock times are interpreted with
zoneinfo; DST transitions affect real elapsed durations. - The library persists named calendar closures and per-day overrides, but not full calendar definitions or arbitrary composition graphs.
Release
python -m build
pytest
The recommended release path uses GitHub Actions plus PyPI Trusted Publishing. Publishing guidance is documented in docs/release.md.
Support
If django-bizcal helps your team, consider sponsoring ongoing maintenance, documentation, and new features through GitHub Sponsors or by reaching out for support and implementation work.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_bizcal-0.5.0.tar.gz.
File metadata
- Download URL: django_bizcal-0.5.0.tar.gz
- Upload date:
- Size: 53.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
829372270beaa09ae15dca91eb3092f6d8a56834a4e7a9518a6ceb7dd9f0a6bd
|
|
| MD5 |
793ed06a5fbab44725b9628ebecab16c
|
|
| BLAKE2b-256 |
2e64ad022a9ee50dc10a76566438c101cddc073f1141b827128e7d9a9e1ee407
|
Provenance
The following attestation bundles were made for django_bizcal-0.5.0.tar.gz:
Publisher:
publish.yml on radix1001/django-bizcal
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_bizcal-0.5.0.tar.gz -
Subject digest:
829372270beaa09ae15dca91eb3092f6d8a56834a4e7a9518a6ceb7dd9f0a6bd - Sigstore transparency entry: 1221342358
- Sigstore integration time:
-
Permalink:
radix1001/django-bizcal@e7405ab34ad5f196cdbb68802468d578b0b5f611 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/radix1001
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e7405ab34ad5f196cdbb68802468d578b0b5f611 -
Trigger Event:
release
-
Statement type:
File details
Details for the file django_bizcal-0.5.0-py3-none-any.whl.
File metadata
- Download URL: django_bizcal-0.5.0-py3-none-any.whl
- Upload date:
- Size: 41.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
57ce0c7759fa7bba7a0d76253bd80aaaa4f8b2a913a2eb4548b40669fbd01e26
|
|
| MD5 |
e27bd29e9a71980bd38f015878f3cf64
|
|
| BLAKE2b-256 |
f6010ee590bbfc90350f2275e283d1cd0a3d6dd036e46cb876fa204cee0bc84d
|
Provenance
The following attestation bundles were made for django_bizcal-0.5.0-py3-none-any.whl:
Publisher:
publish.yml on radix1001/django-bizcal
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_bizcal-0.5.0-py3-none-any.whl -
Subject digest:
57ce0c7759fa7bba7a0d76253bd80aaaa4f8b2a913a2eb4548b40669fbd01e26 - Sigstore transparency entry: 1221342443
- Sigstore integration time:
-
Permalink:
radix1001/django-bizcal@e7405ab34ad5f196cdbb68802468d578b0b5f611 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/radix1001
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e7405ab34ad5f196cdbb68802468d578b0b5f611 -
Trigger Event:
release
-
Statement type: