chore(docs): add migration guide for BS date conversion

Introduce migration script to convert AD dates to BS dates with proper handling.
This commit is contained in:
sankalp committed 2026-06-06 21:52:30 +05:45
1 parent 06606e8d0d
commit caed06951a
1 file changed
+233 -25
+233 -25
View File
@@ -1,45 +1,253 @@
"""
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.db import migrations
from nepali.datetime import nepalidate 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): def convert_ad_to_bs(apps, schema_editor):
"""Logic to convert English (AD) dates to Nepali (BS).""" """Bulk-convert every row's ``SOURCE_FIELD`` into a BS string on
# Set your app and model name here ``TARGET_FIELD``.
MyModel = apps.get_model("YourApp", "YourModel")
# Loop through all records and update them Skips rows whose ``SOURCE_FIELD`` is empty. Logs (does not
for obj in MyModel.objects.all(): silently swallow) unexpected exceptions per row so a single bad
# Name of your old and new fields record can't take the whole migration down.
if obj.birth_date_ad: """
try: MyModel = apps.get_model(APP_LABEL, MODEL_NAME)
# Turn English date into Nepali date fmt = nepkit_settings.BS_DATE_FORMAT # honour the project setting
bs_date = nepalidate.from_date(obj.birth_date_ad)
# Format the date correctly for the database qs = MyModel.objects.all().iterator(chunk_size=CHUNK_SIZE)
obj.birth_date = bs_date.strftime("%Y-%m-%d") converted = skipped = failed = 0
obj.save(update_fields=["birth_date"])
except Exception as e: for obj in qs:
print(f"Error converting object {obj.pk}: {e}") 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): def reverse_bs_to_ad(apps, schema_editor):
"""Optional: Logic to convert back to English dates.""" """Reverse the migration by converting BS strings back to AD ``date``
# Note: BS to AD conversion is also supported by the 'nepali' library. objects via ``nepalidatetime.to_datetime`` / ``nepalidate.to_date``.
pass """
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): 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 = [ dependencies = [
("YourApp", "previous_migration_name"), ("YourApp", "previous_migration_name"),
] ]
operations = [ operations = [
# 1. Add the new field # 1. Add the new BS field as nullable so the RunPython below
# can back-fill it without violating NOT NULL constraints.
#
# migrations.AddField( # migrations.AddField(
# model_name='yourmodel', # model_name="yourmodel",
# name='birth_date', # name="birth_date",
# field=django_nepkit.models.NepaliDateField(null=True, blank=True), # field=django_nepkit.models.NepaliDateField(
# null=True, blank=True,
# ),
# ), # ),
# 2. Run the conversion # 2. Back-fill from the AD source column.
migrations.RunPython(convert_ad_to_bs, reverse_code=reverse_bs_to_ad), migrations.RunPython(convert_ad_to_bs, reverse_code=reverse_bs_to_ad),
# 3. Clean up the old field # 3. Drop the old AD field (separate migration, after this one
# has run on production and you've verified the data).
] ]