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 @@ -
| Obra | Estado | Beca | Placement | Checkout | ||
|---|---|---|---|---|---|---|
| Obra | Estado | Beca | Placement | Carta | Checkout | |
| {{ 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. | ||||||