diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 00000000..efbeee7d --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,33 @@ +# Product + +## Register + +product + +## Users + +Artistas y equipos de obra completan inscripciones, becas, logística, check-in y checkout principalmente desde el teléfono. Coordinación de Arte y responsables de becas revisan expedientes, montos, evidencias y estados principalmente desde computadora, con apoyo móvil durante el evento. + +## Product Purpose + +Ticketera organiza el expediente completo de cada obra de Fuego Austral: propuesta, seguridad, carta de entendimiento, beca, rendición, logística, placement y checkout. La interfaz debe hacer evidente qué falta, quién puede actuar y qué dato queda registrado. + +## Brand Personality + +Clara, comunitaria y confiable. La interfaz acompaña sin burocracia y transmite trazabilidad cuando hay dinero, permisos o evidencia. + +## Anti-references + +Evitar la apariencia de planilla administrativa sin jerarquía, formularios interminables sin progreso, tarjetas anidadas, etiquetas ambiguas y controles que dependan sólo del color. No convertir el backoffice en una copia genérica de Django Admin. + +## Design Principles + +- Una pantalla, una tarea principal y un siguiente paso visible. +- Mostrar estado, responsable y fecha junto al dato que se revisa. +- Los montos siempre muestran moneda, separadores locales y total convertido antes de guardar. +- Progresivo: primero el resumen, luego el detalle bajo demanda. +- La misma acción debe verse y funcionar igual en móvil y escritorio. + +## Accessibility & Inclusion + +Apuntar a WCAG 2.2 AA: contraste suficiente, foco visible, navegación por teclado, etiquetas asociadas, mensajes de error junto al campo y controles táctiles de al menos 44 px. No depender sólo del color para comunicar estados y respetar `prefers-reduced-motion`. diff --git a/events/admin.py b/events/admin.py index 26065559..c33b929e 100644 --- a/events/admin.py +++ b/events/admin.py @@ -1,7 +1,11 @@ from django.contrib import admin +from django import forms from django.contrib.admin.widgets import FilteredSelectMultiple +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet from django.http import HttpResponse from django.db import connection +from decimal import Decimal from django.urls import path from django.shortcuts import render from django.forms import ModelForm @@ -22,9 +26,11 @@ ArtworkInvitation, ArtworkLogisticsPerson, ArtworkPhoto, + ArtworkCheckoutPhoto, ArtworkProvider, ArtworkProviderVehicle, ) +from .forms import LocalizedDecimalField class EventAdminForm(ModelForm): @@ -1050,9 +1056,118 @@ class ArtProgramAdmin(admin.ModelAdmin): date_hierarchy = 'registration_opens' -class ArtworkGrantItemInline(admin.TabularInline): +class ArtworkGrantItemFormSet(BaseInlineFormSet): + phase = None + + def save_new(self, form, commit=True): + item = super().save_new(form, commit=False) + item.phase = self.phase + if commit: + item.save() + return item + + +class ArtworkBudgetItemFormSet(ArtworkGrantItemFormSet): + phase = ArtworkGrantItem.Phase.BUDGET + + +class ArtworkExpenseItemFormSet(ArtworkGrantItemFormSet): + phase = ArtworkGrantItem.Phase.EXPENSE + + +class ArtworkGrantItemAdminForm(ModelForm): + amount = LocalizedDecimalField( + max_digits=14, decimal_places=2, min_value=Decimal('0.01'), + widget=forms.TextInput(attrs={'inputmode': 'decimal', 'placeholder': '150.000,00'}), + ) + exchange_rate = LocalizedDecimalField( + max_digits=14, decimal_places=4, min_value=Decimal('0.0001'), + widget=forms.TextInput(attrs={'inputmode': 'decimal', 'placeholder': '1.234,56'}), + ) + confirm_large_amount = forms.BooleanField( + required=False, + label='Confirmo el monto si supera ARS 1.000.000', + ) + + class Meta: + model = ArtworkGrantItem + fields = ('item_type', 'concept', 'details', 'amount', 'currency', 'exchange_rate', 'rate_date', 'rate_source', 'review_status', 'review_notes') + + def clean(self): + cleaned = super().clean() + amount = cleaned.get('amount') + if cleaned.get('currency') == ArtworkGrantItem.Currency.USD: + amount = amount * (cleaned.get('exchange_rate') or Decimal('0')) if amount else None + if amount and amount >= Decimal('1000000') and not cleaned.get('confirm_large_amount'): + self.add_error('confirm_large_amount', 'Confirmá el monto convertido antes de guardar.') + return cleaned + + +class ArtworkGrantItemInline(admin.StackedInline): model = ArtworkGrantItem + form = ArtworkGrantItemAdminForm extra = 0 + phase = None + fieldsets = ( + ('Concepto', {'fields': (('item_type', 'concept'), 'details')}), + ('Importe', {'fields': (('amount', 'currency', 'exchange_rate'), ('rate_date', 'rate_source'))}), + ('Revisión', {'fields': ('review_status', 'review_notes', 'confirm_large_amount')}), + ) + + def get_queryset(self, request): + return super().get_queryset(request).filter(phase=self.phase) + + def _is_locked(self, obj): + return obj and self.phase == ArtworkGrantItem.Phase.EXPENSE and obj.grant_status == Artwork.GrantStatus.CLOSED + + def has_add_permission(self, request, obj=None): + return not self._is_locked(obj) and super().has_add_permission(request, obj) + + def has_change_permission(self, request, obj=None): + return not self._is_locked(obj) and super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + return not self._is_locked(obj) and super().has_delete_permission(request, obj) + + +class ArtworkBudgetItemInline(ArtworkGrantItemInline): + phase = ArtworkGrantItem.Phase.BUDGET + formset = ArtworkBudgetItemFormSet + verbose_name = 'Ítem de presupuesto' + verbose_name_plural = 'Presupuesto de la beca' + + +class ArtworkExpenseItemInline(ArtworkGrantItemInline): + phase = ArtworkGrantItem.Phase.EXPENSE + formset = ArtworkExpenseItemFormSet + verbose_name = 'Ítem de rendición' + verbose_name_plural = 'Rendición de la beca' + + +class ArtworkGrantItemPhotoAdminForm(ModelForm): + class Meta: + model = ArtworkGrantItemPhoto + fields = '__all__' + + def clean_item(self): + item = self.cleaned_data['item'] + if item.artwork.grant_status == Artwork.GrantStatus.CLOSED: + raise ValidationError('La rendición está aprobada. Reabrila antes de cambiar comprobantes.') + return item + + +@admin.register(ArtworkGrantItemPhoto) +class ArtworkGrantItemPhotoAdmin(admin.ModelAdmin): + form = ArtworkGrantItemPhotoAdminForm + + def _is_locked(self, obj): + return obj and obj.item.artwork.grant_status == Artwork.GrantStatus.CLOSED + + def has_change_permission(self, request, obj=None): + return not self._is_locked(obj) and super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + return not self._is_locked(obj) and super().has_delete_permission(request, obj) class ArtworkPhotoInline(admin.TabularInline): @@ -1060,6 +1175,11 @@ class ArtworkPhotoInline(admin.TabularInline): extra = 0 +class ArtworkCheckoutPhotoInline(admin.TabularInline): + model = ArtworkCheckoutPhoto + extra = 0 + + class ArtworkInvitationInline(admin.TabularInline): model = ArtworkInvitation extra = 0 @@ -1075,17 +1195,84 @@ class ArtworkProviderInline(admin.TabularInline): extra = 0 +class ArtworkAdminForm(ModelForm): + grant_approved_amount_ars = LocalizedDecimalField( + required=False, max_digits=14, decimal_places=2, min_value=Decimal('0.01'), + label='Monto de beca aprobado', + widget=forms.TextInput(attrs={'inputmode': 'decimal', 'placeholder': '450.000,00'}), + ) + confirm_large_grant_amount = forms.BooleanField( + required=False, + label='Confirmo el monto aprobado si supera ARS 1.000.000', + ) + + class Meta: + model = Artwork + fields = '__all__' + + def clean(self): + cleaned = super().clean() + amount = cleaned.get('grant_approved_amount_ars') + if amount and amount >= Decimal('1000000') and not cleaned.get('confirm_large_grant_amount'): + self.add_error('confirm_large_grant_amount', 'Confirmá el monto aprobado antes de guardar.') + return cleaned + + @admin.register(Artwork) class ArtworkAdmin(admin.ModelAdmin): + form = ArtworkAdminForm list_display = ('title', 'event', 'kind', 'status', 'owner', 'grant_status', 'assigned_location', 'checkout_verified_at', 'updated_at') - list_filter = ('event', 'kind', 'status', 'grant_status', 'checkout_completed', 'submitted_at') + list_filter = ('event', 'kind', 'status', 'grant_status', 'checkout_completed', 'understanding_letter_physical_received', 'submitted_at') search_fields = ('title', 'owner__email', 'public_description') - autocomplete_fields = ('owner', 'collaborators', 'operations_group', 'checkout_art_responsible', 'checkout_verified_by') - readonly_fields = ('submitted_at', 'checkout_requested_at', 'created_at', 'updated_at') - inlines = [ArtworkGrantItemInline, ArtworkPhotoInline, ArtworkInvitationInline, ArtworkLogisticsPersonInline, ArtworkProviderInline] - - -admin.site.register(ArtworkGrantItemPhoto) + autocomplete_fields = ('owner', 'collaborators', 'operations_group', 'checkin_art_by', 'checkout_art_responsible', 'checkout_verified_by', 'safety_responsible') + readonly_fields = ('submitted_at', 'checkin_art_by', 'checkout_requested_at', 'checkout_verified_by', 'created_at', 'updated_at', 'version') + fieldsets = ( + ('Obra', {'fields': ('event', 'owner', 'collaborators', 'operations_group', 'kind', 'title')}), + ('Check-in de obra', {'fields': ('checkin_arrived_at', 'checkin_art_at', 'checkin_art_by', 'checkin_placed', 'checkin_placement_changed', 'checkin_placement_change_notes', 'understanding_letter', 'understanding_letter_physical_received', 'understanding_letter_physical_custodian', 'understanding_letter_physical_notes', 'understanding_letter_physical_waiver', 'understanding_letter_physical_waiver_reason')}), + ('Propuesta y seguridad', {'fields': ('proposal', 'dimensions', 'materials', 'technical_needs', 'safety_plan', 'uses_fire', 'fire_details', 'extinguishing_plan', 'power_watts', 'safety_contact', 'safety_responsible')}), + ('Beca', {'fields': ('grant_requested', 'grant_justification', 'grant_status', 'grant_approved_amount_ars', 'confirm_large_grant_amount', 'grant_decision_notes', 'grant_paid_at', 'grant_payment_reference', 'grant_report')}), + ('Desplegable y placement', {'fields': ('public_title', 'public_description', 'preferred_location', 'assigned_location', 'placement_notes')}), + ('Logística', {'fields': ('arrival_date', 'departure_date', 'crew', 'providers')}), + ('Checkout, paso 1, equipo de la obra', {'fields': ('checkout_completed', 'checkout_team_responsible', 'checkout_notes', 'checkout_requested_at')}), + ('Checkout, paso 2, Arte', {'fields': ('checkout_art_responsible', 'checkout_verified_at', 'checkout_verified_by')}), + ('Seguimiento', {'fields': ('status', 'review_feedback', 'benefit_status', 'benefit_notes', 'submitted_at', 'version', 'created_at', 'updated_at')}), + ) + inlines = [ArtworkBudgetItemInline, ArtworkExpenseItemInline, ArtworkPhotoInline, ArtworkCheckoutPhotoInline, ArtworkInvitationInline, ArtworkLogisticsPersonInline, ArtworkProviderInline] + actions = ('approve_grant_reports', 'reopen_grant_reports') + + def get_readonly_fields(self, request, obj=None): + fields = list(super().get_readonly_fields(request, obj)) + if obj and obj.grant_status == Artwork.GrantStatus.CLOSED: + fields.append('grant_report') + return fields + + def save_model(self, request, obj, form, change): + if obj.checkin_art_at and not obj.checkin_art_by_id: + obj.checkin_art_by = request.user + if obj.checkout_verified_at and not obj.checkout_verified_by_id: + obj.checkout_verified_by = request.user + super().save_model(request, obj, form, change) + + @admin.action(description='Aprobar rendiciones seleccionadas') + def approve_grant_reports(self, request, queryset): + reports = queryset.filter(grant_status=Artwork.GrantStatus.REPORTED) + count = reports.count() + for artwork in reports: + artwork.grant_status = Artwork.GrantStatus.CLOSED + artwork.save(update_fields=['grant_status', 'updated_at']) + self.message_user(request, f'{count} rendición(es) aprobada(s).') + + @admin.action(description='Desaprobar y reabrir rendiciones seleccionadas') + def reopen_grant_reports(self, request, queryset): + reports = queryset.filter(grant_status=Artwork.GrantStatus.CLOSED) + count = reports.count() + for artwork in reports: + artwork.grant_status = Artwork.GrantStatus.REPORTED + artwork.save(update_fields=['grant_status', 'updated_at']) + self.message_user(request, f'{count} rendición(es) reabierta(s).') + + +admin.site.register(ArtworkCheckoutPhoto) admin.site.register(ArtworkProviderVehicle) diff --git a/events/art_views.py b/events/art_views.py index 3b550259..5d1f58e9 100644 --- a/events/art_views.py +++ b/events/art_views.py @@ -15,12 +15,12 @@ from .forms import ( ArtworkForm, ArtworkGrantItemForm, ArtworkLogisticsPersonForm, - ArtworkPhotoUploadForm, ArtworkProviderForm, ArtworkProviderVehicleForm, - ArtworkReviewForm, + ArtworkCheckoutPhotoUploadForm, ArtworkPhotoUploadForm, ArtworkProviderForm, ArtworkProviderVehicleForm, + ArtworkGrantItemReviewForm, ArtworkReviewForm, ) from .models import ( ArtProgram, Artwork, ArtworkGrantItem, ArtworkGrantItemPhoto, - ArtworkInvitation, ArtworkLogisticsPerson, ArtworkPhoto, ArtworkProvider, + ArtworkInvitation, ArtworkLogisticsPerson, ArtworkPhoto, ArtworkCheckoutPhoto, ArtworkProvider, ArtworkProviderVehicle, Event, Grupo, GrupoMiembro, GrupoTipo, ) @@ -37,17 +37,19 @@ def _base_context(event=None): def _checkpoints(program): checkpoints = [ - {'key': 'proposal', 'label': 'Propuesta', 'deadline': program.proposal_deadline, 'state': program.checkpoint_state('proposal')}, + {'key': 'proposal', 'anchor': 'propuesta', 'label': 'Propuesta', 'deadline': program.proposal_deadline, 'state': program.checkpoint_state('proposal')}, ] if program.grants_enabled: - checkpoints.append({'key': 'grant', 'label': 'Beca', 'deadline': program.grant_deadline, 'state': program.checkpoint_state('grant')}) + checkpoints.append({'key': 'grant', 'anchor': 'beca', 'label': 'Beca', 'deadline': program.grant_deadline, 'state': program.checkpoint_state('grant')}) checkpoints += [ - {'key': 'guide', 'label': 'Desplegable', 'deadline': program.guide_deadline, 'state': program.checkpoint_state('guide')}, - {'key': 'logistics', 'label': 'Ingreso y desarme', 'deadline': program.logistics_deadline, 'state': program.checkpoint_state('logistics')}, - {'key': 'checkout', 'label': 'Checkout', 'deadline': program.checkout_deadline, 'state': program.checkpoint_state('checkout')}, + {'key': 'guide', 'anchor': 'placement', 'label': 'Desplegable', 'deadline': program.guide_deadline, 'state': program.checkpoint_state('guide')}, + {'key': 'logistics', 'anchor': 'logistica', 'label': 'Ingreso y desarme', 'deadline': program.logistics_deadline, 'state': program.checkpoint_state('logistics')}, + {'key': 'checkout', 'anchor': 'checkout', 'label': 'Checkout', 'deadline': program.checkout_deadline, 'state': program.checkpoint_state('checkout')}, + {'key': 'understanding_letter_digital', 'anchor': 'carta-entendimiento', 'label': 'Carta de entendimiento digital', 'deadline': program.understanding_letter_digital_deadline, 'state': program.checkpoint_state('understanding_letter_digital')}, + {'key': 'understanding_letter_physical', 'anchor': 'carta-entendimiento', 'label': 'Carta de entendimiento física', 'deadline': program.understanding_letter_physical_deadline, 'state': program.checkpoint_state('understanding_letter_physical')}, ] if program.grants_enabled: - checkpoints.append({'key': 'grant_report', 'label': 'Rendición', 'deadline': program.grant_report_deadline, 'state': program.checkpoint_state('grant_report')}) + checkpoints.append({'key': 'grant_report', 'anchor': 'rendicion', 'label': 'Rendición', 'deadline': program.grant_report_deadline, 'state': program.checkpoint_state('grant_report')}) return checkpoints @@ -57,7 +59,8 @@ def _accessible_artworks(user): collaborator_ids = Artwork.objects.filter(collaborators=user).values('pk') managed_events = Event.objects.filter(admins=user).values('pk') return Artwork.objects.filter( - Q(owner=user) | Q(pk__in=collaborator_ids) | Q(event_id__in=managed_events), + Q(owner=user) | Q(pk__in=collaborator_ids) | Q(event_id__in=managed_events) + | Q(checkout_art_responsible=user), ) @@ -116,6 +119,10 @@ def _grant_context(artwork, inline_forms=None): 'expense_items': expenses, 'budget_total_ars': artwork.grant_total_ars(ArtworkGrantItem.Phase.BUDGET), 'expense_total_ars': artwork.grant_total_ars(ArtworkGrantItem.Phase.EXPENSE), + 'grant_over_budget': bool( + artwork.grant_approved_amount_ars + and artwork.grant_total_ars(ArtworkGrantItem.Phase.EXPENSE) > artwork.grant_approved_amount_ars + ), 'budget_create_form': inline_forms.get(('grant-new', ArtworkGrantItem.Phase.BUDGET)) or ArtworkGrantItemForm( instance=ArtworkGrantItem(artwork=artwork, phase=ArtworkGrantItem.Phase.BUDGET), phase=ArtworkGrantItem.Phase.BUDGET, auto_id='grant-budget-new_%s', @@ -154,6 +161,10 @@ def _artwork_context(artwork, program, form, inline_forms=None): 'artwork': artwork, 'checkpoints': _checkpoints(program), 'photo_upload_form': inline_forms.get(('photo-new', None)) or ArtworkPhotoUploadForm(auto_id='photo-new_%s'), + 'checkout_photo_upload_form': inline_forms.get(('checkout-photo-new', None)) or ArtworkCheckoutPhotoUploadForm( + auto_id='checkout-photo-new_%s', + ), + 'checkout_photos': artwork.checkout_photos.all(), 'logistics_people': people, 'person_create_form': inline_forms.get(('person-new', None)) or ArtworkLogisticsPersonForm( instance=ArtworkLogisticsPerson(artwork=artwork), auto_id='person-new_%s', @@ -168,6 +179,7 @@ def _artwork_context(artwork, program, form, inline_forms=None): 'can_edit_budget': _grant_item_editable(artwork, ArtworkGrantItem.Phase.BUDGET, form.actor), 'can_edit_expenses': _grant_item_editable(artwork, ArtworkGrantItem.Phase.EXPENSE, form.actor), 'can_edit_logistics': _logistics_editable(artwork, form.actor), + 'can_edit_checkout': _checkout_editable(artwork, form.actor), 'can_submit_grant': artwork.can_edit(form.actor) and program.is_current and program.checkpoint_state('grant') == 'open' and artwork.grant_status in (Artwork.GrantStatus.NOT_REQUESTED, Artwork.GrantStatus.INFO_REQUIRED), 'can_submit_report': artwork.can_edit(form.actor) and program.is_current and program.checkpoint_state('grant_report') == 'open' and artwork.grant_status in (Artwork.GrantStatus.APPROVED, Artwork.GrantStatus.PAID), }) @@ -176,18 +188,27 @@ def _artwork_context(artwork, program, form, inline_forms=None): def _review_context(artwork, form, user, inline_forms=None): + inline_forms = inline_forms or {} admin_events = Event.objects.order_by('-id') if user.is_superuser else get_admin_events_for_user(user) + grant_context = _grant_context(artwork, inline_forms) + for item in grant_context['budget_items'] + grant_context['expense_items']: + item.review_form = ArtworkGrantItemReviewForm(instance=item, auto_id=f'grant-review-{item.pk}_%s') return { - **_base_context(artwork.event), **_grant_context(artwork, inline_forms), + **_base_context(artwork.event), **grant_context, 'artwork': artwork, 'form': form, 'current_admin_event': artwork.event, 'admin_events': admin_events, 'nav_primary': 'events', 'nav_secondary': f'art_admin_{artwork.event.slug}', + 'can_manage': artwork.can_manage(user), + 'checkout_photo_upload_form': inline_forms.get(('checkout-photo-new', None)) or ArtworkCheckoutPhotoUploadForm( + auto_id='checkout-photo-new_%s', + ), + 'checkout_photos': artwork.checkout_photos.all(), } def _inline_error_response(request, artwork, key, inline_form): - if request.POST.get('return_to') == 'review' and artwork.can_manage(request.user): - review_form = ArtworkReviewForm(instance=artwork) + if request.POST.get('return_to') == 'review' and artwork.can_administer(request.user): + review_form = ArtworkReviewForm(instance=artwork, can_manage=artwork.can_manage(request.user)) return render( request, 'mi_fuego/art/review.html', _review_context(artwork, review_form, request.user, {key: inline_form}), @@ -206,6 +227,12 @@ def _artwork_redirect(artwork, anchor): return redirect(f"{reverse('artwork_edit', args=[artwork.pk])}#{anchor}") +def _checkout_redirect(request, artwork): + if request.POST.get('return_to') == 'review' and artwork.can_administer(request.user): + return redirect(f"{reverse('artwork_review', args=[artwork.event.slug, artwork.pk])}#checkout-report") + return _artwork_redirect(artwork, 'checkout') + + def _grant_redirect(request, artwork, phase): if request.POST.get('return_to') == 'review' and artwork.can_manage(request.user): anchor = 'admin-budget' if phase == ArtworkGrantItem.Phase.BUDGET else 'admin-expenses' @@ -223,7 +250,14 @@ def art_dashboard(request): .distinct() ) context = _base_context() - context.update({'programs': programs, 'artworks': artworks}) + admin_assignments = Artwork.objects.all() if request.user.is_superuser else Artwork.objects.filter( + Q(checkout_art_responsible=request.user) | Q(event__admins=request.user), + ) + context.update({ + 'programs': programs, + 'artworks': artworks, + 'admin_assignments': admin_assignments.select_related('event', 'owner').distinct(), + }) return render(request, 'mi_fuego/art/dashboard.html', context) @@ -241,12 +275,49 @@ def artwork_create(request, event_slug): request.POST or None, request.FILES or None, instance=artwork, program=program, owner=request.user, actor=request.user, action=action, ) - if request.method == 'POST' and form.is_valid(): + initial_budget_form = ArtworkGrantItemForm( + request.POST or None, request.FILES or None, + instance=ArtworkGrantItem(phase=ArtworkGrantItem.Phase.BUDGET, created_by=request.user), + phase=ArtworkGrantItem.Phase.BUDGET, prefix='initial-budget', auto_id='initial-budget_%s', + ) + initial_photo_form = ArtworkPhotoUploadForm( + request.POST or None, request.FILES or None, prefix='initial-photo', auto_id='initial-photo_%s', + ) + initial_person_form = ArtworkLogisticsPersonForm( + request.POST or None, prefix='initial-person', auto_id='initial-person_%s', + ) + for inline_form in (initial_budget_form, initial_photo_form, initial_person_form): + for field in inline_form.fields.values(): + field.widget.attrs['form'] = 'artwork-form' + + has_initial_budget = bool(request.POST.get('initial-budget-concept')) + has_initial_photos = bool(request.FILES.getlist('initial-photo-images')) + has_initial_person = bool(request.POST.get('initial-person-first_name')) + initial_forms_valid = ( + (not has_initial_budget or initial_budget_form.is_valid()) + and (not has_initial_photos or initial_photo_form.is_valid()) + and (not has_initial_person or initial_person_form.is_valid()) + ) + if request.method == 'POST' and form.is_valid() and initial_forms_valid: with transaction.atomic(): if action == 'submit': form.instance.status = Artwork.Status.SUBMITTED form.instance.submitted_at = timezone.now() artwork = form.save() + if has_initial_budget: + initial_budget_form.instance.artwork = artwork + item = initial_budget_form.save() + _save_grant_item_images(item, initial_budget_form.cleaned_data['images'], request.user) + if has_initial_photos: + for image in initial_photo_form.cleaned_data['images']: + ArtworkPhoto.objects.create( + artwork=artwork, image=image, stage=initial_photo_form.cleaned_data['stage'], + caption=initial_photo_form.cleaned_data['caption'], + publication_authorized=initial_photo_form.cleaned_data['publication_authorized'], uploaded_by=request.user, + ) + if has_initial_person: + initial_person_form.instance.artwork = artwork + initial_person_form.save() if action == 'submit': _ensure_operations_group(artwork, program) transaction.on_commit(lambda invitations=list(form.new_invitations): _send_invitations(invitations)) @@ -254,18 +325,26 @@ def artwork_create(request, event_slug): return redirect('artwork_edit', artwork_id=artwork.pk) context = _base_context(program.event) - context.update({'form': form, 'program': program, 'checkpoints': _checkpoints(program), 'is_new': True}) + context.update({ + 'form': form, 'program': program, 'checkpoints': _checkpoints(program), 'is_new': True, + 'initial_budget_form': initial_budget_form, 'initial_photo_form': initial_photo_form, + 'initial_person_form': initial_person_form, + }) return render(request, 'mi_fuego/art/form.html', context) @login_required def artwork_edit(request, artwork_id): - access = _accessible_artworks(request.user) if request.method == 'POST': with transaction.atomic(): - artwork = get_object_or_404(access.select_for_update(), pk=artwork_id) + artwork = get_object_or_404(Artwork.objects.select_for_update(), pk=artwork_id) + if not artwork.can_edit(request.user) and not artwork.can_manage(request.user): + raise Http404 response = _handle_artwork_edit(request, artwork) return response + access = _accessible_artworks(request.user).filter( + Q(owner=request.user) | Q(collaborators=request.user) | Q(event__admins=request.user), + ).distinct() artwork = get_object_or_404(access, pk=artwork_id) return _handle_artwork_edit(request, artwork) @@ -291,13 +370,15 @@ def _handle_artwork_edit(request, artwork): if action == 'submit' or artwork.operations_group_id: _ensure_operations_group(artwork, program) transaction.on_commit(lambda invitations=list(form.new_invitations): _send_invitations(invitations)) - messages.success(request, 'La propuesta fue enviada.' if action == 'submit' else 'Los cambios quedaron guardados.') + messages.success(request, 'La propuesta fue enviada.' if action == 'submit' else 'El borrador quedó guardado.') return redirect('artwork_edit', artwork_id=artwork.pk) return render(request, 'mi_fuego/art/form.html', _artwork_context(artwork, program, form)) def _grant_item_editable(artwork, phase, user): + if phase == ArtworkGrantItem.Phase.EXPENSE and artwork.grant_status == Artwork.GrantStatus.CLOSED: + return False if artwork.can_manage(user): return True program = artwork.event.art_program @@ -319,6 +400,13 @@ def _logistics_editable(artwork, user): return program.is_current and program.checkpoint_state('logistics') == 'open' and artwork.can_edit(user) +def _checkout_editable(artwork, user): + if artwork.can_administer(user): + return True + program = artwork.event.art_program + return program.is_current and program.checkpoint_state('checkout') == 'open' and artwork.can_edit(user) + + def _save_grant_item_images(item, images, user): for image in images: ArtworkGrantItemPhoto.objects.create(item=item, image=image, uploaded_by=user) @@ -375,6 +463,28 @@ def grant_item_edit(request, artwork_id, item_id): return _inline_error_response(request, artwork, ('grant', item.pk), form) +@login_required +def grant_item_review(request, event_slug, artwork_id, item_id): + if request.method != 'POST': + return HttpResponseForbidden('La revisión del ítem requiere una confirmación.') + with transaction.atomic(): + artwork = get_object_or_404( + Artwork.objects.select_for_update(), pk=artwork_id, event__slug=event_slug, + ) + if not artwork.can_manage(request.user): + return HttpResponseForbidden('No tenés permisos para revisar becas en este evento.') + item = get_object_or_404(ArtworkGrantItem.objects.select_for_update(), pk=item_id, artwork=artwork) + form = ArtworkGrantItemReviewForm(request.POST, instance=item) + if form.is_valid(): + form.save() + messages.success(request, f'Revisión de “{item.concept}” guardada.') + else: + for errors in form.errors.values(): + for error in errors: + messages.error(request, error) + return redirect(f"{reverse('artwork_review', args=[event_slug, artwork_id])}#{'admin-budget' if item.phase == ArtworkGrantItem.Phase.BUDGET else 'admin-expenses'}") + + @login_required def grant_item_delete(request, artwork_id, item_id): if request.method != 'POST': @@ -461,6 +571,8 @@ def grant_report_submit(request, artwork_id): messages.error(request, 'Agregá al menos un gasto a la rendición.') elif not artwork.photos.filter(stage__in=(ArtworkPhoto.Stage.FINAL, ArtworkPhoto.Stage.GRANT_REPORT)).exists(): messages.error(request, 'Subí al menos una foto final o de rendición.') + elif artwork.grant_approved_amount_ars and artwork.expense_total_ars > artwork.grant_approved_amount_ars: + messages.error(request, 'La rendición supera el monto aprobado. Revisá los montos antes de enviarla.') else: artwork.grant_status = Artwork.GrantStatus.REPORTED artwork.save(update_fields=['grant_status', 'updated_at']) @@ -472,10 +584,14 @@ def grant_report_submit(request, artwork_id): def artwork_photo_upload(request, artwork_id): access = _accessible_artworks(request.user) artwork = get_object_or_404(access, pk=artwork_id) + if not artwork.can_edit(request.user) and not artwork.can_manage(request.user): + return HttpResponseForbidden('La galería de esta obra no se puede editar.') if request.method != 'POST': return _artwork_redirect(artwork, 'galeria') with transaction.atomic(): artwork = get_object_or_404(access.select_for_update(), pk=artwork_id) + if not artwork.can_edit(request.user) and not artwork.can_manage(request.user): + return HttpResponseForbidden('La galería de esta obra no se puede editar.') form = ArtworkPhotoUploadForm(request.POST, request.FILES, auto_id='photo-new_%s') if form.is_valid(): images = form.cleaned_data['images'] @@ -502,6 +618,8 @@ def artwork_photo_delete(request, artwork_id, photo_id): return HttpResponseForbidden('Método no permitido.') with transaction.atomic(): artwork = get_object_or_404(_accessible_artworks(request.user).select_for_update(), pk=artwork_id) + if not artwork.can_edit(request.user) and not artwork.can_manage(request.user): + return HttpResponseForbidden('La galería de esta obra no se puede editar.') photo = get_object_or_404(ArtworkPhoto.objects.select_for_update(), pk=photo_id, artwork=artwork) protected_evidence = ( artwork.grant_status in (Artwork.GrantStatus.REPORTED, Artwork.GrantStatus.CLOSED) @@ -517,6 +635,52 @@ def artwork_photo_delete(request, artwork_id, photo_id): return _artwork_redirect(artwork, 'galeria') +@login_required +def artwork_checkout_photo_upload(request, artwork_id): + access = _accessible_artworks(request.user) + artwork = get_object_or_404(access, pk=artwork_id) + if request.method != 'POST': + return _checkout_redirect(request, artwork) + with transaction.atomic(): + artwork = get_object_or_404(access.select_for_update(), pk=artwork_id) + if not _checkout_editable(artwork, request.user): + return HttpResponseForbidden('El checkout de esta obra ya no se puede editar.') + if artwork.checkout_verified_at and not artwork.can_administer(request.user): + return HttpResponseForbidden('La evidencia de un checkout verificado no se puede modificar.') + form = ArtworkCheckoutPhotoUploadForm(request.POST, request.FILES, auto_id='checkout-photo-new_%s') + if form.is_valid(): + images = form.cleaned_data['images'] + if artwork.checkout_photos.count() + len(images) > 100: + form.add_error('images', 'El reporte de checkout admite hasta 100 fotos por obra.') + else: + for image in images: + ArtworkCheckoutPhoto.objects.create( + artwork=artwork, image=image, category=form.cleaned_data['category'], + caption=form.cleaned_data['caption'], uploaded_by=request.user, + ) + messages.success(request, f"Se subieron {len(images)} foto(s) al reporte de checkout.") + return _checkout_redirect(request, artwork) + return _inline_error_response(request, artwork, ('checkout-photo-new', None), form) + + +@login_required +def artwork_checkout_photo_delete(request, artwork_id, photo_id): + if request.method != 'POST': + return HttpResponseForbidden('Método no permitido.') + with transaction.atomic(): + artwork = get_object_or_404(_accessible_artworks(request.user).select_for_update(), pk=artwork_id) + photo = get_object_or_404(ArtworkCheckoutPhoto.objects.select_for_update(), pk=photo_id, artwork=artwork) + if not _checkout_editable(artwork, request.user): + return HttpResponseForbidden('Esta foto no se puede eliminar.') + if artwork.checkout_verified_at and not artwork.can_administer(request.user): + return HttpResponseForbidden('La evidencia de un checkout verificado no se puede eliminar.') + storage, image_name = photo.image.storage, photo.image.name + photo.delete() + transaction.on_commit(lambda: storage.delete(image_name)) + messages.success(request, 'Foto eliminada del reporte de checkout.') + return _checkout_redirect(request, artwork) + + @login_required def logistics_person_edit(request, artwork_id, person_id=None): access = _accessible_artworks(request.user) @@ -680,7 +844,7 @@ def art_admin_dashboard(request, event_slug): event = _managed_event(request, event_slug) if not event: return HttpResponseForbidden('No tenés permisos para coordinar Arte en este evento.') - artworks = event.artworks.select_related('owner').prefetch_related('grant_items', 'photos').order_by('status', 'title') + artworks = event.artworks.select_related('owner').prefetch_related('grant_items', 'photos', 'checkout_photos').order_by('status', 'title') admin_events = Event.objects.order_by('-id') if request.user.is_superuser else get_admin_events_for_user(request.user) return render(request, 'mi_fuego/art/admin_dashboard.html', { **_base_context(event), 'artworks': artworks, 'current_admin_event': event, @@ -691,16 +855,24 @@ def art_admin_dashboard(request, event_slug): @login_required def artwork_review(request, event_slug, artwork_id): - event = _managed_event(request, event_slug) - if not event: + event = get_object_or_404(Event, slug=event_slug) + artwork = get_object_or_404(Artwork, pk=artwork_id, event=event) + if not artwork.can_administer(request.user): return HttpResponseForbidden('No tenés permisos para coordinar Arte en este evento.') + can_manage = artwork.can_manage(request.user) if request.method == 'POST': with transaction.atomic(): artwork = get_object_or_404(Artwork.objects.select_for_update(), pk=artwork_id, event=event) was_verified = bool(artwork.checkout_verified_at) - form = ArtworkReviewForm(request.POST, instance=artwork) + had_art_checkin = bool(artwork.checkin_art_at) + if not artwork.can_administer(request.user): + return HttpResponseForbidden('No tenés permisos para coordinar Arte en este evento.') + can_manage = artwork.can_manage(request.user) + form = ArtworkReviewForm(request.POST, request.FILES, instance=artwork, can_manage=can_manage) if form.is_valid(): artwork = form.save(commit=False) + if artwork.checkin_art_at and not had_art_checkin: + artwork.checkin_art_by = request.user if artwork.checkout_verified_at: artwork.checkout_verified_by = request.user artwork.status = Artwork.Status.COMPLETED @@ -712,8 +884,7 @@ def artwork_review(request, event_slug, artwork_id): messages.success(request, 'La revisión quedó guardada.') return redirect('artwork_review', event_slug=event.slug, artwork_id=artwork.pk) else: - artwork = get_object_or_404(Artwork, pk=artwork_id, event=event) - form = ArtworkReviewForm(instance=artwork) + form = ArtworkReviewForm(instance=artwork, can_manage=can_manage) return render(request, 'mi_fuego/art/review.html', _review_context(artwork, form, request.user)) @@ -726,8 +897,8 @@ def art_admin_export(request, event_slug): response['Content-Disposition'] = f'attachment; filename="arte_{event.slug}.csv"' response.write('\ufeff') writer = csv.writer(response) - writer.writerow(['Obra', 'Responsable', 'Estado', 'Beca', 'Presupuesto ARS', 'Rendición ARS', 'Título público', 'Descripción pública', 'Ubicación asignada', 'Checkout verificado']) - for artwork in event.artworks.select_related('owner').prefetch_related('grant_items'): + writer.writerow(['Obra', 'Responsable', 'Estado', 'Beca', 'Presupuesto ARS', 'Rendición ARS', 'Título público', 'Descripción pública', 'Ubicación asignada', 'Carta digital', 'Carta física recibida', 'Responsable carta física', 'Fotos checkout', 'Checkout verificado']) + for artwork in event.artworks.select_related('owner').prefetch_related('grant_items', 'checkout_photos'): writer.writerow([_csv_cell(value) for value in [ artwork.title, artwork.owner.email if artwork.owner else '', @@ -738,6 +909,10 @@ def art_admin_export(request, event_slug): artwork.public_title, artwork.public_description, artwork.assigned_location, + artwork.understanding_letter.name if artwork.understanding_letter else '', + artwork.understanding_letter_physical_received, + artwork.understanding_letter_physical_custodian, + artwork.checkout_photos.count(), artwork.checkout_verified_at.isoformat() if artwork.checkout_verified_at else '', ]]) return response diff --git a/events/forms.py b/events/forms.py index 8fa400e9..87c92c24 100644 --- a/events/forms.py +++ b/events/forms.py @@ -1,4 +1,5 @@ from datetime import timedelta +from decimal import Decimal from django import forms from django.contrib.auth.models import User @@ -7,20 +8,34 @@ from .models import ( Artwork, ArtworkGrantItem, ArtworkInvitation, ArtworkLogisticsPerson, - ArtworkPhoto, ArtworkProvider, ArtworkProviderVehicle, + ArtworkPhoto, ArtworkCheckoutPhoto, ArtworkProvider, ArtworkProviderVehicle, ) +class LocalizedDecimalField(forms.DecimalField): + """Accept both Argentine (150.000,00) and canonical (150000.00) input.""" + + def to_python(self, value): + if isinstance(value, str): + value = value.strip().replace(' ', '') + if ',' in value: + value = value.replace('.', '').replace(',', '.') + return super().to_python(value) + + ARTWORK_BLOCK_FIELDS = { 'proposal': ( - 'kind', 'title', 'proposal', 'dimensions', 'materials', 'technical_needs', + 'title', 'proposal', 'dimensions', 'materials', 'technical_needs', 'uses_fire', 'fire_details', 'extinguishing_plan', 'power_watts', - 'safety_contact', 'safety_plan', + 'safety_plan', 'safety_responsible_email', ), 'grant': ('grant_requested', 'grant_justification'), 'guide': ('public_title', 'public_description', 'preferred_location'), 'logistics': ('arrival_date', 'departure_date'), - 'checkout': ('checkout_completed', 'checkout_team_responsible', 'checkout_art_responsible', 'checkout_notes'), + 'checkout': ( + 'checkout_completed', 'checkout_team_responsible', 'checkout_art_responsible', 'checkout_notes', + ), + 'understanding_letter_digital': ('understanding_letter',), 'grant_report': ('grant_report',), } @@ -41,6 +56,11 @@ class ArtworkForm(forms.ModelForm): label='Colaboradores', help_text='Emails separados por coma. Si todavía no tienen cuenta, recibirán una invitación.', ) + safety_responsible_email = forms.EmailField( + required=False, + label='Responsable de seguridad', + help_text='Debe ser el email de un perfil ya registrado en la aplicación.', + ) expected_version = forms.IntegerField(widget=forms.HiddenInput, required=False) class Meta: @@ -68,30 +88,33 @@ def __init__(self, *args, program, owner, actor=None, action='save', **kwargs): self.actor = actor or owner self.action = action self.is_manager = self.instance.pk and self.instance.can_manage(self.actor) + self.is_contributor = self.instance.pk and self.instance.can_edit(self.actor) self.new_invitations = [] for field in self.fields.values(): field.widget.attrs.setdefault('class', 'form-check-input' if isinstance(field.widget, forms.CheckboxInput) else 'form-control') field.widget.attrs['form'] = 'artwork-form' - self.fields['kind'].widget.attrs['class'] = 'form-select' self.fields['checkout_team_responsible'].widget.attrs['class'] = 'form-select' self.fields['checkout_art_responsible'].widget.attrs['class'] = 'form-select' self.fields['collaborator_emails'].widget.attrs.update({'class': 'form-control', 'placeholder': 'persona@ejemplo.com, otra@ejemplo.com'}) + self.fields['safety_responsible_email'].widget.attrs.update({'class': 'form-control', 'placeholder': 'persona@ejemplo.com'}) + if self.instance.safety_responsible_id: + self.fields['safety_responsible_email'].initial = self.instance.safety_responsible.email self.fields['expected_version'].initial = self.instance.version if self.instance.pk else None self.fields['checkout_team_responsible'].queryset = self.instance.logistics_people.all() if self.instance.pk else ArtworkLogisticsPerson.objects.none() self.fields['checkout_art_responsible'].queryset = _art_responsibles(self.instance) self.fields['checkout_art_responsible'].help_text = 'La coordinación de Arte asigna este responsable.' - if not self.is_manager: + if self.instance.pk and not self.is_manager and not self.is_contributor: + for name, field in self.fields.items(): + if name != 'expected_version': + field.disabled = True + elif not self.is_manager: self.fields['checkout_art_responsible'].disabled = True - # Un borrador puede empezar incompleto; la presentación valida lo indispensable. - self.fields['title'].required = False + # El título identifica la obra; la descripción se puede completar después. + self.fields['title'].required = True self.fields['proposal'].required = False - if not self.instance.pk and not program.registration_is_open(): - self.fields['kind'].choices = [(Artwork.Kind.POPUP, Artwork.Kind.POPUP.label)] - self.fields['kind'].initial = Artwork.Kind.POPUP - if not program.grants_enabled: for name in (*self.BLOCK_FIELDS['grant'], *self.BLOCK_FIELDS['grant_report']): self.fields.pop(name) @@ -105,6 +128,9 @@ def __init__(self, *args, program, owner, actor=None, action='save', **kwargs): if name in self.fields: self.fields[name].disabled = True + if self.instance.grant_status == Artwork.GrantStatus.CLOSED: + self.fields['grant_report'].disabled = True + if self.instance.checkout_verified_at: for name in self.BLOCK_FIELDS['checkout']: self.fields[name].disabled = True @@ -145,13 +171,20 @@ def clean_collaborator_emails(self): raise forms.ValidationError('Podés sumar hasta 20 colaboradores por obra.') return emails + def clean_safety_responsible_email(self): + email = self.cleaned_data['safety_responsible_email'].lower() + if not email: + return None + user = User.objects.filter(email__iexact=email, profile__isnull=False).first() + if not user: + raise forms.ValidationError('Ese email no corresponde a un perfil registrado.') + return user + def clean(self): cleaned = super().clean() now = timezone.now() - if not self.instance.pk and cleaned.get('kind') == Artwork.Kind.PLANNED and not self.program.registration_is_open(now): - self.add_error('kind', 'La inscripción cerró. Podés registrar esta obra como espontánea (popup).') - if not self.instance.pk and cleaned.get('kind') == Artwork.Kind.POPUP and now > self.program.event.end: - self.add_error('kind', 'El evento ya terminó y no admite nuevas obras espontáneas.') + if not self.instance.pk and not self.program.registration_is_open(now): + self.add_error(None, 'La inscripción de obras está cerrada.') arrival = cleaned.get('arrival_date') departure = cleaned.get('departure_date') @@ -179,17 +212,15 @@ def clean(self): Artwork.Status.DRAFT, Artwork.Status.CHANGES_REQUESTED, ): self.add_error(None, 'Esta obra ya fue presentada. La coordinación gestiona su estado desde la revisión.') - for field in ('title', 'proposal'): - if not cleaned.get(field): - self.add_error(field, 'Completá este campo antes de enviar la propuesta.') if cleaned.get('uses_fire'): - for field in ('fire_details', 'extinguishing_plan', 'safety_contact'): + for field in ('fire_details', 'extinguishing_plan', 'safety_responsible_email'): if not cleaned.get(field): self.add_error(field, 'Completá este campo para una obra que utiliza fuego.') return cleaned def save(self, commit=True): artwork = super().save(commit=False) + artwork.safety_responsible = self.cleaned_data.get('safety_responsible_email') if artwork.pk: artwork.version += 1 if commit: @@ -231,6 +262,25 @@ def _sync_collaborators(self, artwork, emails): class ArtworkGrantItemForm(forms.ModelForm): expected_updated_at = forms.CharField(widget=forms.HiddenInput, required=False) + amount = LocalizedDecimalField( + max_digits=14, decimal_places=2, min_value=Decimal('0.01'), + widget=forms.TextInput(attrs={ + 'inputmode': 'decimal', 'autocomplete': 'off', 'data-money-input': 'true', + 'placeholder': '150.000,00', + }), + ) + exchange_rate = LocalizedDecimalField( + max_digits=14, decimal_places=4, min_value=Decimal('0.0001'), + widget=forms.TextInput(attrs={ + 'inputmode': 'decimal', 'autocomplete': 'off', 'data-money-input': 'true', + 'placeholder': '1.234,56', 'data-exchange-rate': 'true', + }), + ) + confirm_large_amount = forms.BooleanField( + required=False, + label='Confirmo el monto si supera ARS 1.000.000', + help_text='Se pide una confirmación extra para evitar errores de ceros o separadores.', + ) class Meta: model = ArtworkGrantItem @@ -254,6 +304,7 @@ def __init__(self, *args, phase, **kwargs): self.fields['expected_updated_at'].initial = self.instance.updated_at.isoformat() if self.instance.pk else '' for field in self.fields.values(): field.widget.attrs.setdefault('class', 'form-select' if isinstance(field.widget, forms.Select) else 'form-control') + self.fields['currency'].widget.attrs['data-currency-select'] = 'true' def clean_images(self): images = self.cleaned_data['images'] @@ -280,6 +331,11 @@ def clean(self): self.add_error('exchange_rate', 'Ingresá una cotización mayor a cero.') if not cleaned.get('rate_source'): self.add_error('rate_source', 'Indicá la fuente y el tipo de dólar utilizado.') + amount_ars = cleaned.get('amount') + if currency == ArtworkGrantItem.Currency.USD and amount_ars and cleaned.get('exchange_rate'): + amount_ars *= cleaned['exchange_rate'] + if amount_ars and amount_ars >= Decimal('1000000') and not cleaned.get('confirm_large_amount'): + self.add_error('confirm_large_amount', 'Confirmá el monto convertido antes de guardar.') return cleaned def save(self, commit=True): @@ -293,6 +349,24 @@ def save(self, commit=True): return item +class ArtworkGrantItemReviewForm(forms.ModelForm): + class Meta: + model = ArtworkGrantItem + fields = ('review_status', 'review_notes') + widgets = {'review_notes': forms.Textarea(attrs={'rows': 3})} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + field.widget.attrs.setdefault('class', 'form-select' if isinstance(field.widget, forms.Select) else 'form-control') + + def clean(self): + cleaned = super().clean() + if cleaned.get('review_status') == ArtworkGrantItem.ReviewStatus.REJECTED and not cleaned.get('review_notes'): + self.add_error('review_notes', 'Explicá qué necesita corregirse para rechazar el ítem.') + return cleaned + + class MultipleImageInput(forms.ClearableFileInput): allow_multiple_selected = True @@ -334,6 +408,26 @@ def clean_images(self): return images +class ArtworkCheckoutPhotoUploadForm(forms.Form): + images = MultipleImageField(label='Fotos de checkout') + category = forms.ChoiceField(choices=ArtworkCheckoutPhoto.Category.choices, label='Categoría') + caption = forms.CharField(required=False, widget=forms.Textarea(attrs={'rows': 4}), label='Detalle') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + field.widget.attrs.setdefault('class', 'form-select' if isinstance(field.widget, forms.Select) else 'form-control') + self.fields['images'].widget.attrs.update({'accept': 'image/*', 'data-image-preview': 'true'}) + + def clean_images(self): + images = self.cleaned_data['images'] + if len(images) > 10: + raise forms.ValidationError('Podés subir hasta 10 fotos por vez.') + if any(image.size > 10 * 1024 * 1024 for image in images): + raise forms.ValidationError('Cada foto puede pesar hasta 10 MB.') + return images + + class ArtworkLogisticsPersonForm(forms.ModelForm): class Meta: model = ArtworkLogisticsPerson @@ -436,10 +530,28 @@ def clean_plate(self): class ArtworkReviewForm(forms.ModelForm): expected_updated_at = forms.CharField(widget=forms.HiddenInput, required=False) + grant_approved_amount_ars = LocalizedDecimalField( + required=False, max_digits=14, decimal_places=2, min_value=Decimal('0.01'), + label='Monto de beca aprobado', + widget=forms.TextInput(attrs={ + 'inputmode': 'decimal', 'autocomplete': 'off', 'data-money-input': 'true', + 'placeholder': '450.000,00', + }), + ) + confirm_large_grant_amount = forms.BooleanField( + required=False, + label='Confirmo el monto aprobado si supera ARS 1.000.000', + help_text='Verificá los separadores y la cantidad de ceros antes de guardar.', + ) class Meta: model = Artwork fields = ( + 'checkin_arrived_at', 'checkin_art_at', 'checkin_placed', + 'checkin_placement_changed', 'checkin_placement_change_notes', + 'understanding_letter', 'understanding_letter_physical_received', + 'understanding_letter_physical_custodian', 'understanding_letter_physical_notes', + 'understanding_letter_physical_waiver', 'understanding_letter_physical_waiver_reason', 'status', 'review_feedback', 'grant_status', 'grant_approved_amount_ars', 'grant_decision_notes', 'grant_paid_at', 'grant_payment_reference', 'assigned_location', 'placement_notes', 'checkout_team_responsible', @@ -447,20 +559,38 @@ class Meta: 'benefit_status', 'benefit_notes', ) widgets = { + 'checkin_arrived_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'), + 'checkin_art_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'), + 'checkin_placement_change_notes': forms.Textarea(attrs={'rows': 4}), 'review_feedback': forms.Textarea(attrs={'rows': 5}), 'grant_decision_notes': forms.Textarea(attrs={'rows': 5}), 'grant_paid_at': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'), 'placement_notes': forms.Textarea(attrs={'rows': 5}), 'checkout_verified_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'), 'benefit_notes': forms.Textarea(attrs={'rows': 4}), + 'understanding_letter_physical_notes': forms.Textarea(attrs={'rows': 4}), } - def __init__(self, *args, **kwargs): + def __init__(self, *args, can_manage=True, **kwargs): super().__init__(*args, **kwargs) + if not can_manage: + allowed = { + 'checkin_arrived_at', 'checkin_art_at', 'checkin_placed', + 'checkin_placement_changed', 'checkin_placement_change_notes', + 'checkout_verified_at', 'understanding_letter', + 'understanding_letter_physical_received', 'understanding_letter_physical_custodian', + 'understanding_letter_physical_notes', 'understanding_letter_physical_waiver', + 'understanding_letter_physical_waiver_reason', + } + for name in tuple(self.fields): + if name not in allowed and name != 'expected_updated_at': + self.fields.pop(name) self.fields['expected_updated_at'].initial = self.instance.updated_at.isoformat() if self.instance.pk else '' if self.instance.pk: - self.fields['checkout_team_responsible'].queryset = self.instance.logistics_people.all() - self.fields['checkout_art_responsible'].queryset = _art_responsibles(self.instance) + if 'checkout_team_responsible' in self.fields: + self.fields['checkout_team_responsible'].queryset = self.instance.logistics_people.all() + if 'checkout_art_responsible' in self.fields: + self.fields['checkout_art_responsible'].queryset = _art_responsibles(self.instance) for field in self.fields.values(): field.widget.attrs.setdefault('class', 'form-select' if isinstance(field.widget, forms.Select) else 'form-control') @@ -472,6 +602,17 @@ def clean(self): self.add_error(None, 'Otra coordinación modificó esta obra. Recargá la página antes de guardar.') if cleaned.get('grant_status') in (Artwork.GrantStatus.APPROVED, Artwork.GrantStatus.PAID) and not cleaned.get('grant_approved_amount_ars'): self.add_error('grant_approved_amount_ars', 'Indicá el monto aprobado.') + if cleaned.get('grant_approved_amount_ars') and cleaned['grant_approved_amount_ars'] >= Decimal('1000000') and not cleaned.get('confirm_large_grant_amount'): + self.add_error('confirm_large_grant_amount', 'Confirmá el monto aprobado antes de guardar.') if cleaned.get('grant_status') == Artwork.GrantStatus.PAID and not cleaned.get('grant_paid_at'): self.add_error('grant_paid_at', 'Indicá cuándo se pagó la beca.') + if cleaned.get('checkout_verified_at') and not self.instance.checkout_completed: + self.add_error('checkout_verified_at', 'Esperá la solicitud de checkout del equipo de la obra.') + if cleaned.get('understanding_letter_physical_received') and not ( + cleaned.get('understanding_letter_physical_custodian') + or cleaned.get('understanding_letter_physical_notes') + ): + self.add_error('understanding_letter_physical_notes', 'Indicá quién tiene la carta física o dónde está guardada.') + if cleaned.get('understanding_letter_physical_waiver') and not cleaned.get('understanding_letter_physical_waiver_reason'): + self.add_error('understanding_letter_physical_waiver_reason', 'Indicá por qué corresponde la excepción por distancia a CABA.') return cleaned diff --git a/events/migrations/0049_artwork_understanding_letter_and_more.py b/events/migrations/0049_artwork_understanding_letter_and_more.py new file mode 100644 index 00000000..5ed0873d --- /dev/null +++ b/events/migrations/0049_artwork_understanding_letter_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.15 on 2026-08-21 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import events.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('events', '0048_merge_art_and_event_requests'), + ] + + operations = [ + migrations.AddField( + model_name='artwork', + name='understanding_letter', + field=models.FileField(blank=True, storage=events.models.private_art_storage, upload_to='art/understanding_letters', verbose_name='Carta de entendimiento digital'), + ), + migrations.AddField( + model_name='artwork', + name='understanding_letter_physical_custodian', + field=models.CharField(blank=True, max_length=200, verbose_name='Responsable de la carta física'), + ), + migrations.AddField( + model_name='artwork', + name='understanding_letter_physical_notes', + field=models.TextField(blank=True, verbose_name='Ubicación o comentarios sobre la carta física'), + ), + migrations.AddField( + model_name='artwork', + name='understanding_letter_physical_received', + field=models.BooleanField(default=False, verbose_name='Carta física recibida'), + ), + migrations.CreateModel( + name='ArtworkCheckoutPhoto', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(storage=events.models.private_art_storage, upload_to='art/checkout')), + ('category', models.CharField(choices=[('dirt', 'M.U.G.R.E.'), ('environmental_damage', 'Daño ambiental'), ('artwork_parts', 'Partes de la obra'), ('burn_remains', 'Restos de quema'), ('cleanup', 'Limpieza y estado final'), ('other', 'Otro')], default='other', max_length=20, verbose_name='Categoría')), + ('caption', models.TextField(blank=True, verbose_name='Detalle')), + ('artwork', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checkout_photos', to='events.artwork')), + ('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Foto de checkout de obra', + 'verbose_name_plural': 'Fotos de checkout de obras', + 'ordering': ['created_at'], + }, + ), + ] diff --git a/events/migrations/0050_artprogram_understanding_letter_digital_deadline_and_more.py b/events/migrations/0050_artprogram_understanding_letter_digital_deadline_and_more.py new file mode 100644 index 00000000..7e11cf70 --- /dev/null +++ b/events/migrations/0050_artprogram_understanding_letter_digital_deadline_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.15 on 2026-08-21 20:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0049_artwork_understanding_letter_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='artprogram', + name='understanding_letter_digital_deadline', + field=models.DateTimeField(blank=True, null=True, verbose_name='Cierre de carta digital'), + ), + migrations.AddField( + model_name='artprogram', + name='understanding_letter_digital_opens', + field=models.DateTimeField(blank=True, null=True, verbose_name='Apertura de carta digital'), + ), + migrations.AddField( + model_name='artprogram', + name='understanding_letter_physical_deadline', + field=models.DateTimeField(blank=True, null=True, verbose_name='Cierre de carta física'), + ), + migrations.AddField( + model_name='artprogram', + name='understanding_letter_physical_opens', + field=models.DateTimeField(blank=True, null=True, verbose_name='Apertura de carta física'), + ), + migrations.AddField( + model_name='artwork', + name='understanding_letter_physical_waiver', + field=models.BooleanField(default=False, help_text='Autoriza no entregarla previamente en CABA; igualmente debe entregarse en el evento antes de empezar a construir.', verbose_name='Excepción de entrega previa por distancia a CABA'), + ), + migrations.AddField( + model_name='artwork', + name='understanding_letter_physical_waiver_reason', + field=models.TextField(blank=True, verbose_name='Motivo de la excepción de carta física'), + ), + ] diff --git a/events/migrations/0051_artwork_checkin_arrived_at_artwork_checkin_art_at_and_more.py b/events/migrations/0051_artwork_checkin_arrived_at_artwork_checkin_art_at_and_more.py new file mode 100644 index 00000000..bf18dfb5 --- /dev/null +++ b/events/migrations/0051_artwork_checkin_arrived_at_artwork_checkin_art_at_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.15 on 2026-08-21 21:40 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('events', '0050_artprogram_understanding_letter_digital_deadline_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='artwork', + name='checkin_arrived_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Hora de llegada de la obra al evento'), + ), + migrations.AddField( + model_name='artwork', + name='checkin_art_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Check-in realizado con Arte'), + ), + migrations.AddField( + model_name='artwork', + name='checkin_art_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='artwork_checkins', to=settings.AUTH_USER_MODEL, verbose_name='Check-in registrado por'), + ), + migrations.AddField( + model_name='artwork', + name='checkin_placed', + field=models.BooleanField(default=False, verbose_name='La obra quedó ubicada'), + ), + migrations.AddField( + model_name='artwork', + name='checkin_placement_change_notes', + field=models.TextField(blank=True, verbose_name='Cambio de placement y motivo'), + ), + migrations.AddField( + model_name='artwork', + name='checkin_placement_changed', + field=models.BooleanField(default=False, verbose_name='El placement original cambió'), + ), + ] diff --git a/events/migrations/0052_artworkgrantitem_review_notes_and_more.py b/events/migrations/0052_artworkgrantitem_review_notes_and_more.py new file mode 100644 index 00000000..5f6cf872 --- /dev/null +++ b/events/migrations/0052_artworkgrantitem_review_notes_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.15 on 2026-08-21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0051_artwork_checkin_arrived_at_artwork_checkin_art_at_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='artworkgrantitem', + name='review_notes', + field=models.TextField(blank=True, verbose_name='Comentarios de la revisión'), + ), + migrations.AddField( + model_name='artworkgrantitem', + name='review_status', + field=models.CharField(choices=[('pending', 'Pendiente de revisión'), ('approved', 'Aceptado'), ('rejected', 'Rechazado')], default='pending', max_length=10, verbose_name='Revisión del ítem'), + ), + ] diff --git a/events/migrations/0053_artwork_safety_responsible.py b/events/migrations/0053_artwork_safety_responsible.py new file mode 100644 index 00000000..7b851369 --- /dev/null +++ b/events/migrations/0053_artwork_safety_responsible.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.15 on 2026-08-24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('events', '0052_artworkgrantitem_review_notes_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='artwork', + name='safety_responsible', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='safety_responsible_artworks', to=settings.AUTH_USER_MODEL, verbose_name='Responsable de seguridad'), + ), + ] diff --git a/events/models.py b/events/models.py index 25b07743..d4847f94 100644 --- a/events/models.py +++ b/events/models.py @@ -508,6 +508,10 @@ class ArtProgram(BaseModel): logistics_deadline = models.DateTimeField(null=True, blank=True, verbose_name='Cierre de logística') checkout_opens = models.DateTimeField(null=True, blank=True, verbose_name='Apertura de checkout') checkout_deadline = models.DateTimeField(null=True, blank=True, verbose_name='Cierre de checkout') + understanding_letter_digital_opens = models.DateTimeField(null=True, blank=True, verbose_name='Apertura de carta digital') + understanding_letter_digital_deadline = models.DateTimeField(null=True, blank=True, verbose_name='Cierre de carta digital') + understanding_letter_physical_opens = models.DateTimeField(null=True, blank=True, verbose_name='Apertura de carta física') + understanding_letter_physical_deadline = models.DateTimeField(null=True, blank=True, verbose_name='Cierre de carta física') grant_report_deadline = models.DateTimeField(null=True, blank=True, verbose_name='Cierre de rendición de becas') reminder_days = models.JSONField(default=default_art_reminder_days, blank=True, verbose_name='Días de anticipación para recordatorios') reminder_email_enabled = models.BooleanField(default=True, verbose_name='Recordatorios por email') @@ -533,6 +537,14 @@ def clean(self): errors['registration_closes'] = 'El cierre no puede ser anterior a la apertura.' if self.checkout_opens and self.checkout_deadline and self.checkout_deadline < self.checkout_opens: errors['checkout_deadline'] = 'El cierre no puede ser anterior a la apertura.' + for label, opens, deadline in ( + ('digital', self.understanding_letter_digital_opens, self.understanding_letter_digital_deadline), + ('física', self.understanding_letter_physical_opens, self.understanding_letter_physical_deadline), + ): + if opens and deadline and deadline < opens: + errors[f'understanding_letter_{"digital" if label == "digital" else "physical"}_deadline'] = ( + f'El cierre de la carta {label} no puede ser anterior a la apertura.' + ) if not isinstance(self.reminder_days, list) or any(not isinstance(day, int) or day < 0 for day in self.reminder_days): errors['reminder_days'] = 'Usá una lista de días enteros no negativos, por ejemplo [7, 3, 1].' if errors: @@ -547,7 +559,8 @@ def registration_is_open(self, at=None): def checkpoint_state(self, block, at=None): at = at or timezone.now() - if block == 'checkout' and self.checkout_opens and at < self.checkout_opens: + opens = getattr(self, f'{block}_opens', None) + if opens and at < opens: return 'upcoming' deadline = getattr(self, f'{block}_deadline') return 'closed' if deadline and at > deadline else 'open' @@ -601,6 +614,10 @@ class BenefitStatus(models.TextChoices): extinguishing_plan = models.TextField(blank=True, verbose_name='Plan y elementos de extinción') power_watts = models.PositiveIntegerField(null=True, blank=True, verbose_name='Potencia eléctrica máxima (W)') safety_contact = models.CharField(max_length=200, blank=True, verbose_name='Responsable de seguridad durante el evento') + safety_responsible = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + related_name='safety_responsible_artworks', verbose_name='Responsable de seguridad', + ) grant_requested = models.BooleanField(default=False, verbose_name='Quiero solicitar una beca') grant_justification = models.TextField(blank=True, verbose_name='Por qué la beca hace posible la obra') @@ -622,6 +639,16 @@ class BenefitStatus(models.TextChoices): crew = models.TextField(blank=True, verbose_name='Equipo que ingresa') providers = models.TextField(blank=True, verbose_name='Proveedores y vehículos') + checkin_arrived_at = models.DateTimeField(null=True, blank=True, verbose_name='Hora de llegada de la obra al evento') + checkin_art_at = models.DateTimeField(null=True, blank=True, verbose_name='Check-in realizado con Arte') + checkin_art_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + related_name='artwork_checkins', verbose_name='Check-in registrado por', + ) + checkin_placed = models.BooleanField(default=False, verbose_name='La obra quedó ubicada') + checkin_placement_changed = models.BooleanField(default=False, verbose_name='El placement original cambió') + checkin_placement_change_notes = models.TextField(blank=True, verbose_name='Cambio de placement y motivo') + checkout_completed = models.BooleanField(default=False, verbose_name='Solicito verificar el retiro y limpieza') checkout_team_responsible = models.ForeignKey( 'ArtworkLogisticsPerson', on_delete=models.SET_NULL, null=True, blank=True, @@ -635,6 +662,26 @@ class BenefitStatus(models.TextChoices): checkout_requested_at = models.DateTimeField(null=True, blank=True, verbose_name='Checkout solicitado') checkout_verified_at = models.DateTimeField(null=True, blank=True, verbose_name='Checkout verificado') checkout_verified_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='verified_artwork_checkouts') + understanding_letter = models.FileField( + upload_to='art/understanding_letters', storage=private_art_storage, + blank=True, verbose_name='Carta de entendimiento digital', + ) + understanding_letter_physical_received = models.BooleanField( + default=False, verbose_name='Carta física recibida', + ) + understanding_letter_physical_custodian = models.CharField( + max_length=200, blank=True, verbose_name='Responsable de la carta física', + ) + understanding_letter_physical_notes = models.TextField( + blank=True, verbose_name='Ubicación o comentarios sobre la carta física', + ) + understanding_letter_physical_waiver = models.BooleanField( + default=False, verbose_name='Excepción de entrega previa por distancia a CABA', + help_text='Autoriza no entregarla previamente en CABA; igualmente debe entregarse en el evento antes de empezar a construir.', + ) + understanding_letter_physical_waiver_reason = models.TextField( + blank=True, verbose_name='Motivo de la excepción de carta física', + ) status = models.CharField(max_length=12, choices=Status.choices, default=Status.DRAFT, verbose_name='Estado de la obra') review_feedback = models.TextField(blank=True, verbose_name='Devolución al equipo de la obra') benefit_status = models.CharField(max_length=10, choices=BenefitStatus.choices, default=BenefitStatus.NOT_EVALUATED, verbose_name='Beneficio para la próxima edición') @@ -656,6 +703,29 @@ def can_edit(self, user): def can_manage(self, user): return user.is_superuser or self.event.admins.filter(pk=user.pk).exists() + def can_administer(self, user): + return self.can_manage(user) or self.checkout_art_responsible_id == user.pk + + def clean(self): + errors = {} + if self.checkout_verified_at and not self.checkout_completed: + errors['checkout_verified_at'] = 'El equipo de la obra debe solicitar el checkout antes de verificarlo.' + if self.understanding_letter_physical_waiver and not self.understanding_letter_physical_waiver_reason: + errors['understanding_letter_physical_waiver_reason'] = 'Indicá por qué corresponde la excepción por distancia a CABA.' + if self.checkin_art_at and not self.checkin_arrived_at: + errors['checkin_arrived_at'] = 'Indicá primero la hora de llegada de la obra.' + if self.checkin_placement_changed and not self.checkin_placed: + errors['checkin_placed'] = 'Marcá que la obra quedó ubicada antes de registrar un cambio de placement.' + if self.checkin_placement_changed and not self.checkin_placement_change_notes: + errors['checkin_placement_change_notes'] = 'Explicá el cambio respecto del placement original.' + if self.understanding_letter_physical_received and not ( + (self.understanding_letter_physical_custodian or '').strip() + or (self.understanding_letter_physical_notes or '').strip() + ): + errors['understanding_letter_physical_notes'] = 'Indicá quién tiene la carta física o dónde está guardada.' + if errors: + raise ValidationError(errors) + def grant_total_ars(self, phase): return sum((item.amount_ars for item in self.grant_items.filter(phase=phase)), Decimal('0.00')) @@ -683,6 +753,11 @@ class ItemType(models.TextChoices): SERVICE = 'service', 'Servicio' OTHER = 'other', 'Otro' + class ReviewStatus(models.TextChoices): + PENDING = 'pending', 'Pendiente de revisión' + APPROVED = 'approved', 'Aceptado' + REJECTED = 'rejected', 'Rechazado' + artwork = models.ForeignKey(Artwork, on_delete=models.CASCADE, related_name='grant_items') phase = models.CharField(max_length=8, choices=Phase.choices) item_type = models.CharField(max_length=10, choices=ItemType.choices, default=ItemType.OTHER, verbose_name='Tipo') @@ -693,6 +768,11 @@ class ItemType(models.TextChoices): exchange_rate = models.DecimalField(max_digits=14, decimal_places=4, default=1, validators=[MinValueValidator(Decimal('0.0001'))], verbose_name='Cotización ARS por USD') rate_date = models.DateField(verbose_name='Fecha de cotización o pago') rate_source = models.CharField(max_length=200, blank=True, verbose_name='Fuente y tipo de cambio') + review_status = models.CharField( + max_length=10, choices=ReviewStatus.choices, default=ReviewStatus.PENDING, + verbose_name='Revisión del ítem', + ) + review_notes = models.TextField(blank=True, verbose_name='Comentarios de la revisión') created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) class Meta: @@ -847,6 +927,30 @@ def __str__(self): return f'{self.get_stage_display()} · {self.artwork}' +class ArtworkCheckoutPhoto(BaseModel): + class Category(models.TextChoices): + DIRT = 'dirt', 'M.U.G.R.E.' + ENVIRONMENTAL_DAMAGE = 'environmental_damage', 'Daño ambiental' + ARTWORK_PARTS = 'artwork_parts', 'Partes de la obra' + BURN_REMAINS = 'burn_remains', 'Restos de quema' + CLEANUP = 'cleanup', 'Limpieza y estado final' + OTHER = 'other', 'Otro' + + artwork = models.ForeignKey(Artwork, on_delete=models.CASCADE, related_name='checkout_photos') + image = models.ImageField(upload_to='art/checkout', storage=private_art_storage) + category = models.CharField(max_length=20, choices=Category.choices, default=Category.OTHER, verbose_name='Categoría') + caption = models.TextField(blank=True, verbose_name='Detalle') + uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) + + class Meta: + ordering = ['created_at'] + verbose_name = 'Foto de checkout de obra' + verbose_name_plural = 'Fotos de checkout de obras' + + def __str__(self): + return f'{self.get_category_display()} · {self.artwork}' + + class ArtworkInvitation(BaseModel): artwork = models.ForeignKey(Artwork, on_delete=models.CASCADE, related_name='invitations') email = models.EmailField() @@ -883,4 +987,5 @@ def __str__(self): auditlog.register(ArtworkProvider) auditlog.register(ArtworkProviderVehicle) auditlog.register(ArtworkPhoto) +auditlog.register(ArtworkCheckoutPhoto) auditlog.register(ArtworkInvitation) diff --git a/events/templates/mi_fuego/art/_field.html b/events/templates/mi_fuego/art/_field.html index 124a8621..505da7d8 100644 --- a/events/templates/mi_fuego/art/_field.html +++ b/events/templates/mi_fuego/art/_field.html @@ -1,6 +1,6 @@ -
+
{% if field.field.widget.input_type == 'checkbox' %} -
+
{{ field }}
diff --git a/events/templates/mi_fuego/art/_grant_items.html b/events/templates/mi_fuego/art/_grant_items.html index f88252aa..e976e4cf 100644 --- a/events/templates/mi_fuego/art/_grant_items.html +++ b/events/templates/mi_fuego/art/_grant_items.html @@ -1,3 +1,4 @@ +{% load art_format %} {% if items %}
@@ -6,9 +7,9 @@ {% for item in items %} - - - + + + {% if can_edit %}{% endif %} @@ -17,7 +18,17 @@ {% if item.details %}{{ item.details }}{% endif %} Fecha: {{ item.rate_date|date:'d/m/Y' }}{% if item.rate_source %} · Fuente: {{ item.rate_source }}{% endif %} {% if item.photos.all %}{% for photo in item.photos.all %}Imagen de {{ item.concept }}{% endfor %}{% endif %} + {% if can_review %}{{ item.get_review_status_display }}{% endif %} + {% if can_review %} +
+ Revisar ítem +
{% csrf_token %} + {% include 'mi_fuego/art/_inline_fields.html' with inline_form=item.review_form %} +
+ +
+ {% endif %} {% if can_edit %} @@ -42,7 +53,7 @@ {% endif %} {% endfor %} - {% if can_edit %}{% endif %} + {% if can_edit %}{% endif %}
{{ item.get_item_type_display }}{{ item.concept }}{{ item.currency }} {{ item.amount|floatformat:2 }}{{ item.exchange_rate|floatformat:-4 }}{{ item.amount_ars|floatformat:2 }}{{ item.currency }} {{ item.amount|art_money }}{{ item.exchange_rate|art_money }}{{ item.amount_ars|art_money }}
{% csrf_token %}{% if return_to %}{% endif %}
Total ARS{{ total_ars|floatformat:2 }}
Total ARS{{ total_ars|art_money }}
diff --git a/events/templates/mi_fuego/art/admin_dashboard.html b/events/templates/mi_fuego/art/admin_dashboard.html index e695ca23..15689d49 100644 --- a/events/templates/mi_fuego/art/admin_dashboard.html +++ b/events/templates/mi_fuego/art/admin_dashboard.html @@ -9,7 +9,7 @@
- + {% for artwork in artworks %} @@ -17,7 +17,8 @@ - + + {% empty %}{% endfor %} diff --git a/events/templates/mi_fuego/art/dashboard.html b/events/templates/mi_fuego/art/dashboard.html index 969671ed..412a79b7 100644 --- a/events/templates/mi_fuego/art/dashboard.html +++ b/events/templates/mi_fuego/art/dashboard.html @@ -38,6 +38,26 @@

{{ program.event.name }}

{% endfor %} + {% if admin_assignments %} +
+
+
+

Backoffice de Arte

+

Obras para revisar

+
+ {{ admin_assignments|length }} obra{{ admin_assignments|length|pluralize:"s" }} +
+ +
+ {% endif %} +

Mis obras

{% if artworks %} @@ -64,6 +84,7 @@

{{ artwork.title }}

{% endif %}
+ {% include 'mi_fuego/art/_image_preview.html' %} +{% include 'mi_fuego/art/_money_inputs.html' %} {% include 'mi_fuego/art/_restore_position.html' %} {% endblock %} diff --git a/events/templatetags/__init__.py b/events/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/events/templatetags/art_format.py b/events/templatetags/art_format.py new file mode 100644 index 00000000..45f8184b --- /dev/null +++ b/events/templatetags/art_format.py @@ -0,0 +1,17 @@ +from decimal import Decimal, InvalidOperation + +from django import template + +register = template.Library() + + +@register.filter +def art_money(value): + """Format an art amount with Argentine separators and two decimals.""" + if value in (None, ''): + return '' + try: + amount = Decimal(str(value)).quantize(Decimal('0.01')) + except (InvalidOperation, TypeError, ValueError): + return value + return f'{amount:,.2f}'.replace(',', 'X').replace('.', ',').replace('X', '.') diff --git a/events/test_art.py b/events/test_art.py index 10f469d0..0864f7c2 100644 --- a/events/test_art.py +++ b/events/test_art.py @@ -3,17 +3,20 @@ from allauth.account.models import EmailAddress from django.contrib.auth.models import User +from django.core.exceptions import ValidationError from django.core.files.uploadedfile import SimpleUploadedFile from django.db import IntegrityError, transaction from django.test import TestCase from django.urls import reverse from django.utils import timezone +from django.contrib.messages import get_messages -from .forms import ArtworkForm, ArtworkGrantItemForm, ArtworkPhotoUploadForm, ArtworkProviderForm +from .forms import ArtworkForm, ArtworkGrantItemForm, ArtworkPhotoUploadForm, ArtworkProviderForm, ArtworkReviewForm from .art_reminders import send_art_reminders +from .art_views import _checkpoints from .models import ( ArtProgram, Artwork, ArtworkGrantItem, ArtworkInvitation, - ArtworkLogisticsPerson, ArtworkPhoto, ArtworkProvider, + ArtworkCheckoutPhoto, ArtworkLogisticsPerson, ArtworkPhoto, ArtworkProvider, ArtworkProviderVehicle, Event, ) from user_profile.models import Profile @@ -63,37 +66,67 @@ def artwork_form(self, data, artwork=None, action='save', actor=None): def test_primary_fields_are_associated_with_artwork_form(self): form = self.artwork_form({}) + self.assertNotIn('kind', form.fields) for field in form.fields.values(): self.assertEqual(field.widget.attrs.get('form'), 'artwork-form') + self.assertTrue(all(checkpoint['anchor'] for checkpoint in _checkpoints(self.program))) - def test_draft_popup_multiple_artworks_and_submission_group(self): - draft = self.artwork_form({'kind': Artwork.Kind.POPUP}) - self.assertTrue(draft.is_valid(), draft.errors) - first = draft.save() - self.assertEqual(first.status, Artwork.Status.DRAFT) - - incomplete_submit = self.artwork_form( - {'kind': Artwork.Kind.POPUP, 'expected_version': first.version}, - artwork=first, - action='submit', - ) - self.assertFalse(incomplete_submit.is_valid()) - + def test_public_submission_requires_only_title_and_is_planned(self): + self.program.registration_closes = timezone.now() + timedelta(days=1) + self.program.save(update_fields=['registration_closes']) + incomplete = self.artwork_form({}) + self.assertFalse(incomplete.is_valid()) self.client.force_login(self.owner) response = self.client.post(reverse('artwork_create', args=[self.event.slug]), { 'kind': Artwork.Kind.POPUP, 'title': 'Faro', - 'proposal': 'Una propuesta completa', 'action': 'submit', }) self.assertEqual(response.status_code, 302) - second = Artwork.objects.exclude(pk=first.pk).get() - self.assertEqual(second.status, Artwork.Status.SUBMITTED) - self.assertEqual(second.operations_group.event, self.event) - self.assertEqual(Artwork.objects.filter(owner=self.owner).count(), 2) + artwork = Artwork.objects.get(owner=self.owner) + self.assertEqual(artwork.kind, Artwork.Kind.PLANNED) + self.assertEqual(artwork.status, Artwork.Status.SUBMITTED) + self.assertEqual(artwork.proposal, '') + self.assertEqual(artwork.operations_group.event, self.event) + + def test_draft_save_does_not_claim_the_proposal_was_sent(self): + self.program.registration_closes = timezone.now() + timedelta(days=1) + self.program.save(update_fields=['registration_closes']) + self.client.force_login(self.owner) + response = self.client.post(reverse('artwork_create', args=[self.event.slug]), { + 'title': 'Borrador', 'action': 'save', + }) + + self.assertEqual(response.status_code, 302) + artwork = Artwork.objects.get(owner=self.owner, title='Borrador') + self.assertEqual(artwork.status, Artwork.Status.DRAFT) + self.assertEqual([str(message) for message in get_messages(response.wsgi_request)], ['El borrador quedó guardado.']) + + def test_creation_can_include_safety_budget_gallery_and_early_entry(self): + self.program.registration_closes = timezone.now() + timedelta(days=1) + self.program.save(update_fields=['registration_closes']) + self.client.force_login(self.owner) + response = self.client.post(reverse('artwork_create', args=[self.event.slug]), { + 'title': 'Faro', 'uses_fire': 'on', 'fire_details': 'Leña controlada', + 'extinguishing_plan': 'Matafuegos ABC', 'safety_responsible_email': self.collaborator.email, + 'initial-budget-item_type': 'materials', 'initial-budget-concept': 'Hierro', + 'initial-budget-amount': '1500', 'initial-budget-currency': 'ARS', + 'initial-budget-exchange_rate': '1', 'initial-budget-rate_date': timezone.localdate(), + 'initial-photo-images': self.image('inicio.gif'), 'initial-photo-stage': ArtworkPhoto.Stage.PROPOSAL, + 'initial-person-first_name': 'Ada', 'initial-person-last_name': 'Sur', + 'initial-person-email': 'ada@example.com', 'initial-person-phone': '+5491112345678', + 'initial-person-document_type': 'DNI', 'initial-person-document_number': '30111222', + 'initial-person-early_entry': 'on', 'initial-person-early_entry_date': timezone.localdate(), + }) + self.assertEqual(response.status_code, 302) + artwork = Artwork.objects.get(owner=self.owner, title='Faro') + self.assertEqual(artwork.safety_responsible, self.collaborator) + self.assertEqual(artwork.grant_items.count(), 1) + self.assertEqual(artwork.photos.count(), 1) + self.assertEqual(artwork.logistics_people.count(), 1) def test_planned_registration_is_closed(self): - form = self.artwork_form({'kind': Artwork.Kind.PLANNED, 'title': 'Faro', 'proposal': 'Propuesta'}, action='submit') + form = self.artwork_form({'title': 'Faro'}, action='submit') self.assertFalse(form.is_valid()) def test_stale_collaborator_update_is_rejected(self): @@ -126,6 +159,20 @@ def test_itemized_grant_uses_frozen_decimal_exchange_rate(self): self.assertEqual(ars.exchange_rate, Decimal('1')) self.assertEqual(ars.amount_ars, Decimal('1000.25')) + localized = ArtworkGrantItemForm({ + 'item_type': 'materials', 'concept': 'Placa', 'amount': '150.000,50', 'currency': 'ARS', + 'exchange_rate': '1', 'rate_date': timezone.localdate(), 'rate_source': '', + }, instance=ArtworkGrantItem(artwork=artwork, created_by=self.owner), phase=ArtworkGrantItem.Phase.BUDGET) + self.assertTrue(localized.is_valid(), localized.errors) + self.assertEqual(localized.cleaned_data['amount'], Decimal('150000.50')) + + large = ArtworkGrantItemForm({ + 'item_type': 'other', 'concept': 'Equipo', 'amount': '1.000.000,00', 'currency': 'ARS', + 'exchange_rate': '1', 'rate_date': timezone.localdate(), 'rate_source': '', + }, instance=ArtworkGrantItem(artwork=artwork), phase=ArtworkGrantItem.Phase.BUDGET) + self.assertFalse(large.is_valid()) + self.assertIn('confirm_large_amount', large.errors) + usd_form = ArtworkGrantItemForm({ 'item_type': 'service', 'concept': 'LEDs', 'amount': '10.50', 'currency': 'USD', 'exchange_rate': '1234.5678', 'rate_date': timezone.localdate(), 'rate_source': 'BNA vendedor', @@ -166,6 +213,10 @@ def test_permissions_invitation_and_multiple_photo_upload(self): self.client.force_login(self.stranger) self.assertEqual(self.client.get(reverse('artwork_edit', args=[artwork.pk])).status_code, 404) + artwork.collaborators.add(self.collaborator, self.admin) + self.client.force_login(self.owner) + self.assertEqual(self.client.get(reverse('artwork_edit', args=[artwork.pk])).status_code, 200) + form = self.artwork_form({ 'kind': Artwork.Kind.POPUP, 'title': artwork.title, 'proposal': artwork.proposal, 'collaborator_emails': 'invitada@example.com', 'expected_version': artwork.version, @@ -394,6 +445,125 @@ def test_security_boundaries(self): self.assertIn("'=IMPORTXML", exported) self.assertEqual(send_art_reminders({'source': 'aws.events'}), 0) + def test_art_responsible_manages_checkout_letter_and_evidence(self): + artwork = Artwork.objects.create( + event=self.event, owner=self.owner, title='Faro', proposal='Texto', + checkout_art_responsible=self.collaborator, + ) + self.client.force_login(self.collaborator) + review_url = reverse('artwork_review', args=[self.event.slug, artwork.pk]) + response = self.client.get(review_url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Carta física recibida') + self.assertNotContains(response, 'Estado de la obra') + + response = self.client.post(review_url, { + 'expected_updated_at': artwork.updated_at.isoformat(), + 'understanding_letter_physical_received': 'on', + 'understanding_letter_physical_custodian': 'Coordinación de Arte', + 'understanding_letter_physical_notes': 'Archivo físico, estante B.', + }) + self.assertEqual(response.status_code, 302) + artwork.refresh_from_db() + self.assertTrue(artwork.understanding_letter_physical_received) + self.assertEqual(artwork.understanding_letter_physical_custodian, 'Coordinación de Arte') + + response = self.client.post(reverse('artwork_checkout_photo_upload', args=[artwork.pk]), { + 'category': ArtworkCheckoutPhoto.Category.BURN_REMAINS, + 'caption': 'Revisar cenizas junto al acceso.', + 'images': self.image('checkout.gif'), + }) + self.assertEqual(response.status_code, 302) + evidence = artwork.checkout_photos.get() + self.assertEqual(evidence.category, ArtworkCheckoutPhoto.Category.BURN_REMAINS) + + self.client.force_login(self.owner) + artwork.checkout_verified_at = timezone.now() + artwork.save() + self.assertEqual( + self.client.post(reverse('artwork_checkout_photo_delete', args=[artwork.pk, evidence.pk])).status_code, + 403, + ) + + def test_approved_report_is_locked_and_checkout_requires_team_request(self): + artwork = Artwork.objects.create( + event=self.event, owner=self.owner, title='Faro', proposal='Texto', + grant_status=Artwork.GrantStatus.CLOSED, + ) + expense = ArtworkGrantItem.objects.create( + artwork=artwork, phase=ArtworkGrantItem.Phase.EXPENSE, + concept='Flete', amount=Decimal('1000'), rate_date=timezone.localdate(), + ) + self.client.force_login(self.admin) + self.assertEqual( + self.client.get(reverse('grant_item_edit', args=[artwork.pk, expense.pk])).status_code, + 403, + ) + form = ArtworkReviewForm({ + 'expected_updated_at': artwork.updated_at.isoformat(), + 'checkout_verified_at': timezone.localtime().strftime('%Y-%m-%dT%H:%M'), + }, instance=artwork) + self.assertFalse(form.is_valid()) + self.assertIn('checkout_verified_at', form.errors) + + def test_event_admin_reviews_each_grant_item(self): + artwork = Artwork.objects.create( + event=self.event, owner=self.owner, title='Faro', proposal='Texto', + grant_status=Artwork.GrantStatus.REPORTED, + ) + item = ArtworkGrantItem.objects.create( + artwork=artwork, phase=ArtworkGrantItem.Phase.EXPENSE, + concept='Flete', amount=Decimal('1000'), rate_date=timezone.localdate(), + ) + review_url = reverse('grant_item_review', args=[self.event.slug, artwork.pk, item.pk]) + + self.client.force_login(self.stranger) + self.assertEqual(self.client.post(review_url, {'review_status': 'approved'}).status_code, 403) + + self.client.force_login(self.admin) + response = self.client.post(review_url, { + 'review_status': ArtworkGrantItem.ReviewStatus.REJECTED, + 'review_notes': 'Falta el comprobante del pago.', + }) + self.assertEqual(response.status_code, 302) + item.refresh_from_db() + self.assertEqual(item.review_status, ArtworkGrantItem.ReviewStatus.REJECTED) + self.assertEqual(item.review_notes, 'Falta el comprobante del pago.') + + def test_understanding_letter_windows_and_distance_waiver(self): + now = timezone.now() + self.program.understanding_letter_digital_opens = now + timedelta(days=1) + self.program.understanding_letter_digital_deadline = now + timedelta(days=10) + self.program.understanding_letter_physical_opens = now + timedelta(days=2) + self.program.understanding_letter_physical_deadline = now + timedelta(days=20) + self.program.save() + artwork = Artwork.objects.create(event=self.event, owner=self.owner, title='Faro', proposal='Texto') + form = self.artwork_form({}, artwork=artwork) + self.assertTrue(form.fields['understanding_letter'].disabled) + + waiver = ArtworkReviewForm({ + 'expected_updated_at': artwork.updated_at.isoformat(), + 'understanding_letter_physical_waiver': 'on', + }, instance=artwork) + self.assertFalse(waiver.is_valid()) + self.assertIn('understanding_letter_physical_waiver_reason', waiver.errors) + + def test_event_checkin_requires_arrival_and_placement_detail(self): + artwork = Artwork.objects.create(event=self.event, owner=self.owner, title='Faro', proposal='Texto') + form = ArtworkReviewForm({ + 'expected_updated_at': artwork.updated_at.isoformat(), + 'checkin_art_at': timezone.localtime().strftime('%Y-%m-%dT%H:%M'), + 'checkin_placement_changed': 'on', + }, instance=artwork) + self.assertFalse(form.is_valid()) + self.assertIn('checkin_arrived_at', form.errors) + self.assertIn('checkin_placed', form.errors) + self.assertIn('checkin_placement_change_notes', form.errors) + + artwork.understanding_letter_physical_received = True + with self.assertRaises(ValidationError): + artwork.full_clean() + def test_grant_report_requires_expense_and_final_photo(self): artwork = Artwork.objects.create( event=self.event, owner=self.owner, title='Faro', proposal='Texto', @@ -423,6 +593,25 @@ def test_grant_report_requires_expense_and_final_photo(self): ) self.assertTrue(artwork.photos.filter(pk=photo.pk).exists()) + def test_grant_report_blocks_expenses_above_approved_amount(self): + artwork = Artwork.objects.create( + event=self.event, owner=self.owner, title='Faro', proposal='Texto', + grant_status=Artwork.GrantStatus.APPROVED, grant_approved_amount_ars=Decimal('1000'), + grant_report='Fondos utilizados.', + ) + ArtworkGrantItem.objects.create( + artwork=artwork, phase=ArtworkGrantItem.Phase.EXPENSE, concept='Materiales', + amount=1001, currency='ARS', exchange_rate=1, rate_date=timezone.localdate(), + ) + ArtworkPhoto.objects.create( + artwork=artwork, image='art/gallery/final.gif', stage=ArtworkPhoto.Stage.FINAL, + uploaded_by=self.owner, + ) + self.client.force_login(self.owner) + self.client.post(reverse('grant_report_submit', args=[artwork.pk])) + artwork.refresh_from_db() + self.assertEqual(artwork.grant_status, Artwork.GrantStatus.APPROVED) + def test_only_one_current_art_program(self): other_event = Event.objects.create( name='Otro', slug='otro', start=timezone.now() + timedelta(days=50), end=timezone.now() + timedelta(days=51), diff --git a/tickets/static/css/barbu-style.css b/tickets/static/css/barbu-style.css index 8187c17d..b7708596 100644 --- a/tickets/static/css/barbu-style.css +++ b/tickets/static/css/barbu-style.css @@ -46,8 +46,8 @@ img { } } -header, -footer { +body > header, +body > footer { background: #1d0402; padding: 1rem !important; diff --git a/user_profile/urls.py b/user_profile/urls.py index c7edf47b..59fa6eac 100644 --- a/user_profile/urls.py +++ b/user_profile/urls.py @@ -46,7 +46,8 @@ artwork_provider_delete, artwork_provider_edit, artwork_vehicle_delete, artwork_vehicle_edit, artwork_create, artwork_edit, artwork_photo_delete, artwork_photo_upload, - artwork_review, grant_item_create, grant_item_delete, grant_item_edit, + artwork_checkout_photo_delete, artwork_checkout_photo_upload, + artwork_review, grant_item_create, grant_item_delete, grant_item_edit, grant_item_review, grant_item_photo_delete, grant_report_submit, grant_submit, logistics_person_delete, logistics_person_edit, ) @@ -82,6 +83,8 @@ path("arte/obra//rendicion/enviar/", grant_report_submit, name="grant_report_submit"), path("arte/obra//fotos/subir/", artwork_photo_upload, name="artwork_photo_upload"), path("arte/obra//fotos//eliminar/", artwork_photo_delete, name="artwork_photo_delete"), + path("arte/obra//checkout/fotos/subir/", artwork_checkout_photo_upload, name="artwork_checkout_photo_upload"), + path("arte/obra//checkout/fotos//eliminar/", artwork_checkout_photo_delete, name="artwork_checkout_photo_delete"), path("arte/obra//logistica/personas/nueva/", logistics_person_edit, name="logistics_person_create"), path("arte/obra//logistica/personas//", logistics_person_edit, name="logistics_person_edit"), path("arte/obra//logistica/personas//eliminar/", logistics_person_delete, name="logistics_person_delete"), @@ -95,6 +98,7 @@ path("mis-eventos//arte/", art_admin_dashboard, name="art_admin_dashboard"), path("mis-eventos//arte/exportar/", art_admin_export, name="art_admin_export"), path("mis-eventos//arte//", artwork_review, name="artwork_review"), + path("mis-eventos//arte//beca/item//revisar/", grant_item_review, name="grant_item_review"), path("mis-eventos/", my_events_view, name="my_events"), path("mis-eventos//", event_admin_view, name="event_admin"), path("mis-eventos//configuracion/", event_management_view, name="event_management"),
ObraEstadoBecaPlacementCheckout
ObraEstadoBecaPlacementCartaCheckout
{{ artwork.get_status_display }} {{ artwork.get_grant_status_display }}{% if artwork.grant_requested %}
Presupuesto ARS {{ artwork.budget_total_ars|floatformat:2 }}{% endif %}
{{ artwork.assigned_location|default:'Sin asignar' }}{% if artwork.checkout_verified_at %}Verificado{% elif artwork.checkout_completed %}Solicitado{% else %}Pendiente{% endif %}{% if artwork.understanding_letter %}Digital{% else %}Sin digital{% endif %}{% if artwork.understanding_letter_physical_received %}
Física recibida{% endif %}
{% if artwork.checkout_verified_at %}Verificado{% elif artwork.checkout_completed %}Solicitado{% else %}Pendiente{% endif %}
{{ artwork.checkout_photos.count }} foto(s)
Revisar
Todavía no hay obras.