diff --git a/deprepagos/urls.py b/deprepagos/urls.py index a4d0688..7a39836 100644 --- a/deprepagos/urls.py +++ b/deprepagos/urls.py @@ -5,7 +5,11 @@ from tickets.admin import admin_caja_view, email_has_account, admin_caja_order_view, admin_direct_tickets_view, \ admin_direct_tickets_buyer_view, admin_direct_tickets_congrats_view -from user_profile.admin_sede_matches import admin_sede_matches_view +from user_profile.admin_sede_matches import ( + admin_sede_matches_assign, + admin_sede_matches_user_search, + admin_sede_matches_view, +) from user_profile.admin_sede_subscriptions import admin_sede_subscriptions_view urlpatterns = [ @@ -20,6 +24,8 @@ name='admin_direct_tickets_congrats_view'), path('admin/sede/subscriptions/', admin_sede_subscriptions_view, name='admin_sede_subscriptions_view'), path('admin/sede/matches/', admin_sede_matches_view, name='admin_sede_matches_view'), + path('admin/sede/matches/users/search/', admin_sede_matches_user_search, name='admin_sede_matches_user_search'), + path('admin/sede/matches/assign/', admin_sede_matches_assign, name='admin_sede_matches_assign'), path('admin/', admin.site.urls), diff --git a/tickets/migrations/0071_alter_order_order_type_alter_tickettype_ticket_count.py b/tickets/migrations/0071_alter_order_order_type_alter_tickettype_ticket_count.py new file mode 100644 index 0000000..26f12f6 --- /dev/null +++ b/tickets/migrations/0071_alter_order_order_type_alter_tickettype_ticket_count.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.15 on 2026-05-23 22:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0070_tickettype_do_not_show_in_checkout'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='order_type', + field=models.CharField(choices=[('INTERNATIONAL_TRANSFER', 'Transferencia Internacional'), ('LOCAL_TRANSFER', 'Transferencia Local'), ('ONLINE_PURCHASE', 'Compra Online'), ('CASH_ONSITE', 'Efectivo'), ('MP_QR_CAJA', 'Mercado Pago QR (Caja)'), ('MP_POINT_CAJA', 'Mercado Pago Postnet (Caja)'), ('OTHER', 'Otro')], default='ONLINE_PURCHASE', max_length=32), + ), + migrations.AlterField( + model_name='tickettype', + name='ticket_count', + field=models.IntegerField(blank=True, help_text='Deprecated: usar stock en caja.EventProductStock', null=True), + ), + ] diff --git a/user_profile/admin_sede_matches.py b/user_profile/admin_sede_matches.py index 67174a4..24289dd 100644 --- a/user_profile/admin_sede_matches.py +++ b/user_profile/admin_sede_matches.py @@ -1,66 +1,15 @@ -from django.contrib import messages from django.contrib.auth.models import User from django.contrib.admin.views.decorators import staff_member_required -from django.core.cache import cache from django.db.models import Q -from django.shortcuts import render, redirect +from django.http import JsonResponse +from django.shortcuts import render from django.views.decorators.http import require_http_methods from user_profile.models import SedeUnmatchedSubscription -from user_profile.services.sede_mercadopago import run_match_audit, apply_subscription_to_profile - - -CACHE_KEY = 'sede_match_audit_latest_v1' -CACHE_TTL_SECONDS = 60 * 60 - - -def _build_user_rows(rows): - users = {} - for row in rows: - if not row.get('matched'): - continue - user_id = row.get('user_id') - user_email = row.get('user_email') - if not user_id or not user_email: - continue - - item = users.setdefault( - user_id, - { - 'user_email': user_email, - 'active_count': 0, - 'matched_count': 0, - 'plans': set(), - 'tiers': set(), - }, - ) - item['matched_count'] += 1 - if row.get('status') == 'authorized': - item['active_count'] += 1 - plan_id = row.get('plan_id') - tier_name = row.get('tier_name') - if plan_id and plan_id != '—': - item['plans'].add(plan_id) - if tier_name and tier_name != '—': - item['tiers'].add(tier_name) - - user_rows = [] - for item in users.values(): - user_rows.append( - { - 'user_email': item['user_email'], - 'active_count': item['active_count'], - 'matched_count': item['matched_count'], - 'plans': ', '.join(sorted(item['plans'])) or '—', - 'tiers': ', '.join(sorted(item['tiers'])) or '—', - } - ) +from user_profile.services.sede_mercadopago import apply_subscription_to_profile - user_rows.sort(key=lambda x: (-x['active_count'], -x['matched_count'], x['user_email'])) - return user_rows - -def _search_users(query, limit=50): +def _search_users(query, limit=25): if not query: return [] @@ -99,83 +48,64 @@ def _build_manual_details(unmatched): @staff_member_required -@require_http_methods(['GET', 'POST']) +@require_http_methods(['GET']) def admin_sede_matches_view(request): - if request.method == 'POST': - action = request.POST.get('action') or '' - if action == 'assign_unmatched': - unmatched_id = request.POST.get('unmatched_id') - user_id = request.POST.get('user_id') - search_query = request.POST.get('search_query', '') - try: - unmatched = SedeUnmatchedSubscription.objects.get(id=unmatched_id) - except SedeUnmatchedSubscription.DoesNotExist: - messages.error(request, 'No se encontró la suscripción no matcheada.') - return redirect('admin_sede_matches_view') - - try: - user = User.objects.select_related('profile').get(id=user_id) - except User.DoesNotExist: - messages.error(request, 'No se encontró el usuario seleccionado.') - return redirect(f"{request.path}?selected_unmatched={unmatched.id}&user_q={search_query}") - - profile = getattr(user, 'profile', None) - if not profile: - messages.error(request, 'El usuario no tiene profile asociado.') - return redirect(f"{request.path}?selected_unmatched={unmatched.id}&user_q={search_query}") - - details = _build_manual_details(unmatched) - apply_subscription_to_profile(profile, details, match_method='manual') - unmatched.delete() - messages.success( - request, - f'Suscripción {details["subscription_id"]} vinculada manualmente a {user.email}.', - ) - return redirect('admin_sede_matches_view') - - report = run_match_audit() - if report.get('error'): - messages.error(request, report['error']) - else: - cache.set(CACHE_KEY, report, CACHE_TTL_SECONDS) - summary = report.get('summary') or {} - messages.success( - request, - ( - f"Audit listo: {summary.get('matched', 0)} match, " - f"{summary.get('unmatched', 0)} sin match, " - f"{summary.get('conflicts', 0)} conflictos, " - f"{summary.get('errors', 0)} errores." - ), - ) - return redirect('admin_sede_matches_view') - - report = cache.get(CACHE_KEY) - rows = (report or {}).get('rows') or [] - user_rows = _build_user_rows(rows) - unmatched_rows = SedeUnmatchedSubscription.objects.order_by('-last_seen_at', '-updated_at')[:200] - selected_unmatched_id = request.GET.get('selected_unmatched') - selected_unmatched = None - if selected_unmatched_id: - selected_unmatched = SedeUnmatchedSubscription.objects.filter(id=selected_unmatched_id).first() - if selected_unmatched is None and unmatched_rows: - selected_unmatched = unmatched_rows[0] - - user_q = request.GET.get('user_q', '').strip() - user_candidates = _search_users(user_q) if user_q else [] + unmatched_rows = ( + SedeUnmatchedSubscription.objects.filter(status='authorized') + .order_by('-last_seen_at', '-updated_at')[:500] + ) return render( request, 'admin/admin_sede_matches.html', { - 'title': 'La Sede — Match Audit', - 'report': report, - 'summary': (report or {}).get('summary') or {}, - 'rows': rows, - 'user_rows': user_rows, - 'generated_at': (report or {}).get('generated_at'), + 'title': 'La Sede — Unmatched Active Subscriptions', 'unmatched_rows': unmatched_rows, - 'selected_unmatched': selected_unmatched, - 'user_q': user_q, - 'user_candidates': user_candidates, }, ) + + +@staff_member_required +@require_http_methods(['GET']) +def admin_sede_matches_user_search(request): + query = (request.GET.get('q') or '').strip() + users = _search_users(query) if query else [] + payload = [ + { + 'id': user.id, + 'full_name': (user.get_full_name() or '').strip(), + 'email': user.email, + 'document_number': user.profile.document_number if getattr(user, 'profile', None) else '', + 'miembro_sede': bool(user.profile.miembro_sede) if getattr(user, 'profile', None) else False, + } + for user in users + ] + return JsonResponse({'results': payload}) + + +@staff_member_required +@require_http_methods(['POST']) +def admin_sede_matches_assign(request): + unmatched_id = request.POST.get('unmatched_id') + user_id = request.POST.get('user_id') + if not unmatched_id or not user_id: + return JsonResponse({'ok': False, 'error': 'unmatched_id and user_id are required'}, status=400) + + unmatched = SedeUnmatchedSubscription.objects.filter(id=unmatched_id).first() + if not unmatched: + return JsonResponse({'ok': False, 'error': 'Unmatched subscription not found'}, status=404) + + user = User.objects.select_related('profile').filter(id=user_id, profile__isnull=False).first() + if not user: + return JsonResponse({'ok': False, 'error': 'User not found or missing profile'}, status=404) + + details = _build_manual_details(unmatched) + apply_subscription_to_profile(user.profile, details, match_method='manual') + subscription_id = unmatched.subscription_id + unmatched.delete() + + return JsonResponse( + { + 'ok': True, + 'message': f'Subscription {subscription_id} matched to {user.email}', + } + ) diff --git a/user_profile/admin_sede_subscriptions.py b/user_profile/admin_sede_subscriptions.py index 9daf224..7837574 100644 --- a/user_profile/admin_sede_subscriptions.py +++ b/user_profile/admin_sede_subscriptions.py @@ -4,6 +4,7 @@ from django.views.decorators.http import require_http_methods from user_profile.models import SedeSubscriptionPlan +from user_profile.sede_sync_cron import dispatch_sede_members_sync from user_profile.services.sede_mercadopago import refresh_sede_subscription_plans @@ -40,6 +41,29 @@ def admin_sede_subscriptions_view(request): messages.success(request, f'Plan {plan.plan_id} {state}.') return redirect('admin_sede_subscriptions_view') + if action == 'run_sync_now': + try: + result = dispatch_sede_members_sync() + if result.get('queued'): + messages.success( + request, + 'Sync La Sede disparado en background (Zappa task). Revisá logs para seguimiento.', + ) + else: + summary = result.get('summary') or {} + messages.success( + request, + ( + f"Sync terminado: {summary.get('matched', 0)} match, " + f"{summary.get('unmatched', 0)} sin match, " + f"{summary.get('conflicts', 0)} conflictos, " + f"{summary.get('errors', 0)} errores." + ), + ) + except Exception as exc: + messages.error(request, f'Error al disparar sync: {exc}') + return redirect('admin_sede_subscriptions_view') + plans = SedeSubscriptionPlan.objects.order_by('-is_enabled', 'plan_name', 'plan_id') return render( request, diff --git a/user_profile/sede_sync_cron.py b/user_profile/sede_sync_cron.py index 1a757d1..2235116 100644 --- a/user_profile/sede_sync_cron.py +++ b/user_profile/sede_sync_cron.py @@ -4,6 +4,11 @@ logger = logging.getLogger(__name__) +try: + from zappa.asynchronous import task +except Exception: # pragma: no cover - zappa package may be unavailable locally + task = None + def sync_sede_members(event, context): """ @@ -20,3 +25,26 @@ def sync_sede_members(event, context): except Exception: logger.exception('Fatal error in La Sede membership sync') raise + + +if task: + @task + def sync_sede_members_async(): + """Run La Sede sync asynchronously via Zappa task.""" + return run_full_sync(log=logger) +else: + def sync_sede_members_async(): + """Fallback when zappa.asynchronous is unavailable.""" + return run_full_sync(log=logger) + + +def dispatch_sede_members_sync(): + """ + Trigger La Sede sync from admin. + On Zappa, enqueue async worker; locally fallback to direct execution. + """ + if task: + sync_sede_members_async() + return {'queued': True, 'mode': 'zappa_task'} + summary = sync_sede_members_async() + return {'queued': False, 'mode': 'direct', 'summary': summary} diff --git a/user_profile/services/sede_mercadopago.py b/user_profile/services/sede_mercadopago.py index 8be236d..b2ff7a6 100644 --- a/user_profile/services/sede_mercadopago.py +++ b/user_profile/services/sede_mercadopago.py @@ -27,7 +27,7 @@ ACTIVE_SUBSCRIPTION_STATUSES = {'authorized'} LAST_NAME_SIMILARITY_THRESHOLD = 0.85 FIRST_NAME_SIMILARITY_THRESHOLD = 0.90 -SUBSCRIPTION_PAYMENTS_MONTHS = 2 +SUBSCRIPTION_PAYMENTS_LOOKBACK_DAYS = 375 ALL_HINT_SOURCES = {'subscription', 'customer', 'payment', 'invoice'} API_RETRY_ATTEMPTS = 4 API_RETRY_BASE_SECONDS = 0.75 @@ -319,15 +319,15 @@ def _mp_api_get(sdk, uri, filters=None): return sdk.preapproval()._get(uri=uri, filters=filters or {}) -def _is_within_last_months(value, months=SUBSCRIPTION_PAYMENTS_MONTHS): +def _is_within_lookback_window(value, days=SUBSCRIPTION_PAYMENTS_LOOKBACK_DAYS): dt = parse_mp_datetime(value) if not dt: return False - cutoff = timezone.now() - timedelta(days=months * 31) + cutoff = timezone.now() - timedelta(days=days) return dt >= cutoff -def fetch_subscription_recent_payments(sdk, subscription_id, log=None, months=SUBSCRIPTION_PAYMENTS_MONTHS): +def fetch_subscription_recent_payments(sdk, subscription_id, log=None, lookback_days=SUBSCRIPTION_PAYMENTS_LOOKBACK_DAYS): """ Fetch subscription invoices via /authorized_payments/search, filter last N months, then load full payment records for payer identification data. @@ -348,12 +348,12 @@ def fetch_subscription_recent_payments(sdk, subscription_id, log=None, months=SU recent_invoices = [ invoice for invoice in invoices - if _is_within_last_months(invoice.get('debit_date') or invoice.get('date_created'), months) + if _is_within_lookback_window(invoice.get('debit_date') or invoice.get('date_created'), lookback_days) ] log.info( - ' %d invoice(s) within last %d month(s)', + ' %d invoice(s) within last %d day(s)', len(recent_invoices), - months, + lookback_days, ) payment_ids = [] @@ -745,27 +745,35 @@ def apply_subscription_to_profile(profile, details, match_method=''): return is_active = details.get('status') in ACTIVE_SUBSCRIPTION_STATUSES - sub, _ = SedeSubscription.objects.update_or_create( + defaults = { + 'plan_id': details.get('plan_id') or '', + 'tier_name': details.get('tier_name') or '', + 'status': details.get('status') or '', + 'payment_method': details.get('payment_method') or '', + 'last_payment_date': details.get('last_payment_date'), + 'last_payment_amount': details.get('last_payment_amount'), + 'next_payment_date': details.get('next_payment_date'), + 'member_since': details.get('member_since'), + 'is_active': is_active, + 'matched_via': match_method or '', + 'synced_at': timezone.now(), + } + + existing = SedeSubscription.objects.filter(subscription_id=subscription_id).select_related('profile').first() + if existing: + # Do not replace an existing subscription/user match on periodic sync. + # Keep the matched profile and only refresh subscription values. + for field, value in defaults.items(): + setattr(existing, field, value) + existing.save(update_fields=[*defaults.keys(), 'updated_at']) + _refresh_profile_membership_summary(existing.profile) + return + + SedeSubscription.objects.create( subscription_id=subscription_id, - defaults={ - 'profile': profile, - 'plan_id': details.get('plan_id') or '', - 'tier_name': details.get('tier_name') or '', - 'status': details.get('status') or '', - 'payment_method': details.get('payment_method') or '', - 'last_payment_date': details.get('last_payment_date'), - 'last_payment_amount': details.get('last_payment_amount'), - 'next_payment_date': details.get('next_payment_date'), - 'member_since': details.get('member_since'), - 'is_active': is_active, - 'matched_via': match_method or '', - 'synced_at': timezone.now(), - }, + profile=profile, + **defaults, ) - if sub.profile_id != profile.id: - sub.profile = profile - sub.save(update_fields=['profile', 'updated_at']) - _refresh_profile_membership_summary(profile) diff --git a/user_profile/templates/admin/admin_sede_matches.html b/user_profile/templates/admin/admin_sede_matches.html index 0323d38..8d7de51 100644 --- a/user_profile/templates/admin/admin_sede_matches.html +++ b/user_profile/templates/admin/admin_sede_matches.html @@ -1,35 +1,57 @@ {% extends "admin/base_site.html" %} {% block content %} -
Vista de verificacion de matches entre suscripciones MercadoPago y usuarios (sin modificar datos).
- - + -Este listado se alimenta cuando corre el sync real y no logra vincular una suscripción automáticamente.
+Solo se muestran suscripciones activas (personas que están pagando) que todavía no están matcheadas a un user_profile.
Total pendientes: {{ unmatched_rows|length }}
-| Subscription ID | Payer | DNI hint | Plan / Tier | -Status | Última vez visto | -Resolver | +Acción | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
{{ unmatched.subscription_id }} |
{{ unmatched.payer_first_name }} {{ unmatched.payer_last_name }} @@ -40,168 +62,213 @@ Suscripciones sin match (persistidas){{ unmatched.plan_id|default:"—" }}{{ unmatched.tier_name|default:"—" }} |
- {{ unmatched.status|default:"—" }} | {{ unmatched.last_seen_at|date:"d/m/Y H:i"|default:"—" }} | - - Seleccionar - + | ||||||||
| No hay suscripciones sin match guardadas. | +No hay suscripciones activas sin match. | |||||||||||
{{ selected_unmatched.subscription_id }} —
- {{ selected_unmatched.payer_first_name }} {{ selected_unmatched.payer_last_name }}
- ({{ selected_unmatched.payer_email|default:"sin email" }})
+