diff --git a/lpon_site/frontend/management/__init__.py b/lpon_site/frontend/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lpon_site/frontend/management/commands/__init__.py b/lpon_site/frontend/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lpon_site/frontend/management/commands/regenerate_offer_codes.py b/lpon_site/frontend/management/commands/regenerate_offer_codes.py new file mode 100644 index 0000000..a9f35bb --- /dev/null +++ b/lpon_site/frontend/management/commands/regenerate_offer_codes.py @@ -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('Команда завершена успешно! ✓'))