Skip to main content

Composable business calendars for Django with holidays, intraday schedules, and timezone-aware operations.

Project description

django-bizcal

PyPI version Python versions CI License: MIT

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.
  • Declarative deadline policies for common operational rules and cutoffs.
  • 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.
  • Context-aware Django deadline-policy resolution for tenant, priority, or workflow specific SLA rules.
  • 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")

Declarative deadline policies

from datetime import datetime
from zoneinfo import ZoneInfo

from django_bizcal import DeadlinePolicyBuilder

policy = DeadlinePolicyBuilder.from_dict(
    {
        "type": "cutoff",
        "cutoff": "15:00",
        "before": {"type": "close_of_business"},
        "after": {"type": "next_business_day", "at": "closing"},
    }
)

deadline = policy.resolve(
    datetime(2026, 3, 5, 16, 0, tzinfo=ZoneInfo("America/Santiago")),
    calendar=cl,
)

config = DeadlinePolicyBuilder.to_dict(policy)

Built-in policy types:

  • business_duration
  • close_of_business
  • business_days
  • same_business_day
  • next_business_day
  • business_days_at_close
  • cutoff

Policies can also be resolved directly from a calendar instance:

deadline = cl.resolve_deadline_policy(
    datetime(2026, 3, 5, 16, 0, tzinfo=ZoneInfo("America/Santiago")),
    policy,
)

deadline = cl.resolve_deadline_policy_dict(
    datetime(2026, 3, 5, 16, 0, tzinfo=ZoneInfo("America/Santiago")),
    {
        "type": "business_duration",
        "business_hours": 8,
    },
)

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.django_api 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.django_api 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")

You can also configure named deadline policies in Django settings:

BIZCAL_DEADLINE_POLICIES = {
    "support_p1": {
        "type": "business_duration",
        "business_hours": 4,
    },
    "vendor_follow_up": {
        "type": "business_days",
        "business_days": 2,
        "at": "13:30",
        "include_start": True,
    },
    "support_cutoff": {
        "type": "cutoff",
        "cutoff": "15:00",
        "before": {"type": "close_of_business"},
        "after": {"type": "next_business_day", "at": "closing"},
    },
}

And, when policy selection depends on business context, resolve it the same way you already resolve calendars:

from django_bizcal.django_api import DeadlinePolicyResolution


def support_policy_resolver(*, context, bizcal_settings):
    priority = str(context["priority"]).strip().lower()
    if priority == "critical":
        return "support_p1"
    tenant = str(context["tenant"]).strip().lower()
    return DeadlinePolicyResolution.for_config(
        {"type": "same_business_day", "at": "closing"},
        name=f"tenant_policy:{tenant}:{priority}",
        cache_key=f"tenant_policy:{tenant}:{priority}",
    )


BIZCAL_DEADLINE_POLICY_RESOLVER = support_policy_resolver

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))

Or compute deadlines from a named policy:

from django_bizcal.django_api import (
    compute_deadline,
    get_deadline_policy,
    get_deadline_policy_config,
    get_deadline_policy_for,
)

deadline = compute_deadline(
    "support_cutoff",
    now(),
    tenant="acme",
    region="cl",
)

policy = get_deadline_policy("support_cutoff")
policy_config = get_deadline_policy_config("support_cutoff")
contextual_policy = get_deadline_policy_for(
    tenant="acme",
    region="cl",
    priority="critical",
)
contextual_deadline = compute_deadline(
    policy_name=None,
    start=now(),
    tenant="acme",
    region="cl",
    priority="critical",
)

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.py
  • examples/helpdesk_sla.py
  • examples/deadline_policies.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 logical name plus an optional cache_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:

  • CalendarHoliday closes a full day
  • CalendarDayOverride replaces 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:

  • CalendarConfig
  • WorkingCalendarConfig
  • UnionCalendarConfig
  • IntersectionCalendarConfig
  • DifferenceCalendarConfig
  • OverrideCalendarConfig

Architecture

  • The domain core lives in src/django_bizcal and stays framework-light.
  • WorkingCalendar handles 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+
  • Officially tested against Django 5.2 LTS and 6.0
  • CI covers the supported upstream Python and Django combinations documented in docs/stability.md

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_bizcal-0.10.1.tar.gz (73.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_bizcal-0.10.1-py3-none-any.whl (48.5 kB view details)

Uploaded Python 3

File details

Details for the file django_bizcal-0.10.1.tar.gz.

File metadata

  • Download URL: django_bizcal-0.10.1.tar.gz
  • Upload date:
  • Size: 73.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for django_bizcal-0.10.1.tar.gz
Algorithm Hash digest
SHA256 9034c4d54d5e6840dd68716cfc3e59b39cd2fed015937098e08571c29ef5fc99
MD5 c72842f86d28b2498178ff4e66238c45
BLAKE2b-256 24991a9034d233f628fdabcc5cc529215c2b696765b7663950630d2b6296427f

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_bizcal-0.10.1.tar.gz:

Publisher: publish.yml on radix1001/django-bizcal

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_bizcal-0.10.1-py3-none-any.whl.

File metadata

  • Download URL: django_bizcal-0.10.1-py3-none-any.whl
  • Upload date:
  • Size: 48.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for django_bizcal-0.10.1-py3-none-any.whl
Algorithm Hash digest
SHA256 ea2d789efec537759d5355a9d028276f42ce0fccf8c9efdcd23e4980091e7931
MD5 9bcd099309fe6612b4783cd9d18f024d
BLAKE2b-256 77299f282e521fd4f6e51aaef34950b614840706e91bb7f2686c69430cd5ace1

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_bizcal-0.10.1-py3-none-any.whl:

Publisher: publish.yml on radix1001/django-bizcal

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page