diff --git a/docs/migration_guide.py b/docs/migration_guide.py index e7932ee..277755b 100644 --- a/docs/migration_guide.py +++ b/docs/migration_guide.py @@ -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 + ``_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). ]