Files
django-nepkit/docs/migration_guide.py
T
sankalp caed06951a chore(docs): add migration guide for BS date conversion
Introduce migration script to convert AD dates to BS dates with proper handling.
2026-06-06 21:52:30 +05:45

254 lines
8.9 KiB
Python

"""
Data-migration recipe for moving an existing AD (``models.DateField``)
column to BS (``NepaliDateField``).
This file is a *template*, not a runnable migration: it shows the
correct shape, the safe defaults, and the gotchas to look out for.
Drop it into your app's ``migrations/`` directory, fill in the
``APP_LABEL`` / ``MODEL_NAME`` / field names, then ``makemigrations``
will pick up the ``Migration`` class at the bottom.
What v0.2.2 changed for migrations
----------------------------------
- ``NepaliDateField`` / ``NepaliDateTimeField`` store ``VARCHAR``
values formatted with ``nepkit_settings.BS_DATE_FORMAT`` /
``BS_DATETIME_FORMAT`` (default ``"%Y-%m-%d"`` /
``"%Y-%m-%d %H:%M:%S"``). A migration that writes a different
format will sort and ``startswith``-filter incorrectly, so the
recipe below honours the live setting.
- ``BaseNepaliBSField.to_python`` / ``get_prep_value`` raise
``ValidationError`` on bad input, so the conversion must produce
valid ``nepalidate`` / ``nepalidatetime`` objects before they
reach the model.
- The framework-agnostic ``django_nepkit.api`` helpers are the
recommended way to do read-side conversion; ``nepalidate.from_date``
is still available for one-off scripts but is no longer the only
path.
"""
from __future__ import annotations
import datetime
import logging
from django.db import migrations
from django.utils import timezone
from django.utils.functional import LazyObject
from nepali.datetime import nepalidate, nepalidatetime
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Settings (filled in at runtime, not at import time)
# ---------------------------------------------------------------------------
class _NepkitSettingsProxy(LazyObject):
"""Defer the ``django_nepkit.conf`` import until first access.
Importing ``django_nepkit.conf`` at module level pulls in Django
settings machinery, which is fine inside a real migration but
trips up linters and unit tests that collect this file in
isolation.
"""
def _setup(self):
from django_nepkit.conf import nepkit_settings
self._wrapped = nepkit_settings
nepkit_settings = _NepkitSettingsProxy()
# ---------------------------------------------------------------------------
# Configuration — EDIT THESE FOR YOUR MODEL
# ---------------------------------------------------------------------------
APP_LABEL = "YourApp" # e.g. "accounts"
MODEL_NAME = "YourModel" # e.g. "Profile"
SOURCE_FIELD = "birth_date_ad" # the existing ``DateField`` / ``DateTimeField``
TARGET_FIELD = "birth_date" # the new ``NepaliDateField`` / ``NepaliDateTimeField``
# Pagination for large tables; the admin may be running this against
# millions of rows. ``iterator(chunk_size=...)`` keeps memory flat.
CHUNK_SIZE = 500
# Conversion errors that we *expect* (e.g. Naive/aware mismatches, an
# out-of-range date). Anything outside this list surfaces to the
# operator so we don't silently skip real bugs.
_CONVERSION_ERRORS = (
ValueError,
TypeError,
OverflowError,
OSError, # e.g. value out of range for ``datetime``
AttributeError, # a ``None``/missing sub-attribute
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ad_to_bs_date(value):
"""Convert a Python ``date`` to a ``nepalidate`` honouring the project
``TIME_ZONE`` setting (the model's ``_convert_from_python`` does
the same on save).
"""
if isinstance(value, datetime.datetime):
if timezone.is_aware(value):
value = timezone.localtime(value)
value = value.date()
return nepalidate.from_date(value)
def _ad_to_bs_datetime(value):
"""Convert a Python ``datetime`` to a ``nepalidatetime``."""
if timezone.is_aware(value):
value = timezone.localtime(value)
elif value is not None:
# Migrations run with ``USE_TZ=True`` by default; an unaware
# value in such a project is almost always a bug, so flag it
# explicitly rather than guessing.
logger.warning(
"convert_ad_to_bs: %s.%s has a naive datetime on a "
"USE_TZ=True project — treating as UTC.",
MODEL_NAME,
SOURCE_FIELD,
)
value = timezone.make_aware(value, datetime.timezone.utc)
return nepalidatetime.from_datetime(value)
# ---------------------------------------------------------------------------
# Migration data callbacks
# ---------------------------------------------------------------------------
def convert_ad_to_bs(apps, schema_editor):
"""Bulk-convert every row's ``SOURCE_FIELD`` into a BS string on
``TARGET_FIELD``.
Skips rows whose ``SOURCE_FIELD`` is empty. Logs (does not
silently swallow) unexpected exceptions per row so a single bad
record can't take the whole migration down.
"""
MyModel = apps.get_model(APP_LABEL, MODEL_NAME)
fmt = nepkit_settings.BS_DATE_FORMAT # honour the project setting
qs = MyModel.objects.all().iterator(chunk_size=CHUNK_SIZE)
converted = skipped = failed = 0
for obj in qs:
ad_value = getattr(obj, SOURCE_FIELD, None)
if ad_value in (None, ""):
skipped += 1
continue
try:
bs_date = _ad_to_bs_date(ad_value)
setattr(obj, TARGET_FIELD, bs_date.strftime(fmt))
obj.save(update_fields=[TARGET_FIELD])
converted += 1
except _CONVERSION_ERRORS as exc:
failed += 1
logger.error(
"convert_ad_to_bs: %s pk=%s value=%r: %s",
MODEL_NAME,
obj.pk,
ad_value,
exc,
)
logger.info(
"convert_ad_to_bs done: converted=%d skipped=%d failed=%d",
converted,
skipped,
failed,
)
def reverse_bs_to_ad(apps, schema_editor):
"""Reverse the migration by converting BS strings back to AD ``date``
objects via ``nepalidatetime.to_datetime`` / ``nepalidate.to_date``.
"""
MyModel = apps.get_model(APP_LABEL, MODEL_NAME)
converted = skipped = failed = 0
for obj in MyModel.objects.all().iterator(chunk_size=CHUNK_SIZE):
bs_value = getattr(obj, TARGET_FIELD, None)
if bs_value in (None, ""):
skipped += 1
continue
try:
parsed = nepalidatetime.strptime(bs_value, nepkit_settings.BS_DATE_FORMAT)
# ``nepalidate`` and ``nepalidatetime`` both expose ``to_date`` /
# ``to_datetime`` for round-tripping. Pick the one your
# ``SOURCE_FIELD`` was.
if hasattr(parsed, "to_date"):
ad_value = parsed.to_date()
else:
ad_value = parsed
setattr(obj, SOURCE_FIELD, ad_value)
obj.save(update_fields=[SOURCE_FIELD])
converted += 1
except _CONVERSION_ERRORS as exc:
failed += 1
logger.error(
"reverse_bs_to_ad: %s pk=%s value=%r: %s",
MODEL_NAME,
obj.pk,
bs_value,
exc,
)
logger.info(
"reverse_bs_to_ad done: converted=%d skipped=%d failed=%d",
converted,
skipped,
failed,
)
# ---------------------------------------------------------------------------
# The migration class — ``makemigrations`` will reformat this on first run.
# ---------------------------------------------------------------------------
class Migration(migrations.Migration):
"""Convert ``YourModel.birth_date_ad`` → ``YourModel.birth_date``.
Steps when you wire this up for real:
1. Add ``birth_date = NepaliDateField(null=True, blank=True)`` to
the model and run ``makemigrations`` — that produces the
``AddField`` operation below.
2. Drop *this* file into ``YourApp/migrations/`` (renamed to
``<seq>_convert_dates.py``) and ``makemigrations`` again so
Django sees the new ``Migration`` class.
3. Once the migration has run on production, remove the
``birth_date_ad`` field with a follow-up migration.
"""
dependencies = [
("YourApp", "previous_migration_name"),
]
operations = [
# 1. Add the new BS field as nullable so the RunPython below
# can back-fill it without violating NOT NULL constraints.
#
# migrations.AddField(
# model_name="yourmodel",
# name="birth_date",
# field=django_nepkit.models.NepaliDateField(
# null=True, blank=True,
# ),
# ),
# 2. Back-fill from the AD source column.
migrations.RunPython(convert_ad_to_bs, reverse_code=reverse_bs_to_ad),
# 3. Drop the old AD field (separate migration, after this one
# has run on production and you've verified the data).
]