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
36 changes: 36 additions & 0 deletions tickets/templates/tickets/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,29 @@
.navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") !important;
}

.impersonation-badge {
position: fixed;
top: 10px;
right: 10px;
z-index: 1200;
background: #ffc107;
color: #212529;
border-radius: 999px;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
font-size: 14px;
font-weight: 600;
}

.impersonation-badge a {
color: #212529;
text-decoration: underline;
font-weight: 700;
}
</style>
<!-- TODO move this shit to some other place -->

Expand Down Expand Up @@ -127,6 +150,13 @@

<body class="home blog ">

{% if request.session.impersonation_admin_user_id %}
<div class="impersonation-badge">
<span>Impersonalizando: {{ user.email }}</span>
<a href="{% url 'stop_impersonation' %}">Salir</a>
</div>
{% endif %}

<div class="content d-flex flex-column min-vh-100">

<div id="masthead" class="site-header {% block hero %}{% endblock %}">
Expand All @@ -138,6 +168,12 @@
<a class="btn btn-danger text-white mb-2 mx-2" href="{% url 'my_ticket' %}">
Mis bonos
</a>
{% if request.session.impersonation_admin_user_id %}
<a class="btn btn-warning mb-2 mx-2 text-dark text-decoration-none"
href="{% url 'stop_impersonation' %}">
Salir de impersonalizacion
</a>
{% endif %}
{% if user.is_staff %}
<a class="btn btn-success mb-2 mx-2 text-white text-decoration-none" href="{% url 'admin:index' %}">
Ver Admin
Expand Down
70 changes: 65 additions & 5 deletions user_profile/admin.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
from django.contrib import admin
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User
from django.utils.html import format_html_join
from django.utils.html import format_html, format_html_join
from django.shortcuts import get_object_or_404, redirect
from django.urls import path, reverse

from .models import Profile, SedeSubscription, SedeSubscriptionPlan, SedeUnmatchedSubscription

from .impersonation import IMPERSONATION_ADMIN_USER_ID_SESSION_KEY

User.__str__ = lambda self: f'{self.first_name} {self.last_name} ({self.email})'

Expand Down Expand Up @@ -54,7 +59,7 @@ class CustomUserAdmin(UserAdmin):
inlines = (ProfileInline,)
list_display = (
'email', 'is_staff', 'is_superuser', 'first_name', 'last_name', 'get_phone', 'get_document_type',
'get_document_number', 'get_miembro_sede')
'get_document_number', 'get_miembro_sede', 'impersonate_link')

search_fields = ('email', 'first_name', 'last_name', 'profile__document_number', 'profile__phone')

Expand All @@ -79,6 +84,61 @@ def get_miembro_sede(self, instance):
get_miembro_sede.short_description = 'La Sede'
get_miembro_sede.boolean = True

def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
'<int:user_id>/impersonate/',
self.admin_site.admin_view(self.impersonate_user_view),
name='user_profile_user_impersonate',
),
]
return custom_urls + urls

def impersonate_link(self, instance):
if instance.is_superuser:
return '-'
url = reverse('admin:user_profile_user_impersonate', args=[instance.pk])
return format_html('<a class="button" href="{}">Impersonalizar</a>', url)

impersonate_link.short_description = 'Impersonalizar'

def impersonate_user_view(self, request, user_id):
if not request.user.is_superuser:
self.message_user(
request,
'Solo superusuarios pueden impersonalizar usuarios.',
level=messages.ERROR,
)
return redirect('admin:auth_user_changelist')

target_user = get_object_or_404(User, pk=user_id)
if target_user.pk == request.user.pk:
self.message_user(
request,
'No podés impersonalizar tu propio usuario.',
level=messages.WARNING,
)
return redirect('admin:auth_user_change', target_user.pk)

if target_user.is_superuser:
self.message_user(
request,
'No se permite impersonalizar otro superusuario.',
level=messages.ERROR,
)
return redirect('admin:auth_user_change', target_user.pk)

request.session.setdefault(IMPERSONATION_ADMIN_USER_ID_SESSION_KEY, request.user.pk)
login(request, target_user, backend=settings.AUTHENTICATION_BACKENDS[0])

self.message_user(
request,
f'Ahora navegás como {target_user.email}.',
level=messages.INFO,
)
return redirect('mi_fuego')


# Quitar el registro original y registrar el nuevo UserAdmin
admin.site.unregister(User)
Expand Down Expand Up @@ -120,6 +180,6 @@ class SedeUnmatchedSubscriptionAdmin(admin.ModelAdmin):

@admin.register(SedeSubscriptionPlan)
class SedeSubscriptionPlanAdmin(admin.ModelAdmin):
list_display = ('plan_id', 'plan_name', 'is_enabled', 'subscriptions_count', 'last_seen_at')
list_display = ('plan_id', 'plan_name', 'billing_cycle', 'is_enabled', 'subscriptions_count', 'last_seen_at')
list_filter = ('is_enabled',)
search_fields = ('plan_id', 'plan_name')
search_fields = ('plan_id', 'plan_name', 'billing_cycle')
28 changes: 2 additions & 26 deletions user_profile/admin_sede_subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
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 All @@ -20,8 +19,8 @@ def admin_sede_subscriptions_view(request):
messages.success(
request,
(
f"Refresh listo: {summary.get('refreshed_plans', 0)} plan(es) "
f"desde {summary.get('total_subscriptions', 0)} suscripción(es)."
f"Refresh de planes listo: {summary.get('refreshed_plans', 0)} plan(es) "
f"upsertados desde {summary.get('total_subscriptions', 0)} suscripción(es)."
),
)
except Exception as exc:
Expand All @@ -41,29 +40,6 @@ 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
1 change: 1 addition & 0 deletions user_profile/impersonation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
IMPERSONATION_ADMIN_USER_ID_SESSION_KEY = 'impersonation_admin_user_id'
18 changes: 18 additions & 0 deletions user_profile/migrations/0009_sedesubscriptionplan_billing_cycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.15 on 2026-05-24 17:45

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('user_profile', '0008_sede_subscription_redesign'),
]

operations = [
migrations.AddField(
model_name='sedesubscriptionplan',
name='billing_cycle',
field=models.CharField(blank=True, default='', max_length=64),
),
]
1 change: 1 addition & 0 deletions user_profile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ def __str__(self):
class SedeSubscriptionPlan(BaseModel):
plan_id = models.CharField(max_length=64, unique=True)
plan_name = models.CharField(max_length=255, blank=True, default='')
billing_cycle = models.CharField(max_length=64, blank=True, default='')
is_enabled = models.BooleanField(default=False)
subscriptions_count = models.PositiveIntegerField(default=0)
last_seen_at = models.DateTimeField(null=True, blank=True)
Expand Down
60 changes: 52 additions & 8 deletions user_profile/services/sede_mercadopago.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,36 +77,78 @@ def _is_forced_active_subscription(subscription_id):
return str(subscription_id) in _get_forced_active_subscription_ids()


def refresh_sede_subscription_plans(log=None):
log = log or logger
sdk = get_mp_sdk()
subscriptions = fetch_all_subscriptions(sdk=sdk)
def _normalize_frequency_type(value):
mapping = {
'days': 'dia',
'day': 'dia',
'months': 'mes',
'month': 'mes',
'years': 'anio',
'year': 'anio',
'weeks': 'semana',
'week': 'semana',
}
return mapping.get(str(value or '').strip().lower(), str(value or '').strip().lower())


def _format_billing_cycle(auto_recurring):
auto_recurring = auto_recurring or {}
frequency = auto_recurring.get('frequency')
frequency_type = _normalize_frequency_type(auto_recurring.get('frequency_type'))
if not frequency or not frequency_type:
return ''
if str(frequency) == '1':
return f'cada {frequency_type}'
return f'cada {frequency} {frequency_type}s'


def _build_plan_catalog(subscriptions):
plan_catalog = {}
for sub in subscriptions:
plan_id = (sub.get('preapproval_plan_id') or '').strip()
if not plan_id:
continue
plan_name = (sub.get('reason') or '').strip()
item = plan_catalog.setdefault(plan_id, {'name': '', 'count': 0})
billing_cycle = _format_billing_cycle(sub.get('auto_recurring'))
item = plan_catalog.setdefault(plan_id, {'name': '', 'count': 0, 'billing_cycle': '', 'cycles': {}})
item['count'] += 1
if plan_name and (not item['name'] or len(plan_name) > len(item['name'])):
item['name'] = plan_name
if billing_cycle:
item['cycles'][billing_cycle] = item['cycles'].get(billing_cycle, 0) + 1

return plan_catalog


def _upsert_subscription_plans(plan_catalog, log=None):
log = log or logger
refreshed = 0
now = timezone.now()
for plan_id, info in plan_catalog.items():
if info['cycles']:
info['billing_cycle'] = max(info['cycles'].items(), key=lambda pair: pair[1])[0]

SedeSubscriptionPlan.objects.update_or_create(
plan_id=plan_id,
defaults={
'plan_name': info['name'] or '',
'billing_cycle': info['billing_cycle'] or '',
'subscriptions_count': info['count'],
'last_seen_at': now,
},
)
refreshed += 1

log.info('Refreshed %d MercadoPago subscription plan(s)', refreshed)
return refreshed


def refresh_sede_subscription_plans(log=None):
log = log or logger
sdk = get_mp_sdk()
subscriptions = fetch_all_subscriptions(sdk=sdk)
refreshed = _upsert_subscription_plans(_build_plan_catalog(subscriptions), log=log)

return {
'total_subscriptions': len(subscriptions),
'refreshed_plans': refreshed,
Expand Down Expand Up @@ -947,7 +989,7 @@ def _process_subscription(sdk, subscription_summary, user_index, assigned_users,
'match_method': 'subscription_id',
'user_id': existing_subscription.profile.user_id,
'user_email': existing_subscription.profile.user.email,
'message': f'Updated existing subscription for {existing_subscription.profile.user.email}',
'message': f'Updated existing subscription for {existing_subscription.profile.user.email}',
})
return result, sub_id if active else None, details

Expand Down Expand Up @@ -983,7 +1025,7 @@ def _process_subscription(sdk, subscription_summary, user_index, assigned_users,
'match_method': match_method,
'user_id': user.id,
'user_email': user.email,
'message': f'Vinculado con {user.email} ({match_method})',
'message': f'Vinculado con {user.email} ({match_method})',
})
if match_method == 'name' and details.get('match_meta'):
result['message'] += f" — score {details['match_meta'].get('score')}"
Expand All @@ -995,7 +1037,7 @@ def _process_subscription(sdk, subscription_summary, user_index, assigned_users,
or _is_forced_active_subscription(sub_id)
)
log.info(
' Matched -> %s via %s (active=%s)',
' Matched -> %s via %s (active=%s)',
user.email,
match_method,
active,
Expand Down Expand Up @@ -1144,6 +1186,7 @@ def run_full_sync(log=None):

log.info('Fetching subscriptions from MercadoPago...')
subscriptions = fetch_all_subscriptions(sdk, plan_ids=plan_ids)
refreshed_plans = _upsert_subscription_plans(_build_plan_catalog(subscriptions), log=log)
authorized_subscriptions = [
sub for sub in subscriptions if (sub.get('status') or '').lower() == 'authorized'
]
Expand Down Expand Up @@ -1232,6 +1275,7 @@ def run_full_sync(log=None):

summary = {
'total': total,
'refreshed_plans': refreshed_plans,
'authorized_total': len(authorized_subscriptions),
'update_only_total': len(update_only_subscriptions),
'matched': matched_count,
Expand Down
Loading
Loading