add: Django Custom Command (1) проверка и перегенерация offer_code (нужно при смене соли)

This commit is contained in:
2026-07-15 22:10:32 +03:00
parent 9fe12c4ab7
commit d221f5e626
3 changed files with 366 additions and 0 deletions
@@ -0,0 +1,366 @@
"""
Django management команда для перегенерации и проверки s_offer_code офферов.
ВАЖНО: При смене OFFER_HASHIDS_SALT нужно обновить ВСЕ коды сразу!
Иначе старые коды перестанут работать и QR-коды станут невалидными.
Примеры использования:
# 1. Проверить все коды (декодирование и валидация)
python manage.py regenerate_offer_codes --check
# 2. Проверить и показать некорректные коды
python manage.py regenerate_offer_codes --check --verbose
# 3. Обновить только некорректные коды
python manage.py regenerate_offer_codes --fix-broken
# 4. Обновить ВСЕ коды (при смене соли)
python manage.py regenerate_offer_codes
# 5. Пробный запуск (ничего не сохранять)
python manage.py regenerate_offer_codes --dry-run
"""
from django.core.management.base import BaseCommand, CommandError
from hashids import Hashids
from lpon_site.settings import OFFER_HASHIDS_SALT, OFFER_HASHIDS_MIN_LENGTH
from frontend.models import TbOffer
class Command(BaseCommand):
"""
Перегенерирует, проверяет и восстанавливает s_offer_code офферов.
"""
help = 'Перегенерирует/проверяет s_offer_code для офферов на основе текущего OFFER_HASHIDS_SALT'
def add_arguments(self, parser):
"""Добавляем опциональные аргументы"""
parser.add_argument(
'--check',
action='store_true',
help='Проверить корректность всех кодов (декодировать обратно в id)',
)
parser.add_argument(
'--fix-broken',
action='store_true',
help='Обновить только коды, которые не декодируются правильно',
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Показать, что будет изменено, но не сохранять',
)
parser.add_argument(
'--verbose',
action='store_true',
help='Показывать подробный прогресс для каждого оффера',
)
def handle(self, *args, **options):
"""Основная логика команды"""
check_mode = options['check']
fix_broken = options['fix_broken']
dry_run = options['dry_run']
verbose = options['verbose']
# Инициализируем Hashids
hashids = Hashids(salt=OFFER_HASHIDS_SALT, min_length=OFFER_HASHIDS_MIN_LENGTH)
# Получаем все офферы
queryset = TbOffer.objects.all()
total_count = queryset.count()
if total_count == 0:
self.stdout.write(self.style.WARNING('Нет офферов для обработки'))
return
self.stdout.write(
self.style.SUCCESS(f'Найдено офферов: {total_count}')
)
# ===== РЕЖИМ 1: ПРОВЕРКА КОДОВ =====
if check_mode:
self._check_codes(queryset, hashids, verbose, total_count)
return
# ===== РЕЖИМ 2: ОБНОВЛЕНИЕ ТОЛЬКО НЕКОРРЕКТНЫХ =====
if fix_broken:
self._fix_broken_codes(queryset, hashids, dry_run, verbose, total_count)
return
# ===== РЕЖИМ 3: ОБНОВЛЕНИЕ ВСЕХ КОДОВ =====
self._regenerate_all_codes(queryset, hashids, dry_run, verbose, total_count)
def _check_codes(self, queryset, hashids, verbose, total_count):
"""
Режим проверки: декодируем все коды обратно и проверяем, что получается исходный id.
"""
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.SUCCESS('РЕЖИМ ПРОВЕРКИ КОДОВ'))
self.stdout.write('=' * 70)
broken_count = 0
valid_count = 0
broken_offers = []
for idx, offer in enumerate(queryset.iterator(chunk_size=1000), 1):
try:
# Пытаемся декодировать код обратно в ID
decoded_ids = hashids.decode(offer.s_offer_code)
if not decoded_ids or decoded_ids[0] != offer.id:
# Код не декодируется или декодируется в неправильный ID
broken_count += 1
broken_offers.append({
'id': offer.id,
'code': offer.s_offer_code,
'decoded': decoded_ids[0] if decoded_ids else None,
'reason': 'Неверный ID' if decoded_ids else 'Не декодируется'
})
if verbose:
self.stdout.write(
self.style.ERROR(
f' [{idx}/{total_count}] ID={offer.id} [err] код "{offer.s_offer_code}" '
f'декодируется в {decoded_ids[0] if decoded_ids else "ОШИБКА"}'
)
)
else:
# Код корректный
valid_count += 1
if verbose:
self.stdout.write(
f' [{idx}/{total_count}] ID={offer.id} [ok!] код "{offer.s_offer_code}"'
)
except Exception as e:
broken_count += 1
broken_offers.append({
'id': offer.id,
'code': offer.s_offer_code,
'error': str(e)
})
if verbose:
self.stdout.write(
self.style.ERROR(
f' [{idx}/{total_count}] ID={offer.id} [err] код "{offer.s_offer_code}" ОШИБКА: {str(e)}'
)
)
# Показываем прогресс каждые 1000
if idx % 1000 == 0:
self.stdout.write(f'Проверено: {idx}/{total_count}')
# Отчет
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.SUCCESS('ИТОГОВЫЙ ОТЧЕТ ПРОВЕРКИ'))
self.stdout.write('=' * 70)
self.stdout.write(self.style.SUCCESS(f'[ok!] Корректных кодов: {valid_count}'))
if broken_count > 0:
self.stdout.write(self.style.ERROR(f'[err] Некорректных кодов: {broken_count}'))
if broken_count <= 20:
# Показываем все если мало
self.stdout.write('\nДетали некорректных кодов:')
for item in broken_offers:
if 'error' in item:
self.stdout.write(
self.style.ERROR(
f" ID={item['id']}, код='{item['code']}', ошибка: {item['error']}"
)
)
else:
self.stdout.write(
self.style.ERROR(
f" ID={item['id']}, код='{item['code']}', "
f"декодируется в {item['decoded']}, причина: {item['reason']}"
)
)
else:
self.stdout.write(f'\nПервые 20 некорректных кодов:')
for item in broken_offers[:20]:
if 'error' in item:
self.stdout.write(
self.style.ERROR(
f" ID={item['id']}, код='{item['code']}', ошибка: {item['error']}"
)
)
else:
self.stdout.write(
self.style.ERROR(
f" ID={item['id']}, код='{item['code']}', "
f"декодируется в {item['decoded']}, причина: {item['reason']}"
)
)
else:
self.stdout.write(self.style.SUCCESS('Все коды корректны! [ok!]'))
self.stdout.write(f'\nОбщее количество: {valid_count + broken_count}')
self.stdout.write(
self.style.WARNING(
'\nДля обновления некорректных кодов используйте: '
'--fix-broken'
)
)
def _fix_broken_codes(self, queryset, hashids, dry_run, verbose, total_count):
"""
Режим исправления: обновляем только те коды которые не декодируются правильно.
"""
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.SUCCESS('РЕЖИМ ИСПРАВЛЕНИЯ НЕКОРРЕКТНЫХ КОДОВ'))
self.stdout.write('=' * 70)
updated = 0
skipped = 0
failed = 0
if dry_run:
self.stdout.write(self.style.WARNING('!! DRY-RUN: изменения НЕ будут сохранены !!'))
for idx, offer in enumerate(queryset.iterator(chunk_size=1000), 1):
try:
# Проверяем корректность текущего кода
decoded_ids = hashids.decode(offer.s_offer_code)
if decoded_ids and decoded_ids[0] == offer.id:
# Код корректный - пропускаем
skipped += 1
if verbose:
self.stdout.write(f' [{idx}/{total_count}] ID={offer.id} [ok!] пропущен (корректный)')
continue
# Код некорректный - обновляем
new_code = hashids.encode(offer.id)
if verbose:
self.stdout.write(
f' [{idx}/{total_count}] ID={offer.id}: '
f'"{offer.s_offer_code}""{new_code}"'
)
if not dry_run:
# Обновляем напрямую (без вызова save() чтобы не было валидации)
TbOffer.objects.filter(id=offer.id).update(s_offer_code=new_code)
updated += 1
except Exception as e:
failed += 1
self.stdout.write(
self.style.ERROR(
f' [{idx}/{total_count}] ID={offer.id} [err] код "{offer.s_offer_code}" ОШИБКА: {str(e)}'
)
)
# Показываем прогресс каждые 1000
if idx % 1000 == 0:
self.stdout.write(f'Обработано: {idx}/{total_count}')
# Отчет
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.SUCCESS('ИТОГОВЫЙ ОТЧЕТ ИСПРАВЛЕНИЯ'))
self.stdout.write('=' * 70)
self.stdout.write(f'Пропущено (корректные): {skipped}')
if not dry_run:
self.stdout.write(self.style.SUCCESS(f'Обновлено: {updated}'))
else:
self.stdout.write(self.style.WARNING(f'Было бы обновлено: {updated} (dry-run)'))
if failed > 0:
self.stdout.write(self.style.ERROR(f'Ошибок: {failed}'))
else:
self.stdout.write(self.style.SUCCESS('Ошибок: 0'))
self.stdout.write(f'\nОбщее количество обработано: {skipped + updated + failed}')
if dry_run:
self.stdout.write(self.style.WARNING('Изменения НЕ были сохранены (dry-run)'))
def _regenerate_all_codes(self, queryset, hashids, dry_run, verbose, total_count):
"""
Режим полного обновления: перегенерируем ВСЕ коды.
ВНИМАНИЕ: Используется при смене OFFER_HASHIDS_SALT!
"""
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.ERROR('РЕЖИМ ПОЛНОГО ОБНОВЛЕНИЯ ВСЕХ КОДОВ'))
self.stdout.write('=' * 70)
self.stdout.write(
self.style.WARNING(
'ВНИМАНИЕ! Это обновит ВСЕ коды. Старые коды и QR-коды перестанут работать!\n'
'Используется только при смене OFFER_HASHIDS_SALT на сервере.'
)
)
updated = 0
unchanged = 0
failed = 0
if dry_run:
self.stdout.write(self.style.WARNING('!! DRY-RUN: изменения НЕ будут сохранены !!'))
for idx, offer in enumerate(queryset.iterator(chunk_size=1000), 1):
try:
# Генерируем новый код
new_code = hashids.encode(offer.id)
# Проверяем изменился ли
if offer.s_offer_code == new_code:
unchanged += 1
if verbose:
self.stdout.write(f' [{idx}/{total_count}] ID={offer.id}: без изменений')
else:
if verbose:
self.stdout.write(
f' [{idx}/{total_count}] ID={offer.id}: '
f'"{offer.s_offer_code}""{new_code}"'
)
if not dry_run:
# Обновляем напрямую в БД
TbOffer.objects.filter(id=offer.id).update(s_offer_code=new_code)
updated += 1
except Exception as e:
failed += 1
self.stdout.write(
self.style.ERROR(
f' [{idx}/{total_count}] ID={offer.id} [err] код "{offer.s_offer_code}" ОШИБКА: {str(e)}'
)
)
# Показываем прогресс каждые 1000
if idx % 1000 == 0:
self.stdout.write(f'Обработано: {idx}/{total_count}')
# Отчет
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.SUCCESS('ИТОГОВЫЙ ОТЧЕТ ПОЛНОГО ОБНОВЛЕНИЯ'))
self.stdout.write('=' * 70)
self.stdout.write(f'Без изменений: {unchanged}')
if not dry_run:
self.stdout.write(self.style.SUCCESS(f'Обновлено: {updated}'))
else:
self.stdout.write(self.style.WARNING(f'Было бы обновлено: {updated} (dry-run)'))
if failed > 0:
self.stdout.write(self.style.ERROR(f'Ошибок: {failed}'))
else:
self.stdout.write(self.style.SUCCESS('Ошибок: 0'))
# Параметры
self.stdout.write(f'\nПараметры Hashids:')
self.stdout.write(f' Соль: {OFFER_HASHIDS_SALT[:16]}...')
self.stdout.write(f' Минимальная длина: {OFFER_HASHIDS_MIN_LENGTH}')
self.stdout.write(f'\nОбщее количество обработано: {unchanged + updated + failed}')
if dry_run:
self.stdout.write(self.style.WARNING('Изменения НЕ были сохранены (dry-run)'))
else:
self.stdout.write(self.style.SUCCESS('Команда завершена успешно! ✓'))