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:
1 file changed
+233
-25
+233
-25
@@ -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 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):
|
||||
"""Logic to convert English (AD) dates to Nepali (BS)."""
|
||||
# Set your app and model name here
|
||||
MyModel = apps.get_model("YourApp", "YourModel")
|
||||
"""Bulk-convert every row's ``SOURCE_FIELD`` into a BS string on
|
||||
``TARGET_FIELD``.
|
||||
|
||||
# Loop through all records and update them
|
||||
for obj in MyModel.objects.all():
|
||||
# Name of your old and new fields
|
||||
if obj.birth_date_ad:
|
||||
try:
|
||||
# Turn English date into Nepali date
|
||||
bs_date = nepalidate.from_date(obj.birth_date_ad)
|
||||
# Format the date correctly for the database
|
||||
obj.birth_date = bs_date.strftime("%Y-%m-%d")
|
||||
obj.save(update_fields=["birth_date"])
|
||||
except Exception as e:
|
||||
print(f"Error converting object {obj.pk}: {e}")
|
||||
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):
|
||||
"""Optional: Logic to convert back to English dates."""
|
||||
# Note: BS to AD conversion is also supported by the 'nepali' library.
|
||||
pass
|
||||
"""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 field
|
||||
# 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),
|
||||
# model_name="yourmodel",
|
||||
# name="birth_date",
|
||||
# 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),
|
||||
# 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).
|
||||
]
|
||||
Reference in new issue
Block a user