Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion deprepagos/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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),

Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
184 changes: 57 additions & 127 deletions user_profile/admin_sede_matches.py
Original file line number Diff line number Diff line change
@@ -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 []

Expand Down Expand Up @@ -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}',
}
)
24 changes: 24 additions & 0 deletions user_profile/admin_sede_subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions user_profile/sede_sync_cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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}
Loading
Loading