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
92 changes: 80 additions & 12 deletions tickets/views/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from django.http import JsonResponse, HttpResponseForbidden
from django.utils import timezone
from tickets.models import NewTicket
from events.models import Event
from events.models import Event, GrupoMiembro
from django.core.exceptions import ObjectDoesNotExist
from user_profile.models import Profile

Expand All @@ -25,6 +25,35 @@ def has_scanner_access(user, event=None):
return user.groups.filter(name='Puerta').exists()


def _group_info_for_ticket(ticket):
"""Return early access and group info for the ticket's holder (or owner)."""
user = ticket.holder or ticket.owner
if not user:
return None
gm = GrupoMiembro.objects.filter(
grupo__event=ticket.event,
user=user,
).select_related('grupo', 'grupo__tipo').first()
if not gm:
return None
has_early = gm.ingreso_anticipado or bool(gm.ingreso_anticipado_fecha)
grupo_label = gm.grupo.nombre
if getattr(gm.grupo, 'tipo', None) and gm.grupo.tipo:
grupo_label = f"{gm.grupo.tipo.nombre} - {gm.grupo.nombre}"
# Fecha desde la cual tiene ingreso anticipado (hora tal como en la DB, sin convertir timezone)
ingreso_desde = None
if gm.ingreso_anticipado_fecha:
ingreso_desde = gm.ingreso_anticipado_fecha.strftime("%d/%m/%Y")
elif gm.grupo.ingreso_anticipado_desde:
ingreso_desde = gm.grupo.ingreso_anticipado_desde.strftime("%d/%m/%Y %H:%M")
return {
'has_early_access': has_early,
'grupo_nombre': grupo_label,
'ingreso_anticipado_desde': ingreso_desde,
'late_checkout': gm.late_checkout,
}


def _ticket_check_response(ticket):
"""Build the JSON response dict for check_ticket / check_ticket_by_dni."""
profile = getattr(ticket.holder, 'profile', None) if ticket.holder else None
Expand All @@ -36,7 +65,7 @@ def _ticket_check_response(ticket):
'document_type': profile.document_type if profile else None,
'document_number': profile.document_number if profile else None,
}
return {
out = {
'key': ticket.key,
'ticket_type': str(ticket.ticket_type),
'is_used': ticket.is_used,
Expand All @@ -61,6 +90,12 @@ def _ticket_check_response(ticket):
'owner_name': f"{ticket.owner.first_name} {ticket.owner.last_name}" if ticket.owner else None,
'user_info': user_info,
}
group_info = _group_info_for_ticket(ticket)
if group_info:
out['group_info'] = group_info
else:
out['group_info'] = None
return out


@user_passes_test(is_admin_or_puerta)
Expand Down Expand Up @@ -207,24 +242,57 @@ def check_ticket(request, ticket_key):

@login_required
def check_ticket_by_dni(request):
"""Look up a ticket by holder DNI for the current event (scanner search by DNI)."""
"""Look up ticket(s) by DNI or last name for the current event. Returns single ticket or list of results."""
event_slug = request.GET.get('event_slug')
dni = (request.GET.get('dni') or '').strip()
q = (request.GET.get('q') or request.GET.get('dni') or '').strip()
if not event_slug:
return JsonResponse({'error': 'Falta el evento (event_slug)'}, status=400)
if not dni:
return JsonResponse({'error': 'Ingresá el DNI para buscar'}, status=400)
if not q:
return JsonResponse({'error': 'Ingresá DNI o apellido para buscar'}, status=400)
try:
event = Event.objects.get(slug=event_slug)
if not has_scanner_access(request.user, event):
return JsonResponse({'error': 'No tienes permisos para verificar tickets de este evento'}, status=403)
profile = Profile.objects.filter(document_number__iexact=dni).first()
if not profile:
return JsonResponse({'error': 'No se encontró ningún bono con ese DNI en este evento'}, status=404)
ticket = NewTicket.objects.filter(event=event, holder=profile.user).order_by('id').first()
if not ticket:

# 1) Try exact DNI match first
profile = Profile.objects.filter(document_number__iexact=q).first()
if profile:
ticket = NewTicket.objects.filter(event=event, holder=profile.user).order_by('id').first()
if ticket:
return JsonResponse(_ticket_check_response(ticket))
return JsonResponse({'error': 'No se encontró ningún bono con ese DNI en este evento'}, status=404)
return JsonResponse(_ticket_check_response(ticket))

# 2) Search by last name (holder)
tickets = (
NewTicket.objects.filter(event=event, holder__last_name__icontains=q)
.select_related('holder', 'ticket_type')
.order_by('holder__last_name', 'holder__first_name')
)
if not tickets.exists():
return JsonResponse({'error': 'No se encontró ningún bono con ese DNI o apellido en este evento'}, status=404)
if tickets.count() == 1:
return JsonResponse(_ticket_check_response(tickets[0]))

# 3) Multiple matches: return list for user to choose
def _holder_doc(t):
if not t.holder:
return ''
p = getattr(t.holder, 'profile', None)
return p.document_number if p else ''
results = []
for t in tickets:
gi = _group_info_for_ticket(t)
results.append({
'key': str(t.key),
'ticket_type': str(t.ticket_type),
'holder_name': f'{t.holder.first_name or ""} {t.holder.last_name or ""}'.strip() if t.holder else '',
'document_number': _holder_doc(t),
'is_used': t.is_used,
'has_early_access': gi['has_early_access'] if gi else False,
'grupo_nombre': gi['grupo_nombre'] if gi else None,
'ingreso_anticipado_desde': gi.get('ingreso_anticipado_desde') if gi else None,
})
return JsonResponse({'results': results})
except Event.DoesNotExist:
return JsonResponse({'error': 'Evento no encontrado'}, status=404)
except Exception as e:
Expand Down
148 changes: 119 additions & 29 deletions user_profile/templates/mi_fuego/admin/scan_tickets.html
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@
<div class="col-md-8 col-lg-6">
<div class="card">
<div class="card-body">
{% if event %}
<!-- Buscar por DNI o apellido -->
<div class="mb-4">
<label class="form-label text-muted small mb-2">Buscar por DNI o apellido</label>
<div class="input-group">
<input type="text" id="dni-search-input" class="form-control form-control-lg" placeholder="DNI o apellido" autocomplete="off">
<button type="button" id="dni-search-btn" class="btn btn-outline-primary btn-lg">
<i class="fas fa-search me-1"></i> Buscar
</button>
</div>
</div>
{% endif %}
<div class="video-container mb-3" style="border-radius: 8px; overflow: hidden; width: 100%; height: 400px; display: flex; align-items: center; justify-content: center; background-color: #000; position: relative;">
<video id="qr-video" style="background-color: #000; display: none;"></video>
<div id="scanning-overlay" class="d-none">
Expand All @@ -142,18 +154,27 @@
<i class="fas fa-stop me-2"></i>Detener Cámara
</button>
</div>
{% if event %}
<!-- Buscar por DNI -->
<div class="border-top pt-3 mt-3">
<label class="form-label text-muted small mb-2">Buscar por DNI</label>
<div class="input-group">
<input type="text" id="dni-search-input" class="form-control form-control-lg" placeholder="Ingresá el DNI" inputmode="numeric" autocomplete="off">
<button type="button" id="dni-search-btn" class="btn btn-outline-primary btn-lg">
<i class="fas fa-search me-1"></i> Buscar
</button>
</div>
</div>
</div>
</div>
</div>
</div>

<div id="results-list-view" class="d-none">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Varios resultados</h5>
<button type="button" id="backFromResults" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Volver
</button>
</div>
<div class="card-body">
<p class="text-muted small mb-3">Elegí el bono para ver el detalle:</p>
<div id="results-list-container" class="list-group list-group-flush">
<!-- filled by JS -->
</div>
{% endif %}
</div>
</div>
</div>
Expand Down Expand Up @@ -213,6 +234,12 @@ <h6 id="ticket-holder" class="mb-0"></h6>
<h6 id="ticket-scanned-by" class="mb-0"></h6>
</div>
</div>
<div class="col-12 d-none" id="group-info-container">
<div class="detail-item">
<label class="text-muted">Ingreso anticipado / Grupo</label>
<h6 id="ticket-group-info" class="mb-0"></h6>
</div>
</div>
</div>
</div>
<div id="ticket-actions" class="d-flex justify-content-center">
Expand Down Expand Up @@ -473,6 +500,8 @@ <h5 class="modal-title" id="photoModalLabel">Imagen</h5>
function showScannerView() {
document.getElementById('scanner-view').classList.remove('d-none');
document.getElementById('ticket-view').classList.add('d-none');
const rlv = document.getElementById('results-list-view');
if (rlv) rlv.classList.add('d-none');
document.getElementById('error-message').classList.add('d-none');
document.getElementById('success-message').classList.add('d-none');
document.getElementById('notes-photos-section').classList.add('d-none');
Expand All @@ -482,11 +511,40 @@ <h5 class="modal-title" id="photoModalLabel">Imagen</h5>
function showTicketView() {
document.getElementById('scanner-view').classList.add('d-none');
document.getElementById('ticket-view').classList.remove('d-none');
const rlv = document.getElementById('results-list-view');
if (rlv) rlv.classList.add('d-none');
document.getElementById('error-message').classList.add('d-none');
document.getElementById('success-message').classList.add('d-none');

// Don't hide saved-info-section here - let showSavedInformation handle it
// The saved-info-section visibility should be managed by showSavedInformation function
}

function showResultsListView(results) {
document.getElementById('scanner-view').classList.add('d-none');
document.getElementById('ticket-view').classList.add('d-none');
document.getElementById('results-list-view').classList.remove('d-none');
document.getElementById('error-message').classList.add('d-none');
const container = document.getElementById('results-list-container');
if (!container) return;
container.innerHTML = results.map(function(r) {
const usedBadge = r.is_used ? '<span class="badge bg-warning text-dark ms-2">Usado</span>' : '';
let earlyLine = '';
if (r.has_early_access && r.grupo_nombre) {
const desde = r.ingreso_anticipado_desde ? ' — desde ' + r.ingreso_anticipado_desde : '';
earlyLine = '<br><small class="text-success"><i class="fas fa-clock me-1"></i>Ingreso anticipado — ' + (r.grupo_nombre || '') + desde + '</small>';
} else if (r.grupo_nombre) {
earlyLine = '<br><small class="text-muted">Grupo: ' + r.grupo_nombre + '</small>';
}
return '<div class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">' +
'<div><strong>' + (r.holder_name || '—') + '</strong>' + (r.document_number ? ' <span class="text-muted">' + r.document_number + '</span>' : '') + '<br><small class="text-muted">' + (r.ticket_type || '') + '</small>' + usedBadge + earlyLine + '</div>' +
'<button type="button" class="btn btn-sm btn-outline-primary ver-bono-btn" data-key="' + r.key + '"><i class="fas fa-ticket-alt me-1"></i> Ver bono</button>' +
'</div>';
}).join('');
container.querySelectorAll('.ver-bono-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
checkTicket(btn.dataset.key);
});
});
}

async function initializeScanner() {
Expand Down Expand Up @@ -644,9 +702,9 @@ <h5 class="modal-title" id="photoModalLabel">Imagen</h5>
const dniInput = document.getElementById('dni-search-input');
const btn = document.getElementById('dni-search-btn');
if (!dniInput || !btn) return;
const dni = (dniInput.value || '').trim();
if (!dni) {
showError('Ingresá el DNI para buscar');
const q = (dniInput.value || '').trim();
if (!q) {
showError('Ingresá DNI o apellido para buscar');
return;
}
const eventSlug = '{{ event.slug|default:"" }}';
Expand All @@ -657,7 +715,7 @@ <h5 class="modal-title" id="photoModalLabel">Imagen</h5>
try {
btn.disabled = true;
document.getElementById('error-message').classList.add('d-none');
const url = `/api/tickets/check-by-dni/?event_slug=${encodeURIComponent(eventSlug)}&dni=${encodeURIComponent(dni)}`;
const url = `/api/tickets/check-by-dni/?event_slug=${encodeURIComponent(eventSlug)}&q=${encodeURIComponent(q)}`;
const response = await fetch(url, {
headers: {
'X-CSRFToken': getCSRFToken(),
Expand All @@ -666,23 +724,36 @@ <h5 class="modal-title" id="photoModalLabel">Imagen</h5>
});
const data = await response.json();
if (response.ok) {
displayTicketInfo(data);
showTicketView();
if (isScanning) {
await stopScanning();
const toggleButton = document.getElementById('toggleCamera');
const startButton = document.getElementById('startCameraBtn');
startButton.style.display = 'block';
toggleButton.classList.add('d-none');
isScanning = false;
if (data.results && data.results.length > 0) {
showResultsListView(data.results);
if (isScanning) {
await stopScanning();
const toggleButton = document.getElementById('toggleCamera');
const startButton = document.getElementById('startCameraBtn');
startButton.style.display = 'block';
toggleButton.classList.add('d-none');
isScanning = false;
}
dniInput.value = '';
} else {
displayTicketInfo(data);
showTicketView();
if (isScanning) {
await stopScanning();
const toggleButton = document.getElementById('toggleCamera');
const startButton = document.getElementById('startCameraBtn');
startButton.style.display = 'block';
toggleButton.classList.add('d-none');
isScanning = false;
}
dniInput.value = '';
}
dniInput.value = '';
} else {
showError(data.error || 'No se encontró ningún bono con ese DNI');
showError(data.error || 'No se encontró ningún bono');
}
} catch (err) {
console.error('Check by DNI error:', err);
showError('Error al buscar por DNI: ' + (err.message || 'Error desconocido'));
console.error('Check by DNI/apellido error:', err);
showError('Error al buscar: ' + (err.message || 'Error desconocido'));
} finally {
btn.disabled = false;
}
Expand Down Expand Up @@ -904,6 +975,21 @@ <h5 class="modal-title" id="photoModalLabel">Imagen</h5>
scannedByContainer.classList.add('d-none');
}

// Ingreso anticipado / Grupo (con fechas)
const groupInfoContainer = document.getElementById('group-info-container');
const ticketGroupInfo = document.getElementById('ticket-group-info');
if (ticket.group_info && ticket.group_info.grupo_nombre) {
groupInfoContainer.classList.remove('d-none');
const early = ticket.group_info.has_early_access ? 'Sí' : 'No';
let text = 'Ingreso anticipado: ' + early + ' — Grupo: ' + ticket.group_info.grupo_nombre;
if (ticket.group_info.ingreso_anticipado_desde) {
text += ' — desde ' + ticket.group_info.ingreso_anticipado_desde;
}
ticketGroupInfo.textContent = text;
} else {
groupInfoContainer.classList.add('d-none');
}

// Check if ticket has existing data (notes or photos)
const hasNotes = ticket.notes && ticket.notes.trim();
const hasPhotos = ticket.photos && ticket.photos.length > 0;
Expand Down Expand Up @@ -1291,6 +1377,10 @@ <h5 class="modal-title" id="photoModalLabel">Imagen</h5>
}
});
}
const backFromResults = document.getElementById('backFromResults');
if (backFromResults) {
backFromResults.addEventListener('click', showScannerView);
}

// Add event listener for increment attendees left button
const incrementBtn = document.getElementById('decrement-occupancy-btn');
Expand Down
Loading