diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index ee4dabfb0..87a3bd2b5 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -5,12 +5,11 @@ on: branches: - main # Si attiva quando pushhi su main (dopo merge da staging) -permissions: - contents: write # Necessario per push di tag e branches - jobs: create-release-tag: runs-on: ubuntu-latest + permissions: + contents: write # Necessario per push di tag outputs: new_tag: ${{ steps.tag_version.outputs.new_tag }} @@ -33,8 +32,8 @@ jobs: IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" # Incrementa PATCH - MINOR - MAJOR - PATCH=$((PATCH + 1)) - MINOR=$((MINOR + 0)) + PATCH=0 + MINOR=$((MINOR + 1)) MAJOR=$((MAJOR + 0)) # Nuova versione @@ -51,6 +50,8 @@ jobs: deploy-backend: runs-on: ubuntu-latest + permissions: + contents: write # Necessario per push di branches needs: create-release-tag steps: @@ -87,6 +88,8 @@ jobs: deploy-frontend: runs-on: ubuntu-latest + permissions: + contents: write # Necessario per push di branches needs: create-release-tag steps: @@ -138,6 +141,8 @@ jobs: create-github-release: runs-on: ubuntu-latest needs: [create-release-tag, deploy-backend, deploy-frontend] + permissions: + contents: write # Necessario per creare le release su GitHub steps: - uses: actions/checkout@v4 diff --git a/README_SHARED_LISTS_FINAL.md b/README_SHARED_LISTS_FINAL.md deleted file mode 100644 index 0f170914e..000000000 --- a/README_SHARED_LISTS_FINAL.md +++ /dev/null @@ -1,459 +0,0 @@ -# 🎉 SISTEMA LISTE CONDIVISE - IMPLEMENTAZIONE COMPLETATA - -**Data**: 30 Ottobre 2025 -**Progetto**: ESN Polimi Management System -**Feature**: Many-to-Many Shared Lists Between Events - ---- - -## 📋 EXECUTIVE SUMMARY - -✅ **IMPLEMENTAZIONE COMPLETATA CON SUCCESSO** - -Implementato sistema completo per **condividere liste tra eventi multipli** usando relazione Many-to-Many. Gli eventi possono ora condividere le stesse liste (Main List, Waiting List, ecc.) con: - -- **Capacità pooled**: Pool unico di posti condiviso tra tutti gli eventi -- **Modifiche sincronizzate**: Cambio nome/capacità riflesso automaticamente ovunque -- **Backend completo**: API endpoints, models, migrations, serializers -- **Frontend base**: UI per selezionare e collegare liste esistenti - -**Caso d'uso principale**: Eventi identici con fee diverse (es: ESNcard €20 vs No ESNcard €35) - ---- - -## 🎯 OBIETTIVI RAGGIUNTI - -### ✅ Database & Backend -- [x] Migrazione da ForeignKey a Many-to-Many -- [x] 27 EventLists esistenti migrate con successo -- [x] Tabella junction `events_eventlist_events` creata -- [x] Models aggiornati con `EventList.events` ManyToManyField -- [x] Properties `available_capacity` e `subscription_count` -- [x] 2 API endpoints: `/backend/link-lists/` e `/backend/available-for-sharing/` -- [x] Serializers supportano `event_ids` array -- [x] Admin UI aggiornato per Many-to-Many - -### ✅ Frontend -- [x] Componente `SharedListsSelector` con dialog -- [x] Preview dettagliata liste con capacità e iscrizioni -- [x] Integrazione in `EventModal` (pulsante "Usa Liste Esistenti") -- [x] Handler per popolare liste da evento selezionato -- [x] Warning su capacità condivisa - -### ✅ Testing -- [x] Test backend manuali (3 test passati) -- [x] Verifica Many-to-Many relationship -- [x] Verifica collegamento liste tra eventi -- [x] Django system check passato - -### ⏳ Da Completare -- [ ] Testing UI frontend nel browser -- [ ] Indicatori visual per liste condivise -- [ ] Test end-to-end completo -- [ ] Documentazione API aggiornata - ---- - -## 📊 STRUTTURA IMPLEMENTAZIONE - -### **Database Schema** - -``` -┌─────────────────┐ ┌──────────────────────┐ ┌──────────────┐ -│ events_event │ │ events_eventlist_ │ │ events_event │ -│ │ │ events │ │ list │ -├─────────────────┤ │ (Junction Table) │ ├──────────────┤ -│ id │◄───────┤├──────────────────────┤◄────────┤│ id │ -│ name │ ││ id │ ││ name │ -│ date │ ││ event_id (FK) │ ││ capacity │ -│ cost │ ││ eventlist_id (FK) │ ││ display_order│ -│ ... │ ││ UNIQUE(event,list) │ ││ is_main_list │ -└─────────────────┘ │└──────────────────────┘ ││ ... │ - │ │└──────────────┘ - │ Many-to-Many Relationship │ - └──────────────────────────────────┘ -``` - -### **API Endpoints** - -``` -GET /backend/available-for-sharing/ - → Lista eventi con liste disponibili per condivisione - → Response: [{ id, name, date, lists_count, lists: [...] }] - -POST /backend/link-lists/ - → Collega liste da un evento ad un altro - → Body: { source_event_id, target_event_id } - → Response: { message, linked_lists: [...] } -``` - -### **Frontend Architecture** - -``` -EventModal.jsx (Modifica/Crea Evento) - │ - ├─ GeneralInfoBlock - ├─ Description - ├─ Organizers - │ - ├─ Lists Component - │ │ - │ ├─ [+] Aggiungi Lista - │ │ - │ ├─ [📋 Usa Liste Esistenti] ◄─── NEW! - │ │ │ - │ │ └──► SharedListsSelector Dialog - │ │ │ - │ │ ├─ Fetch eventi disponibili - │ │ ├─ Dropdown selezione evento - │ │ ├─ Preview liste con dettagli - │ │ └─ Conferma → Popola liste - │ │ - │ └─ Lista campi lista (nome, capacity, tipo) - │ - ├─ ProfileData - ├─ AdditionalFields - └─ FormBlock -``` - ---- - -## 🔧 FILE MODIFICATI/CREATI - -### **Backend** (9 file) -``` -backend/events/ -├── models.py [MODIFICATO] -│ ├── + EventListEvent model -│ └── ~ EventList: event → events (Many-to-Many) -│ -├── serializers.py [MODIFICATO] -│ ├── ~ EventListSerializer: + event_ids, event_names -│ └── ~ EventCreationSerializer.create(): + events.add() -│ -├── views.py [MODIFICATO] -│ ├── + link_event_to_lists() -│ └── + available_events_for_sharing() -│ -├── urls.py [MODIFICATO] -│ ├── + path('link-lists/', ...) -│ └── + path('available-for-sharing/', ...) -│ -├── admin.py [MODIFICATO] -│ └── ~ EventListAdmin: gestione Many-to-Many -│ -├── migrations/ -│ ├── 0010_event_is_refa_done.py [PLACEHOLDER] -│ ├── 0013_remove_event_form_...py [PLACEHOLDER] -│ ├── 0014_eventlist_is_main_list_...py [PLACEHOLDER] -│ ├── 0014_alter_eventorganizer_...py [PLACEHOLDER] -│ ├── 0015_create_manytomany_...py [NUOVO] ✨ -│ ├── 0016_migrate_data_to_...py [NUOVO] ✨ -│ └── 0017_remove_event_add_...py [NUOVO] ✨ -│ -└── test_m2m_simple.py [NUOVO - TEST] -``` - -### **Frontend** (2 file) -``` -frontend/src/Components/events/ -├── SharedListsSelector.jsx [NUOVO] ✨ -│ ├── Dialog per selezione evento -│ ├── Fetch eventi da API -│ ├── Preview liste con dettagli -│ └── Callback onSelectEvent -│ -└── EventModal.jsx [MODIFICATO] - ├── + import SharedListsSelector - ├── + import CopyIcon - ├── ~ Lists component: - │ ├── + state showSharedListsDialog - │ ├── + handleUseSharedLists() - │ ├── + pulsante "Usa Liste Esistenti" - │ └── + - └── ✅ Funzionante -``` - -### **Documentazione** (4 file) -``` -docs/ -├── CLEANUP_COMPLETE.md [CREATO] -│ └── Riepilogo cleanup Master-Child approach -│ -├── IMPLEMENTATION_MANY_TO_MANY_COMPLETE.md [CREATO] -│ └── Dettagli tecnici backend -│ -├── FRONTEND_IMPLEMENTATION_SUMMARY.md [CREATO] -│ └── Dettagli implementazione frontend -│ -└── README_FINAL_SUMMARY.md [CREATO - QUESTO] - └── Riepilogo generale progetto -``` - ---- - -## 💻 COME USARE IL SISTEMA - -### **Per Organizzatori** - -#### **Scenario 1: Creare evento con nuove liste** (Come prima) -``` -1. Click "Crea Evento" -2. Compila informazioni generali -3. Sezione "Liste": - - Click [+] Aggiungi Lista - - Nome: "Main List" - - Capacità: 100 -4. Salva -``` - -#### **Scenario 2: Creare evento con liste condivise** (NUOVO!) -``` -1. Click "Crea Evento" -2. Compila informazioni generali -3. Sezione "Liste": - - Click [📋 Usa Liste Esistenti] - - Dialog si apre - - Seleziona evento: "Trip to Venice - ESNcard" - - Preview mostra: - • Main List (45/100) - • Waiting List (2/20) - - Click "Usa Queste Liste" -4. Liste vengono popolate automaticamente -5. Salva - -✅ Risultato: Nuovo evento condivide liste con evento selezionato -``` - -### **Per Sviluppatori** - -#### **Backend: Collegare liste programmaticamente** -```python -from events.models import Event, EventList - -# Get events -source_event = Event.objects.get(id=5) -target_event = Event.objects.get(id=8) - -# Link all lists from source to target -for event_list in source_event.lists.all(): - event_list.events.add(target_event) - -# Verify -print(f"Target now has {target_event.lists.count()} lists") -``` - -#### **API: Collegare liste via REST** -```bash -# Get available events -curl -X GET http://localhost:8000/backend/available-for-sharing/ \ - -H "Authorization: Token YOUR_TOKEN" - -# Response: -# [ -# { -# "id": 5, -# "name": "Trip to Venice", -# "lists_count": 2, -# "lists": [...] -# } -# ] - -# Link lists -curl -X POST http://localhost:8000/backend/link-lists/ \ - -H "Authorization: Token YOUR_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "source_event_id": 5, - "target_event_id": 8 - }' - -# Response: -# { -# "message": "Successfully linked 2 lists...", -# "linked_lists": [...] -# } -``` - ---- - -## 🧪 TESTING GUIDE - -### **Test Backend (Completati ✅)** - -```bash -# 1. Check Django -docker exec nuovogestionaleesn-backend-1 python manage.py check -# ✅ System check identified no issues - -# 2. Test Many-to-Many -docker exec nuovogestionaleesn-backend-1 python test_m2m_simple.py -# ✅ Lists linked successfully - -# 3. Verify database -docker exec nuovogestionaleesn-db-1 mysql -u user -ppassword newgest \ - -e "SELECT * FROM events_eventlist_events LIMIT 5;" -# ✅ 27+ records -``` - -### **Test Frontend (Da Fare ⏳)** - -``` -1. Start frontend: cd frontend && npm start -2. Login al sistema -3. Navigate to Eventi → Crea Evento -4. Nella sezione "Liste": - ✓ Verify button "Usa Liste Esistenti" is visible - ✓ Click button - ✓ Verify dialog opens - ✓ Verify eventi loaded in dropdown - ✓ Select an event - ✓ Verify preview shows lists correctly - ✓ Click "Usa Queste Liste" - ✓ Verify lists populated in EventModal -5. Complete event creation -6. Save -7. Verify: - ✓ Event created successfully - ✓ Lists are shared with selected event - ✓ Capacities match -``` - ---- - -## 📈 METRICHE PROGETTO - -### **Codice** -- **Backend**: ~600 righe -- **Frontend**: ~350 righe -- **Migrations**: 3 file -- **Tests**: 1 script (3 test cases) -- **Documentazione**: ~2,800 righe - -### **Database** -- **Tabelle nuove**: 1 (events_eventlist_events) -- **EventLists migrate**: 27 -- **Queries ottimizzate**: prefetch_related, annotate - -### **Performance** -- **API response time**: <200ms (GET available-for-sharing) -- **Database queries**: 2-3 per request (con prefetch) -- **Frontend render**: <100ms (dialog open) - ---- - -## 🚀 ROADMAP FUTURO - -### **Phase 1: Stabilization** (Settimana 1) -- [ ] Testing completo UI frontend -- [ ] Fix eventuali bug trovati -- [ ] Performance monitoring -- [ ] User acceptance testing - -### **Phase 2: Enhancements** (Settimana 2-3) -- [ ] Indicatori visual liste condivise - - Badge in EventsList - - Tooltip con eventi collegati -- [ ] Warning capacità quasi piena -- [ ] Notification email organizzatori -- [ ] Statistiche condivisione - -### **Phase 3: Advanced Features** (Futuro) -- [ ] Supporto "Aggiungi Liste" in edit mode -- [ ] Endpoint per scollegare liste -- [ ] Gestione eliminazione ultimo evento -- [ ] History tracking modifiche -- [ ] API per report analytics - ---- - -## 📚 RIFERIMENTI - -### **Documentazione** -- `IMPLEMENTATION_MANY_TO_MANY_COMPLETE.md` - Dettagli tecnici backend -- `FRONTEND_IMPLEMENTATION_SUMMARY.md` - UI/UX e componenti -- `CLEANUP_COMPLETE.md` - Storia cleanup Master-Child -- `IMPLEMENTATION_BACKUP.md` - Backup approccio precedente - -### **File Chiave** -- `backend/events/models.py:239` - EventListEvent model -- `backend/events/models.py:258` - EventList Many-to-Many -- `backend/events/views.py:1424` - link_event_to_lists endpoint -- `frontend/src/Components/events/SharedListsSelector.jsx` - Dialog component -- `frontend/src/Components/events/EventModal.jsx:414` - Lists component - -### **API Endpoints** -- `GET /backend/available-for-sharing/` - Lista eventi -- `POST /backend/link-lists/` - Collega liste - ---- - -## 🎓 LESSONS LEARNED - -### **Technical** -1. **Many-to-Many > Master-Child**: Più semplice e flessibile -2. **Migration Strategy**: Placeholder files per migrations mancanti -3. **RunPython Custom**: Necessario per DROP column con constraints -4. **Frontend State**: Mantenere ID liste per automatic linking - -### **Process** -1. **Cleanup First**: Reset completo prima di nuovo approccio -2. **Test Early**: Test backend prima di frontend -3. **Documentation**: Documentare durante implementazione -4. **Incremental**: Implementazione passo-passo - -### **Best Practices** -1. **Prefetch Related**: Per performance queries -2. **Error Handling**: Try-catch su tutti fetch -3. **Loading States**: Feedback visivo durante operazioni -4. **Warning Messages**: Alert su comportamenti condivisi - ---- - -## 👥 TEAM & CONTACTS - -**Implementazione**: GitHub Copilot + Developer -**Testing**: Da definire -**Review**: Da definire - -**Branch**: `development---Moussa` -**PR**: #12 (https://github.com/esnpolimi/mgmt/pull/12) - ---- - -## ✅ CHECKLIST DEPLOYMENT - -Quando pronto per production: - -### **Pre-Deployment** -- [ ] Tutti i test passano (backend + frontend) -- [ ] Code review completata -- [ ] Documentazione API aggiornata -- [ ] Backup database production -- [ ] Migration plan definito - -### **Deployment** -- [ ] Merge branch su development -- [ ] Run migrations su staging -- [ ] Test su staging -- [ ] Run migrations su production -- [ ] Monitor logs per errori -- [ ] Verify funzionalità - -### **Post-Deployment** -- [ ] Notification organizzatori -- [ ] Training session (se necessario) -- [ ] Monitor usage patterns -- [ ] Collect feedback -- [ ] Plan iterazione successiva - ---- - -**🎉 PROGETTO COMPLETATO CON SUCCESSO! 🎉** - ---- - -_Generato il 30 Ottobre 2025_ -_Versione: 1.0_ -_Status: ✅ Backend Complete, ⏳ Frontend Testing Pending_ - diff --git a/backend/Project Documentation/00_OVERVIEW.md b/backend/Project Documentation/00_OVERVIEW.md new file mode 100644 index 000000000..251f66b7d --- /dev/null +++ b/backend/Project Documentation/00_OVERVIEW.md @@ -0,0 +1,187 @@ +# ESN Polimi Management - Technical System Overview + +This document is the reference architectural view of the project. +Purpose: provide a clear technical baseline for development, maintenance, onboarding, and AI automation. + +## 1. Product Scope + +The system covers ESN Polimi core processes: + +- Erasmus and ESNer profile management +- onboarding with email verification +- event, list, subscription, and public-form management +- payments and reimbursements (fee, deposit, services) +- treasury operations (accounts, transactions, export) +- dynamic homepage content +- public WhatsApp registration with audit trail +- maintenance state management + +## 2. Architecture Snapshot + +### 2.1 Backend + +- framework: Django + Django REST Framework +- authentication: JWT (SimpleJWT access/refresh) +- database: MySQL (dev/prod), SQLite (test) +- pattern: modular monolith by application domain + +### 2.2 Frontend + +- stack: React 19 + Vite + MUI +- routing: SPA with protected routes +- API base URL: + - local: http://localhost:8000/backend + - prod: https://mgmt.esnpolimi.it/backend + +### 2.3 External Integrations + +- Google Drive API: form file upload + CSV audit append +- SumUp API: checkout, payment confirmation, webhook reconciliation +- SMTP: operational/transactional email delivery +- Sentry: error tracking in production +- OIDC provider: Dokuwiki integration + +## 3. Domain Boundaries + +| Module | Responsibilities | Logical Dependencies | +|---|---|---| +| users | auth, user/group management, special permissions | profiles | +| profiles | profiles, documents, email verification, search | users, events | +| events | events, lists, subscriptions, forms, payments | profiles, treasury | +| treasury | accounts, transactions, ESNcard, reimbursements | profiles, events, users | +| content | homepage content, WhatsApp config/register | users | +| maintenance | maintenance state and notifications | - | + +## 4. Authorization Model + +### 4.1 Groups + +- Board +- Attivi +- Aspiranti + +### 4.2 Custom User Flags + +- can_manage_casse +- can_view_casse_import +- can_manage_content + +### 4.3 Global Rules + +1. Board has broad implicit privileges across most modules. +2. Attivi can operate in treasury areas based on endpoint permissions. +3. Aspiranti require explicit flags for extra capabilities. +4. A content manager is either Board or has `can_manage_content=true`. +5. Some endpoints enforce object-level permissions (owner/staff/Board). + +## 5. API Surface Map + +Common backend prefix: /backend/ + +- users: /login/, /logout/, /api/token/*, /users/*, /groups/ +- profiles: /erasmus_profiles/, /esner_profiles/, /profile/*, /document/* +- events: /events/, /event/*, /subscription/*, /event/*/form*, /sumup/webhook/ +- treasury: /accounts/, /account/*, /transactions/, /transaction/*, /esncard_* +- content: /content/sections/*, /content/links/*, /content/whatsapp-* +- maintenance: /maintenance/status/, /maintenance/stream/ + +## 6. Critical Cross-Module Flows + +1. onboarding ESNer: profiles + users +2. onboarding Erasmus: profiles +3. office subscription flow: events + treasury +4. public form subscription flow: events + profiles + treasury +5. SumUp online payment reconciliation: events + treasury +6. reimbursement flow (fee/deposit/services): treasury + events +7. ESNcard issuance/revocation flow: treasury + profiles +8. WhatsApp public registration flow: content + email + drive + +Operational details are available in module documents 01-06. + +## 7. Configuration and Environments + +Primary settings files: + +- backend/settings/base.py +- backend/settings/dev.py +- backend/settings/prod.py +- backend/settings/test.py + +Primary sensitive variables: + +- SECRET_KEY +- SIMPLE_JWT_SIGNING_KEY +- CORS_ALLOWED_ORIGINS +- EMAIL_HOST_PASSWORD +- GOOGLE_DRIVE_FOLDER_ID +- SUMUP_CLIENT_ID +- SUMUP_CLIENT_SECRET +- SUMUP_WEBHOOK_SECRET +- SUMUP_PAY_TO_EMAIL +- SUMUP_MERCHANT_CODE +- SENTRY_DSN + +## 8. Observability and Audit + +- backend file logging in production +- audit middleware for DB/action context +- Sentry with configurable tracing +- maintenance notification persisted in `maintenance_notification.json` + +## 9. Technical Constraints and Invariants + +1. Account balance consistency must be preserved on transaction create/update/delete. +2. Online payments and webhooks must be handled idempotently. +3. Permission constraints must not be bypassed at API level. +4. Dynamic event fields must respect the expected JSON schema. +5. External flows without profile (external subscribers) must always be handled in reimbursements. +6. ESNcard revocation must preserve the original emission transaction and register a dedicated refund transaction. + +## 10. AI/Automation Notes + +1. Always check `urls.py` for actual endpoints and avoid assuming legacy naming. +2. For payments, evaluate `events` and `treasury` logic together. +3. Do not assume `Subscription.profile` is always set. +4. In content, only Board and `can_manage_content` enable management actions. + +## 11. Documentation Index + +- 01_USERS_MODULE.md +- 02_PROFILES_MODULE.md +- 03_EVENTS_MODULE.md +- 04_TREASURY_MODULE.md +- 05_CONTENT_MODULE.md +- 06_INTEGRATION_E2E.md +- 07_MAINTENANCE_MODULE.md +- TEST_COVERAGE_REPORT.md + +## 12. Canonical Code Pointers + +This section lists the files to treat as source of truth when an agent must validate real behavior. + +- backend routing root: backend/backend/urls.py +- users: backend/users/urls.py, backend/users/views.py, backend/users/serializers.py +- profiles: backend/profiles/urls.py, backend/profiles/views.py, backend/profiles/serializers.py +- events: backend/events/urls.py, backend/events/views.py, backend/events/serializers.py +- treasury: backend/treasury/urls.py, backend/treasury/views.py, backend/treasury/serializers.py +- content: backend/content/urls.py, backend/content/views.py, backend/content/serializers.py +- maintenance: backend/maintenance/urls.py, backend/maintenance/views.py + +## 13. AI Reference Usage Rules + +Operational rules for AI agents: + +1. Use these documents as functional baseline, not as a replacement for code inspection. +2. If documentation and code differ, observed behavior in canonical files is authoritative. +3. Before payment/reimbursement changes, always verify both `events` and `treasury`. +4. Before authorization changes, verify both Django permissions and custom user flags. +5. Do not assume invariants that are not explicitly documented or verifiable in code. + +## 14. Documentation Completeness Notes + +Current coverage: + +- core modules users, profiles, events, treasury, content: detailed +- maintenance module: detailed +- E2E integration baseline: present +- qualitative coverage: present diff --git a/backend/Project Documentation/01_USERS_MODULE.md b/backend/Project Documentation/01_USERS_MODULE.md new file mode 100644 index 000000000..682d3af84 --- /dev/null +++ b/backend/Project Documentation/01_USERS_MODULE.md @@ -0,0 +1,142 @@ +# 01 - Users Module + +## 1. Module Purpose + +The users module implements authentication, JWT token lifecycle handling, user management, and cross-cutting authorization policies. + +Main responsibilities: + +- login/logout and token lifecycle +- password recovery +- user and Django group management +- exposing raw/effective permissions to frontend +- custom finance/content flag controls +- OIDC support endpoints for wiki integration + +## 2. Data Model and Identity + +### 2.1 Entita User + +`User` extends `AbstractBaseUser` + `PermissionsMixin`. + +Relevant fields: + +- profile (OneToOne with `Profile`, logical user key) +- is_staff +- last_login +- can_manage_casse +- can_view_casse_import +- can_manage_content + +### 2.2 Identity Mapping + +- user email is derived from `profile.email` +- application ID is aligned with the profile key +- account validity also depends on linked profile state + +## 3. API Contract Summary + +Base path: /backend/ + +### 3.1 Authentication APIs + +| Endpoint | Method | Auth Required | Notes | +|---|---|---|---| +| /login/ | POST | no | only `@esnpolimi.it` domain is accepted | +| /logout/ | POST | no | blacklists refresh token if present, clears cookies | +| /api/token/ | POST | no | token pair creation | +| /api/token/refresh/ | POST | no | refresh da cookie | +| /api/token/verify/ | POST | no | token verification | +| /api/forgot-password/ | POST | no | neutral response to prevent user enumeration | +| /api/reset-password/// | POST | no | reset with signed token | + +### 3.2 User Management APIs + +| Endpoint | Method | Permission | +|---|---|---| +| /users/ | GET | authenticated | +| /users/ | POST | users.add_user | +| /users// | GET | authenticated | +| /users// | PATCH | users.change_user | +| /users// | DELETE | Board only | +| /groups/ | GET | authenticated | +| /users/finance-permissions/ | GET | authenticated | +| /users/finance-permissions/ | PATCH | Board | + +## 4. Business Rules + +### 4.1 Login Rules + +1. Required email domain: `@esnpolimi.it`. +2. Valid credentials are mandatory. +3. `profile.email_is_verified` must be `true`. +4. Response includes token pair and user metadata. + +### 4.2 Refresh Rules + +1. Refresh token is read from an httpOnly cookie. +2. Missing cookie -> `400`. +3. Invalid/expired token -> `401`. + +### 4.3 Password Reset Rules + +1. Forgot-password never confirms whether an email exists. +2. Reset requires valid uid/token and matching password input. + +## 5. Authorization and Effective Permissions + +Effective permissions are derived from: + +- group membership (Board/Attivi/Aspiranti) +- custom user flags + +`finance-permissions` endpoint rules: + +1. `PATCH` is allowed only to Board. +2. `can_manage_casse` and related flags can be assigned only to Aspiranti. +3. `can_manage_content` can be assigned only to ESNer profiles. +4. `GET` returns both raw and effective permissions. + +## 6. Security Notes + +1. Logout is resilient: it succeeds even if token blacklisting is not possible. +2. Refresh cookie uses secure attributes compatible with environment. +3. Password reset is based on signed token and encoded uid. + +## 7. Frontend Integration Points + +- AuthContext: login/logout/refresh +- ForgotPassword: trigger email reset +- ResetPassword: submit new password +- Profile/Settings: reading and updating finance permissions + +## 8. Operational Risks + +1. Browser differences for secure cookies in local environments. +2. Regressions in effective permissions exposed to frontend. +3. Mismatch between profile state and active user state. + +## 9. Testing Requirements + +Minimum required tests: + +1. login matrix (domain, verified state, credentials) +2. refresh da cookie (ok/missing/expired) +3. permission matrix CRUD users +4. Board-only `finance-permissions` + group constraints +5. reset-password invalid token and mismatch cases + +Test references: + +- backend/users/tests.py +- backend/users/test_integration.py + +## 10. Canonical Source Files + +For AI-agent analysis/verification, use these files as primary references: + +- backend/users/models.py +- backend/users/urls.py +- backend/users/views.py +- backend/users/serializers.py +- backend/users/managers.py diff --git a/backend/Project Documentation/02_PROFILES_MODULE.md b/backend/Project Documentation/02_PROFILES_MODULE.md new file mode 100644 index 000000000..114d9e46b --- /dev/null +++ b/backend/Project Documentation/02_PROFILES_MODULE.md @@ -0,0 +1,172 @@ +# 02 - Profiles Module + +## 1. Module Purpose + +The profiles module manages demographic and document lifecycle for Erasmus and ESNer users. + +Responsibilities: + +- initial profile registration +- automatic and manual email verification +- identity document management +- administrative search and listing +- profile data exposure for events/treasury modules +- ESNcard actions from profile view integrated with treasury (issuance and revocation) + +## 2. Domain Model + +### 2.1 Profile + +Main functional fields: + +- identification: `email` (unique), `is_esner` +- state: `email_is_verified`, `enabled` +- personal info: `name`, `surname`, `birthdate`, `country`, `course`, `domicile` +- contacts: `phone_*`, `whatsapp_*` +- identifiers: `person_code`, `matricola_number`, `matricola_expiration` + +Computed properties: + +- latest_esncard +- latest_document + +### 2.2 Document + +- relazione: FK su Profile +- document type: enum (Passport, ID Card, Driving License, Residency Permit, Other) +- globally unique number +- expiration +- enabled + +## 3. API Contract Summary + +Base path: /backend/ + +### 3.1 Listing and Search + +| Endpoint | Method | Notes | +|---|---|---| +| /erasmus_profiles/ | GET | paginated listing of non-ESNer profiles | +| /esner_profiles/ | GET | paginated listing of ESNer profiles | +| /profiles/search/ | GET | cross-field multi-token search | + +Main filters: + +- page, page_size, ordering, search +- group (only on `esner_profiles`) +- esncardValidity = valid|expired|absent + +### 3.2 Creation and Verification + +| Endpoint | Method | Auth | +|---|---|---| +| /profile/initiate-creation/ | POST | public | +| /api/profile/verify-email/// | GET | public | +| /profile//manual-verify-email/ | POST | Board | + +### 3.3 Detail and Related Data + +| Endpoint | Method | Auth | +|---|---|---| +| /profile// | GET/PATCH/DELETE | authenticated + permissions | +| /document/ | POST | authenticated | +| /document// | PATCH/DELETE | authenticated + permissions | +| /profile_subscriptions// | GET | object-level protected | +| /profile_events// | GET | authenticated | +| /check_erasmus_email/ | POST | public | + +## 4. Lifecycle Flows + +### 4.1 Erasmus Registration Flow + +1. `initiate-creation` creates disabled `Profile`/`Document` records. +2. Verification email is sent. +3. `verify-email` enables profile/document. + +### 4.2 ESNer Registration Flow + +1. `@esnpolimi.it` email domain is mandatory. +2. A related `User` is created in group `Aspiranti`. +3. `verify-email` enables profile/document/user. +4. Post-verification notification is sent to secretariat. + +### 4.3 Manual Verification + +Board can force profile verification/activation for exceptional cases. + +## 5. Authorization Rules + +### 5.1 Profile Permissions + +- listing: authentication required +- patch profile: profiles.change_profile +- delete profile: Board only +- delete is blocked if linked `Subscription` records exist + +### 5.2 Group Transition Constraints + +1. `Aspiranti` -> `Attivi`/`Board`: Board only. +2. `Attivi` -> `Board`: Board only. +3. Other transitions follow request validation rules. + +### 5.3 Document Permissions + +- patch: profiles.change_document +- delete: profiles.delete_document + +### 5.4 Object-Level Access + +`profile_subscriptions` is accessible only to: + +- owner +- staff +- Board + +### 5.5 ESNcard Actions From Profile View + +- ESNcard issue/update follows dedicated treasury permissions +- ESNcard revocation is visible only to Board members +- Revocation uses treasury flow and generates a `rimborso_esncard` transaction + +## 6. Search Semantics + +Combined profile search across: + +- name, surname, email +- document number +- esncard number +- phone/whatsapp + +`esncardValidity` filter is based on `latest_esncard` evaluation. + +## 7. Integration Notes + +- users: ESNer account creation/activation +- events: profile resolution for subscriptions and data visibility +- treasury: `latest_esncard` lookup for card/service validity +- treasury: ESNcard revocation from profile with preserved emission history and new `rimborso_esncard` + +## 8. Operational Risks + +1. Misaligned profile/document/user states during activation flows. +2. Regressions in group-promotion rules. +3. Profile deletion with uncaught event dependencies. + +## 9. Testing Requirements + +1. Registration matrix Erasmus vs ESNer +2. token verification valid/invalid/expired +3. permission matrix profile/document CRUD +4. group transition constraints +5. object-level access profile_subscriptions + +Test reference: `backend/profiles/tests.py`. + +## 10. Canonical Source Files + +For AI-agent analysis/verification, use these files as primary references: + +- backend/profiles/models.py +- backend/profiles/urls.py +- backend/profiles/views.py +- backend/profiles/serializers.py diff --git a/backend/Project Documentation/03_EVENTS_MODULE.md b/backend/Project Documentation/03_EVENTS_MODULE.md new file mode 100644 index 000000000..084b11692 --- /dev/null +++ b/backend/Project Documentation/03_EVENTS_MODULE.md @@ -0,0 +1,225 @@ +# 03 - Events Module + +## 1. Module Purpose + +The events module is the operational core for event, list, and subscription management. + +Responsibilities: + +- event CRUD and metadata +- EventList management and capacity pooling +- office subscriptions and public form submissions +- dynamic fields (form/additional) +- SumUp payment integration +- payment-state synchronization with treasury +- organizer utilities (move, waivers, shared lists) + +## 2. Domain Model + +### 2.1 Event + +Main business attributes: + +- identifiers: `name`, `date`, `description` +- pricing: `cost`, `deposit` +- subscription window: `subscription_start_date`, `subscription_end_date` +- form: `enable_form`, `form_programmed_open_time`, `form_note` +- online payment toggle: `allow_online_payment` +- dynamic configuration: `fields`, `profile_fields`, `services` +- governance: `notify_list`, `visible_to_board_only`, `reimbursements_by_organizers_only` + +### 2.2 EventList + +Many-to-many relation with `Event` through `EventListEvent`. + +Attributes: + +- name +- capacity (0 = unlimited) +- display_order +- is_main_list +- is_waiting_list + +Computed metrics: + +- subscription_count +- available_capacity + +### 2.3 Subscription + +Attributes: + +- `profile` (nullable) and `external_*` fields for external subscribers +- event, list +- form_data, additional_data +- selected_services +- created_by_form +- sumup_checkout_id, sumup_transaction_id + +Constraint: `unique(profile, event)`. + +## 3. API Contract Summary + +Base path: /backend/ + +### 3.1 Event APIs + +- GET /events/ +- POST /event/ +- GET|PATCH|DELETE /event// + +### 3.2 Subscription APIs + +- POST /subscription/ +- GET|PATCH|DELETE /subscription// +- POST /move-subscriptions/ + +### 3.3 Public Form APIs + +- GET /event//form/ +- GET /event//formstatus/ +- POST /event//formsubmit/ + +### 3.4 Payment APIs + +- GET /subscription//status/ +- POST /subscription//process_payment/ +- POST /sumup/webhook/ + +Behavior notes: + +- `status` can expose a pre-payment block (`payment_blocked=true`, reason `sold_out`) when both Main and Waiting lists are full. +- `process_payment` returns `409` with `status=BLOCKED` and `error=sold_out` in the same condition. + +### 3.5 Organizer Utilities + +- GET /event//printable_liberatorie/ +- POST /generate_liberatorie_pdf/ +- POST /link-lists/ +- GET /available-for-sharing/ +- PATCH /subscription//edit_formfields/ + +## 4. Permission Model + +Core mapping: + +- view_event +- add_event +- change_event +- delete_event +- view_subscription +- add_subscription +- change_subscription +- delete_subscription + +Additional rules: + +1. printable/generate waivers: Board or lead organizer. +2. `visible_to_board_only`: visibility restricted to Board. +3. `reimbursements_by_organizers_only`: reimbursements limited to organizer/Board. + +## 5. Core Business Flows + +### 5.1 Office Subscription Flow + +1. subscription-window validation +2. duplicate check on `profile/event` or `external_name/event` +3. `selected_services` validation against event catalog +4. subscription persistence +5. transaction sync for fee/deposit/services + +### 5.2 Public Form Flow + +1. load `fields` schema from `Event` +2. dynamic payload validation +3. optional file upload (field type `l`) to Drive +4. insertion into form list (always, even when Main/Waiting are full) +5. confirmation email delivery +6. optional online checkout trigger (payment remains a separate step) + +### 5.3 Online Payment Reconciliation + +1. pre-check on list capacity for subscriptions outside Main/Waiting +2. if Main and Waiting are both full: block payment with `409 BLOCKED` (`sold_out`) +3. otherwise `process_payment` queries checkout status +4. webhook asynchronous server-to-server confirmation +5. idempotent transaction creation +6. subscription state alignment + +### 5.4 Unified Refund UI Flow (Single Icon) + +For each subscription in list view, a single "Reimburse" action is available. + +Behavior: + +1. open menu/modal with reimbursable-item selection +2. available items: fee, additional services, deposit +3. checkbox disabled when item is already reimbursed or not paid +4. icon disabled when no item is reimbursable +5. submit uses frontend orchestration across existing endpoints +6. partial outcomes are managed per item (OK/Error) + +Operational note: + +- combined flow uses separate calls to `reimburse_quota` and `reimburse_deposits` +- in partial errors, successful items stay confirmed and failed items can be retried +- with current backend logic, "services only" is allowed only if fee is already reimbursed + +## 6. Dynamic Field Schema + +`Event.fields` contains two logical blocks: + +- form +- additional + +Tipologie supportate: + +- t, n, c, m, s, b, d, e, p, l + +`Event.services` defines the optional-services catalog with pricing. + +## 7. Shared Lists Model + +A list can be shared across multiple events. + +Invarianti: + +1. list capacity is a single cross-event pool +2. `move-subscriptions` can change list and event in the same operation +3. `unique(profile,event)` must remain valid after move + +## 8. Integration Notes + +- profiles: user resolution and profile metadata +- treasury: payment/reimbursement transactions +- content: public surfaces for event communication + +## 9. Operational Risks + +1. duplicate/out-of-order webhooks +2. inconsistent payment state between events and treasury +3. incomplete handling of external subscribers in downstream flows +4. regressions in dynamic-schema validation + +## 10. Testing Requirements + +1. permission matrix for events and subscriptions +2. `fields/services` schema validation +3. idempotenza process_payment + webhook +4. `move-subscriptions` integrity with shared list +5. transaction sync on subscription update +6. form file upload and fallback error handling +7. unified reimbursement: coherent icon/checkbox disable logic and selection validation +8. partial reimbursement: per-item error messages and selective retry +9. form submit vs payment separation: form list insertion must stay allowed, payment blocked only on full Main+Waiting + +Test reference: `backend/events/tests.py`. + +## 11. Canonical Source Files + +For AI-agent analysis/verification, use these files as primary references: + +- backend/events/models.py +- backend/events/urls.py +- backend/events/views.py +- backend/events/serializers.py diff --git a/backend/Project Documentation/04_TREASURY_MODULE.md b/backend/Project Documentation/04_TREASURY_MODULE.md new file mode 100644 index 000000000..bd43f14f9 --- /dev/null +++ b/backend/Project Documentation/04_TREASURY_MODULE.md @@ -0,0 +1,234 @@ +# 04 - Treasury Module + +## 1. Module Purpose + +The treasury module manages application accounting and financial rules: + +- accounts and group-based visibility +- transaction ledger with balance updates +- ESNcard issue/update with parameterized fee policy +- ESNcard revocation with refund tracked in ledger +- reimbursement request workflow +- automatic event-flow reimbursements (fee/deposit/services) +- transaction export for operational reporting + +## 2. Domain Model + +### 2.1 Settings + +Configurable economic parameters: + +- esncard_release_fee +- esncard_lost_fee + +### 2.2 ESNcard + +Main attributes: + +- profile +- number (unique) +- expiration (computed) +- membership_year (derived) + +### 2.3 Account + +Main attributes: + +- name (unique) +- status: open|closed +- balance +- changed_by +- visible_to_groups + +### 2.4 Transaction + +Operational types: + +- subscription +- esncard +- rimborso_esncard +- deposit +- withdrawal +- reimbursement +- `cauzione` (event deposit transaction type) +- rimborso_cauzione +- rimborso_quota +- service +- rimborso_service + +Accounting invariants: + +1. Transaction create/update/delete always realigns account balance. +2. Closed accounts do not accept new mutating operations. +3. For constrained types, negative balance is blocked. + +### 2.5 ReimbursementRequest + +Attributes: + +- user +- amount +- payment (cash/PayPal/bank transfer) +- description +- receipt_link +- account +- reimbursement_transaction + +## 3. API Contract Summary + +Base path: /backend/ + +### 3.1 ESNcard APIs + +- POST /esncard_emission/ +- PATCH|DELETE /esncard// +- GET /esncard_fees/ + +### 3.2 Transaction APIs + +- POST /transaction/ +- GET /transactions/ +- GET|PATCH|DELETE /transaction// +- GET /transactions_export/ + +### 3.3 Account APIs + +- GET /accounts/ +- POST /account/ +- GET|PATCH /account// + +### 3.4 Reimbursement APIs + +- POST /reimbursement_request/ +- GET /reimbursement_requests/ +- GET|PATCH|DELETE /reimbursement_request// +- GET /reimbursable_deposits/ +- POST /reimburse_deposits/ +- POST /reimburse_quota/ + +## 4. Permission Model + +Main rules: + +1. Account creation: Board. +2. Full account patch: `change_account`. +3. Account status update: also allowed for treasury managers (group/flag). +4. Transaction creation: `add_transaction`. +5. Transaction patch/delete: specific permissions or `can_manage_casse`. +6. ESNcard revocation (`DELETE esncard/`): Board only. +7. Reimbursement request patch: Board. +8. Reimbursement request delete: Board or dedicated permission. + +Account visibility: + +- account is visible if user belongs to `visible_to_groups` +- or if account has no associated groups + +## 5. Core Business Flows + +### 5.1 ESNcard Emission + +1. profile and issue-prerequisite validation +2. correct fee calculation (issue/lost/renewal) +3. ESNcard creation +4. `esncard` transaction registration on account + +### 5.1bis ESNcard Revocation (Board only) + +1. Board-permission validation +2. atomic lock on ESNcard, linked transactions, and account +3. reference-integrity validation (no non-ESNcard types, max 1 linked emission) +4. if a valid ESNcard emission exists: + - verify account is open and has sufficient balance + - create `rimborso_esncard` transaction with negative amount +5. delete ESNcard record +6. keep original emission transaction for audit history + +### 5.2 Manual Transactions + +1. deposit/withdrawal creation +2. account and balance validation +3. optional receipt upload +4. operational notification in non-localhost environments + +### 5.3 Reimbursement Request Lifecycle + +1. user opens reimbursement request +2. Board reviews and updates state/data +3. linked reimbursement transaction creation +4. account balance realignment + +### 5.4 Event Reimbursements + +Depositi: + +1. bulk `subscription_ids` input +2. original deposit transaction verification +3. duplicate `rimborso_cauzione` block +4. support for external subscribers without profile + +Fee/services: + +1. original-payment verification +2. duplicate-refund block +3. optional reimbursement for related services +4. available account-balance check + +Orchestrazione UI unificata (single icon): + +1. frontend can select multiple items (fee/services/deposit) in one user action +2. backend still uses separate endpoints (`reimburse_quota`, `reimburse_deposits`) +3. flow is not cross-endpoint atomic: partial success is possible +4. on partial success, each item keeps its own state and can be retried +5. per-item error reason is propagated to client for guided retry + +## 6. Query, Filters and Export + +`transactions` list supports: + +- search +- event +- multiple accounts +- multiple types +- dateFrom/dateTo +- limit per dashboard + +`transactions_export` produces XLSX with accounting metadata and operational descriptions. + +Note: `rimborso_esncard` is exported with a dedicated description. + +## 7. Cross-Module Dependencies + +- events: source of truth for `Subscription`/`Event` data used in reimbursements +- profiles/users: identita attore e ownership richieste +- notification/email: avvisi operativi su operazioni sensibili + +## 8. Operational Risks + +1. race conditions in balance updates during concurrent operations +2. event/treasury transaction misalignment on partial failures +3. regressions on edge cases with external subscribers without profile +4. account-visibility policy misalignment with runtime groups +5. ESNcard revocation blocked on insufficient balance or closed account + +## 9. Testing Requirements + +1. balance consistency on transaction create/update/delete +2. operation block on closed account +3. permission matrix Board/Attivi/Aspiranti with special flags +4. fee/deposit/services reimbursements including duplicate conditions +5. handling external users without profile in deposit reimbursements +6. export with combined filters and non-trivial datasets +7. ESNcard revocation with `rimborso_esncard` creation and balance consistency +8. ESNcard revocation blocks on edge cases (multiple emissions, anomalous references, insufficient balance, closed account) + +Test reference: `backend/treasury/tests.py`. + +## 10. Canonical Source Files + +For AI-agent analysis/verification, use these files as primary references: + +- backend/treasury/models.py +- backend/treasury/urls.py +- backend/treasury/views.py +- backend/treasury/serializers.py diff --git a/backend/Project Documentation/05_CONTENT_MODULE.md b/backend/Project Documentation/05_CONTENT_MODULE.md new file mode 100644 index 000000000..ce0539edd --- /dev/null +++ b/backend/Project Documentation/05_CONTENT_MODULE.md @@ -0,0 +1,152 @@ +# 05 - Content Module + +## 1. Module Purpose + +The content module governs dynamic homepage content and the public WhatsApp workflow. + +Responsibilities: + +- management of editorial sections and ordered links +- authorization enforcement for content managers +- centralized WhatsApp link configuration +- public registration with validation, email delivery, and CSV audit on Drive + +## 2. Domain Model + +### 2.1 ContentSection + +Main attributes: + +- title (enum, unique): LINK_UTILI | WIKI_TUTORIAL +- order +- is_active +- created_by, created_at, updated_at + +### 2.2 ContentLink + +Main attributes: + +- section (FK) +- name +- description +- url +- color +- order +- created_by, created_at, updated_at + +### 2.3 WhatsAppConfig + +Singleton configuration (`pk=1`): + +- whatsapp_link +- updated_at +- updated_by + +## 3. API Contract Summary + +Base path: /backend/content/ + +### 3.1 Sections APIs + +- GET /sections/ +- GET /sections/active_sections/ +- POST /sections/ +- PATCH /sections// +- DELETE /sections// + +### 3.2 Links APIs + +- GET /links/ +- POST /links/ +- PATCH /links// +- DELETE /links// + +### 3.3 WhatsApp APIs + +- GET|PATCH /whatsapp-config/ +- POST /whatsapp-register/ + +## 4. Permission Model + +Central guard: `_can_manage_content(user)` + +True conditions: + +- user in Board +- user.can_manage_content + +Endpoint policy: + +1. `sections/links` GET: authenticated. +2. sections/links POST/PATCH/DELETE: content manager. +3. `whatsapp-config` GET: authenticated. +4. whatsapp-config PATCH: content manager. +5. `whatsapp-register` POST: public (`AllowAny`). + +## 5. Core Business Flows + +### 5.1 Editorial CRUD Flow + +1. manager creates/updates section or link +2. ordering is applied through `order` +3. `is_active` controls homepage exposure +4. audit metadata preserves change traceability + +### 5.2 Public WhatsApp Registration + +Required input: + +- first_name +- last_name +- email +- is_international +- home_university +- course_of_study + +Sequence: + +1. serializer validation +2. application of eligibility rules (international/erasmus) +3. verify configured `whatsapp_link` presence +4. send email with group link +5. append audit CSV to Google Drive with timestamp/outcome + +Target CSV: `cronologia richieste gruppo whatsapp.csv`. + +## 6. Integration Notes + +- home frontend: rendering active sections and ordered links +- content manager frontend: complete content/configuration administration +- external services: SMTP for emails, Drive API for audit trail + +## 7. Operational Constraints + +1. `active_sections` is filtered server-side. +2. CSV append is protected by in-process lock to reduce collisions. +3. Email/Drive errors must be traced and observable. + +## 8. Operational Risks + +1. inconsistent WhatsApp singleton configuration across environments +2. local lock may be insufficient in multi-process deployments +3. regressions in content-manager access policy +4. non-uniform external error handling (SMTP/Drive) + +## 9. Testing Requirements + +1. permission matrix for Board, `can_manage_content`, standard user +2. validazioni ContentLink (url/color/name/order) +3. `whatsapp-register` allowed and blocked paths +4. failure path email e append CSV +5. verify `active_sections` returns only active content + +Test reference: `backend/content/tests.py`. + +## 10. Canonical Source Files + +For AI-agent analysis/verification, use these files as primary references: + +- backend/content/models.py +- backend/content/urls.py +- backend/content/views.py +- backend/content/serializers.py diff --git a/backend/Project Documentation/06_INTEGRATION_E2E.md b/backend/Project Documentation/06_INTEGRATION_E2E.md new file mode 100644 index 000000000..dd89eaa6d --- /dev/null +++ b/backend/Project Documentation/06_INTEGRATION_E2E.md @@ -0,0 +1,216 @@ +# 06 - Integration & E2E + +## 1. Document Purpose + +This specification defines the cross-module flows that represent the functional acceptance baseline. + +Scope: + +- users +- profiles +- events +- treasury +- content +- maintenance + +## 2. End-to-End Critical Scenarios + +### 2.1 Scenario A - Complete ESNer Onboarding + +Preconditions: + +- valid institutional email domain +- working email verification endpoint + +Sequence: + +1. POST profile/initiate-creation con is_esner=true +2. GET verify-email with valid uid/token +3. POST login +4. access protected endpoints +5. verify initial group and activation state + +Test oracles: + +- active user +- profile aligned with initial role +- valid token lifecycle + +### 2.2 Scenario B - Erasmus onboarding + public form + +Preconditions: + +- event with active form + +Sequence: + +1. Erasmus registration and email verification +2. read public form +3. submit valid payload +4. create subscription in form list +5. send confirmation email + +Test oracles: + +- persistence of form/additional data +- correct subscription state + +### 2.3 Scenario C - SumUp payment reconciliation + +Preconditions: + +- allow_online_payment=true + +Sequence: + +1. formsubmit creates subscription in Form List and generates checkout_id +2. if Main and Waiting are both full, `status` reports `payment_blocked=true` (`sold_out`) +3. in the same sold-out condition, `process_payment` returns `409 BLOCKED` +4. otherwise `process_payment` or webhook confirms `paid` status +5. create local transactions (fee/deposit/services) +6. payment-status alignment on subscription + +Test oracles: + +- idempotency on duplicate webhooks +- no double accounting +- form submit remains accepted even when payment is blocked by full Main+Waiting capacity + +### 2.4 Scenario D - Reimbursements (fee/deposit/services) + +Preconditions: + +- original payment transactions exist + +Sequence: + +1. open single "Reimburse" action on subscription +2. select items (fee/services/deposit) with automatic disable for invalid items +3. orchestrate calls to reimburse_quota and/or reimburse_deposits +4. generate refund transactions for successful items +5. update account balance +6. block duplicate reimbursement attempts + +Test oracles: + +- anti-duplicate constraints respected +- handling of external users without profile preserved +- partial-outcome management: precise error for failed item + selective retry + +### 2.5 Scenario E - ESNcard emission/revocation + accounting + +Sequence: + +1. issue card on valid profile +2. correct fee calculation for the use case +3. create ESNcard transaction +4. card revocation by Board user +5. creation of `rimborso_esncard` transaction on the same account +6. verify emission transaction remains in history +7. verify account balance after refund + +Test oracles: + +- correct fee policy +- ledger consistency (emission + refund) +- emission transaction preserved for audit +- revocation blocked on accounting edge cases (insufficient balance, closed account) + +### 2.6 Scenario F - Content + WhatsApp public workflow + +Sequence: + +1. manager updates `whatsapp-config` +2. public user submits `whatsapp-register` +3. send email link +4. append audit CSV on Drive + +Test oracles: + +- access policy respected +- audit trail present + +### 2.7 Scenario G - Maintenance notification + +Sequence: + +1. admin triggers notification +2. authenticated clients read maintenance/status +3. banner frontend visible +4. clear notification + +Test oracles: + +- correct state propagation +- consistent notification reset + +## 3. Test Asset Mapping + +Dedicated integration suites: + +- backend/test_integration_e2e.py +- backend/users/test_integration.py + +Cross-domain support from module suites: + +- backend/events/tests.py +- backend/treasury/tests.py +- backend/profiles/tests.py +- backend/content/tests.py + +## 4. Release Regression Gate + +Minimum checklist for each release: + +1. login, refresh, logout +2. ESNer/Erasmus registration and verification +3. event creation + main lists +4. office subscription and public form +5. SumUp payment + idempotent webhook + sold-out pre-payment block +6. fee/deposit/services reimbursements +7. ESNcard issue and revocation +8. transaction export +9. CRUD content home +10. whatsapp register end-to-end + +## 5. Cross-Module Risk Register + +1. payment misalignment between events and treasury +2. race conditions on account balance updates +3. out-of-order or duplicate webhooks +4. backend/frontend authorization mismatch +5. regressions on external-subscription flows + +## 6. Test Strategy Guidelines + +1. unit tests for local rules and permissions +2. integration API test per side-effect DB +3. synthetic E2E tests on mission-critical flows +4. deterministic mocks for SumUp, Drive, SMTP + +## 7. Acceptance Baseline + +An E2E scenario is considered accepted only if: + +1. final state is persisted and verifiable +2. external side effects are tracked (email/webhook/export) +3. accounting/authorization invariants are respected +4. no duplication occurs in idempotent operations + +## 8. Traceability Map (Scenario -> Test Assets) + +Reference mapping for quick agent-driven troubleshooting: + +- Scenario A: backend/users/test_integration.py, backend/profiles/tests.py +- Scenario B: backend/profiles/tests.py, backend/events/tests.py +- Scenario C: backend/events/tests.py, backend/treasury/tests.py +- Scenario D: backend/treasury/tests.py, backend/events/tests.py +- Scenario E: backend/treasury/tests.py, backend/profiles/tests.py +- Scenario F: backend/content/tests.py +- Scenario G: backend/maintenance/views.py (comportamento), backend/test_integration_e2e.py + +Note: this map indicates the primary tests to inspect; it does not imply exhaustive edge-case coverage. + +Documentation reference for Scenario G: + +- 07_MAINTENANCE_MODULE.md diff --git a/backend/Project Documentation/07_MAINTENANCE_MODULE.md b/backend/Project Documentation/07_MAINTENANCE_MODULE.md new file mode 100644 index 000000000..63009a849 --- /dev/null +++ b/backend/Project Documentation/07_MAINTENANCE_MODULE.md @@ -0,0 +1,131 @@ +# 07 - Maintenance Module + +## 1. Module Purpose + +The maintenance module manages application-maintenance notifications for authenticated clients. + +Responsibilities: + +- expose current maintenance state +- push events via SSE with controlled connections +- trigger and clear notifications via staff-only admin page +- persist notification state in a shared JSON file + +## 2. Domain Model + +The module does not use dedicated Django models. + +Application state is persisted in: + +- backend/maintenance_notification.json + +Payload schema: + +- notification_id (UUID or null) +- triggered_at (ISO datetime or null) + +Invariants: + +1. `notification_id = null` indicates no active notification. +2. `message` keeps a default operational text when not provided. +3. `triggered_at` is set only when notification is active. + +## 3. API Contract Summary + +Base path: /backend/ + +### 3.1 Streaming API + +- GET /maintenance/stream/ + +Behavior: + +- requires JWT access token in query parameter `token` +- returns HTTP `403` when token is missing or invalid +- sends a maintenance event when `notification_id` changes +- sends periodic heartbeat to keep connection alive +- auto-rotates connection after a time window to recycle workers + +### 3.2 Polling API + +- GET /maintenance/status/ + +Behavior: + +- requires authentication (`IsAuthenticated`) +- returns current notification state (`notification_id`, `message`, `triggered_at`) + +### 3.3 Admin Action Endpoint + +- GET|POST /admin/maintenance-notify/ + +Behavior: + +- accessible only to staff members +- `action=send`: creates a new notification with UUID and timestamp +- `action=clear`: resets state to no active notification + +## 4. Permission Model + +Main rules: + +1. maintenance stream requires valid JWT token. +2. maintenance status requires an authenticated user. +3. maintenance admin page requires `staff_member_required`. +4. trigger/clear are available only from authorized admin UI. + +## 5. Core Business Flows + +### 5.1 Trigger Maintenance Notification + +1. staff opens maintenance admin page +2. submits `action=send` +3. backend generates `notification_id` and `triggered_at` +4. state is written to `maintenance_notification.json` +5. SSE clients detect change and receive maintenance event + +### 5.2 Clear Maintenance Notification + +1. staff submits `action=clear` +2. backend resets payload with `notification_id = null` +3. state is updated in `maintenance_notification.json` +4. polling/SSE clients no longer receive active state + +### 5.3 Client Consumption Flow + +1. authenticated client reads state from `/maintenance/status/` +2. optionally opens `EventSource` on `/maintenance/stream/?token=` +3. on maintenance event, shows UI banner/notification +4. on clear, removes banner + +## 6. Operational Constraints + +1. File-based persistence depends on shared local filesystem access. +2. SSE on WSGI relies on controlled long-lived connections with timeout/recycle. +3. Stream authentication via query param is required due to EventSource header limits. +4. Heartbeats are required to prevent infrastructure idle timeouts. + +## 7. Operational Risks + +1. race condition on file writes in multi-process environments. +2. state mismatch across instances if storage is not shared. +3. token exposure in query string inside infrastructure logs. +4. worker saturation if stream is not limited/recycled correctly. + +## 8. Testing Requirements + +1. stream without token returns 403. +2. stream with invalid/expired token returns 403. +3. status requires authentication and returns full payload. +4. `action=send` creates `notification_id` and `triggered_at`. +5. action clear resets notification_id and triggered_at. +6. verify maintenance-event emission when `notification_id` changes. + +## 9. Canonical Source Files + +For AI-agent analysis/verification, use these files as primary references: + +- backend/maintenance/urls.py +- backend/maintenance/views.py +- backend/backend/urls.py +- backend/maintenance_notification.json diff --git a/backend/Project Documentation/TEST_COVERAGE_REPORT.md b/backend/Project Documentation/TEST_COVERAGE_REPORT.md new file mode 100644 index 000000000..e9f7cf053 --- /dev/null +++ b/backend/Project Documentation/TEST_COVERAGE_REPORT.md @@ -0,0 +1,131 @@ +# Test Coverage Report - ESN Polimi Management + +Last update date: 2026-04-01 +Scope: Django backend (users, profiles, events, treasury, content) + +## 1. Quality Scope + +This report describes functional test coverage and residual risks. + +Methodology note: + +- counts represent the number of `test_` functions, not line coverage percentage +- this metric reflects suite breadth, not branch-level depth + +## 2. Test Inventory + +| Test File | Number of `test_` Functions | +|---|---:| +| backend/users/tests.py | 40 | +| backend/users/test_integration.py | 14 | +| backend/profiles/tests.py | 70 | +| backend/events/tests.py | 114 | +| backend/treasury/tests.py | 83 | +| backend/content/tests.py | 43 | +| backend/test_integration_e2e.py | 6 | +| Total | 370 | + +## 3. Functional Coverage Matrix + +### 3.1 Users + +- JWT authentication (login/refresh/logout) +- reset and forgot password +- user and group CRUD +- special application-permission management + +### 3.2 Profiles + +- Erasmus/ESNer onboarding +- automatic email verification and Board manual verification +- profile and document CRUD +- search and filters +- support endpoints for events/subscriptions + +### 3.3 Events + +- event/list CRUD +- office and public-form subscriptions +- dynamic field schema and additional fields +- optional services with pricing +- SumUp flows (checkout/process/webhook) +- sold-out handling: form submission stays allowed in Form List, payment blocked only when Main+Waiting are full +- shared lists and move subscriptions +- waivers and organizer utilities + +### 3.4 Treasury + +- account CRUD and group-based visibility +- transaction CRUD with balance impact +- ESNcard issuance and fee policy +- ESNcard revocation with tracked refund (`rimborso_esncard`) +- reimbursement request lifecycle +- deposit/fee/services reimbursements +- export XLSX + +### 3.5 Content + +- section and link CRUD +- policy content manager (Board o can_manage_content) +- WhatsApp singleton configuration +- public `whatsapp-register` workflow with email and Drive CSV audit + +### 3.6 E2E Integration + +- multi-module tests in `backend/test_integration_e2e.py` +- coverage of critical onboarding/events/payments/reimbursements flows + +## 4. Residual Risk Register + +1. Treasury concurrency: race conditions on simultaneous balance updates. +2. Scalability: large datasets on events/transactions/reimbursements. +3. Webhook/token security: replay, ordering, expiration, abuse rate. +4. Frontend-backend contracts: payload drift on public forms/payments. + +## 5. Recommended Test Enhancements + +1. Add concurrency tests for accounting transactions. +2. Introduce explicit contract tests for public endpoints. +3. Strengthen webhook idempotency tests with duplicate/out-of-order events. +4. Add automated E2E smoke tests in release gate. + +## 6. Execution Commands + +Full execution: + +```bash +python manage.py test +``` + +Per module: + +```bash +python manage.py test users +python manage.py test profiles +python manage.py test events +python manage.py test treasury +python manage.py test content +python manage.py test test_integration_e2e +``` + +Execution with test settings: + +```bash +DJANGO_SETTINGS_MODULE=backend.settings.test python manage.py test +``` + +## 7. Maintenance Policy + +1. Every bug fix must include at least one regression test. +2. Every new API must include permissions, validation, happy path, and error path tests. +3. Financial flows must include balance and idempotency checks. +4. This report must be updated for every significant test-suite change. + +## 8. AI Agent Interpretation Notes + +Rules for correct report usage by AI agents: + +1. Do not use total test count as the only quality metric. +2. For high-risk changes, consult `06_INTEGRATION_E2E.md` first. +3. Always validate residual gaps against the requested change. +4. If report and real suite differ, current test execution is authoritative. diff --git a/backend/README_APIs.md b/backend/README_APIs.md index 70ff5e547..ac970ab81 100644 --- a/backend/README_APIs.md +++ b/backend/README_APIs.md @@ -3,13 +3,13 @@ python, using [Django](https://www.djangoproject.com) and [Django Rest Framework The code is divided into 4 apps that handle different functionalities of the management platform. -- **profiles**: manages the registration of new profiles (erasmus or esners), adding or editing documents or matricole, +- **profiles**: manages registration of new profiles (Erasmus or ESNers), including documents and student IDs (`matricole`), releasing esncards, retrieving data and search through it. - **events**: manages events, i.e. creation, editing, registering a profile to an event, etc. - **treasury**: manages transactions (created when a payment is issued, for example when registering to an event or - releasing an esncard) and accounts (i.e. casse) + releasing an ESNcard) and cash accounts - **users**: handles authentication @@ -37,7 +37,7 @@ The structure of every app is as follows: `POST /profiles` -Creates a profile with related document and matricola. `verified` field is initialized to false. +Creates a profile with related document and student ID (`matricola`). `verified` field is initialized to false. #### **Parameters:** @@ -62,14 +62,14 @@ Creates a profile with related document and matricola. `verified` field is initi `GET /profiles` -Returns all profiles without linked documents, matricole or ESNcards. +Returns all profiles without linked documents, student IDs, or ESNcards. ### Fetch specified profile * `GET /profiles/` If existing, returns detailed profile corresponding to the primary key (pk), including list of the profile's documents, -matricole and ESNcards. +student IDs and ESNcards. ### Update specified profile * @@ -137,13 +137,13 @@ Updates the fields of the specified document. Deletes the specified document. -# Matricola endpoints +# Student ID (`matricola`) Endpoints -### Create matricola +### Create Student ID (`matricola`) `POST /matricole/` -Creates a new matricola associated to the specified profile. +Creates a new student ID (`matricola`) associated with the specified profile. #### **Parameters** @@ -153,18 +153,18 @@ Creates a new matricola associated to the specified profile. | number | string | yes | document number | | expiration | timestamp | yes | expiration date | -### Update matricola +### Update Student ID (`matricola`) `PATCH /matricole/` -Updates the fields of the specified matricola. +Updates the fields of the specified student ID (`matricola`). | name | type | mandatory | description | |------------|-----------|-----------|-----------------| | number | string | no | document number | | expiration | timestamp | no | expiration date | -### Delete matricola +### Delete Student ID (`matricola`) `DELETE /matricole/` @@ -172,32 +172,59 @@ Deletes the specified # ESNcard endpoints -### Create ESNcard +### Emit ESNcard -`POST /esncards` +`POST /esncard_emission/` -Creates a new ESNcard associated to the specified profile. +Creates a new ESNcard associated to the specified profile and registers the related treasury transaction +(`type=esncard`) on the selected account. #### **Parameters** -| name | type | mandatory | description | -|------------|-----------|-----------|-----------------------------------------------| -| email | string | yes | email corresponding to the associated profile | -| number | string | yes | document number | -| expiration | timestamp | yes | expiration date | +| name | type | mandatory | description | +|---------------|--------|-----------|--------------------| +| profile_id | int | yes | profile identifier | +| esncard_number| string | yes | ESNcard number | +| account_id | int | yes | treasury account | ### Update ESNcard -`PATCH /esncards/` +`PATCH /esncard//` -Updates the fields of the specified ESNcard. +Updates editable fields of the specified ESNcard. #### **Parameters** -| name | type | mandatory | description | -|------------|-----------|-----------|-----------------| -| number | string | no | document number | -| expiration | timestamp | no | expiration date | +| name | type | mandatory | description | +|--------|--------|-----------|---------------| +| number | string | no | ESNcard number| + +### Revoke ESNcard (Board only) + +`DELETE /esncard//` + +Revokes the specified ESNcard. + +Behavior: + +- deletes the ESNcard record +- keeps the original ESNcard emission transaction +- creates a new treasury refund transaction (`type=rimborso_esncard`) with negative amount on the same account + +#### **Response fields** + +| name | type | description | +|-------------------------|--------|-------------| +| message | string | operation result summary | +| revoked_esncard_number | string | revoked card number | +| original_transaction_id | int\|null | ESNcard emission transaction id | +| refund_transaction_id | int\|null | generated refund transaction id | + +#### **Common errors** + +- `401`: caller is not a Board member +- `404`: ESNcard does not exist +- `409`: automatic revoke blocked (linked anomalous transactions, multiple linked emissions, closed account, insufficient balance) # User endpoints @@ -205,7 +232,7 @@ Updates the fields of the specified ESNcard. `POST /users` -Creates a user and its associated profile, document and matricola. Profile's `verified` field is initialized to false. +Creates a user and its associated profile, document, and student ID (`matricola`). Profile's `verified` field is initialized to false. #### **Parameters:** @@ -343,7 +370,7 @@ Updates specific subscription. Deletes subscription. Possible only if there are no transactions related to the subscription. -# Account (cassa) +# Cash Account ### Fetch accounts diff --git a/backend/README_Content.md b/backend/README_Content.md index 7156f72cd..5862598e1 100644 --- a/backend/README_Content.md +++ b/backend/README_Content.md @@ -1,136 +1,138 @@ -# Sistema di Gestione Contenuti Dinamici +# Dynamic Content Management System -## Descrizione +## Description -Questo modulo permette di gestire dinamicamente i contenuti della home page attraverso un'interfaccia di amministrazione. Il sistema è progettato per gestire due categorie fisse di contenuti: **LINK UTILI** e **WIKI E TUTORIAL**. +This module allows dynamic management of homepage content through an administration interface. The system is designed around two fixed content categories: **USEFUL LINKS** and **WIKI AND TUTORIALS**. -## Caratteristiche Principali +## Main Features -- **Due categorie predefinite**: LINK UTILI e WIKI E TUTORIAL -- **Campi obbligatori per ogni link**: Titolo, Descrizione, URL e Colore -- **Gestione completa dal pannello admin**: Solo membri Board possono modificare -- **Tutti i link vengono letti dal database**: Nessun contenuto hardcoded +- **Two predefined categories**: USEFUL LINKS and WIKI AND TUTORIALS +- **Mandatory fields for each link**: Title, Description, URL, and Color +- **Full admin-panel management**: Board members and users with can_manage_content=True can modify content +- **All links are loaded from the database**: no hardcoded content -## Struttura +## Structure ### Backend (`backend/content/`) -- **models.py**: Definisce i modelli `ContentSection` e `ContentLink` - - `ContentSection`: Sezione di contenuti (es: "LINK UTILI") - - `ContentLink`: Singolo link all'interno di una sezione +- **models.py**: defines `ContentSection` and `ContentLink` + - `ContentSection`: content section (for example "USEFUL LINKS") + - `ContentLink`: single link inside a section -- **serializers.py**: Serializer REST per le API -- **views.py**: ViewSet con permessi (solo Board può modificare) -- **urls.py**: Route API per gestione contenuti -- **admin.py**: Interfaccia Django Admin +- **serializers.py**: REST serializers for API payloads +- **views.py**: ViewSets with permission checks (Board members or users with can_manage_content=True can modify) +- **urls.py**: API routes for content management +- **admin.py**: Django Admin integration ### Frontend -- **Pages/ContentManager.jsx**: Pagina di amministrazione per gestire sezioni e link -- **Pages/Home.jsx**: Pagina home aggiornata per leggere contenuti dinamici -- **Components/ProtectedRoute.jsx**: Aggiornato per supportare `requiredGroup` +- **Pages/ContentManager.jsx**: admin page for sections and links +- **Pages/Home.jsx**: homepage rendering dynamic content +- **Components/ProtectedRoute.jsx**: supports `requiredGroup` ## API Endpoints -``` -GET /backend/content/sections/ - Lista sezioni -GET /backend/content/sections/active_sections/ - Sezioni attive con link -POST /backend/content/sections/ - Crea sezione (solo Board) -PATCH /backend/content/sections/{id}/ - Modifica sezione (solo Board) -DELETE /backend/content/sections/{id}/ - Elimina sezione (solo Board) - -GET /backend/content/links/ - Lista link -POST /backend/content/links/ - Crea link (solo Board) -PATCH /backend/content/links/{id}/ - Modifica link (solo Board) -DELETE /backend/content/links/{id}/ - Elimina link (solo Board) +```text +GET /backend/content/sections/ - list sections +GET /backend/content/sections/active_sections/ - list active sections with links +POST /backend/content/sections/ - create section (Board or users with can_manage_content) +PATCH /backend/content/sections/{id}/ - update section (Board or users with can_manage_content) +DELETE /backend/content/sections/{id}/ - delete section (Board or users with can_manage_content) + +GET /backend/content/links/ - list links +POST /backend/content/links/ - create link (Board or users with can_manage_content) +PATCH /backend/content/links/{id}/ - update link (Board or users with can_manage_content) +DELETE /backend/content/links/{id}/ - delete link (Board or users with can_manage_content) ``` -## Permessi +## Permissions -- **Lettura**: Tutti gli utenti autenticati -- **Modifica**: Solo membri del Board +- **Read**: all authenticated users +- **Write**: Board members and users with can_manage_content=True -## Setup e Migrazione +## Setup and Migration -1. **Eseguire le migrazioni**: +1. **Run migrations**: ```bash cd backend python manage.py migrate ``` -2. **Popolare il database con i dati iniziali**: +2. **Populate initial content data**: ```bash python manage.py populate_content ``` -Questo comando creerà automaticamente le due sezioni (LINK UTILI e WIKI E TUTORIAL) e popolerà i link iniziali. +This command automatically creates the two fixed sections (USEFUL LINKS and WIKI AND TUTORIALS) and populates initial links. -## Modelli +## Models ### ContentSection -- `title`: Categoria (LINK_UTILI o WIKI_TUTORIAL) - **Campo unico** -- `order`: Ordine di visualizzazione -- `is_active`: Flag per attivare/disattivare -- `created_by`: Utente creatore -- `created_at`/`updated_at`: Timestamp +- `title`: category (`LINK_UTILI` or `WIKI_TUTORIAL`) - **unique field** +- `order`: display order +- `is_active`: active/inactive flag +- `created_by`: creator user +- `created_at`/`updated_at`: timestamps -**Nota**: Le sezioni sono fisse, solo due categorie possibili. +Note: sections are fixed to two categories by design. ### ContentLink -- `section`: ForeignKey a ContentSection -- `name`: Titolo del link - **Obbligatorio** -- `description`: Descrizione del link - **Obbligatorio** -- `url`: URL del link - **Obbligatorio** -- `color`: Colore esadecimale (es: #1976d2) - **Obbligatorio** -- `order`: Ordine all'interno della sezione -- `is_active`: Flag per attivare/disattivare -- `created_by`: Utente creatore -- `created_at`/`updated_at`: Timestamp +- `section`: foreign key to `ContentSection` +- `name`: link title - **required** +- `description`: link description - **required** +- `url`: link URL - **required** +- `color`: hexadecimal color (for example `#1976d2`) - **required** +- `order`: order inside the section +- `is_active`: active/inactive flag +- `created_by`: creator user +- `created_at`/`updated_at`: timestamps + +All `name`, `description`, `url`, and `color` fields are mandatory. -**Tutti i campi name, description, url e color sono obbligatori.** +## Special Actions -## Azioni Speciali +~~For links that should trigger custom actions (for example opening a modal), set `action_type`.~~ -~~Per link che devono eseguire azioni custom (come aprire modali), impostare il campo `action_type`~~ +**Removed**: `action_type` has been removed. All links are standard URL links. -**Rimosso**: Il campo `action_type` è stato rimosso. Tutti i link ora sono link standard che aprono URL. +## Fallback Behavior -## Fallback +~~If dynamic content loading fails, the homepage automatically uses static hardcoded fallback content.~~ -~~Se il caricamento dei contenuti dinamici fallisce, la home page usa automaticamente i contenuti statici hardcoded come fallback.~~ +**Updated**: all content is loaded from the database. Static fallback content is no longer used. If loading fails, the user sees an error message. -**Aggiornato**: Tutti i contenuti ora vengono caricati dal database. Non ci sono più contenuti statici di fallback. In caso di errore, viene mostrato un messaggio di errore all'utente. +## Access to Content Manager -## Accesso alla Pagina di Gestione +The content management page is available to Board members and users with the Content Manager role (grantable by Board members via the Profiles page) via: +- sidebar item: "Content Management" +- direct URL: `/content-manager` -La pagina di gestione contenuti è accessibile solo ai membri del Board tramite: -- Menu laterale: "Gestione Contenuti" -- URL diretto: `/content-manager` +Access is controlled by the `can_manage_content=True` permission. -## Funzionalità della Pagina di Gestione +## Content Manager Capabilities -### Sezioni -- **Due sezioni fisse**: LINK UTILI e WIKI E TUTORIAL -- Solo visualizzazione, non si possono creare/eliminare sezioni -- Ogni sezione mostra il numero di link contenuti +### Sections +- **Two fixed sections**: USEFUL LINKS and WIKI AND TUTORIALS +- display-only for sections (no create/delete in UI) +- each section shows the current number of links -### Link -- Aggiunta link a una sezione -- **Campi obbligatori**: - - **Titolo**: Nome del link - - **Descrizione**: Testo descrittivo del link - - **Link/URL**: URL completo (es: https://...) - - **Colore**: Colore in formato esadecimale (es: #1976d2) -- Impostazione ordine di visualizzazione -- Attivazione/disattivazione -- Eliminazione +### Links +- add a link to a section +- **required fields**: + - **Title**: link name + - **Description**: descriptive text + - **Link/URL**: full URL (for example `https://...`) + - **Color**: hexadecimal color (for example `#1976d2`) +- set display order +- activate/deactivate +- delete -### Validazione -La pagina di gestione valida che tutti i campi obbligatori siano compilati prima di salvare. +### Validation +The page validates required fields before save. -## Note Tecniche +## Technical Notes -- I contenuti vengono caricati all'apertura della home page -- La cache non è implementata (ogni visita alla home carica i dati freschi) -- I contenuti inattivi non vengono mostrati agli utenti -- L'ordine dei contenuti è gestibile tramite il campo `order` +- Content is loaded whenever the homepage is opened. +- Caching is currently not implemented (fresh data on each homepage visit). +- Inactive content is not shown to users. +- Display ordering is controlled by the `order` field. diff --git a/backend/README_Models.md b/backend/README_Models.md index 36b4e787b..cbe59000f 100644 --- a/backend/README_Models.md +++ b/backend/README_Models.md @@ -1,33 +1,33 @@ -# Eventi -Obiettivo: integrare le funzionalità di gestione degli eventi e delle iscrizioni in un'unica piattaforma, eliminando la necessità di utilizzare sia un gestionale che un foglio Excel. La piattaforma permetterà di gestire i pagamenti, i rimborsi e l'organizzazione degli eventi in modo più efficiente e centralizzato. +# Events +Goal: integrate event and subscription management features into a single platform, removing the need to use both a management tool and an Excel sheet. The platform supports payments, reimbursements, and event operations in a more efficient and centralized way. -### Funzionalità Principali +### Main Features -1. **Creazione e Gestione degli Eventi** - - Ogni evento può avere attributi come nome, data, descrizione, RE ed RS. - - È possibile specificare diverse tabelle per ogni evento, ad esempio "Main List" e "Waiting List", con nome e capienza. +1. **Event Creation and Management** + - Each event can include attributes such as name, date, description, RE, and RS. + - Multiple lists can be configured per event, such as "Main List" and "Waiting List", each with its own name and capacity. -2. **Iscrizioni (Subscriptions)** - - Ogni riga di una tabella è associata a un'iscrizione, che rappresenta l'iscrizione di un profilo a un evento. - - Una subscription può essere presente in una sola tabella alla volta. +2. **Subscriptions** + - Each row in a list is linked to a subscription, representing one profile enrolled in one event. + - A subscription can belong to only one list at a time. -3. **Colonne delle Tabelle** - - **Profile Fields**: Colonne che includono dati anagrafici degli iscritti (nome, cognome, numero di telefono, ESN card, ecc.). Queste colonne sono immutabili e vengono popolate automaticamente dal database. - - **Form Fields**: Colonne che vengono riempite con le risposte fornite dagli iscritti attraverso un form. Queste colonne sono modificabili dagli organizzatori. - - **Additional Fields**: Colonne aggiuntive compilabili direttamente dagli organizzatori per supportare l'organizzazione dell'evento (es. annotazioni, richieste di noleggio, ecc.). Ogni campo ha attributi booleani per la visibilità e l'editabilità da parte dell'ufficio. +3. **List Columns** + - **Profile Fields**: Columns containing participant profile data (name, surname, phone number, ESN card, and so on). These columns are immutable and automatically populated from the database. + - **Form Fields**: Columns populated with answers submitted by participants through a form. These columns can be edited by organizers. + - **Additional Fields**: Extra columns filled directly by organizers to support event operations (for example notes or rental requests). Each field includes boolean attributes for office visibility and editability. -4. **Gestione delle Righe** - - Le righe possono essere trasferite da una tabella all'altra rispettando la capienza massima. - - È possibile aggiungere manualmente delle righe, associandole a un profilo esistente. +4. **Row Management** + - Rows can be moved between lists while respecting capacity limits. + - Organizers can manually add rows by linking them to an existing profile. -5. **Gestione dei Pagamenti** - - Ogni riga ha un pulsante che apre un modale con la lista delle transazioni effettuate per quello specifico evento. - - Il modale permette di visualizzare lo storico dei pagamenti/rimborsi e di effettuare nuove transazioni. - - È possibile creare un campo aggiuntivo "Stato" per indicare manualmente lo stato dei pagamenti (es. "Pagato", "Non pagato", "Rimborsato"). +5. **Payment Management** + - Each row has a button that opens a modal with the list of transactions for that specific event. + - The modal allows viewing payment/reimbursement history and creating new transactions. + - An optional additional field called "Status" can be used to manually track payment state (for example "Paid", "Unpaid", "Reimbursed"). -### Vantaggi +### Benefits -- **Centralizzazione**: Tutte le informazioni e le funzionalità sono integrate in un'unica piattaforma. -- **Flessibilità**: La possibilità di aggiungere e modificare campi e tabelle permette una gestione personalizzata degli eventi. -- **Efficienza**: La gestione automatizzata dei dati anagrafici e delle iscrizioni riduce gli errori e semplifica l'organizzazione. +- **Centralization**: All information and capabilities are available in one platform. +- **Flexibility**: Custom fields and lists allow tailored event management. +- **Efficiency**: Automated profile data and subscription flows reduce errors and simplify operations. diff --git a/backend/backend/db_audit.py b/backend/backend/db_audit.py index b4eb7a912..e06f899af 100644 --- a/backend/backend/db_audit.py +++ b/backend/backend/db_audit.py @@ -217,7 +217,7 @@ def _on_m2m_changed(sender, instance, action, reverse, model, pk_set, **kwargs): "operation": action, "reverse": reverse, "related_model": model._meta.label, - "related_pks": sorted(list(pk_set)) if pk_set else [], + "related_pks": sorted(pk_set) if pk_set else [], } ) diff --git a/backend/content/tests.py b/backend/content/tests.py index 8a7478d6d..2e14e217e 100644 --- a/backend/content/tests.py +++ b/backend/content/tests.py @@ -112,7 +112,7 @@ def test_section_create_as_board(self): self.assertTrue(ContentSection.objects.filter(title="LINK_UTILI").exists()) def test_section_create_as_attivi(self): - """Attivi can create sections (finance permission).""" + """Attivi without explicit content role cannot create sections.""" profile = _create_profile("attivo@esnpolimi.it") user = _create_user(profile) user.groups.add(self.group_attivi) @@ -122,10 +122,10 @@ def test_section_create_as_attivi(self): "title": "WIKI_TUTORIAL", }) - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, 403) def test_section_create_as_aspirante_with_flag(self): - """Aspiranti with can_manage_casse can create sections.""" + """Aspiranti with finance-only flag cannot create sections.""" profile = _create_profile("aspirante@esnpolimi.it") user = _create_user(profile) user.groups.add(self.group_aspiranti) @@ -137,6 +137,20 @@ def test_section_create_as_aspirante_with_flag(self): "title": "LINK_UTILI", }) + self.assertEqual(response.status_code, 403) + + def test_section_create_with_explicit_content_role(self): + """Users with explicit content-management role can create sections.""" + profile = _create_profile("content.manager@esnpolimi.it") + user = _create_user(profile) + user.can_manage_content = True + user.save(update_fields=["can_manage_content"]) + self.authenticate(user) + + response = self.client.post("/backend/content/sections/", { + "title": "WIKI_TUTORIAL", + }) + self.assertEqual(response.status_code, 201) def test_section_update_requires_permission(self): @@ -606,7 +620,7 @@ def test_whatsapp_config_patch_allowed_for_board(self): self.assertEqual(config.whatsapp_link, "https://chat.whatsapp.com/board-link") def test_whatsapp_config_patch_allowed_for_attivi(self): - """PATCH whatsapp-config should succeed for Attivi users.""" + """PATCH whatsapp-config should be forbidden for Attivi without explicit role.""" profile = _create_profile("attivo@esnpolimi.it") user = _create_user(profile) user.groups.add(self.group_attivi) @@ -618,9 +632,25 @@ def test_whatsapp_config_patch_allowed_for_attivi(self): format="json", ) + self.assertEqual(response.status_code, 403) + + def test_whatsapp_config_patch_allowed_for_explicit_content_role(self): + """PATCH whatsapp-config should succeed for users with explicit content role.""" + profile = _create_profile("content.manager@esnpolimi.it") + user = _create_user(profile) + user.can_manage_content = True + user.save(update_fields=["can_manage_content"]) + self.authenticate(user) + + response = self.client.patch( + "/backend/content/whatsapp-config/", + {"whatsapp_link": "https://chat.whatsapp.com/content-manager-link"}, + format="json", + ) + self.assertEqual(response.status_code, 200) config = WhatsAppConfig.get_instance() - self.assertEqual(config.whatsapp_link, "https://chat.whatsapp.com/attivi-link") + self.assertEqual(config.whatsapp_link, "https://chat.whatsapp.com/content-manager-link") @patch("content.views.send_mail") @patch("content.views._append_to_whatsapp_log") diff --git a/backend/content/views.py b/backend/content/views.py index 3849d002c..2f77a6e41 100644 --- a/backend/content/views.py +++ b/backend/content/views.py @@ -32,6 +32,14 @@ _drive_log_lock = threading.Lock() +def _can_manage_content(user): + """Centralize content management authorization across endpoints.""" + return ( + user.groups.filter(name='Board').exists() + or getattr(user, 'can_manage_content', False) + ) + + def _get_drive_service(): credentials = service_account.Credentials.from_service_account_file( settings.GOOGLE_SERVICE_ACCOUNT_FILE, @@ -113,7 +121,7 @@ def _append_to_whatsapp_log(data, outcome): logger.info(f"WhatsApp CSV log appended for {data['email']} — {outcome}") return None except Exception as e: - logger.exception("Drive CSV logging failed") + logger.exception(f"Drive CSV logging failed: {e}") return "Drive logging failed" @@ -121,7 +129,7 @@ class IsContentManagerOrReadOnly(permissions.BasePermission): """ Custom permission for content management. - GET: All authenticated users - - POST/PUT/PATCH/DELETE: Board, Attivi, or users with can_manage_content flag + - POST/PUT/PATCH/DELETE: Board users or users with explicit content-management flag """ def has_permission(self, request, view): if request.method in permissions.SAFE_METHODS: @@ -131,15 +139,7 @@ def has_permission(self, request, view): if not user or not user.is_authenticated: return False - # Board and Attivi always have permission - if user.groups.filter(name__in=['Board', 'Attivi']).exists(): - return True - - # Users with content/finance management flags - if getattr(user, 'can_manage_content', False) or getattr(user, 'can_manage_casse', False): - return True - - return False + return _can_manage_content(user) class ContentSectionViewSet(viewsets.ModelViewSet): @@ -189,7 +189,7 @@ def perform_create(self, serializer): def whatsapp_config(request): """ GET /backend/content/whatsapp-config/ → returns the current WhatsApp link. - PATCH /backend/content/whatsapp-config/ → updates the link (board/attivi only). + PATCH /backend/content/whatsapp-config/ → updates the link (authorized managers). """ instance = WhatsAppConfig.get_instance() @@ -197,12 +197,9 @@ def whatsapp_config(request): serializer = WhatsAppConfigSerializer(instance) return Response(serializer.data) - # PATCH – board/attivi or content managers can edit + # PATCH - authorized content managers can edit user = request.user - can_edit = ( - user.groups.filter(name__in=['Board', 'Attivi']).exists() - or getattr(user, 'can_manage_content', False) - ) + can_edit = _can_manage_content(user) if not can_edit: return Response({'detail': 'Permission denied.'}, status=drf_status.HTTP_403_FORBIDDEN) diff --git a/backend/docs/TEST_COVERAGE_REPORT.md b/backend/docs/TEST_COVERAGE_REPORT.md deleted file mode 100644 index ea6e16d53..000000000 --- a/backend/docs/TEST_COVERAGE_REPORT.md +++ /dev/null @@ -1,355 +0,0 @@ -# Test Coverage Report - Nuovo Gestionale ESN - -**Data**: Generato automaticamente -**Obiettivo**: Valutazione della copertura dei test per tutti i moduli del sistema - ---- - -## Executive Summary - -Sono stati aggiunti **60 nuovi test** per aumentare la copertura complessiva del progetto, concentrandosi specialmente sulle funzionalità Services (nuova feature) e sui casi edge della Tesoreria (modulo critico). - -### Nuovi Test Aggiunti -- **Events - Services**: 25 test cases (NUOVO FILE) -- **Treasury - Edge Cases**: 35 test cases (NUOVO FILE) -- **Totale nuovi test**: 60 - ---- - -## 1. Eventi - Modulo Events - -### Test Coverage Attuale - -#### A. Funzionalità Core (Pre-esistenti) -- Eventi CRUD completo -- Liste eventi (main list, waiting list, form list) -- Sottoscrizioni eventi -- Gestione capacità liste -- Spostamento tra liste -- Campi form dinamici -- Campi addizionali - -#### B. Servizi (Services) - NUOVO (25 test) - -**File**: `backend/events/test_services.py` - -**Classi di Test**: -1. `ServiceValidationTests` (12 test) - - Schema validazione servizi - - Matching servizi per ID e nome - - Validazione ID invalidi - - Validazione quantità (zero, negativi) - - Servizi mancanti in selected_services - -2. `ServiceCostCalculationTests` (3 test) - - Calcolo corretto totale servizi - - Quantità multiple per servizio - - Eventi senza servizi - -3. `ServiceStatusTests` (2 test) - - Tracking status_services (pending/paid/none) - - Aggiornamento stato servizi - -4. `ServiceEdgeCaseTests` (8 test) - - Array vuoti - - Oggetti malformati - - Conversione tipi - - Quantità molto grandi - - Servizi duplicati - -**Copertura Services**: **COMPLETA** -- ✅ Schema validation -- ✅ Selection workflow -- ✅ Cost calculations (quantity * price) -- ✅ Status tracking -- ✅ Edge cases -- ✅ Invalid inputs -- ✅ Update operations - -**Gap Identificati**: -- ⚠️ Test di integrazione: Servizi + Pagamenti completi non testati end-to-end -- ⚠️ Test performance: Caricamento eventi con molti servizi (~100+) - ---- - -## 2. Tesoreria - Modulo Treasury - -### Test Coverage Attuale - -#### A. Funzionalità Core (Pre-esistenti) -- Account CRUD -- Transaction CRUD -- ESNcard creazione e gestione -- Reimbursement request workflow - -#### B. Edge Cases - NUOVO (35 test) - -**File**: `backend/treasury/test_edge_cases.py` - -**Classi di Test**: -1. `AccountBalanceEdgeCaseTests` (6 test) - - Somma di molteplici transazioni - - Precisione decimale (2 cifre) - - Prevenzione saldi negativi - - Effetto eliminazione transazioni sul saldo - - Reject transazioni su account chiusi - -2. `ESNcardComplexTests` (4 test) - - Validazione lunghezza numero carta - - Prevenzione acquisto con saldo insufficiente - - Multiple carte per profilo - - Prevenzione duplicati in PATCH - -3. `TransactionComplexTests` (3 test) - - Spostamento fondi tra account - - Modifica importo aggiorna saldi - - Gestione account null - -4. `ReimbursementComplexTests` (5 test) - - Ciclo completo rimborso - - Validazione zero/negativi - - Visibilità lista rimborsi per utente - -5. `DepositReimbursementTests` (3 test) - - Rimborso senza cauzione - - Prevenzione duplicati - - Operazioni bulk (5 subscription) - -6. `AccountVisibilityTests` (3 test) - - Account pubblico (no groups) - - Accesso ristretto a gruppo - - Multiple groups - -**Copertura Treasury Edge Cases**: **MOLTO BUONA** -- ✅ Integrità finanziaria (balance calculations) -- ✅ Consistenza dati -- ✅ Business rules (no negatives, closures, etc.) -- ✅ Decimal precision -- ✅ Bulk operations -- ✅ Visibility rules - -**Gap Identificati**: -- ⚠️ Test concorrenza: Transazioni simultanee sullo stesso account -- ⚠️ Test performance: Query su >10000 transazioni -- ⚠️ Test audit: Verifica completa changelog per modifiche sensibili - ---- - -## 3. Profili - Modulo Profiles - -### Test Coverage Attuale -- Profile CRUD -- Group management -- Permissions -- Model properties (is_attivo, is_volontario, etc.) - -**Copertura**: BUONA - -**Gap Identificati**: -- ⚠️ Group promotion workflow: Test per Board che promuove Aspiranti→Attivi -- ⚠️ Bulk operations: Promozione multipla di profili - ---- - -## 4. Utenti - Modulo Users - -### Test Coverage Attuale -- User registration -- Email verification -- Login/Logout -- Password reset -- ESNer vs External workflows - -**Copertura**: BUONA - -**Gap Identificati**: -- ⚠️ Rate limiting: Test per prevenzionespam registrazione -- ⚠️ Security: Test token expiration e reuse - ---- - -## 5. Contenuti - Modulo Content - -### Test Coverage Attuale -- Content pages CRUD -- FAQ CRUD -- Media management - -**Copertura**: SUFFICIENTE - -**Gap Identificati**: -- ⚠️ Media upload: Test upload file di diverse dimensioni -- ⚠️ Content versioning: Se implementato, testare rollback - ---- - -## 6. Integrazione End-to-End - -### Test Coverage Attuale -- **Flusso registrazione Erasmus completo** -- **Flusso iscrizione evento con pagamento** -- **Flusso eventi con servizi** -- **Flusso acquisto ESNcard** -- **Flusso deposito e rimborso** -- **Flussi multi-modulo complessi** - -**File**: `backend/test_integration_e2e.py` (NUOVO - 6 test classes) - -**Classi di Test**: -1. `CompleteEventSubscriptionFlowTests` (2 test) - - Erasmus registra → iscrive evento → paga quota - - Evento con servizi → iscrizione con servizi → pagamento completo - -2. `ESNcardPurchaseFlowTests` (1 test) - - Pagamento membership → emissione ESNcard → aggiornamento saldo - -3. `DepositReimbursementFlowTests` (1 test) - - Iscrizione → pagamento deposito → fine evento → rimborso deposito - -4. `MultiModuleCompleteFlowTests` (2 test) - - Journey completo nuovo utente (ESNcard → evento → servizi) - - Workflow organizzatore evento completo - -**Copertura E2E**: **SIGNIFICATIVAMENTE MIGLIORATA** (da MINIMA a BUONA) -- ✅ Flusso completo iscrizione evento con pagamento -- ✅ Flusso eventi con servizi e pagamento -- ✅ Flusso ESNcard end-to-end -- ✅ Ciclo completo deposito/rimborso -- ✅ Integrazione profiles + events + treasury -- ✅ Workflow organizzatore multi-fase - -**Gap Identificati**: -- ⚠️ Flusso registrazione utente (email verification flow) -- ⚠️ Flusso password reset completo -- ⚠️ Flusso richiesta rimborso spese → approvazione → esecuzione - ---- - -## Analisi Copertura per Criticità - -### Moduli CRITICI (Alta Priorità) -| Modulo | Copertura Attuale | Nuovi Test | Sufficienza | -|--------|-------------------|------------|-------------| -| **Treasury** | Buona | +35 edge cases | ✅ SUFFICIENTE | -| **Events - Core** | Buona | - | ✅ SUFFICIENTE | -| **Events - Services** | Completa | +25 nuovi | ✅ SUFFICIENTE | -| **Users** | Buona | - | ✅ SUFFICIENTE | - -### Moduli MEDI (Media Priorità) -| Modulo | Copertura Attuale | Nuovi Test | Sufficienza | -|--------|-------------------|------------|-------------| -| **Profiles** | Buona | - | ✅ SUFFICIENTE | -| **Content** | Sufficiente | - | ⚠️ QUASI SUFFICIENTE | - -### Moduli BASSI (Bassa Priorità) -| Modulo | Copertura Attuale | Nuovi Test | Sufficienza | -|--------|-------------------|------------|-------------| -| **Integration E2E** | Buona | +6 test classes | ✅ SUFFICIENTE | - ---- - -## Raccomandazioni - -### Priorità ALTA -1. ~~**Aggiungere test di integrazione E2E**~~ ✅ COMPLETATO - - ✅ Flusso completo iscrizione evento + pagamento - - ✅ Flusso completo rimborso - - Mancante: Flusso registrazione con verifica email - -2. **Test di concorrenza per Treasury**: - - Transazioni simultanee - - Race conditions su saldi - -### Priorità MEDIA -3. **Test di performance**: - - Eventi con molti servizi (>100) - - Query su grandi volumi di transazioni (>10000) - -4. **Test di sicurezza**: - - Token expiration e reuse - - Rate limiting - - Permission boundaries - -### Priorità BASSA -5. **Test di usabilità**: - - Flussi utente completi - - Error handling user-friendly - ---- - -## Metriche Complessive - -### Test Count Totale -| Modulo | Test Pre-esistenti | Nuovi Test | Totale Finale | -|--------|-------------------|------------|---------------| -| Users | ~20 | 0 | ~20 | -| Profiles | ~15 | 0 | ~15 | -| Events | ~40 | +25 | ~65 | -| Treasury | ~25 | +35 | ~60 | -| Content | ~10 | 0 | ~10 | -| **Integration E2E** | ~5 | **+6** | **~11** | -| **TOTALE** | **~115** | **+66** | **~181** | - -### Copertura per Tipo di Test -- **Unit Tests**: ~65% dei test totali -- **Integration Tests**: ~20% dei test totali (incrementati da 15%) -- **Edge Case Tests**: ~15% dei test totali (incrementati significativamente) -- **E2E Tests**: ~6% dei test totali (incrementati da 3%) - ---- - -## Conclusione - -### Punti di Forza -✅ **Copertura eccellente** per Services (nuova feature) -✅ **Copertura robusta** per Treasury edge cases (modulo critico) -✅ **Copertura solida E2E** per flussi principali (**NUOVO**) -✅ **Buona copertura** per funzionalità core di tutti i moduli -✅ **66 nuovi test** aggiunti con focus su criticità, casi limite, e integrazione - -### Aree di Miglioramento -⚠️ **Test di registrazione utente E2E** con email verification -⚠️ **Test di concorrenza** assenti -⚠️ **Test di performance** assenti -⚠️ **Test di sicurezza** parziali - -### Risposta alla Domanda: "Sono Sufficienti?" - -**Per Development/Staging**: **SÌ, SUFFICIENTI** ✅✅ -- Copertura solida per casi d'uso normali -- Edge cases critici coperti -- Moduli critici (Treasury, Services) ben testati -- **Flussi E2E principali coperti** (**NUOVO**) - -**Per Production Critical Systems**: **SÌ, SUFFICIENTI** ✅ -- Flussi critici testati end-to-end -- Edge cases finanziari validati -- Integrazione tra moduli verificata -- Mancano solo test di concorrenza e performance sotto carico - -**Raccomandazione Finale**: -Il sistema è **pronto per deployment in ambiente production** con la copertura attuale. I test E2E aggiunti coprono i flussi critici utente. Per sistemi ad alto traffico (>1000 utenti simultanei), si raccomandano ulteriori **10-15 test** focalizzati su: -- 5 test di concorrenza per Treasury -- 5 test di performance/stress -- 5 test di sicurezza avanzati (rate limiting, token handling) - ---- - -## Modifiche Recenti - -### Consolidamento Test Files -- ✅ **events/test_services.py** → Integrato in **events/tests.py** -- ✅ **treasury/test_edge_cases.py** → Integrato in **treasury/tests.py** -- ✅ **Nuovo file**: **test_integration_e2e.py** con 6 test classes per flussi E2E completi - -### Benefici del Consolidamento -- File di test organizzati per modulo -- Più facile manutenzione -- Esecuzione test più intuitiva (`python manage.py test events`, `python manage.py test treasury`) -- Test E2E separati in file dedicato per chiarezza - ---- - -**Report aggiornato da**: GitHub Copilot -**Data ultimo aggiornamento**: Completamento E2E integration tests -**Documentazione**: Tutti i file in `backend/docs/test_specifications/` sono coerenti con i test effettivi diff --git a/backend/docs/test_specifications/00_OVERVIEW.md b/backend/docs/test_specifications/00_OVERVIEW.md deleted file mode 100644 index 7e5f93a6d..000000000 --- a/backend/docs/test_specifications/00_OVERVIEW.md +++ /dev/null @@ -1,228 +0,0 @@ -# ESN Polimi Gestionale - Test Specifications Overview - -## Indice Generale - -Questo documento fornisce una panoramica completa del sistema gestionale ESN Polimi e guida la creazione di unit tests per ogni modulo. - ---- - -## Architettura del Sistema - -### Stack Tecnologico -- **Backend**: Django 4.x + Django REST Framework -- **Database**: PostgreSQL (prod) / SQLite (dev) -- **Autenticazione**: JWT (SimpleJWT) -- **File Storage**: Google Drive API -- **Pagamenti**: SumUp API -- **Email**: Django Email Backend - -### Moduli Principali - -| Modulo | Descrizione | Dipendenze | -|--------|-------------|------------| -| `users` | Autenticazione, gestione utenti, permessi | `profiles` | -| `profiles` | Profili Erasmus/ESNers, documenti | - | -| `events` | Eventi, iscrizioni, liste, form pubblici | `profiles`, `treasury` | -| `treasury` | Casse, transazioni, ESNcard, rimborsi | `profiles`, `events`, `users` | -| `content` | Sezioni contenuto homepage | `users` | - ---- - -## 🔐 Sistema di Ruoli e Permessi - -### Gruppi Django -1. **Board** - Accesso completo a tutto il sistema -2. **Attivi** - Membri attivi ESN con permessi estesi -3. **Aspiranti** - Nuovi membri con permessi limitati - -### Permessi Speciali (User Model) -- `can_manage_casse` - Permesso extra per gestire casse (concesso da Board agli Aspiranti) -- `can_view_casse_import` - Permesso per vedere importi casse (concesso da Board agli Aspiranti) - -### Regole di Visibilità -- **SumUp Account**: Visibile solo a Board -- **Balance Casse**: Board vede tutto, Attivi tutto tranne SumUp, Aspiranti solo se flag attivo - ---- - -## Struttura dei Documenti di Test - -``` -docs/test_specifications/ -├── 00_OVERVIEW.md # Questo file -├── 01_USERS_MODULE.md # Test per users/ -├── 02_PROFILES_MODULE.md # Test per profiles/ -├── 03_EVENTS_MODULE.md # Test per events/ -├── 04_TREASURY_MODULE.md # Test per treasury/ -└── 05_CONTENT_MODULE.md # Test per content/ -``` - ---- - -## Convenzioni di Testing - -### Setup Base per Tutti i Test - -```python -from django.test import TestCase -from django.contrib.auth.models import Group -from rest_framework.test import APITestCase, APIClient -from rest_framework_simplejwt.tokens import RefreshToken - -class BaseTestCase(APITestCase): - @classmethod - def setUpTestData(cls): - # Crea gruppi - cls.board_group = Group.objects.create(name='Board') - cls.attivi_group = Group.objects.create(name='Attivi') - cls.aspiranti_group = Group.objects.create(name='Aspiranti') - - def setUp(self): - self.client = APIClient() - - def create_profile(self, email, is_esner=False, **kwargs): - from profiles.models import Profile - return Profile.objects.create( - email=email, - name=kwargs.get('name', 'Test'), - surname=kwargs.get('surname', 'User'), - email_is_verified=True, - enabled=True, - is_esner=is_esner, - **kwargs - ) - - def create_user(self, profile, group=None, password='testpass123'): - from users.models import User - user = User.objects.create_user(profile=profile, password=password) - if group: - user.groups.add(group) - return user - - def authenticate_user(self, user): - refresh = RefreshToken.for_user(user) - self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {refresh.access_token}') - return refresh - - def create_base_user(self): - """Crea utente base (Aspiranti)""" - profile = self.create_profile('base@esnpolimi.it', is_esner=True) - return self.create_user(profile, self.aspiranti_group) - - def create_board_user(self): - """Crea utente Board""" - profile = self.create_profile('board@esnpolimi.it', is_esner=True) - return self.create_user(profile, self.board_group) -``` - -### Categorie di Test - -1. **Unit Tests** - Test di singole funzioni/metodi -2. **Integration Tests** - Test di flussi API completi -3. **Permission Tests** - Test di controllo accessi -4. **Validation Tests** - Test di validazione dati -5. **Edge Case Tests** - Test di casi limite - -### Naming Convention - -```python -def test___(self): - """ - Test che quando ritorna - """ -``` - -Esempio: -```python -def test_login_with_valid_credentials_returns_tokens(self): - """Test che il login con credenziali valide ritorna access e refresh token""" - -def test_login_with_non_esn_email_returns_403(self): - """Test che il login con email non @esnpolimi.it ritorna 403""" -``` - ---- - -## 🔄 Flussi Principali da Testare - -### 1. Flusso Registrazione ESNer -1. Creazione profilo con email @esnpolimi.it -2. Creazione documento -3. Creazione utente con password -4. Invio email verifica -5. Verifica email e attivazione -6. Primo login - -### 2. Flusso Registrazione Erasmus -1. Creazione profilo con email qualsiasi -2. Creazione documento -3. Invio email verifica -4. Verifica email e attivazione -5. (Nessun login - solo profilo) - -### 3. Flusso Evento -1. Creazione evento con liste -2. Configurazione form pubblico -3. Iscrizione via form -4. Pagamento online (SumUp) -5. Spostamento tra liste -6. Rimborsi - -### 4. Flusso Treasury -1. Apertura/chiusura casse -2. Emissione ESNcard -3. Transazioni (depositi, prelievi) -4. Richieste rimborso -5. Export bilancio - ---- - -## Note Importanti - -### Mock Services -- **Google Drive API**: Mock per upload file -- **SumUp API**: Mock per pagamenti -- **Email Backend**: Usa `django.core.mail.outbox` in test - -### Database Constraints -- `Profile.email` è unique -- `Profile.person_code` e `matricola_number` sono unique (nullable) -- `User.profile` è la primary key (FK a Profile.email) -- `Subscription` ha constraint unique su (profile, event) - -### Timezone -- Tutti i datetime sono timezone-aware -- Usare `django.utils.timezone.now()` nei test - ---- - -## 📊 Coverage Target - -| Modulo | Target | -|--------|--------| -| users | 90% | -| profiles | 90% | -| events | 85% | -| treasury | 85% | -| content | 80% | - ---- - -## 🚀 Esecuzione Tests - -```bash -# Tutti i test -python manage.py test - -# Modulo specifico -python manage.py test users -python manage.py test profiles -python manage.py test events -python manage.py test treasury -python manage.py test content - -# Con coverage -coverage run manage.py test -coverage report -coverage html -``` diff --git a/backend/docs/test_specifications/01_USERS_MODULE.md b/backend/docs/test_specifications/01_USERS_MODULE.md deleted file mode 100644 index e3c4de0c1..000000000 --- a/backend/docs/test_specifications/01_USERS_MODULE.md +++ /dev/null @@ -1,671 +0,0 @@ -# 01 - Users Module Test Specifications - -## Panoramica Modulo - -Il modulo `users` gestisce: -- Autenticazione (login/logout) -- Gestione token JWT -- Gestione utenti -- Reset password -- Permessi finanziari (can_manage_casse, can_view_casse_import) -- Gruppi - ---- - -## File del Modulo - -| File | Descrizione | -|------|-------------| -| `models.py` | Model User con campi custom | -| `managers.py` | UserManager per creazione utenti | -| `views.py` | Endpoint autenticazione e CRUD utenti | -| `serializers.py` | Serializers per User e Token | -| `urls.py` | Route del modulo | - ---- - -## Modello User - -```python -class User(AbstractBaseUser, PermissionsMixin): - profile = models.OneToOneField(Profile, primary_key=True) # FK a Profile.email - is_staff = models.BooleanField(default=False) - date_joined = models.DateTimeField(auto_now_add=True) - last_login = models.DateTimeField(null=True) - can_manage_casse = models.BooleanField(default=False) - can_view_casse_import = models.BooleanField(default=False) -``` - ---- - -## Endpoints - -### 1. POST `/backend/login/` -**Descrizione**: Login utente ESNer -**Autenticazione**: No -**Permessi**: Pubblico - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| U-L-001 | Login con credenziali valide | email @esnpolimi.it, password corretta | access token, refresh token | 200 | -| U-L-002 | Login con email non @esnpolimi.it | email @gmail.com | "Solo email @esnpolimi.it sono ammesse" | 403 | -| U-L-003 | Login con password errata | email corretta, password sbagliata | "Credenziali invalide" | 403 | -| U-L-004 | Login con email non verificata | email non verificata | "Email non verificata" | 403 | -| U-L-005 | Login con utente inesistente | email non registrata | "Credenziali invalide" | 403 | -| U-L-006 | Login primo accesso | utente che non ha mai fatto login | first_login flag, last_login null | 200 | -| U-L-007 | Login secondo accesso | utente con last_login esistente | last_login popolato | 200 | -| U-L-008 | Login con email vuota | email mancante | Errore validazione | 400 | -| U-L-009 | Login con password vuota | password mancante | Errore validazione | 400 | - -```python -class LoginTestCase(BaseTestCase): - - def test_login_with_valid_credentials_returns_tokens(self): - """U-L-001: Login con credenziali valide ritorna tokens""" - profile = self.create_profile('test@esnpolimi.it', is_esner=True) - user = self.create_user(profile, password='testpass123') - - response = self.client.post('/backend/login/', { - 'email': 'test@esnpolimi.it', - 'password': 'testpass123' - }) - - self.assertEqual(response.status_code, 200) - self.assertIn('access', response.data) - self.assertIn('refresh', response.data) - - def test_login_with_non_esn_email_returns_403(self): - """U-L-002: Login con email non @esnpolimi.it ritorna 403""" - response = self.client.post('/backend/login/', { - 'email': 'test@gmail.com', - 'password': 'testpass123' - }) - - self.assertEqual(response.status_code, 403) - self.assertIn('Solo email @esnpolimi.it', response.data['detail']) - - def test_login_with_wrong_password_returns_403(self): - """U-L-003: Login con password errata ritorna 403""" - profile = self.create_profile('test@esnpolimi.it', is_esner=True) - self.create_user(profile, password='correctpass') - - response = self.client.post('/backend/login/', { - 'email': 'test@esnpolimi.it', - 'password': 'wrongpass' - }) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.data['detail'], 'Credenziali invalide') - - def test_login_with_unverified_email_returns_403(self): - """U-L-004: Login con email non verificata ritorna 403""" - profile = self.create_profile('test@esnpolimi.it', is_esner=True) - profile.email_is_verified = False - profile.save() - self.create_user(profile, password='testpass123') - - response = self.client.post('/backend/login/', { - 'email': 'test@esnpolimi.it', - 'password': 'testpass123' - }) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.data['detail'], 'Email non verificata') - - def test_login_first_time_sets_first_login_flag(self): - """U-L-006: Primo login imposta first_login flag""" - profile = self.create_profile('test@esnpolimi.it', is_esner=True) - user = self.create_user(profile, password='testpass123') - - self.assertIsNone(user.last_login) - - response = self.client.post('/backend/login/', { - 'email': 'test@esnpolimi.it', - 'password': 'testpass123' - }) - - self.assertEqual(response.status_code, 200) - # Il token contiene user con last_login null per indicare primo login -``` - ---- - -### 2. POST `/backend/logout/` -**Descrizione**: Logout utente -**Autenticazione**: No (ma accetta refresh token) -**Permessi**: Pubblico - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| U-LO-001 | Logout con refresh token valido | refresh token | "Log out avvenuto con successo" | 200 | -| U-LO-002 | Logout senza refresh token | nessun token | Successo (graceful) | 200 | -| U-LO-003 | Logout con refresh token invalido | token malformato | Successo (graceful) | 200 | - -```python -class LogoutTestCase(BaseTestCase): - - def test_logout_with_valid_token_succeeds(self): - """U-LO-001: Logout con token valido ha successo""" - profile = self.create_profile('test@esnpolimi.it', is_esner=True) - user = self.create_user(profile) - refresh = self.authenticate_user(user) - - response = self.client.post('/backend/logout/', { - 'refresh': str(refresh) - }) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['detail'], 'Log out avvenuto con successo') - - def test_logout_without_token_succeeds_gracefully(self): - """U-LO-002: Logout senza token ha successo (graceful)""" - response = self.client.post('/backend/logout/', {}) - - self.assertEqual(response.status_code, 200) -``` - ---- - -### 3. POST `/backend/api/token/refresh/` -**Descrizione**: Refresh del token JWT -**Autenticazione**: No (richiede cookie refresh_token) -**Permessi**: Pubblico - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| U-TR-001 | Refresh con token valido | refresh token in cookie | Nuovo access token | 200 | -| U-TR-002 | Refresh senza cookie | nessun cookie | "Token di refresh non trovato" | 400 | -| U-TR-003 | Refresh con token scaduto | token expired | Errore token | 401 | -| U-TR-004 | Refresh con token blacklisted | token invalidato | Errore token | 401 | - -```python -class TokenRefreshTestCase(BaseTestCase): - - def test_refresh_without_cookie_returns_400(self): - """U-TR-002: Refresh senza cookie ritorna 400""" - response = self.client.post('/backend/api/token/refresh/', { - 'email': 'test@esnpolimi.it' - }) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.data['detail'], 'Token di refresh non trovato') -``` - ---- - -### 4. GET/POST `/backend/users/` -**Descrizione**: Lista utenti / Creazione utente -**Autenticazione**: Sì -**Permessi**: GET tutti, POST richiede `users.add_user` - -#### Scenari di Test - GET - -| ID | Scenario | User | Expected | Status | -|----|----------|------|----------|--------| -| U-UL-001 | Lista utenti come utente autenticato | Aspirante | Lista utenti | 200 | -| U-UL-002 | Lista utenti non autenticato | - | Unauthorized | 401 | - -#### Scenari di Test - POST - -| ID | Scenario | User | Input | Expected | Status | -|----|----------|------|-------|----------|--------| -| U-UC-001 | Crea utente con permesso | Board | dati validi | Utente creato | 201 | -| U-UC-002 | Crea utente senza permesso | Aspirante | dati validi | "Non autorizzato" | 401 | -| U-UC-003 | Crea utente con dati invalidi | Board | dati mancanti | Errori validazione | 400 | - -```python -class UserListTestCase(BaseTestCase): - - def test_get_users_authenticated_returns_list(self): - """U-UL-001: GET users autenticato ritorna lista""" - user = self.create_base_user() - self.authenticate_user(user) - - response = self.client.get('/backend/users/') - - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response.data, list) - - def test_get_users_unauthenticated_returns_401(self): - """U-UL-002: GET users non autenticato ritorna 401""" - response = self.client.get('/backend/users/') - - self.assertEqual(response.status_code, 401) - - def test_create_user_without_permission_returns_401(self): - """U-UC-002: POST users senza permesso ritorna 401""" - user = self.create_base_user() - self.authenticate_user(user) - - new_profile = self.create_profile('new@esnpolimi.it', is_esner=True) - - response = self.client.post('/backend/users/', { - 'profile': new_profile.email, - 'password': 'newpass123' - }) - - self.assertEqual(response.status_code, 401) -``` - ---- - -### 5. GET/PATCH/DELETE `/backend/users//` -**Descrizione**: Dettaglio/Modifica/Elimina utente -**Autenticazione**: Sì -**Permessi**: GET tutti, PATCH richiede `users.change_user`, DELETE solo Board - -#### Scenari di Test - -| ID | Scenario | User | Expected | Status | -|----|----------|------|----------|--------| -| U-UD-001 | GET utente esistente | Autenticato | Dettagli utente | 200 | -| U-UD-002 | GET utente inesistente | Autenticato | "Utente non trovato" | 404 | -| U-UD-003 | PATCH utente con permesso | Board | Utente aggiornato | 200 | -| U-UD-004 | PATCH utente senza permesso | Aspirante | "Non autorizzato" | 401 | -| U-UD-005 | DELETE utente come Board | Board | Utente eliminato | 204 | -| U-UD-006 | DELETE utente come Attivi | Attivi | "Non autorizzato" | 401 | - -```python -class UserDetailTestCase(BaseTestCase): - - def test_get_user_detail_returns_user_data(self): - """U-UD-001: GET user detail ritorna dati utente""" - user = self.create_base_user() - self.authenticate_user(user) - - response = self.client.get(f'/backend/users/{user.profile.email}/') - - self.assertEqual(response.status_code, 200) - - def test_get_nonexistent_user_returns_404(self): - """U-UD-002: GET utente inesistente ritorna 404""" - user = self.create_base_user() - self.authenticate_user(user) - - response = self.client.get('/backend/users/nonexistent@esnpolimi.it/') - - self.assertEqual(response.status_code, 404) - - def test_delete_user_as_board_succeeds(self): - """U-UD-005: DELETE user come Board ha successo""" - board_user = self.create_board_user() - self.authenticate_user(board_user) - - target_profile = self.create_profile('target@esnpolimi.it', is_esner=True) - target_user = self.create_user(target_profile, self.aspiranti_group) - - response = self.client.delete(f'/backend/users/{target_user.profile.email}/') - - self.assertEqual(response.status_code, 204) - - def test_delete_user_as_non_board_returns_401(self): - """U-UD-006: DELETE user come non-Board ritorna 401""" - attivi_profile = self.create_profile('attivi@esnpolimi.it', is_esner=True) - attivi_user = self.create_user(attivi_profile, self.attivi_group) - self.authenticate_user(attivi_user) - - target_profile = self.create_profile('target@esnpolimi.it', is_esner=True) - target_user = self.create_user(target_profile, self.aspiranti_group) - - response = self.client.delete(f'/backend/users/{target_user.profile.email}/') - - self.assertEqual(response.status_code, 401) -``` - ---- - -### 6. POST `/backend/api/forgot-password/` -**Descrizione**: Richiesta reset password -**Autenticazione**: No -**Permessi**: Pubblico - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| U-FP-001 | Reset con email esistente | email registrata | Email inviata + messaggio generico | 200 | -| U-FP-002 | Reset con email inesistente | email non registrata | Messaggio generico (no info leak) | 200 | -| U-FP-003 | Reset senza email | email mancante | "L'indirizzo email è obbligatorio" | 400 | - -```python -class ForgotPasswordTestCase(BaseTestCase): - - def test_forgot_password_with_existing_email_sends_email(self): - """U-FP-001: Forgot password con email esistente invia email""" - from django.core import mail - - profile = self.create_profile('test@esnpolimi.it', is_esner=True) - self.create_user(profile) - - response = self.client.post('/backend/api/forgot-password/', { - 'email': 'test@esnpolimi.it' - }) - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(mail.outbox), 1) - self.assertIn('Reimposta la tua password', mail.outbox[0].subject) - - def test_forgot_password_with_nonexistent_email_returns_generic_message(self): - """U-FP-002: Forgot password con email inesistente ritorna messaggio generico""" - response = self.client.post('/backend/api/forgot-password/', { - 'email': 'nonexistent@esnpolimi.it' - }) - - self.assertEqual(response.status_code, 200) - # Messaggio generico per non rivelare se email esiste - self.assertIn('message', response.data) - - def test_forgot_password_without_email_returns_400(self): - """U-FP-003: Forgot password senza email ritorna 400""" - response = self.client.post('/backend/api/forgot-password/', {}) - - self.assertEqual(response.status_code, 400) -``` - ---- - -### 7. POST `/backend/api/reset-password///` -**Descrizione**: Esegue reset password -**Autenticazione**: No -**Permessi**: Pubblico (richiede token valido) - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| U-RP-001 | Reset con token valido | uid, token, password, confirm | Password aggiornata | 200 | -| U-RP-002 | Reset con token invalido | uid corretto, token sbagliato | "Link non valido o scaduto" | 400 | -| U-RP-003 | Reset con uid invalido | uid sbagliato | "Link non valido" | 400 | -| U-RP-004 | Reset con password mismatch | password != confirm_password | "Le password non corrispondono" | 400 | -| U-RP-005 | Reset senza password | password mancante | Errore validazione | 400 | - -```python -class ResetPasswordTestCase(BaseTestCase): - - def test_reset_password_with_valid_token_succeeds(self): - """U-RP-001: Reset password con token valido ha successo""" - from django.contrib.auth.tokens import default_token_generator - from django.utils.encoding import force_bytes - from django.utils.http import urlsafe_base64_encode - - profile = self.create_profile('test@esnpolimi.it', is_esner=True) - user = self.create_user(profile, password='oldpass') - - uid = urlsafe_base64_encode(force_bytes(user.pk)) - token = default_token_generator.make_token(user) - - response = self.client.post(f'/backend/api/reset-password/{uid}/{token}/', { - 'password': 'newpassword123', - 'confirm_password': 'newpassword123' - }) - - self.assertEqual(response.status_code, 200) - - # Verifica che la nuova password funzioni - user.refresh_from_db() - self.assertTrue(user.check_password('newpassword123')) - - def test_reset_password_with_mismatched_passwords_returns_400(self): - """U-RP-004: Reset con password diverse ritorna 400""" - from django.contrib.auth.tokens import default_token_generator - from django.utils.encoding import force_bytes - from django.utils.http import urlsafe_base64_encode - - profile = self.create_profile('test@esnpolimi.it', is_esner=True) - user = self.create_user(profile) - - uid = urlsafe_base64_encode(force_bytes(user.pk)) - token = default_token_generator.make_token(user) - - response = self.client.post(f'/backend/api/reset-password/{uid}/{token}/', { - 'password': 'password1', - 'confirm_password': 'password2' - }) - - self.assertEqual(response.status_code, 400) - self.assertIn('Le password non corrispondono', response.data['error']) -``` - ---- - -### 8. GET `/backend/groups/` -**Descrizione**: Lista gruppi disponibili -**Autenticazione**: Sì -**Permessi**: Tutti gli utenti autenticati - -#### Scenari di Test - -| ID | Scenario | User | Expected | Status | -|----|----------|------|----------|--------| -| U-GL-001 | Lista gruppi autenticato | Qualsiasi | Lista gruppi | 200 | -| U-GL-002 | Lista gruppi non autenticato | - | Unauthorized | 401 | - -```python -class GroupListTestCase(BaseTestCase): - - def test_get_groups_authenticated_returns_list(self): - """U-GL-001: GET groups autenticato ritorna lista""" - user = self.create_base_user() - self.authenticate_user(user) - - response = self.client.get('/backend/groups/') - - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response.data, list) - # Verifica che i gruppi creati siano presenti - group_names = [g['name'] for g in response.data] - self.assertIn('Board', group_names) - self.assertIn('Attivi', group_names) - self.assertIn('Aspiranti', group_names) -``` - ---- - -### 9. GET/PATCH `/backend/users/finance-permissions/` -**Descrizione**: Gestione permessi finanziari per Aspiranti -**Autenticazione**: Sì -**Permessi**: GET tutti, PATCH solo Board - -#### Scenari di Test - -| ID | Scenario | User | Query Param | Expected | Status | -|----|----------|------|-------------|----------|--------| -| U-FIN-001 | GET permessi utente | Autenticato | email=target | Permessi raw + effective | 200 | -| U-FIN-002 | GET senza email param | Autenticato | - | "Missing 'email' parameter" | 400 | -| U-FIN-003 | GET utente inesistente | Autenticato | email=nonexistent | "Utente non trovato" | 404 | -| U-FIN-004 | PATCH permessi come Board | Board | email=aspirante | Permessi aggiornati | 200 | -| U-FIN-005 | PATCH permessi come non-Board | Attivi | email=aspirante | "Solo Board può modificare" | 403 | -| U-FIN-006 | PATCH permessi a non-Aspirante | Board | email=attivi | "Solo applicabili agli Aspiranti" | 400 | -| U-FIN-007 | PATCH permessi a non-ESNer | Board | email=erasmus | "Il profilo non è un ESNer" | 400 | - -```python -class FinancePermissionsTestCase(BaseTestCase): - - def test_get_finance_permissions_returns_raw_and_effective(self): - """U-FIN-001: GET permessi ritorna valori raw ed effective""" - board_user = self.create_board_user() - self.authenticate_user(board_user) - - aspirante_profile = self.create_profile('aspirante@esnpolimi.it', is_esner=True) - aspirante = self.create_user(aspirante_profile, self.aspiranti_group) - - response = self.client.get('/backend/users/finance-permissions/', { - 'email': 'aspirante@esnpolimi.it' - }) - - self.assertEqual(response.status_code, 200) - self.assertIn('can_manage_casse', response.data) - self.assertIn('can_view_casse_import', response.data) - self.assertIn('effective_can_manage_casse', response.data) - self.assertIn('effective_can_view_casse_import', response.data) - - def test_patch_finance_permissions_as_board_succeeds(self): - """U-FIN-004: PATCH permessi come Board ha successo""" - board_user = self.create_board_user() - self.authenticate_user(board_user) - - aspirante_profile = self.create_profile('aspirante@esnpolimi.it', is_esner=True) - aspirante = self.create_user(aspirante_profile, self.aspiranti_group) - - response = self.client.patch('/backend/users/finance-permissions/?email=aspirante@esnpolimi.it', { - 'can_manage_casse': True, - 'can_view_casse_import': True - }) - - self.assertEqual(response.status_code, 200) - self.assertTrue(response.data['can_manage_casse']) - self.assertTrue(response.data['effective_can_manage_casse']) - - def test_patch_finance_permissions_to_attivi_returns_400(self): - """U-FIN-006: PATCH permessi a Attivi ritorna 400""" - board_user = self.create_board_user() - self.authenticate_user(board_user) - - attivi_profile = self.create_profile('attivi@esnpolimi.it', is_esner=True) - attivi = self.create_user(attivi_profile, self.attivi_group) - - response = self.client.patch('/backend/users/finance-permissions/?email=attivi@esnpolimi.it', { - 'can_manage_casse': True - }) - - self.assertEqual(response.status_code, 400) - self.assertIn('Solo applicabili agli Aspiranti', response.data['error']) -``` - ---- - -## 🔧 UserManager Tests - -```python -class UserManagerTestCase(TestCase): - - def test_create_user_without_profile_raises_error(self): - """Test che create_user senza profile solleva errore""" - from users.models import User - - with self.assertRaises(ValueError) as context: - User.objects.create_user(profile=None, password='test') - - self.assertIn('profile must be set', str(context.exception)) - - def test_create_superuser_sets_required_flags(self): - """Test che create_superuser imposta is_staff e is_superuser""" - from users.models import User - from profiles.models import Profile - - profile = Profile.objects.create( - email='super@esnpolimi.it', - name='Super', - surname='User', - email_is_verified=True, - enabled=True - ) - - user = User.objects.create_superuser(profile=profile, password='test') - - self.assertTrue(user.is_staff) - self.assertTrue(user.is_superuser) - self.assertTrue(user.is_active) - - def test_make_random_password_returns_correct_length(self): - """Test che make_random_password genera password della lunghezza corretta""" - from users.models import User - - password = User.objects.make_random_password(length=15) - - self.assertEqual(len(password), 15) -``` - ---- - -## 📊 Serializers Tests - -```python -class UserSerializerTestCase(TestCase): - - def test_user_serializer_excludes_password(self): - """Test che UserSerializer esclude il campo password""" - from users.serializers import UserSerializer - from profiles.models import Profile - from users.models import User - - profile = Profile.objects.create( - email='test@esnpolimi.it', - name='Test', - surname='User', - email_is_verified=True, - enabled=True - ) - user = User.objects.create_user(profile=profile, password='secret') - - serializer = UserSerializer(user) - - self.assertNotIn('password', serializer.data) - - -class UserReactSerializerTestCase(BaseTestCase): - - def test_effective_can_manage_casse_for_board(self): - """Test che Board ha sempre effective_can_manage_casse True""" - from users.serializers import UserReactSerializer - - board_user = self.create_board_user() - - serializer = UserReactSerializer(board_user) - - self.assertTrue(serializer.data['effective_can_manage_casse']) - - def test_effective_can_manage_casse_for_aspirante_with_flag(self): - """Test che Aspirante con flag ha effective_can_manage_casse True""" - from users.serializers import UserReactSerializer - - aspirante_profile = self.create_profile('asp@esnpolimi.it', is_esner=True) - aspirante = self.create_user(aspirante_profile, self.aspiranti_group) - aspirante.can_manage_casse = True - aspirante.save() - - serializer = UserReactSerializer(aspirante) - - self.assertTrue(serializer.data['effective_can_manage_casse']) - - def test_restricted_accounts_for_attivi(self): - """Test che Attivi ha SumUp in restricted_accounts""" - from users.serializers import UserReactSerializer - - attivi_profile = self.create_profile('attivi@esnpolimi.it', is_esner=True) - attivi = self.create_user(attivi_profile, self.attivi_group) - - serializer = UserReactSerializer(attivi) - - self.assertIn('SumUp', serializer.data['restricted_accounts']) - - def test_restricted_accounts_for_board_is_empty(self): - """Test che Board non ha restricted_accounts""" - from users.serializers import UserReactSerializer - - board_user = self.create_board_user() - - serializer = UserReactSerializer(board_user) - - self.assertEqual(serializer.data['restricted_accounts'], []) -``` - ---- - -## Checklist Test Coverage - -- [ ] Login con tutte le casistiche email -- [ ] Login primo accesso vs accessi successivi -- [ ] Logout con/senza token -- [ ] Token refresh da cookie -- [ ] CRUD utenti con permessi -- [ ] Reset password flow completo -- [ ] Finance permissions per ogni gruppo -- [ ] UserManager edge cases -- [ ] Serializers con dati corretti diff --git a/backend/docs/test_specifications/02_PROFILES_MODULE.md b/backend/docs/test_specifications/02_PROFILES_MODULE.md deleted file mode 100644 index c48be5197..000000000 --- a/backend/docs/test_specifications/02_PROFILES_MODULE.md +++ /dev/null @@ -1,771 +0,0 @@ -# 02 - Profiles Module Test Specifications - -## Panoramica Modulo - -Il modulo `profiles` gestisce: -- Profili Erasmus e ESNers -- Documenti d'identità -- Flusso di registrazione e verifica email -- Ricerca profili -- Gestione matricole - ---- - -## File del Modulo - -| File | Descrizione | -|------|-------------| -| `models.py` | Models Profile, Document, BaseEntity | -| `views.py` | Endpoint CRUD profili e documenti | -| `serializers.py` | Serializers per profili e documenti | -| `tokens.py` | Token generator per verifica email | -| `urls.py` | Route del modulo | - ---- - -## Modelli - -### Profile -```python -class Profile(BaseEntity): - id = AutoField(primary_key=True) - email = EmailField(unique=True) - email_is_verified = BooleanField(default=False) - name = CharField(max_length=128) - surname = CharField(max_length=128) - birthdate = DateField(null=True) - country = CharField(max_length=2, null=True) # ISO code - course = CharField(choices=Course.choices, null=True) - phone_prefix = CharField(max_length=10, null=True) - phone_number = CharField(max_length=20, null=True) - whatsapp_prefix = CharField(max_length=10, null=True) - whatsapp_number = CharField(max_length=20, null=True) - person_code = CharField(max_length=10, unique=True, null=True) - domicile = CharField(max_length=256, null=True) - is_esner = BooleanField(default=False) - matricola_number = CharField(max_length=10, unique=True, null=True) - matricola_expiration = DateField(null=True) -``` - -### Document -```python -class Document(BaseEntity): - id = AutoField(primary_key=True) - profile = ForeignKey(Profile) - type = CharField(choices=Type.choices) - number = CharField(unique=True) - expiration = DateField() -``` - ---- - -## Endpoints - -### 1. GET `/backend/erasmus_profiles/` -**Descrizione**: Lista profili Erasmus (is_esner=False) con paginazione -**Autenticazione**: Sì -**Permessi**: Tutti autenticati - -#### Parametri Query -- `page`: numero pagina -- `page_size`: elementi per pagina -- `search`: ricerca multi-campo -- `ordering`: ordinamento (-created_at default) -- `esncardValidity`: filtro validità ESNcard (valid, expired, absent) - -#### Scenari di Test - -| ID | Scenario | Query Params | Expected | Status | -|----|----------|--------------|----------|--------| -| P-EL-001 | Lista senza filtri | - | Lista paginata Erasmus | 200 | -| P-EL-002 | Lista con ricerca nome | search=Mario | Profili con "Mario" | 200 | -| P-EL-003 | Lista con ricerca email | search=@gmail | Profili con @gmail | 200 | -| P-EL-004 | Lista con ricerca ESNcard | search=IT123 | Profili con ESNcard | 200 | -| P-EL-005 | Lista ordinata per nome | ordering=name | Ordinati per nome | 200 | -| P-EL-006 | Lista ordinata desc | ordering=-name | Ordinati desc | 200 | -| P-EL-007 | Paginazione pagina 2 | page=2&page_size=10 | Pagina 2 | 200 | -| P-EL-008 | Pagina invalida | page=999 | Errore pagina | 400 | -| P-EL-009 | Filtro ESNcard valida | esncardValidity=valid | Solo con card valida | 200 | -| P-EL-010 | Filtro ESNcard assente | esncardValidity=absent | Solo senza card | 200 | -| P-EL-011 | Non autenticato | - | Unauthorized | 401 | - -```python -class ErasmusProfileListTestCase(BaseTestCase): - - def setUp(self): - super().setUp() - # Crea profili Erasmus di test - for i in range(15): - self.create_profile(f'erasmus{i}@university.edu', is_esner=False, - name=f'Erasmus{i}', surname=f'Student{i}') - - def test_list_erasmus_profiles_returns_paginated_results(self): - """P-EL-001: Lista Erasmus ritorna risultati paginati""" - user = self.create_base_user() - self.authenticate_user(user) - - response = self.client.get('/backend/erasmus_profiles/') - - self.assertEqual(response.status_code, 200) - self.assertIn('results', response.data) - self.assertIn('count', response.data) - - def test_search_by_name_filters_correctly(self): - """P-EL-002: Ricerca per nome filtra correttamente""" - user = self.create_base_user() - self.authenticate_user(user) - - # Crea profilo specifico - self.create_profile('mario@test.com', is_esner=False, name='Mario', surname='Rossi') - - response = self.client.get('/backend/erasmus_profiles/?search=Mario') - - self.assertEqual(response.status_code, 200) - results = response.data['results'] - self.assertTrue(all('Mario' in r['name'] for r in results)) - - def test_pagination_page_2(self): - """P-EL-007: Paginazione pagina 2 funziona""" - user = self.create_base_user() - self.authenticate_user(user) - - response = self.client.get('/backend/erasmus_profiles/?page=2&page_size=5') - - self.assertEqual(response.status_code, 200) - self.assertLessEqual(len(response.data['results']), 5) -``` - ---- - -### 2. GET `/backend/esner_profiles/` -**Descrizione**: Lista profili ESNers (is_esner=True) con paginazione -**Autenticazione**: Sì -**Permessi**: Tutti autenticati - -#### Parametri Query Extra (rispetto a Erasmus) -- `group`: filtro per gruppo utente (Board, Attivi, Aspiranti) - -#### Scenari di Test - -| ID | Scenario | Query Params | Expected | Status | -|----|----------|--------------|----------|--------| -| P-ENL-001 | Lista senza filtri | - | Lista paginata ESNers | 200 | -| P-ENL-002 | Filtro per gruppo Board | group=Board | Solo Board members | 200 | -| P-ENL-003 | Filtro gruppi multipli | group=Board,Attivi | Board + Attivi | 200 | -| P-ENL-004 | Ricerca per cognome | search=Rossi | ESNers con Rossi | 200 | - -```python -class ESNerProfileListTestCase(BaseTestCase): - - def test_filter_by_group_returns_only_group_members(self): - """P-ENL-002: Filtro per gruppo ritorna solo membri del gruppo""" - board_user = self.create_board_user() - self.authenticate_user(board_user) - - # Crea ESNers in gruppi diversi - attivi_profile = self.create_profile('attivi@esnpolimi.it', is_esner=True) - self.create_user(attivi_profile, self.attivi_group) - - response = self.client.get('/backend/esner_profiles/?group=Board') - - self.assertEqual(response.status_code, 200) - # Verifica che solo Board members siano ritornati -``` - ---- - -### 3. POST `/backend/profile/initiate-creation/` -**Descrizione**: Inizia creazione profilo (ESNer o Erasmus) -**Autenticazione**: No -**Permessi**: Pubblico - -#### Scenari di Test - ESNer - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| P-IC-001 | Crea ESNer con dati validi | email @esnpolimi.it, tutti i campi | Profilo creato, email inviata | 201 | -| P-IC-002 | Crea ESNer con email non ESN | email @gmail.com, is_esner=True | Errore email | 400 | -| P-IC-003 | Crea ESNer senza password | is_esner=True, no password | Utente non creato | 500 | -| P-IC-004 | Email ESNer già esistente | email già registrata | Errore unique | 400 | - -#### Scenari di Test - Erasmus - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| P-IC-005 | Crea Erasmus con dati validi | email qualsiasi, tutti i campi | Profilo creato, email inviata | 201 | -| P-IC-006 | Crea Erasmus senza documento | manca document_ fields | Errore validazione | 400 | -| P-IC-007 | Email Erasmus già esistente | email già registrata | Errore unique | 400 | - -```python -class InitiateProfileCreationTestCase(BaseTestCase): - - def test_create_esner_with_valid_data_sends_verification_email(self): - """P-IC-001: Crea ESNer con dati validi invia email verifica""" - from django.core import mail - - response = self.client.post('/backend/profile/initiate-creation/', { - 'email': 'new@esnpolimi.it', - 'name': 'Mario', - 'surname': 'Rossi', - 'birthdate': '1995-01-15', - 'country': 'IT', - 'is_esner': True, - 'password': 'securepass123', - 'document_type': 'ID Card', - 'document_number': 'AB123456', - 'document_expiration': '2030-01-01' - }) - - self.assertEqual(response.status_code, 201) - self.assertEqual(len(mail.outbox), 1) - self.assertIn('Verifica email', mail.outbox[0].subject) - - # Verifica profilo creato ma non attivo - from profiles.models import Profile - profile = Profile.objects.get(email='new@esnpolimi.it') - self.assertFalse(profile.enabled) - self.assertFalse(profile.email_is_verified) - - def test_create_esner_with_non_esn_email_returns_400(self): - """P-IC-002: Crea ESNer con email non @esnpolimi.it ritorna 400""" - response = self.client.post('/backend/profile/initiate-creation/', { - 'email': 'test@gmail.com', - 'name': 'Mario', - 'surname': 'Rossi', - 'is_esner': True, - 'password': 'test123', - 'document_type': 'ID Card', - 'document_number': 'AB123456', - 'document_expiration': '2030-01-01' - }) - - self.assertEqual(response.status_code, 400) - self.assertIn('email', response.data) - - def test_create_erasmus_sends_english_email(self): - """P-IC-005: Crea Erasmus invia email in inglese""" - from django.core import mail - - response = self.client.post('/backend/profile/initiate-creation/', { - 'email': 'erasmus@university.edu', - 'name': 'John', - 'surname': 'Doe', - 'birthdate': '1998-05-20', - 'country': 'DE', - 'is_esner': False, - 'document_type': 'Passport', - 'document_number': 'DE12345678', - 'document_expiration': '2028-01-01' - }) - - self.assertEqual(response.status_code, 201) - self.assertEqual(len(mail.outbox), 1) - self.assertIn('Email verification', mail.outbox[0].subject) - - def test_create_profile_with_duplicate_email_returns_400(self): - """P-IC-004: Email duplicata ritorna 400""" - self.create_profile('existing@esnpolimi.it', is_esner=True) - - response = self.client.post('/backend/profile/initiate-creation/', { - 'email': 'existing@esnpolimi.it', - 'name': 'Mario', - 'surname': 'Rossi', - 'is_esner': True, - 'password': 'test123', - 'document_type': 'ID Card', - 'document_number': 'NEW123456', - 'document_expiration': '2030-01-01' - }) - - self.assertEqual(response.status_code, 400) -``` - ---- - -### 4. GET `/backend/api/profile/verify-email///` -**Descrizione**: Verifica email e attiva profilo -**Autenticazione**: No -**Permessi**: Pubblico (richiede token valido) - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| P-VE-001 | Verifica con token valido | uid, token corretti | Profilo attivato | 200 | -| P-VE-002 | Verifica con token invalido | token sbagliato | "Link non valido" | 400 | -| P-VE-003 | Verifica con uid invalido | uid malformato | "Link non valido" | 400 | -| P-VE-004 | Verifica già effettuata | profilo già verificato | "Email già verificata" | 200 | -| P-VE-005 | Verifica ESNer attiva anche user | is_esner=True | User.is_active=True | 200 | - -```python -class VerifyEmailTestCase(BaseTestCase): - - def test_verify_email_activates_profile_and_document(self): - """P-VE-001: Verifica email attiva profilo e documento""" - from profiles.tokens import email_verification_token - from django.utils.encoding import force_bytes - from django.utils.http import urlsafe_base64_encode - from profiles.models import Profile, Document - - # Crea profilo non verificato - profile = Profile.objects.create( - email='test@esnpolimi.it', - name='Test', - surname='User', - email_is_verified=False, - enabled=False, - is_esner=True - ) - Document.objects.create( - profile=profile, - type='ID Card', - number='TEST123', - expiration='2030-01-01', - enabled=False - ) - - uid = urlsafe_base64_encode(force_bytes(profile.pk)) - token = email_verification_token.make_token(profile) - - response = self.client.get(f'/backend/api/profile/verify-email/{uid}/{token}/') - - self.assertEqual(response.status_code, 200) - - profile.refresh_from_db() - self.assertTrue(profile.email_is_verified) - self.assertTrue(profile.enabled) - - doc = Document.objects.get(profile=profile) - self.assertTrue(doc.enabled) - - def test_verify_esner_activates_user(self): - """P-VE-005: Verifica ESNer attiva anche User""" - from profiles.tokens import email_verification_token - from django.utils.encoding import force_bytes - from django.utils.http import urlsafe_base64_encode - from profiles.models import Profile - from users.models import User - - profile = Profile.objects.create( - email='esner@esnpolimi.it', - name='Test', - surname='User', - email_is_verified=False, - enabled=False, - is_esner=True - ) - user = User.objects.create_user(profile=profile, password='test') - user.is_active = False - user.save() - - uid = urlsafe_base64_encode(force_bytes(profile.pk)) - token = email_verification_token.make_token(profile) - - response = self.client.get(f'/backend/api/profile/verify-email/{uid}/{token}/') - - self.assertEqual(response.status_code, 200) - - user.refresh_from_db() - self.assertTrue(user.is_active) -``` - ---- - -### 5. GET/PATCH/DELETE `/backend/profile//` -**Descrizione**: Dettaglio/Modifica/Elimina profilo -**Autenticazione**: Sì -**Permessi**: GET tutti, PATCH richiede `profiles.change_profile`, DELETE solo Board - -#### Scenari di Test - GET - -| ID | Scenario | User | Expected | Status | -|----|----------|------|----------|--------| -| P-PD-001 | GET profilo esistente | Autenticato | Dettagli profilo completi | 200 | -| P-PD-002 | GET profilo inesistente | Autenticato | "Il profilo non esiste" | 404 | -| P-PD-003 | GET include has_subscriptions | Autenticato | Flag presente | 200 | - -#### Scenari di Test - PATCH - -| ID | Scenario | User | Input | Expected | Status | -|----|----------|------|-------|----------|--------| -| P-PP-001 | PATCH nome/cognome | Con permesso | name, surname | Aggiornato | 200 | -| P-PP-002 | PATCH senza permesso | Aspirante | dati | "Non hai i permessi" | 403 | -| P-PP-003 | PATCH email (vietato) | Board | email | Email non cambia | 200 | -| P-PP-004 | PATCH gruppo Aspirante->Attivi | Board | group=Attivi | Gruppo cambiato | 200 | -| P-PP-005 | PATCH gruppo Aspirante->Board | Attivi | group=Board | "Solo Board può" | 403 | -| P-PP-006 | PATCH person_code 00000000 | Board | person_code=00000000 | Salvato come null | 200 | - -#### Scenari di Test - DELETE - -| ID | Scenario | User | Expected | Status | -|----|----------|------|----------|--------| -| P-PDE-001 | DELETE profilo senza iscrizioni | Board | Profilo eliminato | 200 | -| P-PDE-002 | DELETE profilo con iscrizioni | Board | "Elimina iscrizioni prima" | 400 | -| P-PDE-003 | DELETE come non-Board | Attivi | "Non hai i permessi" | 401 | -| P-PDE-004 | DELETE ESNer elimina anche User | Board | User eliminato | 200 | - -```python -class ProfileDetailTestCase(BaseTestCase): - - def test_get_profile_includes_has_subscriptions_flag(self): - """P-PD-003: GET profilo include has_subscriptions""" - user = self.create_base_user() - self.authenticate_user(user) - - erasmus = self.create_profile('erasmus@test.com', is_esner=False) - - response = self.client.get(f'/backend/profile/{erasmus.pk}/') - - self.assertEqual(response.status_code, 200) - self.assertIn('has_subscriptions', response.data) - - def test_patch_group_aspirante_to_attivi_as_board_succeeds(self): - """P-PP-004: Cambio gruppo Aspirante->Attivi come Board ha successo""" - board_user = self.create_board_user() - self.authenticate_user(board_user) - - aspirante_profile = self.create_profile('aspirante@esnpolimi.it', is_esner=True) - aspirante = self.create_user(aspirante_profile, self.aspiranti_group) - - response = self.client.patch(f'/backend/profile/{aspirante_profile.pk}/', { - 'group': 'Attivi' - }) - - self.assertEqual(response.status_code, 200) - - aspirante.refresh_from_db() - self.assertTrue(aspirante.groups.filter(name='Attivi').exists()) - - def test_patch_person_code_zeros_becomes_null(self): - """P-PP-006: person_code con tutti zeri diventa null""" - board_user = self.create_board_user() - self.authenticate_user(board_user) - - profile = self.create_profile('test@esnpolimi.it', is_esner=True) - - response = self.client.patch(f'/backend/profile/{profile.pk}/', { - 'person_code': '00000000' - }) - - self.assertEqual(response.status_code, 200) - - profile.refresh_from_db() - self.assertIsNone(profile.person_code) - - def test_delete_profile_with_subscriptions_returns_400(self): - """P-PDE-002: DELETE profilo con iscrizioni ritorna 400""" - from events.models import Event, EventList, Subscription - - board_user = self.create_board_user() - self.authenticate_user(board_user) - - erasmus = self.create_profile('erasmus@test.com', is_esner=False) - - # Crea evento e iscrizione - event = Event.objects.create(name='Test Event', date='2025-06-01') - event_list = EventList.objects.create(name='Main List', capacity=100, is_main_list=True) - event_list.events.add(event) - Subscription.objects.create(profile=erasmus, event=event, list=event_list) - - response = self.client.delete(f'/backend/profile/{erasmus.pk}/') - - self.assertEqual(response.status_code, 400) - self.assertIn('iscrizioni', response.data['error']) - - def test_delete_esner_also_deletes_user(self): - """P-PDE-004: DELETE ESNer elimina anche User""" - from users.models import User - - board_user = self.create_board_user() - self.authenticate_user(board_user) - - esner_profile = self.create_profile('target@esnpolimi.it', is_esner=True) - target_user = self.create_user(esner_profile, self.aspiranti_group) - - response = self.client.delete(f'/backend/profile/{esner_profile.pk}/') - - self.assertEqual(response.status_code, 200) - self.assertFalse(User.objects.filter(profile=esner_profile.email).exists()) -``` - ---- - -### 6. POST `/backend/document/` -**Descrizione**: Crea nuovo documento -**Autenticazione**: Sì -**Permessi**: Tutti autenticati - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| P-DC-001 | Crea documento valido | tutti i campi | Documento creato | 200 | -| P-DC-002 | Crea documento duplicato | number già esistente | Errore unique | 400 | -| P-DC-003 | Crea documento senza tipo | type mancante | Errore validazione | 400 | - -```python -class DocumentCreationTestCase(BaseTestCase): - - def test_create_document_with_valid_data_succeeds(self): - """P-DC-001: Crea documento con dati validi ha successo""" - user = self.create_base_user() - self.authenticate_user(user) - - profile = self.create_profile('erasmus@test.com', is_esner=False) - - response = self.client.post('/backend/document/', { - 'profile': profile.pk, - 'type': 'Passport', - 'number': 'NEW12345', - 'expiration': '2030-01-01' - }) - - self.assertEqual(response.status_code, 200) - - def test_create_duplicate_document_number_returns_400(self): - """P-DC-002: Documento con numero duplicato ritorna 400""" - from profiles.models import Document - - user = self.create_base_user() - self.authenticate_user(user) - - profile1 = self.create_profile('erasmus1@test.com', is_esner=False) - Document.objects.create(profile=profile1, type='Passport', number='SAME123', expiration='2030-01-01') - - profile2 = self.create_profile('erasmus2@test.com', is_esner=False) - - response = self.client.post('/backend/document/', { - 'profile': profile2.pk, - 'type': 'ID Card', - 'number': 'SAME123', - 'expiration': '2030-01-01' - }) - - self.assertEqual(response.status_code, 400) -``` - ---- - -### 7. PATCH/DELETE `/backend/document//` -**Descrizione**: Modifica/Elimina documento -**Autenticazione**: Sì -**Permessi**: PATCH richiede `profiles.change_document`, DELETE richiede `profiles.delete_document` - -#### Scenari di Test - -| ID | Scenario | User | Expected | Status | -|----|----------|------|----------|--------| -| P-DD-001 | PATCH con permesso | Con permesso | Aggiornato | 200 | -| P-DD-002 | PATCH senza permesso | Aspirante | "Non hai i permessi" | 403 | -| P-DD-003 | DELETE con permesso | Con permesso | Eliminato | 200 | -| P-DD-004 | DELETE documento inesistente | Con permesso | "Il documento non esiste" | 404 | - ---- - -### 8. GET `/backend/profiles/search/` -**Descrizione**: Ricerca profili per nome/cognome/ESNcard -**Autenticazione**: Sì -**Permessi**: Tutti autenticati - -#### Parametri Query -- `q`: query di ricerca (min 2 caratteri) -- `valid_only`: solo profili attivi (true/false) -- `esner_only`: solo ESNers (true/false) - -#### Scenari di Test - -| ID | Scenario | Query Params | Expected | Status | -|----|----------|--------------|----------|--------| -| P-PS-001 | Ricerca con query valida | q=Mario | Risultati matching | 200 | -| P-PS-002 | Ricerca query troppo corta | q=M | Lista vuota | 200 | -| P-PS-003 | Ricerca solo ESNers | q=Test&esner_only=true | Solo ESNers | 200 | -| P-PS-004 | Ricerca per ESNcard | q=IT123 | Profili con card | 200 | - -```python -class ProfileSearchTestCase(BaseTestCase): - - def test_search_with_valid_query_returns_results(self): - """P-PS-001: Ricerca con query valida ritorna risultati""" - user = self.create_base_user() - self.authenticate_user(user) - - self.create_profile('mario@test.com', is_esner=False, name='Mario', surname='Rossi') - self.create_profile('luigi@test.com', is_esner=False, name='Luigi', surname='Bianchi') - - response = self.client.get('/backend/profiles/search/?q=Mario') - - self.assertEqual(response.status_code, 200) - self.assertTrue(len(response.data['results']) > 0) - - def test_search_with_short_query_returns_empty(self): - """P-PS-002: Ricerca con query corta ritorna vuoto""" - user = self.create_base_user() - self.authenticate_user(user) - - response = self.client.get('/backend/profiles/search/?q=M') - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['results'], []) -``` - ---- - -### 9. POST `/backend/check_erasmus_email/` -**Descrizione**: Verifica se email appartiene a Erasmus (pubblico) -**Autenticazione**: No -**Permessi**: Pubblico - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| P-CE-001 | Email Erasmus attivo | email esistente | id, email, esncard_number | 200 | -| P-CE-002 | Email non esistente | email nuova | error: email_not_found | 200 | -| P-CE-003 | Email non attiva | email non verificata | error: email_not_active | 200 | -| P-CE-004 | Email mancante | - | "Email required" | 400 | - -```python -class CheckErasmusEmailTestCase(BaseTestCase): - - def test_check_active_erasmus_returns_profile_data(self): - """P-CE-001: Email Erasmus attivo ritorna dati profilo""" - profile = self.create_profile('erasmus@test.com', is_esner=False) - - response = self.client.post('/backend/check_erasmus_email/', { - 'email': 'erasmus@test.com' - }) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['id'], profile.id) - self.assertEqual(response.data['email'], 'erasmus@test.com') - - def test_check_nonexistent_email_returns_not_found(self): - """P-CE-002: Email non esistente ritorna errore""" - response = self.client.post('/backend/check_erasmus_email/', { - 'email': 'nonexistent@test.com' - }) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['error'], 'email_not_found') -``` - ---- - -### 10. GET `/backend/profile_subscriptions//` -**Descrizione**: Lista iscrizioni di un profilo -**Autenticazione**: Sì -**Permessi**: Tutti autenticati - -```python -class ProfileSubscriptionsTestCase(BaseTestCase): - - def test_get_subscriptions_for_profile_returns_list(self): - """Test lista iscrizioni per profilo""" - from events.models import Event, EventList, Subscription - - user = self.create_base_user() - self.authenticate_user(user) - - profile = self.create_profile('erasmus@test.com', is_esner=False) - event = Event.objects.create(name='Test', date='2025-06-01') - event_list = EventList.objects.create(name='ML', is_main_list=True) - event_list.events.add(event) - Subscription.objects.create(profile=profile, event=event, list=event_list) - - response = self.client.get(f'/backend/profile_subscriptions/{profile.pk}/') - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 1) -``` - ---- - -## Model Property Tests - -```python -class ProfileModelTestCase(TestCase): - - def test_latest_esncard_returns_most_recent(self): - """Test che latest_esncard ritorna la più recente""" - from profiles.models import Profile - from treasury.models import ESNcard - - profile = Profile.objects.create( - email='test@test.com', name='Test', surname='User', - email_is_verified=True, enabled=True - ) - - card1 = ESNcard.objects.create(profile=profile, number='OLD123') - card2 = ESNcard.objects.create(profile=profile, number='NEW456') - - self.assertEqual(profile.latest_esncard, card2) - - def test_latest_document_returns_most_recent_enabled(self): - """Test che latest_document ritorna il più recente enabled""" - from profiles.models import Profile, Document - - profile = Profile.objects.create( - email='test@test.com', name='Test', surname='User', - email_is_verified=True, enabled=True - ) - - doc1 = Document.objects.create( - profile=profile, type='Passport', number='DOC1', - expiration='2030-01-01', enabled=True - ) - doc2 = Document.objects.create( - profile=profile, type='ID Card', number='DOC2', - expiration='2030-01-01', enabled=False # Disabled - ) - - self.assertEqual(profile.latest_document, doc1) - - -class DocumentModelTestCase(TestCase): - - def test_is_valid_returns_true_for_future_expiration(self): - """Test che is_valid ritorna True per documento non scaduto""" - from profiles.models import Profile, Document - - profile = Profile.objects.create( - email='test@test.com', name='Test', surname='User', - email_is_verified=True, enabled=True - ) - doc = Document.objects.create( - profile=profile, type='Passport', number='TEST123', - expiration='2030-01-01' - ) - - self.assertTrue(doc.is_valid) - - def test_is_valid_returns_false_for_past_expiration(self): - """Test che is_valid ritorna False per documento scaduto""" - from profiles.models import Profile, Document - - profile = Profile.objects.create( - email='test@test.com', name='Test', surname='User', - email_is_verified=True, enabled=True - ) - doc = Document.objects.create( - profile=profile, type='Passport', number='TEST123', - expiration='2020-01-01' - ) - - self.assertFalse(doc.is_valid) -``` - ---- - -## Checklist Test Coverage - -- [ ] Lista profili con tutti i filtri -- [ ] Flusso registrazione ESNer completo -- [ ] Flusso registrazione Erasmus completo -- [ ] Verifica email con token -- [ ] CRUD profili con permessi -- [ ] CRUD documenti con permessi -- [ ] Ricerca profili -- [ ] Check email Erasmus -- [ ] Cambio gruppi con regole di permesso -- [ ] Placeholder zeros -> null -- [ ] Model properties (latest_esncard, latest_document, is_valid) diff --git a/backend/docs/test_specifications/03_EVENTS_MODULE.md b/backend/docs/test_specifications/03_EVENTS_MODULE.md deleted file mode 100644 index c91116857..000000000 --- a/backend/docs/test_specifications/03_EVENTS_MODULE.md +++ /dev/null @@ -1,1067 +0,0 @@ -# 03 - Events Module Test Specifications - -## Panoramica Modulo - -Il modulo `events` gestisce: -- Eventi e Liste Eventi -- Iscrizioni (Subscriptions) -- Form dinamici con campi personalizzati -- Integrazione pagamenti SumUp -- Organizzatori eventi -- Liberatorie PDF -- Servizi selezionabili - ---- - -## File del Modulo - -| File | Descrizione | -|------|-------------| -| `models.py` | Models Event, EventList, Subscription, EventOrganizer | -| `views.py` | Endpoint CRUD eventi e iscrizioni (~2085 linee) | -| `serializers.py` | Serializers complessi con nested data | -| `urls.py` | Route del modulo | - ---- - -## Modelli - -### Event -```python -class Event(models.Model): - id = AutoField(primary_key=True) - name = CharField(max_length=100) - description = CharField(max_length=4096, null=True) - date = DateField() - time = TimeField(null=True) - location = CharField(max_length=200, null=True) - gmap_link = URLField(null=True) - image = URLField(null=True) - enabled = BooleanField(default=True) - available_services = JSONField(null=True) # Array di servizi disponibili - form = JSONField(null=True) # Schema form dinamico - event_type = CharField(choices=['event', 'trip'], default='event') -``` - -### EventList -```python -class EventList(models.Model): - id = AutoField(primary_key=True) - events = ManyToManyField(Event) - name = CharField(max_length=100) - capacity = PositiveIntegerField() - is_main_list = BooleanField(default=False) - is_open = BooleanField(default=True) - price = DecimalField(default=0.00) - deposit = DecimalField(default=0.00) - selling_date = DateTimeField(null=True) - end_selling_date = DateTimeField(null=True) - sold = PositiveIntegerField(default=0) - enabled = BooleanField(default=True) -``` - -### Subscription -```python -class Subscription(models.Model): - id = AutoField(primary_key=True) - profile = ForeignKey(Profile, null=True) # Nullable per utenti esterni - event = ForeignKey(Event) - list = ForeignKey(EventList) - selected_services = JSONField(null=True) # Servizi selezionati - form_data = JSONField(null=True) # Risposte form - pending_payment = BooleanField(default=False) - payment_id = CharField(null=True) - payment_confirmed = BooleanField(default=False) - refunded = BooleanField(default=False) - deposit_payed = BooleanField(default=False) - payed = BooleanField(default=False) - liberatoria = BooleanField(default=False) - notes = TextField(null=True) - subscription_date = DateTimeField(auto_now_add=True) - - # Campi per utenti esterni (external users) - external_name = CharField(max_length=255, null=True) - external_email = EmailField(null=True) - external_first_name = CharField(max_length=100, null=True) - external_last_name = CharField(max_length=100, null=True) - external_has_esncard = BooleanField(default=False) - external_esncard_number = CharField(max_length=50, null=True) - external_whatsapp_number = CharField(max_length=50, null=True) -``` - -### EventOrganizer -```python -class EventOrganizer(models.Model): - event = ForeignKey(Event) - user = ForeignKey(User) -``` - ---- - -## Endpoints - -### 1. GET `/backend/events/` -**Descrizione**: Lista eventi con paginazione -**Autenticazione**: No (pubblico) - -#### Parametri Query -- `page`, `page_size`: paginazione -- `ordering`: ordinamento -- `event_type`: filtro tipo (event/trip) - -#### Scenari di Test - -| ID | Scenario | Query Params | Expected | Status | -|----|----------|--------------|----------|--------| -| E-EL-001 | Lista eventi pubblici | - | Solo enabled=True | 200 | -| E-EL-002 | Filtro tipo event | event_type=event | Solo eventi | 200 | -| E-EL-003 | Filtro tipo trip | event_type=trip | Solo viaggi | 200 | -| E-EL-004 | Include has_lists flag | - | Flag per ogni evento | 200 | - -```python -class EventListTestCase(TestCase): - - def test_list_events_returns_only_enabled(self): - """E-EL-001: Lista eventi ritorna solo enabled""" - from events.models import Event - - Event.objects.create(name='Active', date='2025-06-01', enabled=True) - Event.objects.create(name='Disabled', date='2025-06-01', enabled=False) - - response = self.client.get('/backend/events/') - - self.assertEqual(response.status_code, 200) - names = [e['name'] for e in response.data['results']] - self.assertIn('Active', names) - self.assertNotIn('Disabled', names) - - def test_filter_by_event_type(self): - """E-EL-002: Filtro per tipo event funziona""" - from events.models import Event - - Event.objects.create(name='Party', date='2025-06-01', event_type='event') - Event.objects.create(name='Barcelona', date='2025-06-01', event_type='trip') - - response = self.client.get('/backend/events/?event_type=event') - - self.assertEqual(response.status_code, 200) - self.assertTrue(all(e['event_type'] == 'event' for e in response.data['results'])) -``` - ---- - -### 2. POST `/backend/events/` -**Descrizione**: Crea nuovo evento -**Autenticazione**: Sì -**Permessi**: `events.add_event` - -#### Scenari di Test - -| ID | Scenario | User | Input | Expected | Status | -|----|----------|------|-------|----------|--------| -| E-EC-001 | Crea evento base | Board | name, date | Evento creato | 201 | -| E-EC-002 | Crea evento con servizi | Board | + available_services | Servizi salvati | 201 | -| E-EC-003 | Crea evento con form | Board | + form JSON | Form salvato | 201 | -| E-EC-004 | Crea senza permesso | Aspiranti | dati | Forbidden | 403 | -| E-EC-005 | Crea con date passata | Board | date nel passato | Evento creato | 201 | - -```python -class EventCreationTestCase(BaseTestCase): - - def test_create_event_with_services(self): - """E-EC-002: Crea evento con servizi disponibili""" - board = self.create_board_user() - self.authenticate_user(board) - - response = self.client.post('/backend/events/', { - 'name': 'Trip to Barcelona', - 'date': '2025-07-15', - 'event_type': 'trip', - 'available_services': [ - {'name': 'Bus', 'price': 50}, - {'name': 'Hotel', 'price': 100}, - {'name': 'Guide', 'price': 20} - ] - }, format='json') - - self.assertEqual(response.status_code, 201) - self.assertEqual(len(response.data['available_services']), 3) - - def test_create_event_with_dynamic_form(self): - """E-EC-003: Crea evento con form dinamico""" - board = self.create_board_user() - self.authenticate_user(board) - - form_schema = { - 'fields': [ - {'name': 'tshirt_size', 'type': 'select', 'options': ['S', 'M', 'L', 'XL'], 'required': True}, - {'name': 'food_allergies', 'type': 'text', 'required': False}, - {'name': 'emergency_contact', 'type': 'text', 'required': True} - ] - } - - response = self.client.post('/backend/events/', { - 'name': 'Summer Camp', - 'date': '2025-08-01', - 'form': form_schema - }, format='json') - - self.assertEqual(response.status_code, 201) - self.assertIsNotNone(response.data['form']) -``` - ---- - -### 3. GET `/backend/events//` -**Descrizione**: Dettaglio evento -**Autenticazione**: No (pubblico) - -#### Scenari di Test - -| ID | Scenario | Expected | Status | -|----|----------|----------|--------| -| E-ED-001 | GET evento esistente | Dettagli completi + liste | 200 | -| E-ED-002 | GET evento inesistente | Not found | 404 | -| E-ED-003 | GET include subscriptions_count | Count iscrizioni | 200 | - ---- - -### 4. PATCH `/backend/events//` -**Descrizione**: Modifica evento -**Autenticazione**: Sì -**Permessi**: `events.change_event` - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| E-EU-001 | Aggiorna nome | name | Nome aggiornato | 200 | -| E-EU-002 | Aggiorna servizi | available_services | Servizi aggiornati | 200 | -| E-EU-003 | Disabilita evento | enabled=False | Evento disabilitato | 200 | -| E-EU-004 | Aggiorna senza permesso | - | Forbidden | 403 | - ---- - -### 5. DELETE `/backend/events//` -**Descrizione**: Elimina evento -**Autenticazione**: Sì -**Permessi**: `events.delete_event` - -#### Scenari di Test - -| ID | Scenario | Preconditions | Expected | Status | -|----|----------|---------------|----------|--------| -| E-EDE-001 | Elimina evento senza iscrizioni | Nessuna iscrizione | Eliminato | 200 | -| E-EDE-002 | Elimina evento con iscrizioni | Iscrizioni presenti | "Elimina prima" | 400 | -| E-EDE-003 | Elimina senza permesso | No permission | Forbidden | 403 | - -```python -class EventDeleteTestCase(BaseTestCase): - - def test_delete_event_with_subscriptions_returns_error(self): - """E-EDE-002: Elimina evento con iscrizioni ritorna errore""" - from events.models import Event, EventList, Subscription - from profiles.models import Profile - - board = self.create_board_user() - self.authenticate_user(board) - - event = Event.objects.create(name='Test', date='2025-06-01') - event_list = EventList.objects.create( - name='Main', capacity=100, is_main_list=True, price=10 - ) - event_list.events.add(event) - - profile = Profile.objects.create( - email='test@test.com', name='Test', surname='User', - email_is_verified=True, enabled=True - ) - Subscription.objects.create(profile=profile, event=event, list=event_list) - - response = self.client.delete(f'/backend/events/{event.pk}/') - - self.assertEqual(response.status_code, 400) -``` - ---- - -### 6. GET `/backend/event_lists/` -**Descrizione**: Lista delle EventList -**Autenticazione**: Sì -**Permessi**: Autenticati - ---- - -### 7. POST `/backend/event_lists/` -**Descrizione**: Crea nuova EventList -**Autenticazione**: Sì -**Permessi**: `events.add_eventlist` - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| E-LC-001 | Crea lista base | name, capacity, event_ids | Lista creata | 201 | -| E-LC-002 | Crea lista main | is_main_list=True | Solo una main per evento | 201 | -| E-LC-003 | Crea con selling_date | date futura | Lista con apertura programmata | 201 | -| E-LC-004 | Crea con prezzo e deposito | price, deposit | Prezzi salvati | 201 | - -```python -class EventListCreationTestCase(BaseTestCase): - - def test_create_list_with_selling_dates(self): - """E-LC-003: Crea lista con date di vendita""" - from events.models import Event - - board = self.create_board_user() - self.authenticate_user(board) - - event = Event.objects.create(name='Test', date='2025-06-01') - - response = self.client.post('/backend/event_lists/', { - 'name': 'Early Bird', - 'capacity': 50, - 'event_ids': [event.pk], - 'price': '25.00', - 'deposit': '10.00', - 'selling_date': '2025-05-01T00:00:00Z', - 'end_selling_date': '2025-05-15T23:59:59Z' - }, format='json') - - self.assertEqual(response.status_code, 201) - self.assertIsNotNone(response.data['selling_date']) -``` - ---- - -### 8. GET/PATCH/DELETE `/backend/event_lists//` -**Descrizione**: Dettaglio/Modifica/Elimina EventList -**Autenticazione**: Sì -**Permessi**: Rispettivi permessi CRUD - ---- - -### 9. GET `/backend/subscriptions/` -**Descrizione**: Lista iscrizioni -**Autenticazione**: Sì - -#### Parametri Query -- `event`: ID evento -- `list`: ID lista -- `profile`: ID profilo -- `payed`: filtro pagato -- `deposit_payed`: filtro deposito pagato - -```python -class SubscriptionListTestCase(BaseTestCase): - - def test_filter_by_event_returns_correct_subscriptions(self): - """Test filtro per evento""" - from events.models import Event, EventList, Subscription - - user = self.create_base_user() - self.authenticate_user(user) - - event1 = Event.objects.create(name='Event1', date='2025-06-01') - event2 = Event.objects.create(name='Event2', date='2025-06-02') - - # Setup lists and subscriptions - list1 = EventList.objects.create(name='L1', capacity=100, is_main_list=True) - list1.events.add(event1) - list2 = EventList.objects.create(name='L2', capacity=100, is_main_list=True) - list2.events.add(event2) - - profile = self.create_profile('test@test.com', is_esner=False) - Subscription.objects.create(profile=profile, event=event1, list=list1) - Subscription.objects.create(profile=profile, event=event2, list=list2) - - response = self.client.get(f'/backend/subscriptions/?event={event1.pk}') - - self.assertEqual(response.status_code, 200) - self.assertTrue(all(s['event'] == event1.pk for s in response.data['results'])) -``` - ---- - -### 10. POST `/backend/subscriptions/` -**Descrizione**: Crea iscrizione (endpoint per ESNers) -**Autenticazione**: Sì -**Permessi**: `events.add_subscription` - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| E-SC-001 | Crea iscrizione valida | profile_id, event_id, list_id | Iscrizione creata | 201 | -| E-SC-002 | Iscrizione duplicata | stesso profile/event | Errore "già iscritto" | 400 | -| E-SC-003 | Lista piena | sold >= capacity | Errore "lista piena" | 400 | -| E-SC-004 | Lista non aperta | selling_date futuro | Errore | 400 | -| E-SC-005 | Lista chiusa | is_open=False | Errore | 400 | -| E-SC-006 | Con servizi selezionati | + selected_services | Servizi salvati | 201 | -| E-SC-007 | Con form_data | + form_data | Dati form salvati | 201 | - -```python -class SubscriptionCreationTestCase(BaseTestCase): - - def test_create_subscription_increments_sold_count(self): - """E-SC-001: Creare iscrizione incrementa contatore sold""" - from events.models import Event, EventList - - board = self.create_board_user() - self.authenticate_user(board) - - event = Event.objects.create(name='Test', date='2025-06-01') - event_list = EventList.objects.create( - name='Main', capacity=100, is_main_list=True, sold=5 - ) - event_list.events.add(event) - - profile = self.create_profile('erasmus@test.com', is_esner=False) - - response = self.client.post('/backend/subscriptions/', { - 'profile': profile.pk, - 'event': event.pk, - 'list': event_list.pk - }) - - self.assertEqual(response.status_code, 201) - - event_list.refresh_from_db() - self.assertEqual(event_list.sold, 6) - - def test_duplicate_subscription_returns_error(self): - """E-SC-002: Iscrizione duplicata ritorna errore""" - from events.models import Event, EventList, Subscription - - board = self.create_board_user() - self.authenticate_user(board) - - event = Event.objects.create(name='Test', date='2025-06-01') - event_list = EventList.objects.create( - name='Main', capacity=100, is_main_list=True - ) - event_list.events.add(event) - - profile = self.create_profile('erasmus@test.com', is_esner=False) - Subscription.objects.create(profile=profile, event=event, list=event_list) - - response = self.client.post('/backend/subscriptions/', { - 'profile': profile.pk, - 'event': event.pk, - 'list': event_list.pk - }) - - self.assertEqual(response.status_code, 400) - - def test_subscription_to_full_list_returns_error(self): - """E-SC-003: Iscrizione a lista piena ritorna errore""" - from events.models import Event, EventList - - board = self.create_board_user() - self.authenticate_user(board) - - event = Event.objects.create(name='Test', date='2025-06-01') - event_list = EventList.objects.create( - name='Main', capacity=10, is_main_list=True, sold=10 # FULL - ) - event_list.events.add(event) - - profile = self.create_profile('erasmus@test.com', is_esner=False) - - response = self.client.post('/backend/subscriptions/', { - 'profile': profile.pk, - 'event': event.pk, - 'list': event_list.pk - }) - - self.assertEqual(response.status_code, 400) - self.assertIn('piena', str(response.data).lower()) -``` - ---- - -### 11. PATCH `/backend/subscriptions//` -**Descrizione**: Modifica iscrizione -**Autenticazione**: Sì -**Permessi**: `events.change_subscription` - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| E-SU-001 | Aggiorna note | notes | Note aggiornate | 200 | -| E-SU-002 | Segna pagato | payed=True | payed=True | 200 | -| E-SU-003 | Segna deposito pagato | deposit_payed=True | Flag aggiornato | 200 | -| E-SU-004 | Segna liberatoria | liberatoria=True | Flag aggiornato | 200 | -| E-SU-005 | Aggiorna servizi | selected_services | Servizi aggiornati | 200 | -| E-SU-006 | Aggiorna senza permesso | - | Forbidden | 403 | - ---- - -### 12. DELETE `/backend/subscriptions//` -**Descrizione**: Elimina iscrizione -**Autenticazione**: Sì -**Permessi**: `events.delete_subscription` - -#### Scenari di Test - -| ID | Scenario | Preconditions | Expected | Status | -|----|----------|---------------|----------|--------| -| E-SD-001 | Elimina iscrizione | Iscrizione esistente | Eliminata, sold-- | 200 | -| E-SD-002 | Elimina decrementa sold | sold=5 | sold=4 | 200 | -| E-SD-003 | Elimina senza permesso | No permission | Forbidden | 403 | - -```python -class SubscriptionDeleteTestCase(BaseTestCase): - - def test_delete_subscription_decrements_sold(self): - """E-SD-002: Eliminare iscrizione decrementa contatore sold""" - from events.models import Event, EventList, Subscription - - board = self.create_board_user() - self.authenticate_user(board) - - event = Event.objects.create(name='Test', date='2025-06-01') - event_list = EventList.objects.create( - name='Main', capacity=100, is_main_list=True, sold=5 - ) - event_list.events.add(event) - - profile = self.create_profile('erasmus@test.com', is_esner=False) - subscription = Subscription.objects.create( - profile=profile, event=event, list=event_list - ) - - response = self.client.delete(f'/backend/subscriptions/{subscription.pk}/') - - self.assertEqual(response.status_code, 200) - - event_list.refresh_from_db() - self.assertEqual(event_list.sold, 4) -``` - ---- - -### 13. POST `/backend/event//formsubmit/` -**Descrizione**: Sottomissione form pubblico (Erasmus self-registration) -**Autenticazione**: No (pubblico) - -#### Request Body -```json -{ - "email": "erasmus@university.edu", - "list_id": 1, - "form_data": { - "tshirt_size": "M", - "food_allergies": "None" - }, - "selected_services": ["Bus", "Hotel"] -} -``` - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| E-SF-001 | Submit form valido | tutti i campi | Iscrizione creata, email sent | 200 | -| E-SF-002 | Submit email non esistente | email non in DB | Errore | 400 | -| E-SF-003 | Submit lista piena | lista sold=capacity | Errore | 400 | -| E-SF-004 | Submit già iscritto | profile già iscritto | Errore | 400 | -| E-SF-005 | Submit form required mancanti | manca campo required | Errore validazione | 400 | -| E-SF-006 | Submit evento senza form | evento form=null | Solo lista | 200 | -| E-SF-007 | Submit utente esterno completo | tutti campi esterni | Iscrizione creata con dati esterni | 200 | -| E-SF-008 | Submit utente esterno campi mancanti | manca first_name o last_name | Errore validazione | 400 | -| E-SF-009 | Submit utente esterno con ESNcard | has_esncard=True + numero | ESNcard salvata | 200 | -| E-SF-010 | Submit utente esterno senza ESNcard | has_esncard=False | Campo numero null | 200 | - -```python -class SubmitFormTestCase(TestCase): - - def test_submit_form_creates_subscription_and_sends_email(self): - """E-SF-001: Submit form crea iscrizione e invia email""" - from django.core import mail - from events.models import Event, EventList - from profiles.models import Profile - - event = Event.objects.create( - name='Party', - date='2025-06-01', - form={'fields': [{'name': 'tshirt', 'type': 'select', 'options': ['S', 'M', 'L']}]} - ) - event_list = EventList.objects.create( - name='Main', capacity=100, is_main_list=True, is_open=True - ) - event_list.events.add(event) - - profile = Profile.objects.create( - email='erasmus@test.com', name='Test', surname='User', - email_is_verified=True, enabled=True - ) - - response = self.client.post(f'/backend/event/{event.pk}/formsubmit/', { - 'email': 'erasmus@test.com', - 'list_id': event_list.pk, - 'form_data': {'tshirt': 'M'} - }, format='json') - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(mail.outbox), 1) # Confirmation email - - def test_submit_form_external_user_with_full_details(self): - """E-SF-007: Submit form utente esterno con tutti i campi""" - from django.core import mail - from events.models import Event, EventList, Subscription - - event = Event.objects.create( - name='Party', - date='2025-06-01', - enable_form=True, - is_allow_external=True - ) - event_list = EventList.objects.create( - name='Form List', capacity=100, is_main_list=False, is_open=True - ) - event_list.events.add(event) - - response = self.client.post(f'/backend/event/{event.pk}/formsubmit/', { - 'email': 'external@domain.com', - 'form_data': {}, - 'external_first_name': 'John', - 'external_last_name': 'Doe', - 'external_has_esncard': True, - 'external_esncard_number': 'ITA-POL-12345-24', - 'external_whatsapp_number': '+39 3331234567' - }, format='json') - - self.assertEqual(response.status_code, 200) - self.assertTrue(response.data['success']) - - # Verify subscription created with external fields - subscription = Subscription.objects.get(event=event) - self.assertEqual(subscription.external_first_name, 'John') - self.assertEqual(subscription.external_last_name, 'Doe') - self.assertEqual(subscription.external_has_esncard, True) - self.assertEqual(subscription.external_esncard_number, 'ITA-POL-12345-24') - self.assertEqual(subscription.external_whatsapp_number, '+39 3331234567') - - def test_submit_form_external_user_missing_required_fields(self): - """E-SF-008: Submit form utente esterno con campi mancanti ritorna errore""" - from events.models import Event, EventList - - event = Event.objects.create( - name='Party', - date='2025-06-01', - enable_form=True, - is_allow_external=True - ) - event_list = EventList.objects.create( - name='Form List', capacity=100, is_main_list=False, is_open=True - ) - event_list.events.add(event) - - response = self.client.post(f'/backend/event/{event.pk}/formsubmit/', { - 'email': 'external@domain.com', - 'form_data': {}, - 'external_first_name': 'John' - # Missing external_last_name - }, format='json') - - self.assertEqual(response.status_code, 400) - self.assertIn('Nome e cognome', response.data['error']) -``` - ---- - -### 14. POST `/backend/sumup/checkout//` -**Descrizione**: Inizia pagamento SumUp per iscrizione -**Autenticazione**: No (pubblico, link diretto) - -#### Scenari di Test (MOCK SumUp) - -| ID | Scenario | Preconditions | Expected | Status | -|----|----------|---------------|----------|--------| -| E-SUM-001 | Crea checkout valido | Iscrizione non pagata | checkout_id returned | 200 | -| E-SUM-002 | Checkout già pagato | payed=True | Errore "già pagato" | 400 | -| E-SUM-003 | Checkout già in pending | pending_payment=True | Errore | 400 | - -```python -class SumUpCheckoutTestCase(TestCase): - - @patch('events.views.requests.post') - def test_create_checkout_returns_checkout_id(self, mock_post): - """E-SUM-001: Crea checkout ritorna checkout_id""" - from events.models import Event, EventList, Subscription - from profiles.models import Profile - - # Mock SumUp response - mock_post.return_value.status_code = 200 - mock_post.return_value.json.return_value = { - 'id': 'checkout_123', - 'checkout_reference': 'ref_123' - } - - event = Event.objects.create(name='Test', date='2025-06-01') - event_list = EventList.objects.create( - name='Main', capacity=100, is_main_list=True, price=25 - ) - event_list.events.add(event) - - profile = Profile.objects.create( - email='test@test.com', name='Test', surname='User', - email_is_verified=True, enabled=True - ) - subscription = Subscription.objects.create( - profile=profile, event=event, list=event_list, - payed=False, pending_payment=False - ) - - response = self.client.post(f'/backend/sumup/checkout/{subscription.pk}/') - - self.assertEqual(response.status_code, 200) - self.assertIn('checkout_id', response.data) -``` - ---- - -### 15. POST `/backend/sumup/complete//` -**Descrizione**: Completa pagamento SumUp (webhook o redirect) -**Autenticazione**: No - -#### Scenari di Test (MOCK SumUp) - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| E-SUMC-001 | Pagamento completato | checkout_id valido | payed=True, transaction creata | 200 | -| E-SUMC-002 | Pagamento fallito | status=FAILED | pending_payment=False | 200 | -| E-SUMC-003 | Checkout non trovato | checkout_id invalido | Errore | 400 | - -```python -class SumUpCompleteTestCase(TestCase): - - @patch('events.views.requests.get') - def test_complete_payment_creates_transaction(self, mock_get): - """E-SUMC-001: Pagamento completato crea transazione""" - from events.models import Event, EventList, Subscription - from profiles.models import Profile - from treasury.models import Transaction - - # Mock SumUp status check - mock_get.return_value.status_code = 200 - mock_get.return_value.json.return_value = { - 'status': 'PAID', - 'amount': 25.00, - 'checkout_reference': 'ref_123', - 'transactions': [{'payment_type': 'CARD'}] - } - - event = Event.objects.create(name='Test', date='2025-06-01') - event_list = EventList.objects.create( - name='Main', capacity=100, is_main_list=True, price=25 - ) - event_list.events.add(event) - - profile = Profile.objects.create( - email='test@test.com', name='Test', surname='User', - email_is_verified=True, enabled=True - ) - subscription = Subscription.objects.create( - profile=profile, event=event, list=event_list, - payed=False, pending_payment=True, payment_id='checkout_123' - ) - - response = self.client.post(f'/backend/sumup/complete/{subscription.pk}/') - - self.assertEqual(response.status_code, 200) - - subscription.refresh_from_db() - self.assertTrue(subscription.payed) - self.assertTrue(subscription.payment_confirmed) - - # Verifica transazione creata - transaction = Transaction.objects.filter( - subscription=subscription - ).first() - self.assertIsNotNone(transaction) -``` - ---- - -### 16. POST `/backend/move_subscriptions/` -**Descrizione**: Sposta iscrizioni da una lista all'altra -**Autenticazione**: Sì -**Permessi**: `events.change_subscription` - -#### Request Body -```json -{ - "from_list_id": 1, - "to_list_id": 2, - "subscription_ids": [1, 2, 3] -} -``` - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| E-MS-001 | Sposta iscrizioni valide | ids esistenti | Iscrizioni spostate | 200 | -| E-MS-002 | Sposta a lista piena | target piena | Errore | 400 | -| E-MS-003 | Lista target non esiste | to_list_id invalido | Errore | 400 | -| E-MS-004 | Aggiorna sold count | - | from--, to++ | 200 | - -```python -class MoveSubscriptionsTestCase(BaseTestCase): - - def test_move_subscriptions_updates_sold_counts(self): - """E-MS-004: Spostare iscrizioni aggiorna contatori sold""" - from events.models import Event, EventList, Subscription - - board = self.create_board_user() - self.authenticate_user(board) - - event = Event.objects.create(name='Test', date='2025-06-01') - - from_list = EventList.objects.create( - name='From', capacity=100, is_main_list=True, sold=5 - ) - from_list.events.add(event) - - to_list = EventList.objects.create( - name='To', capacity=100, is_main_list=False, sold=2 - ) - to_list.events.add(event) - - profile = self.create_profile('test@test.com', is_esner=False) - sub = Subscription.objects.create( - profile=profile, event=event, list=from_list - ) - - response = self.client.post('/backend/move_subscriptions/', { - 'from_list_id': from_list.pk, - 'to_list_id': to_list.pk, - 'subscription_ids': [sub.pk] - }, format='json') - - self.assertEqual(response.status_code, 200) - - from_list.refresh_from_db() - to_list.refresh_from_db() - - self.assertEqual(from_list.sold, 4) - self.assertEqual(to_list.sold, 3) -``` - ---- - -### 17. GET `/backend/liberatorie//` -**Descrizione**: Genera PDF liberatorie per evento -**Autenticazione**: Sì -**Permessi**: Autenticato - -#### Scenari di Test - -| ID | Scenario | Preconditions | Expected | Status | -|----|----------|---------------|----------|--------| -| E-LIB-001 | Genera PDF evento con iscrizioni | Iscrizioni presenti | PDF generato | 200 | -| E-LIB-002 | Genera PDF evento senza iscrizioni | Nessuna iscrizione | PDF vuoto/errore | 200 | - ---- - -### 18. EventOrganizer Endpoints - -#### POST `/backend/organizers/` -**Descrizione**: Aggiunge organizzatore a evento -**Autenticazione**: Sì -**Permessi**: `events.add_eventorganizer` - -#### DELETE `/backend/organizers//` -**Descrizione**: Rimuove organizzatore -**Permessi**: `events.delete_eventorganizer` - -```python -class EventOrganizerTestCase(BaseTestCase): - - def test_add_organizer_to_event(self): - """Test aggiunta organizzatore""" - from events.models import Event - - board = self.create_board_user() - self.authenticate_user(board) - - event = Event.objects.create(name='Test', date='2025-06-01') - user = self.create_base_user() - - response = self.client.post('/backend/organizers/', { - 'event': event.pk, - 'user': user.pk - }) - - self.assertEqual(response.status_code, 201) - - def test_organizer_can_view_event_subscriptions(self): - """Test che organizzatore può vedere iscrizioni evento""" - # Test che organizzatore ha accesso speciale - pass -``` - ---- - -## Integration Tests - Flusso Completo - -### Test Flusso Registrazione Erasmus -```python -class ErasmusSubscriptionFlowTestCase(TestCase): - """Test del flusso completo: registrazione -> iscrizione -> pagamento""" - - def test_complete_erasmus_subscription_flow(self): - """Test flusso completo Erasmus""" - from django.core import mail - from events.models import Event, EventList - from profiles.models import Profile - - # 1. Crea evento con lista - event = Event.objects.create( - name='Welcome Party', - date='2025-06-01', - form={'fields': [{'name': 'diet', 'type': 'text'}]} - ) - event_list = EventList.objects.create( - name='Standard', capacity=100, is_main_list=True, price=15 - ) - event_list.events.add(event) - - # 2. Registra profilo Erasmus (simula già registrato e verificato) - profile = Profile.objects.create( - email='student@university.edu', - name='John', - surname='Doe', - email_is_verified=True, - enabled=True, - is_esner=False - ) - - # 3. Submit form iscrizione - response = self.client.post(f'/backend/event/{event.pk}/formsubmit/', { - 'email': 'student@university.edu', - 'list_id': event_list.pk, - 'form_data': {'diet': 'Vegetarian'} - }, format='json') - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(mail.outbox), 1) # Confirmation email - - # 4. Verifica iscrizione creata - from events.models import Subscription - subscription = Subscription.objects.get(profile=profile, event=event) - self.assertEqual(subscription.form_data['diet'], 'Vegetarian') - self.assertFalse(subscription.payed) -``` - -### Test Flusso Pagamento -```python -class PaymentFlowTestCase(TestCase): - """Test flusso pagamento SumUp""" - - @patch('events.views.requests.post') - @patch('events.views.requests.get') - def test_complete_payment_flow(self, mock_get, mock_post): - """Test flusso checkout -> pagamento -> conferma""" - from events.models import Event, EventList, Subscription - from profiles.models import Profile - - # Setup mocks - mock_post.return_value.status_code = 200 - mock_post.return_value.json.return_value = { - 'id': 'chk_123', 'checkout_reference': 'ref_123' - } - mock_get.return_value.status_code = 200 - mock_get.return_value.json.return_value = { - 'status': 'PAID', 'amount': 15.00, - 'transactions': [{'payment_type': 'CARD'}] - } - - # Setup data - event = Event.objects.create(name='Test', date='2025-06-01') - event_list = EventList.objects.create( - name='Main', capacity=100, price=15 - ) - event_list.events.add(event) - profile = Profile.objects.create( - email='test@test.com', name='T', surname='U', - email_is_verified=True, enabled=True - ) - subscription = Subscription.objects.create( - profile=profile, event=event, list=event_list - ) - - # 1. Crea checkout - response = self.client.post(f'/backend/sumup/checkout/{subscription.pk}/') - self.assertEqual(response.status_code, 200) - - # 2. Completa pagamento - response = self.client.post(f'/backend/sumup/complete/{subscription.pk}/') - self.assertEqual(response.status_code, 200) - - # 3. Verifica stato - subscription.refresh_from_db() - self.assertTrue(subscription.payed) - self.assertTrue(subscription.payment_confirmed) -``` - ---- - -## Checklist Test Coverage - -### Eventi -- [ ] CRUD eventi con permessi -- [ ] Filtri tipo evento -- [ ] Eventi con servizi disponibili -- [ ] Eventi con form dinamico -- [ ] Delete evento con/senza iscrizioni - -### Liste Evento -- [ ] CRUD liste con permessi -- [ ] Date vendita (selling_date, end_selling_date) -- [ ] Contatore sold -- [ ] Stato is_open - -### Iscrizioni -- [ ] Crea iscrizione (endpoint interno) -- [ ] Duplicati bloccati -- [ ] Lista piena bloccata -- [ ] Form data salvato -- [ ] Servizi selezionati salvati -- [ ] Aggiornamento note/flag -- [x] Eliminazione con sold-- - -### Form Submission -- [x] Submit pubblico valido -- [x] Validazione campi required -- [x] Email conferma inviata -- [x] Profilo non esistente -- [x] Submit utente esterno con dati completi (first_name, last_name, ESNcard, WhatsApp) -- [x] Submit utente esterno con campi mancanti (validazione) -- [x] Submit utente esterno con ESNcard -- [x] Submit utente esterno senza ESNcard - -### SumUp Integration (MOCK) -- [x] Crea checkout -- [x] Completa pagamento -- [x] Gestione errori -- [x] Transazione creata - -### Servizi (Services) -- [x] Event con servizi validi -- [x] Subscription con servizi selezionati -- [x] Validazione servizi sconosciuti -- [x] Validazione quantity zero/negativa -- [x] Calcolo costo servizi -- [x] Update servizi in subscription -- [x] Servizi con match by name -- [x] Servizi con prezzi decimali -- [x] Status services tracking - -### Altri -- [x] Move subscriptions -- [x] Liberatorie PDF -- [x] Organizzatori evento diff --git a/backend/docs/test_specifications/04_TREASURY_MODULE.md b/backend/docs/test_specifications/04_TREASURY_MODULE.md deleted file mode 100644 index 4a1f21d13..000000000 --- a/backend/docs/test_specifications/04_TREASURY_MODULE.md +++ /dev/null @@ -1,1061 +0,0 @@ -# 04 - Treasury Module Test Specifications - -## Panoramica Modulo - -Il modulo `treasury` gestisce: -- Conti (Accounts) e movimenti finanziari -- Transazioni di vario tipo -- ESNcard e quota associativa -- Richieste di rimborso -- Import/Export dati -- Depositi e rimborsi - ---- - -## File del Modulo - -| File | Descrizione | -|------|-------------| -| `models.py` | Models Account, Transaction, ESNcard, ReimbursementRequest, Settings | -| `views.py` | Endpoint finanziari (~1049 linee) | -| `serializers.py` | Serializers per dati finanziari | -| `exceptions.py` | Eccezioni custom | -| `urls.py` | Route del modulo | - ---- - -## Modelli - -### Account -```python -class Account(BaseEntity): - id = AutoField(primary_key=True) - name = CharField(max_length=100) - type = CharField(choices=['bank', 'cash', 'digital']) - balance = DecimalField(default=0.00) - enabled = BooleanField(default=True) -``` - -### Transaction -```python -class Transaction(BaseEntity): - id = AutoField(primary_key=True) - account = ForeignKey(Account, null=True) - type = CharField(choices=[ - 'subscription_payment', # Pagamento iscrizione evento - 'membership_fee', # Quota associativa - 'esncard_emission', # Emissione ESNcard - 'reimbursement', # Rimborso spese - 'transfer_in', # Trasferimento in entrata - 'transfer_out', # Trasferimento in uscita - 'deposit_payment', # Pagamento deposito - 'deposit_reimbursement', # Rimborso deposito - 'other' # Altro - ]) - amount = DecimalField() - description = CharField(max_length=500, null=True) - date = DateTimeField(auto_now_add=True) - profile = ForeignKey(Profile, null=True) - subscription = ForeignKey(Subscription, null=True) - esncard = ForeignKey(ESNcard, null=True) - reimbursement_request = ForeignKey(ReimbursementRequest, null=True) - enabled = BooleanField(default=True) -``` - -### ESNcard -```python -class ESNcard(BaseEntity): - id = AutoField(primary_key=True) - profile = ForeignKey(Profile) - number = CharField(unique=True) - emission_date = DateField(auto_now_add=True) - expiration_date = DateField() # +1 anno da emission - enabled = BooleanField(default=True) -``` - -### ReimbursementRequest -```python -class ReimbursementRequest(BaseEntity): - id = AutoField(primary_key=True) - user = ForeignKey(User) - description = CharField(max_length=500) - amount = DecimalField() - date = DateField() - status = CharField(choices=['pending', 'approved', 'rejected', 'reimbursed']) - receipt_url = URLField(null=True) # Google Drive link - notes = TextField(null=True) - enabled = BooleanField(default=True) -``` - -### Settings -```python -class Settings(models.Model): - membership_fee = DecimalField(default=5.00) # Quota associativa - esncard_price = DecimalField(default=10.00) # Prezzo ESNcard -``` - ---- - -## Endpoints - -### 1. GET `/backend/accounts/` -**Descrizione**: Lista conti -**Autenticazione**: Sì -**Permessi**: `treasury.view_account` O `can_view_casse_import` - -#### Scenari di Test - -| ID | Scenario | User | Expected | Status | -|----|----------|------|----------|--------| -| T-AL-001 | Lista come Board | Board | Tutti i conti | 200 | -| T-AL-002 | Lista come Aspirante con can_view_casse_import | Aspirante + permesso | Conti | 200 | -| T-AL-003 | Lista come Aspirante senza permesso | Aspirante | Forbidden | 403 | -| T-AL-004 | Lista solo enabled | Qualsiasi | Solo enabled=True | 200 | - -```python -class AccountListTestCase(BaseTestCase): - - def test_board_can_list_accounts(self): - """T-AL-001: Board può listare conti""" - from treasury.models import Account - - board = self.create_board_user() - self.authenticate_user(board) - - Account.objects.create(name='Cassa', type='cash', balance=100) - Account.objects.create(name='Banca', type='bank', balance=1000) - - response = self.client.get('/backend/accounts/') - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 2) - - def test_aspirante_with_permission_can_list_accounts(self): - """T-AL-002: Aspirante con can_view_casse_import può listare""" - from treasury.models import Account - - aspirante = self.create_base_user() - aspirante.can_view_casse_import = True - aspirante.save() - self.authenticate_user(aspirante) - - Account.objects.create(name='Cassa', type='cash', balance=100) - - response = self.client.get('/backend/accounts/') - - self.assertEqual(response.status_code, 200) - - def test_aspirante_without_permission_gets_403(self): - """T-AL-003: Aspirante senza permesso riceve 403""" - aspirante = self.create_base_user() - self.authenticate_user(aspirante) - - response = self.client.get('/backend/accounts/') - - self.assertEqual(response.status_code, 403) -``` - ---- - -### 2. POST `/backend/accounts/` -**Descrizione**: Crea nuovo conto -**Autenticazione**: Sì -**Permessi**: `treasury.add_account` - -#### Scenari di Test - -| ID | Scenario | User | Input | Expected | Status | -|----|----------|------|-------|----------|--------| -| T-AC-001 | Crea conto cash | Board | name, type=cash | Conto creato | 201 | -| T-AC-002 | Crea conto bank | Board | type=bank | Conto creato | 201 | -| T-AC-003 | Crea senza permesso | Aspirante | - | Forbidden | 403 | -| T-AC-004 | Crea con nome duplicato | Board | nome esistente | Conto creato | 201 | - -```python -class AccountCreationTestCase(BaseTestCase): - - def test_create_cash_account(self): - """T-AC-001: Crea conto cassa""" - board = self.create_board_user() - self.authenticate_user(board) - - response = self.client.post('/backend/accounts/', { - 'name': 'Cassa Principale', - 'type': 'cash', - 'balance': '0.00' - }) - - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data['type'], 'cash') -``` - ---- - -### 3. GET/PATCH/DELETE `/backend/accounts//` -**Descrizione**: Dettaglio/Modifica/Elimina conto -**Autenticazione**: Sì -**Permessi**: Rispettivi permessi CRUD - -#### Scenari di Test - -| ID | Scenario | User | Expected | Status | -|----|----------|------|----------|--------| -| T-AD-001 | GET conto | Board | Dettagli conto | 200 | -| T-AD-002 | PATCH balance | Board | Balance aggiornato | 200 | -| T-AD-003 | DELETE conto senza transazioni | Board | Eliminato | 200 | -| T-AD-004 | DELETE conto con transazioni | Board | Errore | 400 | - ---- - -### 4. GET `/backend/transactions/` -**Descrizione**: Lista transazioni con filtri -**Autenticazione**: Sì -**Permessi**: `treasury.view_transaction` O `can_view_casse_import` - -#### Parametri Query -- `account`: ID conto -- `type`: tipo transazione -- `profile`: ID profilo -- `date_from`, `date_to`: range date -- `page`, `page_size`: paginazione - -#### Scenari di Test - -| ID | Scenario | Query Params | Expected | Status | -|----|----------|--------------|----------|--------| -| T-TL-001 | Lista senza filtri | - | Tutte le transazioni | 200 | -| T-TL-002 | Filtro per conto | account=1 | Solo del conto | 200 | -| T-TL-003 | Filtro per tipo | type=esncard_emission | Solo ESNcard | 200 | -| T-TL-004 | Filtro per profilo | profile=123 | Solo del profilo | 200 | -| T-TL-005 | Filtro date range | date_from, date_to | Nel range | 200 | - -```python -class TransactionListTestCase(BaseTestCase): - - def test_filter_by_account(self): - """T-TL-002: Filtro per conto funziona""" - from treasury.models import Account, Transaction - - board = self.create_board_user() - self.authenticate_user(board) - - acc1 = Account.objects.create(name='Cassa', type='cash') - acc2 = Account.objects.create(name='Banca', type='bank') - - Transaction.objects.create(account=acc1, type='other', amount=10) - Transaction.objects.create(account=acc2, type='other', amount=20) - - response = self.client.get(f'/backend/transactions/?account={acc1.pk}') - - self.assertEqual(response.status_code, 200) - self.assertTrue(all(t['account'] == acc1.pk for t in response.data['results'])) - - def test_filter_by_type(self): - """T-TL-003: Filtro per tipo funziona""" - from treasury.models import Account, Transaction - - board = self.create_board_user() - self.authenticate_user(board) - - acc = Account.objects.create(name='Cassa', type='cash') - Transaction.objects.create(account=acc, type='esncard_emission', amount=10) - Transaction.objects.create(account=acc, type='membership_fee', amount=5) - - response = self.client.get('/backend/transactions/?type=esncard_emission') - - self.assertEqual(response.status_code, 200) - self.assertTrue(all(t['type'] == 'esncard_emission' for t in response.data['results'])) -``` - ---- - -### 5. POST `/backend/transactions/` -**Descrizione**: Crea nuova transazione -**Autenticazione**: Sì -**Permessi**: `treasury.add_transaction` O `can_manage_casse` - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| T-TC-001 | Crea transazione other | account, type, amount | Transazione creata | 201 | -| T-TC-002 | Crea con profilo | + profile | FK salvata | 201 | -| T-TC-003 | Crea aggiorna balance conto | amount positivo | account.balance += | 201 | -| T-TC-004 | Crea senza permesso | Aspirante | Forbidden | 403 | -| T-TC-005 | Aspirante con can_manage_casse | + permesso | Transazione creata | 201 | - -```python -class TransactionCreationTestCase(BaseTestCase): - - def test_create_transaction_updates_account_balance(self): - """T-TC-003: Creare transazione aggiorna balance conto""" - from treasury.models import Account - - board = self.create_board_user() - self.authenticate_user(board) - - account = Account.objects.create(name='Cassa', type='cash', balance=100) - - response = self.client.post('/backend/transactions/', { - 'account': account.pk, - 'type': 'other', - 'amount': '50.00', - 'description': 'Entrata varia' - }) - - self.assertEqual(response.status_code, 201) - - account.refresh_from_db() - self.assertEqual(account.balance, Decimal('150.00')) - - def test_aspirante_with_can_manage_casse_can_create(self): - """T-TC-005: Aspirante con can_manage_casse può creare""" - from treasury.models import Account - - aspirante = self.create_base_user() - aspirante.can_manage_casse = True - aspirante.save() - self.authenticate_user(aspirante) - - account = Account.objects.create(name='Cassa', type='cash') - - response = self.client.post('/backend/transactions/', { - 'account': account.pk, - 'type': 'other', - 'amount': '25.00' - }) - - self.assertEqual(response.status_code, 201) -``` - ---- - -### 6. PATCH/DELETE `/backend/transactions//` -**Descrizione**: Modifica/Elimina transazione -**Autenticazione**: Sì -**Permessi**: Rispettivi permessi - -#### Scenari di Test - -| ID | Scenario | Action | Expected | Status | -|----|----------|--------|----------|--------| -| T-TUD-001 | PATCH description | Modifica descrizione | Aggiornata | 200 | -| T-TUD-002 | DELETE transazione | Elimina | Balance aggiornato | 200 | -| T-TUD-003 | DELETE decrementa balance | amount era 50 | balance -= 50 | 200 | - -```python -class TransactionDeleteTestCase(BaseTestCase): - - def test_delete_transaction_updates_balance(self): - """T-TUD-003: Eliminare transazione aggiorna balance""" - from treasury.models import Account, Transaction - - board = self.create_board_user() - self.authenticate_user(board) - - account = Account.objects.create(name='Cassa', type='cash', balance=150) - transaction = Transaction.objects.create( - account=account, type='other', amount=50 - ) - - response = self.client.delete(f'/backend/transactions/{transaction.pk}/') - - self.assertEqual(response.status_code, 200) - - account.refresh_from_db() - self.assertEqual(account.balance, Decimal('100.00')) -``` - ---- - -### 7. POST `/backend/esncard/emit/` -**Descrizione**: Emette ESNcard per un profilo -**Autenticazione**: Sì -**Permessi**: `treasury.add_esncard` O `can_manage_casse` - -#### Request Body -```json -{ - "profile_id": 123, - "card_number": "IT-POL-1234567", - "account_id": 1 -} -``` - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| T-ESN-001 | Emette ESNcard valida | tutti i campi | Card + Transaction creati | 201 | -| T-ESN-002 | Emette con numero duplicato | card_number esistente | Errore unique | 400 | -| T-ESN-003 | Profilo già ha card valida | card non scaduta | Errore | 400 | -| T-ESN-004 | Profilo con card scaduta | card scaduta | Nuova card emessa | 201 | -| T-ESN-005 | Emette include quota associativa | - | 2 transazioni (card + quota) | 201 | -| T-ESN-006 | Emette senza quota se già pagata | nell'anno | 1 transazione (solo card) | 201 | - -```python -class ESNcardEmissionTestCase(BaseTestCase): - - def test_emit_esncard_creates_card_and_transactions(self): - """T-ESN-001: Emettere ESNcard crea card e transazioni""" - from treasury.models import Account, ESNcard, Transaction - - board = self.create_board_user() - self.authenticate_user(board) - - account = Account.objects.create(name='Cassa', type='cash', balance=0) - profile = self.create_profile('erasmus@test.com', is_esner=False) - - response = self.client.post('/backend/esncard/emit/', { - 'profile_id': profile.pk, - 'card_number': 'IT-POL-0001234', - 'account_id': account.pk - }) - - self.assertEqual(response.status_code, 201) - - # Verifica ESNcard creata - card = ESNcard.objects.get(profile=profile) - self.assertEqual(card.number, 'IT-POL-0001234') - - # Verifica transazioni (ESNcard + quota associativa) - transactions = Transaction.objects.filter(esncard=card) - self.assertGreaterEqual(len(transactions), 1) - - # Verifica balance aggiornato - account.refresh_from_db() - self.assertGreater(account.balance, 0) - - def test_emit_duplicate_card_number_returns_error(self): - """T-ESN-002: Numero card duplicato ritorna errore""" - from treasury.models import Account, ESNcard - - board = self.create_board_user() - self.authenticate_user(board) - - account = Account.objects.create(name='Cassa', type='cash') - profile1 = self.create_profile('erasmus1@test.com', is_esner=False) - profile2 = self.create_profile('erasmus2@test.com', is_esner=False) - - ESNcard.objects.create( - profile=profile1, - number='IT-POL-SAME123', - expiration_date='2026-01-01' - ) - - response = self.client.post('/backend/esncard/emit/', { - 'profile_id': profile2.pk, - 'card_number': 'IT-POL-SAME123', - 'account_id': account.pk - }) - - self.assertEqual(response.status_code, 400) - - def test_emit_when_valid_card_exists_returns_error(self): - """T-ESN-003: Profilo con card valida ritorna errore""" - from treasury.models import Account, ESNcard - from datetime import date, timedelta - - board = self.create_board_user() - self.authenticate_user(board) - - account = Account.objects.create(name='Cassa', type='cash') - profile = self.create_profile('erasmus@test.com', is_esner=False) - - # Card valida esistente - ESNcard.objects.create( - profile=profile, - number='IT-POL-EXISTING', - expiration_date=date.today() + timedelta(days=365) - ) - - response = self.client.post('/backend/esncard/emit/', { - 'profile_id': profile.pk, - 'card_number': 'IT-POL-NEW1234', - 'account_id': account.pk - }) - - self.assertEqual(response.status_code, 400) - - def test_emit_with_expired_card_succeeds(self): - """T-ESN-004: Profilo con card scaduta può avere nuova""" - from treasury.models import Account, ESNcard - from datetime import date, timedelta - - board = self.create_board_user() - self.authenticate_user(board) - - account = Account.objects.create(name='Cassa', type='cash') - profile = self.create_profile('erasmus@test.com', is_esner=False) - - # Card scaduta - ESNcard.objects.create( - profile=profile, - number='IT-POL-EXPIRED', - expiration_date=date.today() - timedelta(days=1) - ) - - response = self.client.post('/backend/esncard/emit/', { - 'profile_id': profile.pk, - 'card_number': 'IT-POL-NEW1234', - 'account_id': account.pk - }) - - self.assertEqual(response.status_code, 201) -``` - ---- - -### 8. GET `/backend/esncards/` -**Descrizione**: Lista ESNcards -**Autenticazione**: Sì -**Permessi**: `treasury.view_esncard` O `can_view_casse_import` - -#### Parametri Query -- `profile`: ID profilo -- `valid`: solo valide (true/false) -- `search`: ricerca numero - -```python -class ESNcardListTestCase(BaseTestCase): - - def test_filter_valid_cards(self): - """Test filtro carte valide""" - from treasury.models import ESNcard - from datetime import date, timedelta - - board = self.create_board_user() - self.authenticate_user(board) - - profile = self.create_profile('test@test.com', is_esner=False) - - # Carta valida - ESNcard.objects.create( - profile=profile, number='VALID123', - expiration_date=date.today() + timedelta(days=30) - ) - # Carta scaduta - ESNcard.objects.create( - profile=profile, number='EXPIRED123', - expiration_date=date.today() - timedelta(days=30) - ) - - response = self.client.get('/backend/esncards/?valid=true') - - self.assertEqual(response.status_code, 200) - # Solo carte valide -``` - ---- - -### 9. POST `/backend/reimbursement_requests/` -**Descrizione**: Crea richiesta di rimborso -**Autenticazione**: Sì -**Permessi**: Tutti autenticati - -#### Request Body -```json -{ - "description": "Spese per evento", - "amount": "50.00", - "date": "2025-01-15", - "receipt_url": "https://drive.google.com/..." -} -``` - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| T-RR-001 | Crea richiesta valida | tutti i campi | Richiesta creata, status=pending | 201 | -| T-RR-002 | Crea senza receipt | manca receipt_url | Richiesta creata | 201 | -| T-RR-003 | Crea con importo negativo | amount=-10 | Errore validazione | 400 | - -```python -class ReimbursementRequestCreationTestCase(BaseTestCase): - - def test_create_reimbursement_request_sets_pending_status(self): - """T-RR-001: Crea richiesta imposta status pending""" - from treasury.models import ReimbursementRequest - - user = self.create_base_user() - self.authenticate_user(user) - - response = self.client.post('/backend/reimbursement_requests/', { - 'description': 'Acquisto materiale evento', - 'amount': '75.50', - 'date': '2025-01-20' - }) - - self.assertEqual(response.status_code, 201) - - req = ReimbursementRequest.objects.get(pk=response.data['id']) - self.assertEqual(req.status, 'pending') - self.assertEqual(req.user, user) -``` - ---- - -### 10. GET `/backend/reimbursement_requests/` -**Descrizione**: Lista richieste rimborso -**Autenticazione**: Sì -**Permessi**: Board vede tutte, altri vedono solo le proprie - -#### Scenari di Test - -| ID | Scenario | User | Expected | Status | -|----|----------|------|----------|--------| -| T-RRL-001 | Board vede tutte | Board | Tutte le richieste | 200 | -| T-RRL-002 | Aspirante vede solo proprie | Aspirante | Solo sue richieste | 200 | - -```python -class ReimbursementRequestListTestCase(BaseTestCase): - - def test_board_sees_all_requests(self): - """T-RRL-001: Board vede tutte le richieste""" - from treasury.models import ReimbursementRequest - - board = self.create_board_user() - other_user = self.create_base_user() - - ReimbursementRequest.objects.create( - user=board, description='Board req', amount=10, date='2025-01-01' - ) - ReimbursementRequest.objects.create( - user=other_user, description='Other req', amount=20, date='2025-01-02' - ) - - self.authenticate_user(board) - response = self.client.get('/backend/reimbursement_requests/') - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['results']), 2) - - def test_aspirante_sees_only_own_requests(self): - """T-RRL-002: Aspirante vede solo proprie richieste""" - from treasury.models import ReimbursementRequest - - aspirante = self.create_base_user() - other = self.create_base_user(email='other@esnpolimi.it') - - ReimbursementRequest.objects.create( - user=aspirante, description='My req', amount=10, date='2025-01-01' - ) - ReimbursementRequest.objects.create( - user=other, description='Other req', amount=20, date='2025-01-02' - ) - - self.authenticate_user(aspirante) - response = self.client.get('/backend/reimbursement_requests/') - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['results']), 1) - self.assertEqual(response.data['results'][0]['user'], aspirante.pk) -``` - ---- - -### 11. PATCH `/backend/reimbursement_requests//` -**Descrizione**: Modifica richiesta (approvazione/rifiuto) -**Autenticazione**: Sì -**Permessi**: Board può cambiare status, owner può modificare solo se pending - -#### Scenari di Test - -| ID | Scenario | User | Input | Expected | Status | -|----|----------|------|-------|----------|--------| -| T-RRU-001 | Board approva | Board | status=approved | Status aggiornato | 200 | -| T-RRU-002 | Board rifiuta | Board | status=rejected, notes | Status + note | 200 | -| T-RRU-003 | Owner modifica pending | Owner | description | Aggiornato | 200 | -| T-RRU-004 | Owner modifica approved | Owner | - | Errore | 403 | -| T-RRU-005 | Non-owner modifica | Altro | - | Forbidden | 403 | - -```python -class ReimbursementRequestUpdateTestCase(BaseTestCase): - - def test_board_can_approve_request(self): - """T-RRU-001: Board può approvare richiesta""" - from treasury.models import ReimbursementRequest - - board = self.create_board_user() - user = self.create_base_user() - - req = ReimbursementRequest.objects.create( - user=user, description='Test', amount=50, date='2025-01-01' - ) - - self.authenticate_user(board) - response = self.client.patch(f'/backend/reimbursement_requests/{req.pk}/', { - 'status': 'approved' - }) - - self.assertEqual(response.status_code, 200) - - req.refresh_from_db() - self.assertEqual(req.status, 'approved') - - def test_owner_cannot_modify_approved_request(self): - """T-RRU-004: Owner non può modificare richiesta approvata""" - from treasury.models import ReimbursementRequest - - user = self.create_base_user() - - req = ReimbursementRequest.objects.create( - user=user, description='Test', amount=50, - date='2025-01-01', status='approved' - ) - - self.authenticate_user(user) - response = self.client.patch(f'/backend/reimbursement_requests/{req.pk}/', { - 'description': 'Changed' - }) - - self.assertIn(response.status_code, [400, 403]) -``` - ---- - -### 12. POST `/backend/reimbursement_requests//reimburse/` -**Descrizione**: Rimborsa una richiesta approvata -**Autenticazione**: Sì -**Permessi**: `treasury.add_transaction` O Board - -#### Request Body -```json -{ - "account_id": 1 -} -``` - -#### Scenari di Test - -| ID | Scenario | Preconditions | Expected | Status | -|----|----------|---------------|----------|--------| -| T-RRR-001 | Rimborsa approvata | status=approved | Transaction creata, status=reimbursed | 200 | -| T-RRR-002 | Rimborsa pending | status=pending | Errore | 400 | -| T-RRR-003 | Rimborsa già rimborsata | status=reimbursed | Errore | 400 | -| T-RRR-004 | Decrementa balance conto | - | account.balance -= amount | 200 | - -```python -class ReimbursementReimburseTestCase(BaseTestCase): - - def test_reimburse_creates_transaction_and_updates_balance(self): - """T-RRR-001: Rimborsare crea transazione e aggiorna balance""" - from treasury.models import Account, ReimbursementRequest, Transaction - - board = self.create_board_user() - self.authenticate_user(board) - - account = Account.objects.create(name='Cassa', type='cash', balance=500) - user = self.create_base_user() - - req = ReimbursementRequest.objects.create( - user=user, description='Test', amount=75, - date='2025-01-01', status='approved' - ) - - response = self.client.post(f'/backend/reimbursement_requests/{req.pk}/reimburse/', { - 'account_id': account.pk - }) - - self.assertEqual(response.status_code, 200) - - # Verifica transazione - transaction = Transaction.objects.get(reimbursement_request=req) - self.assertEqual(transaction.type, 'reimbursement') - self.assertEqual(transaction.amount, Decimal('-75.00')) - - # Verifica balance - account.refresh_from_db() - self.assertEqual(account.balance, Decimal('425.00')) - - # Verifica status - req.refresh_from_db() - self.assertEqual(req.status, 'reimbursed') -``` - ---- - -### 13. POST `/backend/reimburse_deposit//` -**Descrizione**: Rimborsa deposito di un'iscrizione -**Autenticazione**: Sì -**Permessi**: `treasury.add_transaction` O `can_manage_casse` - -#### Scenari di Test - -| ID | Scenario | Preconditions | Expected | Status | -|----|----------|---------------|----------|--------| -| T-RD-001 | Rimborsa deposito pagato | deposit_payed=True | Transaction creata | 200 | -| T-RD-002 | Rimborsa deposito non pagato | deposit_payed=False | Errore | 400 | -| T-RD-003 | Già rimborsato | refunded=True | Errore | 400 | - -```python -class ReimburseDepositTestCase(BaseTestCase): - - def test_reimburse_deposit_creates_transaction(self): - """T-RD-001: Rimborsare deposito crea transazione""" - from events.models import Event, EventList, Subscription - from treasury.models import Account, Transaction - - board = self.create_board_user() - self.authenticate_user(board) - - account = Account.objects.create(name='Cassa', type='cash', balance=500) - - event = Event.objects.create(name='Trip', date='2025-06-01') - event_list = EventList.objects.create( - name='Main', capacity=50, deposit=100, price=200 - ) - event_list.events.add(event) - - profile = self.create_profile('test@test.com', is_esner=False) - subscription = Subscription.objects.create( - profile=profile, event=event, list=event_list, - deposit_payed=True - ) - - response = self.client.post(f'/backend/reimburse_deposit/{subscription.pk}/', { - 'account_id': account.pk - }) - - self.assertEqual(response.status_code, 200) - - # Verifica transazione - transaction = Transaction.objects.filter( - subscription=subscription, type='deposit_reimbursement' - ).first() - self.assertIsNotNone(transaction) - - # Verifica subscription - subscription.refresh_from_db() - self.assertTrue(subscription.refunded) -``` - ---- - -### 14. POST `/backend/reimburse_quota//` -**Descrizione**: Rimborsa quota associativa -**Autenticazione**: Sì -**Permessi**: Board - -#### Scenari di Test - -| ID | Scenario | Expected | Status | -|----|----------|----------|--------| -| T-RQ-001 | Rimborsa quota pagata quest'anno | Transaction creata | 200 | -| T-RQ-002 | Quota non pagata | Errore | 400 | - ---- - -### 15. GET `/backend/export/transactions/` -**Descrizione**: Esporta transazioni in CSV/Excel -**Autenticazione**: Sì -**Permessi**: Board - -#### Parametri Query -- `format`: csv/xlsx -- `account`: filtro conto -- `date_from`, `date_to`: range date - -```python -class ExportTransactionsTestCase(BaseTestCase): - - def test_export_csv_returns_file(self): - """Test export CSV""" - from treasury.models import Account, Transaction - - board = self.create_board_user() - self.authenticate_user(board) - - account = Account.objects.create(name='Cassa', type='cash') - Transaction.objects.create(account=account, type='other', amount=100) - - response = self.client.get('/backend/export/transactions/?format=csv') - - self.assertEqual(response.status_code, 200) - self.assertEqual(response['Content-Type'], 'text/csv') -``` - ---- - -### 16. GET `/backend/settings/` -**Descrizione**: Ottiene settings tesoreria -**Autenticazione**: Sì - -### PATCH `/backend/settings/` -**Descrizione**: Modifica settings -**Permessi**: Board - -```python -class SettingsTestCase(BaseTestCase): - - def test_board_can_update_settings(self): - """Test Board può modificare settings""" - from treasury.models import Settings - - Settings.objects.create(membership_fee=5, esncard_price=10) - - board = self.create_board_user() - self.authenticate_user(board) - - response = self.client.patch('/backend/settings/', { - 'esncard_price': '12.00' - }) - - self.assertEqual(response.status_code, 200) - - settings = Settings.objects.first() - self.assertEqual(settings.esncard_price, Decimal('12.00')) -``` - ---- - -## Integration Tests - -### Test Flusso ESNcard Completo -```python -class ESNcardFullFlowTestCase(BaseTestCase): - """Test flusso: registrazione -> ESNcard -> transazioni""" - - def test_complete_esncard_flow(self): - """Test flusso completo emissione ESNcard""" - from treasury.models import Account, ESNcard, Transaction, Settings - - # Setup settings - Settings.objects.create(membership_fee=5, esncard_price=10) - - board = self.create_board_user() - self.authenticate_user(board) - - # 1. Crea conto - account = Account.objects.create(name='Cassa', type='cash', balance=0) - - # 2. Crea profilo Erasmus - profile = self.create_profile('erasmus@test.com', is_esner=False) - - # 3. Emetti ESNcard - response = self.client.post('/backend/esncard/emit/', { - 'profile_id': profile.pk, - 'card_number': 'IT-POL-TEST123', - 'account_id': account.pk - }) - - self.assertEqual(response.status_code, 201) - - # 4. Verifica - card = ESNcard.objects.get(profile=profile) - transactions = Transaction.objects.filter(esncard=card) - - # Dovrebbero esserci 2 transazioni: card (10€) + quota (5€) - self.assertEqual(transactions.count(), 2) - - # Balance = 10 + 5 = 15€ - account.refresh_from_db() - self.assertEqual(account.balance, Decimal('15.00')) -``` - -### Test Flusso Rimborso Completo -```python -class ReimbursementFullFlowTestCase(BaseTestCase): - """Test flusso: richiesta -> approvazione -> rimborso""" - - def test_complete_reimbursement_flow(self): - """Test flusso completo rimborso""" - from treasury.models import Account, ReimbursementRequest - - account = Account.objects.create(name='Cassa', type='cash', balance=1000) - - # 1. Utente crea richiesta - user = self.create_base_user() - self.authenticate_user(user) - - response = self.client.post('/backend/reimbursement_requests/', { - 'description': 'Spese evento', - 'amount': '150.00', - 'date': '2025-01-15' - }) - req_id = response.data['id'] - - # 2. Board approva - board = self.create_board_user() - self.authenticate_user(board) - - response = self.client.patch(f'/backend/reimbursement_requests/{req_id}/', { - 'status': 'approved' - }) - self.assertEqual(response.status_code, 200) - - # 3. Board rimborsa - response = self.client.post(f'/backend/reimbursement_requests/{req_id}/reimburse/', { - 'account_id': account.pk - }) - self.assertEqual(response.status_code, 200) - - # 4. Verifica - req = ReimbursementRequest.objects.get(pk=req_id) - self.assertEqual(req.status, 'reimbursed') - - account.refresh_from_db() - self.assertEqual(account.balance, Decimal('850.00')) # 1000 - 150 -``` - ---- - -## Checklist Test Coverage - -### Conti -- [ ] CRUD conti con permessi -- [ ] Permessi custom (can_view_casse_import) -- [ ] Balance aggiornato da transazioni - -### Transazioni -- [ ] CRUD transazioni con permessi -- [ ] Filtri per conto/tipo/profilo/date -- [ ] Balance aggiornato create/delete -- [ ] Permessi custom (can_manage_casse) - -### ESNcard -- [ ] Emissione con transazione -- [ ] Validazione numero duplicato -- [x] Validazione card già valida -- [x] Rinnovo card scaduta -- [x] Quota associativa automatica -- [x] Duplicate number validation -- [x] Multiple cards per profile -- [x] Patch to duplicate number rejected - -### Rimborsi -- [x] CRUD richieste -- [x] Visibilità Board vs owner -- [x] Flusso approvazione -- [x] Esecuzione rimborso -- [x] Rimborso deposito iscrizione -- [x] Rimborso quota associativa -- [x] Zero/negative amount validation -- [x] Insufficient balance handling -- [x] Closed account handling -- [x] Duplicate reimbursement prevention -- [x] Bulk deposit reimbursement - -### Export -- [x] Export CSV -- [x] Export Excel -- [x] Filtri export - -### Settings -- [x] GET settings -- [x] PATCH settings (Board only) - -### Account Balance Edge Cases -- [x] Multiple transactions sum -- [x] Decimal precision -- [x] Negative balance prevention -- [x] Balance after transaction deletion -- [x] Closed account transaction rejection -- [x] Transaction move between accounts -- [x] Transaction amount change - -### Account Visibility -- [x] Visible to all (no groups) -- [x] Restricted to specific group -- [x] Visible to multiple groups diff --git a/backend/docs/test_specifications/05_CONTENT_MODULE.md b/backend/docs/test_specifications/05_CONTENT_MODULE.md deleted file mode 100644 index d07eee867..000000000 --- a/backend/docs/test_specifications/05_CONTENT_MODULE.md +++ /dev/null @@ -1,694 +0,0 @@ -# 05 - Content Module Test Specifications - -## Panoramica Modulo - -Il modulo `content` gestisce: -- Sezioni di contenuto per homepage/pagine -- Link associati alle sezioni -- Contenuti pubblici e gestiti - ---- - -## File del Modulo - -| File | Descrizione | -|------|-------------| -| `models.py` | Models ContentSection, ContentLink | -| `views.py` | ViewSets CRUD | -| `serializers.py` | Serializers per contenuti | -| `urls.py` | Route del modulo | - ---- - -## Modelli - -### ContentSection -```python -class ContentSection(models.Model): - id = AutoField(primary_key=True) - title = CharField(max_length=200, choices=CATEGORY_CHOICES, unique=True) - # Only two valid categories: 'LINK_UTILI' and 'WIKI_TUTORIAL' - order = IntegerField(default=0) - is_active = BooleanField(default=True) # Filtra le sezioni visibili - created_at = DateTimeField(auto_now_add=True) - updated_at = DateTimeField(auto_now=True) - created_by = ForeignKey(User, on_delete=SET_NULL, null=True) - - # Note: title is unique and limited to 2 choices only -``` - -### ContentLink -```python -class ContentLink(models.Model): - id = AutoField(primary_key=True) - section = ForeignKey(ContentSection, on_delete=CASCADE, related_name='links') - name = CharField(max_length=200) # Not 'title' - this is the link name - url = URLField() - color = CharField(max_length=20, default="#1976d2") # Default blue color - order = IntegerField(default=0) - created_at = DateTimeField(auto_now_add=True) - updated_at = DateTimeField(auto_now=True) - created_by = ForeignKey(User, on_delete=SET_NULL, null=True) -``` - ---- - -## Endpoints - -### 1. GET `/backend/content/sections/` -**Descrizione**: Lista sezioni contenuto -**Autenticazione**: No (pubblico) - -#### Scenari di Test - -| ID | Scenario | Expected | Status | -|----|----------|----------|--------| -| C-SL-001 | Lista sezioni | Solo enabled=True | 200 | -| C-SL-002 | Ordine corretto | Ordinate per campo order | 200 | -| C-SL-003 | Include links | Links nested nella risposta | 200 | - -```python -class ContentSectionListTestCase(TestCase): - - def test_list_returns_only_enabled_sections(self): - """C-SL-001: Lista ritorna solo sezioni enabled""" - from content.models import ContentSection - - ContentSection.objects.create(title='Active', enabled=True, order=1) - ContentSection.objects.create(title='Disabled', enabled=False, order=2) - - response = self.client.get('/backend/content/sections/') - - self.assertEqual(response.status_code, 200) - titles = [s['title'] for s in response.data] - self.assertIn('Active', titles) - self.assertNotIn('Disabled', titles) - - def test_list_returns_ordered_by_order_field(self): - """C-SL-002: Lista ordinata per campo order""" - from content.models import ContentSection - - ContentSection.objects.create(title='Third', order=3) - ContentSection.objects.create(title='First', order=1) - ContentSection.objects.create(title='Second', order=2) - - response = self.client.get('/backend/content/sections/') - - self.assertEqual(response.status_code, 200) - titles = [s['title'] for s in response.data] - self.assertEqual(titles, ['First', 'Second', 'Third']) - - def test_list_includes_nested_links(self): - """C-SL-003: Lista include links nested""" - from content.models import ContentSection, ContentLink - - section = ContentSection.objects.create(title='Links Section', order=1) - ContentLink.objects.create( - section=section, title='Link 1', url='https://example.com/1' - ) - ContentLink.objects.create( - section=section, title='Link 2', url='https://example.com/2' - ) - - response = self.client.get('/backend/content/sections/') - - self.assertEqual(response.status_code, 200) - section_data = response.data[0] - self.assertIn('links', section_data) - self.assertEqual(len(section_data['links']), 2) -``` - ---- - -### 2. POST `/backend/content/sections/` -**Descrizione**: Crea nuova sezione -**Autenticazione**: Sì -**Permessi**: `content.add_contentsection` - -#### Scenari di Test - -| ID | Scenario | User | Input | Expected | Status | -|----|----------|------|-------|----------|--------| -| C-SC-001 | Crea sezione | Board | title, description, order | Sezione creata | 201 | -| C-SC-002 | Crea senza titolo | Board | manca title | Errore validazione | 400 | -| C-SC-003 | Crea senza permesso | Aspiranti | - | Forbidden | 403 | -| C-SC-004 | Crea con links | Board | + links array | Sezione + links | 201 | - -```python -class ContentSectionCreationTestCase(BaseTestCase): - - def test_create_section_with_valid_data(self): - """C-SC-001: Crea sezione con dati validi""" - board = self.create_board_user() - self.authenticate_user(board) - - response = self.client.post('/backend/content/sections/', { - 'title': 'New Section', - 'description': 'Description here', - 'order': 5 - }) - - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data['title'], 'New Section') - - def test_create_section_without_title_returns_400(self): - """C-SC-002: Crea sezione senza titolo ritorna 400""" - board = self.create_board_user() - self.authenticate_user(board) - - response = self.client.post('/backend/content/sections/', { - 'description': 'No title', - 'order': 1 - }) - - self.assertEqual(response.status_code, 400) - self.assertIn('title', response.data) - - def test_aspirante_cannot_create_section(self): - """C-SC-003: Aspirante non può creare sezioni""" - aspirante = self.create_base_user() - self.authenticate_user(aspirante) - - response = self.client.post('/backend/content/sections/', { - 'title': 'Test', - 'order': 1 - }) - - self.assertEqual(response.status_code, 403) -``` - ---- - -### 3. GET `/backend/content/sections//` -**Descrizione**: Dettaglio sezione -**Autenticazione**: No (pubblico) - -#### Scenari di Test - -| ID | Scenario | Expected | Status | -|----|----------|----------|--------| -| C-SD-001 | GET sezione esistente | Dettagli + links | 200 | -| C-SD-002 | GET sezione inesistente | Not found | 404 | -| C-SD-003 | GET sezione disabled | Not found (o enabled) | 404 | - -```python -class ContentSectionDetailTestCase(TestCase): - - def test_get_section_returns_details_with_links(self): - """C-SD-001: GET sezione ritorna dettagli con links""" - from content.models import ContentSection, ContentLink - - section = ContentSection.objects.create( - title='Test Section', - description='Test description', - order=1 - ) - ContentLink.objects.create( - section=section, title='Link', url='https://example.com' - ) - - response = self.client.get(f'/backend/content/sections/{section.pk}/') - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['title'], 'Test Section') - self.assertEqual(len(response.data['links']), 1) - - def test_get_nonexistent_section_returns_404(self): - """C-SD-002: GET sezione inesistente ritorna 404""" - response = self.client.get('/backend/content/sections/99999/') - - self.assertEqual(response.status_code, 404) -``` - ---- - -### 4. PATCH `/backend/content/sections//` -**Descrizione**: Modifica sezione -**Autenticazione**: Sì -**Permessi**: `content.change_contentsection` - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| C-SU-001 | Aggiorna titolo | title | Titolo aggiornato | 200 | -| C-SU-002 | Aggiorna order | order | Ordine aggiornato | 200 | -| C-SU-003 | Disabilita sezione | enabled=False | Sezione disabilitata | 200 | -| C-SU-004 | Aggiorna senza permesso | - | Forbidden | 403 | - -```python -class ContentSectionUpdateTestCase(BaseTestCase): - - def test_update_section_title(self): - """C-SU-001: Aggiorna titolo sezione""" - from content.models import ContentSection - - board = self.create_board_user() - self.authenticate_user(board) - - section = ContentSection.objects.create(title='Old Title', order=1) - - response = self.client.patch(f'/backend/content/sections/{section.pk}/', { - 'title': 'New Title' - }) - - self.assertEqual(response.status_code, 200) - - section.refresh_from_db() - self.assertEqual(section.title, 'New Title') - - def test_disable_section(self): - """C-SU-003: Disabilita sezione""" - from content.models import ContentSection - - board = self.create_board_user() - self.authenticate_user(board) - - section = ContentSection.objects.create(title='Test', order=1, enabled=True) - - response = self.client.patch(f'/backend/content/sections/{section.pk}/', { - 'enabled': False - }) - - self.assertEqual(response.status_code, 200) - - section.refresh_from_db() - self.assertFalse(section.enabled) -``` - ---- - -### 5. DELETE `/backend/content/sections//` -**Descrizione**: Elimina sezione -**Autenticazione**: Sì -**Permessi**: `content.delete_contentsection` - -#### Scenari di Test - -| ID | Scenario | Preconditions | Expected | Status | -|----|----------|---------------|----------|--------| -| C-SDE-001 | Elimina sezione | - | Sezione eliminata | 200/204 | -| C-SDE-002 | Elimina cascade links | Sezione con links | Links eliminati | 200/204 | -| C-SDE-003 | Elimina senza permesso | Aspirante | Forbidden | 403 | - -```python -class ContentSectionDeleteTestCase(BaseTestCase): - - def test_delete_section_cascades_links(self): - """C-SDE-002: Eliminare sezione elimina anche links""" - from content.models import ContentSection, ContentLink - - board = self.create_board_user() - self.authenticate_user(board) - - section = ContentSection.objects.create(title='Test', order=1) - link = ContentLink.objects.create( - section=section, title='Link', url='https://example.com' - ) - - response = self.client.delete(f'/backend/content/sections/{section.pk}/') - - self.assertIn(response.status_code, [200, 204]) - self.assertFalse(ContentLink.objects.filter(pk=link.pk).exists()) -``` - ---- - -### 6. GET `/backend/content/links/` -**Descrizione**: Lista links (opzionale, se esposto) -**Autenticazione**: No - -```python -class ContentLinkListTestCase(TestCase): - - def test_list_links_returns_all_enabled(self): - """Test lista links ritorna solo enabled""" - from content.models import ContentSection, ContentLink - - section = ContentSection.objects.create(title='Test', order=1) - ContentLink.objects.create( - section=section, title='Active', url='https://a.com', enabled=True - ) - ContentLink.objects.create( - section=section, title='Disabled', url='https://b.com', enabled=False - ) - - response = self.client.get('/backend/content/links/') - - if response.status_code == 200: - titles = [l['title'] for l in response.data] - self.assertIn('Active', titles) - self.assertNotIn('Disabled', titles) -``` - ---- - -### 7. POST `/backend/content/links/` -**Descrizione**: Crea nuovo link -**Autenticazione**: Sì -**Permessi**: `content.add_contentlink` - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| C-LC-001 | Crea link valido | section, title, url | Link creato | 201 | -| C-LC-002 | Crea con icona | + icon | Icona salvata | 201 | -| C-LC-003 | Crea senza sezione | manca section | Errore | 400 | -| C-LC-004 | Crea con URL invalido | url non valido | Errore | 400 | - -```python -class ContentLinkCreationTestCase(BaseTestCase): - - def test_create_link_with_valid_data(self): - """C-LC-001: Crea link con dati validi""" - from content.models import ContentSection - - board = self.create_board_user() - self.authenticate_user(board) - - section = ContentSection.objects.create(title='Test', order=1) - - response = self.client.post('/backend/content/links/', { - 'section': section.pk, - 'title': 'New Link', - 'url': 'https://example.com', - 'order': 1 - }) - - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data['title'], 'New Link') - - def test_create_link_with_icon(self): - """C-LC-002: Crea link con icona""" - from content.models import ContentSection - - board = self.create_board_user() - self.authenticate_user(board) - - section = ContentSection.objects.create(title='Test', order=1) - - response = self.client.post('/backend/content/links/', { - 'section': section.pk, - 'title': 'Instagram', - 'url': 'https://instagram.com/esn', - 'icon': 'instagram', - 'order': 1 - }) - - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data['icon'], 'instagram') - - def test_create_link_with_invalid_url_returns_400(self): - """C-LC-004: URL invalido ritorna 400""" - from content.models import ContentSection - - board = self.create_board_user() - self.authenticate_user(board) - - section = ContentSection.objects.create(title='Test', order=1) - - response = self.client.post('/backend/content/links/', { - 'section': section.pk, - 'title': 'Bad Link', - 'url': 'not-a-url', - 'order': 1 - }) - - self.assertEqual(response.status_code, 400) - self.assertIn('url', response.data) -``` - ---- - -### 8. PATCH `/backend/content/links//` -**Descrizione**: Modifica link -**Autenticazione**: Sì -**Permessi**: `content.change_contentlink` - -#### Scenari di Test - -| ID | Scenario | Input | Expected | Status | -|----|----------|-------|----------|--------| -| C-LU-001 | Aggiorna titolo | title | Titolo aggiornato | 200 | -| C-LU-002 | Aggiorna URL | url | URL aggiornato | 200 | -| C-LU-003 | Cambia sezione | section | Sezione cambiata | 200 | -| C-LU-004 | Disabilita link | enabled=False | Link disabilitato | 200 | - -```python -class ContentLinkUpdateTestCase(BaseTestCase): - - def test_update_link_url(self): - """C-LU-002: Aggiorna URL link""" - from content.models import ContentSection, ContentLink - - board = self.create_board_user() - self.authenticate_user(board) - - section = ContentSection.objects.create(title='Test', order=1) - link = ContentLink.objects.create( - section=section, title='Link', url='https://old.com' - ) - - response = self.client.patch(f'/backend/content/links/{link.pk}/', { - 'url': 'https://new.com' - }) - - self.assertEqual(response.status_code, 200) - - link.refresh_from_db() - self.assertEqual(link.url, 'https://new.com') - - def test_move_link_to_different_section(self): - """C-LU-003: Sposta link in altra sezione""" - from content.models import ContentSection, ContentLink - - board = self.create_board_user() - self.authenticate_user(board) - - section1 = ContentSection.objects.create(title='Section 1', order=1) - section2 = ContentSection.objects.create(title='Section 2', order=2) - link = ContentLink.objects.create( - section=section1, title='Link', url='https://example.com' - ) - - response = self.client.patch(f'/backend/content/links/{link.pk}/', { - 'section': section2.pk - }) - - self.assertEqual(response.status_code, 200) - - link.refresh_from_db() - self.assertEqual(link.section, section2) -``` - ---- - -### 9. DELETE `/backend/content/links//` -**Descrizione**: Elimina link -**Autenticazione**: Sì -**Permessi**: `content.delete_contentlink` - -#### Scenari di Test - -| ID | Scenario | Expected | Status | -|----|----------|----------|--------| -| C-LDE-001 | Elimina link | Link eliminato | 200/204 | -| C-LDE-002 | Elimina inesistente | Not found | 404 | -| C-LDE-003 | Elimina senza permesso | Forbidden | 403 | - -```python -class ContentLinkDeleteTestCase(BaseTestCase): - - def test_delete_link(self): - """C-LDE-001: Elimina link""" - from content.models import ContentSection, ContentLink - - board = self.create_board_user() - self.authenticate_user(board) - - section = ContentSection.objects.create(title='Test', order=1) - link = ContentLink.objects.create( - section=section, title='Link', url='https://example.com' - ) - - response = self.client.delete(f'/backend/content/links/{link.pk}/') - - self.assertIn(response.status_code, [200, 204]) - self.assertFalse(ContentLink.objects.filter(pk=link.pk).exists()) -``` - ---- - -## Integration Tests - -### Test Gestione Contenuti Homepage -```python -class HomepageContentTestCase(BaseTestCase): - """Test gestione completa contenuti homepage""" - - def test_create_complete_homepage_structure(self): - """Test creazione struttura completa homepage""" - from content.models import ContentSection, ContentLink - - board = self.create_board_user() - self.authenticate_user(board) - - # 1. Crea sezioni - response = self.client.post('/backend/content/sections/', { - 'title': 'Social Media', - 'description': 'Follow us!', - 'order': 1 - }) - section1_id = response.data['id'] - - response = self.client.post('/backend/content/sections/', { - 'title': 'Useful Links', - 'description': 'Important resources', - 'order': 2 - }) - section2_id = response.data['id'] - - # 2. Aggiungi links - self.client.post('/backend/content/links/', { - 'section': section1_id, - 'title': 'Instagram', - 'url': 'https://instagram.com/esn', - 'icon': 'instagram', - 'order': 1 - }) - self.client.post('/backend/content/links/', { - 'section': section1_id, - 'title': 'Facebook', - 'url': 'https://facebook.com/esn', - 'icon': 'facebook', - 'order': 2 - }) - self.client.post('/backend/content/links/', { - 'section': section2_id, - 'title': 'ESN International', - 'url': 'https://esn.org', - 'order': 1 - }) - - # 3. Verifica struttura - response = self.client.get('/backend/content/sections/') - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 2) - - # Prima sezione (order=1) dovrebbe essere Social Media - self.assertEqual(response.data[0]['title'], 'Social Media') - self.assertEqual(len(response.data[0]['links']), 2) -``` - -### Test Riordino Sezioni -```python -class ReorderSectionsTestCase(BaseTestCase): - """Test riordino sezioni""" - - def test_reorder_sections_by_updating_order(self): - """Test riordino sezioni aggiornando campo order""" - from content.models import ContentSection - - board = self.create_board_user() - self.authenticate_user(board) - - section1 = ContentSection.objects.create(title='First', order=1) - section2 = ContentSection.objects.create(title='Second', order=2) - section3 = ContentSection.objects.create(title='Third', order=3) - - # Sposta Third in prima posizione - self.client.patch(f'/backend/content/sections/{section3.pk}/', {'order': 0}) - - # Verifica nuovo ordine - response = self.client.get('/backend/content/sections/') - - titles = [s['title'] for s in response.data] - self.assertEqual(titles[0], 'Third') -``` - ---- - -## Model Tests - -```python -class ContentSectionModelTestCase(TestCase): - - def test_section_default_values(self): - """Test valori default sezione""" - from content.models import ContentSection - - section = ContentSection.objects.create(title='Test') - - self.assertEqual(section.order, 0) - self.assertTrue(section.enabled) - self.assertIsNone(section.description) - - def test_section_str_representation(self): - """Test __str__ sezione""" - from content.models import ContentSection - - section = ContentSection.objects.create(title='My Section') - - self.assertEqual(str(section), 'My Section') - - -class ContentLinkModelTestCase(TestCase): - - def test_link_default_values(self): - """Test valori default link""" - from content.models import ContentSection, ContentLink - - section = ContentSection.objects.create(title='Test') - link = ContentLink.objects.create( - section=section, title='Link', url='https://example.com' - ) - - self.assertEqual(link.order, 0) - self.assertTrue(link.enabled) - self.assertIsNone(link.icon) - - def test_link_cascade_on_section_delete(self): - """Test cascade delete link quando elimini sezione""" - from content.models import ContentSection, ContentLink - - section = ContentSection.objects.create(title='Test') - link = ContentLink.objects.create( - section=section, title='Link', url='https://example.com' - ) - link_pk = link.pk - - section.delete() - - self.assertFalse(ContentLink.objects.filter(pk=link_pk).exists()) -``` - ---- - -## Checklist Test Coverage - -### Sezioni -- [ ] Lista sezioni pubbliche -- [ ] Filtro enabled -- [ ] Ordinamento per order -- [ ] Include links nested -- [ ] CRUD con permessi -- [ ] Cascade delete links - -### Links -- [ ] Lista links -- [ ] CRUD con permessi -- [ ] Validazione URL -- [ ] Gestione icone -- [ ] Spostamento tra sezioni - -### Integrazioni -- [ ] Struttura homepage completa -- [ ] Riordino sezioni -- [ ] Riordino links - -### Model Properties -- [ ] Valori default -- [ ] String representation -- [ ] Cascade relationships diff --git a/backend/docs/test_specifications/06_INTEGRATION_E2E.md b/backend/docs/test_specifications/06_INTEGRATION_E2E.md deleted file mode 100644 index acf68b735..000000000 --- a/backend/docs/test_specifications/06_INTEGRATION_E2E.md +++ /dev/null @@ -1,636 +0,0 @@ -# 06 - Integration & E2E Test Specifications - -## Panoramica - -Questo documento descrive i test di integrazione e End-to-End che coprono flussi completi attraverso più moduli. - ---- - -## 🔄 Flussi Cross-Module - -### 1. Flusso Completo Registrazione ESNer - -**Moduli coinvolti**: profiles, users - -```python -class ESNerRegistrationFlowTestCase(TestCase): - """ - Flusso: - 1. Registra profilo ESNer con email @esnpolimi.it - 2. Verifica email - 3. Login - 4. Accesso risorse autenticate - """ - - def test_complete_esner_registration_to_login(self): - from django.core import mail - from profiles.models import Profile - from users.models import User - from profiles.tokens import email_verification_token - from django.utils.encoding import force_bytes - from django.utils.http import urlsafe_base64_encode - - # 1. Registrazione - response = self.client.post('/backend/profile/initiate-creation/', { - 'email': 'new.esner@esnpolimi.it', - 'name': 'Mario', - 'surname': 'Rossi', - 'birthdate': '1995-01-15', - 'country': 'IT', - 'is_esner': True, - 'password': 'SecurePass123!', - 'document_type': 'ID Card', - 'document_number': 'AB123456', - 'document_expiration': '2030-01-01' - }) - - self.assertEqual(response.status_code, 201) - self.assertEqual(len(mail.outbox), 1) - - # Verifica profilo e user creati ma non attivi - profile = Profile.objects.get(email='new.esner@esnpolimi.it') - self.assertFalse(profile.email_is_verified) - self.assertFalse(profile.enabled) - - user = User.objects.get(profile=profile) - self.assertFalse(user.is_active) - - # 2. Verifica email - uid = urlsafe_base64_encode(force_bytes(profile.pk)) - token = email_verification_token.make_token(profile) - - response = self.client.get(f'/backend/api/profile/verify-email/{uid}/{token}/') - - self.assertEqual(response.status_code, 200) - - profile.refresh_from_db() - user.refresh_from_db() - - self.assertTrue(profile.email_is_verified) - self.assertTrue(profile.enabled) - self.assertTrue(user.is_active) - - # 3. Login - response = self.client.post('/backend/api/login/', { - 'email': 'new.esner@esnpolimi.it', - 'password': 'SecurePass123!' - }) - - self.assertEqual(response.status_code, 200) - self.assertIn('access', response.data) - - # 4. Accesso risorsa autenticata - access_token = response.data['access'] - self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}') - - response = self.client.get('/backend/erasmus_profiles/') - - self.assertEqual(response.status_code, 200) -``` - ---- - -### 2. Flusso Completo Registrazione Erasmus + Iscrizione Evento - -**Moduli coinvolti**: profiles, events, treasury (opzionale per pagamento) - -```python -class ErasmusEventSubscriptionFlowTestCase(TestCase): - """ - Flusso: - 1. Registra profilo Erasmus - 2. Verifica email - 3. Iscrivi a evento tramite form pubblico - 4. Paga tramite SumUp (mock) - """ - - @patch('events.views.requests.post') - @patch('events.views.requests.get') - def test_erasmus_from_registration_to_paid_subscription(self, mock_get, mock_post): - from django.core import mail - from profiles.models import Profile - from profiles.tokens import email_verification_token - from django.utils.encoding import force_bytes - from django.utils.http import urlsafe_base64_encode - from events.models import Event, EventList, Subscription - - # Setup SumUp mocks - mock_post.return_value.status_code = 200 - mock_post.return_value.json.return_value = { - 'id': 'chk_123', 'checkout_reference': 'ref_123' - } - mock_get.return_value.status_code = 200 - mock_get.return_value.json.return_value = { - 'status': 'PAID', 'amount': 15.00, - 'transactions': [{'payment_type': 'CARD'}] - } - - # 1. Crea evento con lista - event = Event.objects.create( - name='Welcome Party', - date='2025-06-15', - form={'fields': [{'name': 'diet', 'type': 'text'}]} - ) - event_list = EventList.objects.create( - name='Standard', capacity=100, is_main_list=True, - price=15, is_open=True - ) - event_list.events.add(event) - - # 2. Registra Erasmus - response = self.client.post('/backend/profile/initiate-creation/', { - 'email': 'erasmus.student@university.edu', - 'name': 'John', - 'surname': 'Doe', - 'birthdate': '1998-05-20', - 'country': 'DE', - 'is_esner': False, - 'document_type': 'Passport', - 'document_number': 'DE12345678', - 'document_expiration': '2030-01-01' - }) - - self.assertEqual(response.status_code, 201) - - # 3. Verifica email - profile = Profile.objects.get(email='erasmus.student@university.edu') - uid = urlsafe_base64_encode(force_bytes(profile.pk)) - token = email_verification_token.make_token(profile) - - self.client.get(f'/backend/api/profile/verify-email/{uid}/{token}/') - - profile.refresh_from_db() - self.assertTrue(profile.email_is_verified) - - # 4. Submit form evento - mail.outbox.clear() - - response = self.client.post(f'/backend/submit_form/{event.pk}/', { - 'email': 'erasmus.student@university.edu', - 'list_id': event_list.pk, - 'form_data': {'diet': 'Vegetarian'} - }, format='json') - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(mail.outbox), 1) # Confirmation email - - subscription = Subscription.objects.get(profile=profile, event=event) - self.assertFalse(subscription.payed) - - # 5. Inizia pagamento - response = self.client.post(f'/backend/sumup/checkout/{subscription.pk}/') - - self.assertEqual(response.status_code, 200) - - subscription.refresh_from_db() - self.assertTrue(subscription.pending_payment) - - # 6. Completa pagamento - response = self.client.post(f'/backend/sumup/complete/{subscription.pk}/') - - self.assertEqual(response.status_code, 200) - - subscription.refresh_from_db() - self.assertTrue(subscription.payed) - self.assertTrue(subscription.payment_confirmed) -``` - ---- - -### 3. Flusso ESNcard con Quota Associativa - -**Moduli coinvolti**: profiles, treasury - -```python -class ESNcardMembershipFlowTestCase(BaseTestCase): - """ - Flusso: - 1. Crea profilo Erasmus verificato - 2. Emetti ESNcard (include quota associativa automatica) - 3. Verifica transazioni create - """ - - def test_esncard_emission_with_membership_fee(self): - from treasury.models import Account, ESNcard, Transaction, Settings - from profiles.models import Profile - - # Setup - Settings.objects.create(membership_fee=5, esncard_price=10) - account = Account.objects.create(name='Cassa', type='cash', balance=0) - - profile = Profile.objects.create( - email='erasmus@test.com', - name='Test', - surname='User', - email_is_verified=True, - enabled=True, - is_esner=False - ) - - board = self.create_board_user() - self.authenticate_user(board) - - # Emetti ESNcard - response = self.client.post('/backend/esncard/emit/', { - 'profile_id': profile.pk, - 'card_number': 'IT-POL-0001234', - 'account_id': account.pk - }) - - self.assertEqual(response.status_code, 201) - - # Verifica - card = ESNcard.objects.get(profile=profile) - transactions = Transaction.objects.filter(profile=profile) - - # Dovrebbero esserci 2 transazioni - self.assertEqual(transactions.count(), 2) - - # Una per ESNcard, una per quota - types = [t.type for t in transactions] - self.assertIn('esncard_emission', types) - self.assertIn('membership_fee', types) - - # Balance = 10 (card) + 5 (quota) = 15 - account.refresh_from_db() - self.assertEqual(account.balance, Decimal('15.00')) -``` - ---- - -### 4. Flusso Rimborso Spese Completo - -**Moduli coinvolti**: users, treasury - -```python -class ReimbursementFlowTestCase(BaseTestCase): - """ - Flusso: - 1. ESNer crea richiesta rimborso - 2. Board approva - 3. Board effettua rimborso - 4. Verifica transazione e balance - """ - - def test_complete_reimbursement_workflow(self): - from treasury.models import Account, ReimbursementRequest, Transaction - - # Setup - account = Account.objects.create(name='Cassa', type='cash', balance=1000) - - # 1. ESNer crea richiesta - esner = self.create_base_user() - self.authenticate_user(esner) - - response = self.client.post('/backend/reimbursement_requests/', { - 'description': 'Acquisto materiale per evento Welcome', - 'amount': '125.50', - 'date': '2025-01-20', - 'receipt_url': 'https://drive.google.com/file/abc123' - }) - - self.assertEqual(response.status_code, 201) - req_id = response.data['id'] - - req = ReimbursementRequest.objects.get(pk=req_id) - self.assertEqual(req.status, 'pending') - - # 2. Board approva - board = self.create_board_user() - self.authenticate_user(board) - - response = self.client.patch(f'/backend/reimbursement_requests/{req_id}/', { - 'status': 'approved', - 'notes': 'Approvato - materiale evento' - }) - - self.assertEqual(response.status_code, 200) - - req.refresh_from_db() - self.assertEqual(req.status, 'approved') - - # 3. Board rimborsa - response = self.client.post(f'/backend/reimbursement_requests/{req_id}/reimburse/', { - 'account_id': account.pk - }) - - self.assertEqual(response.status_code, 200) - - # 4. Verifica - req.refresh_from_db() - self.assertEqual(req.status, 'reimbursed') - - transaction = Transaction.objects.get(reimbursement_request=req) - self.assertEqual(transaction.type, 'reimbursement') - self.assertEqual(transaction.amount, Decimal('-125.50')) - - account.refresh_from_db() - self.assertEqual(account.balance, Decimal('874.50')) -``` - ---- - -### 5. Flusso Viaggio con Deposito e Rimborso - -**Moduli coinvolti**: events, treasury - -```python -class TripDepositFlowTestCase(BaseTestCase): - """ - Flusso viaggio: - 1. Crea iscrizione a viaggio con deposito - 2. Segna deposito pagato - 3. Dopo evento, rimborsa deposito - """ - - def test_trip_deposit_and_reimbursement(self): - from events.models import Event, EventList, Subscription - from treasury.models import Account, Transaction - - board = self.create_board_user() - self.authenticate_user(board) - - # Setup - account = Account.objects.create(name='Cassa', type='cash', balance=500) - - event = Event.objects.create( - name='Trip to Barcelona', - date='2025-07-15', - event_type='trip' - ) - event_list = EventList.objects.create( - name='Main List', - capacity=40, - is_main_list=True, - price=200, - deposit=50 - ) - event_list.events.add(event) - - profile = self.create_profile('erasmus@test.com', is_esner=False) - - # 1. Crea iscrizione - response = self.client.post('/backend/subscriptions/', { - 'profile': profile.pk, - 'event': event.pk, - 'list': event_list.pk - }) - - self.assertEqual(response.status_code, 201) - subscription = Subscription.objects.get(profile=profile, event=event) - - # 2. Segna deposito pagato (in persona) - response = self.client.patch(f'/backend/subscriptions/{subscription.pk}/', { - 'deposit_payed': True - }) - - self.assertEqual(response.status_code, 200) - - subscription.refresh_from_db() - self.assertTrue(subscription.deposit_payed) - - # 3. Dopo evento - Rimborsa deposito - response = self.client.post(f'/backend/reimburse_deposit/{subscription.pk}/', { - 'account_id': account.pk - }) - - self.assertEqual(response.status_code, 200) - - # Verifica - subscription.refresh_from_db() - self.assertTrue(subscription.refunded) - - transaction = Transaction.objects.filter( - subscription=subscription, - type='deposit_reimbursement' - ).first() - self.assertIsNotNone(transaction) - self.assertEqual(transaction.amount, Decimal('-50.00')) - - account.refresh_from_db() - self.assertEqual(account.balance, Decimal('450.00')) -``` - ---- - -### 6. Flusso Cambio Gruppo ESNer - -**Moduli coinvolti**: profiles, users - -```python -class ESNerGroupPromotionFlowTestCase(BaseTestCase): - """ - Flusso: - 1. ESNer inizia come Aspirante - 2. Board lo promuove ad Attivi - 3. Verifica nuovi permessi - """ - - def test_promote_aspirante_to_attivi(self): - from django.contrib.auth.models import Group - - # 1. Crea ESNer Aspirante - aspirante_profile = self.create_profile('aspirante@esnpolimi.it', is_esner=True) - aspirante = self.create_user(aspirante_profile, self.aspiranti_group) - - # Verifica permessi iniziali (limitati) - self.authenticate_user(aspirante) - - # Aspirante non può creare eventi - response = self.client.post('/backend/events/', { - 'name': 'Test', 'date': '2025-06-01' - }) - self.assertEqual(response.status_code, 403) - - # 2. Board promuove ad Attivi - board = self.create_board_user() - self.authenticate_user(board) - - response = self.client.patch(f'/backend/profile/{aspirante_profile.pk}/', { - 'group': 'Attivi' - }) - - self.assertEqual(response.status_code, 200) - - # 3. Verifica nuovo gruppo - aspirante.refresh_from_db() - self.assertTrue(aspirante.groups.filter(name='Attivi').exists()) - self.assertFalse(aspirante.groups.filter(name='Aspiranti').exists()) - - # Verifica nuovi permessi (Attivi può creare eventi) - self.authenticate_user(aspirante) - - response = self.client.post('/backend/events/', { - 'name': 'Test Event', - 'date': '2025-06-01' - }) - - # Potrebbe essere 201 se Attivi ha permission, o 403 se solo Board - # Dipende dalla configurazione permessi -``` - ---- - -### 7. Flusso Permessi Finanziari Custom - -**Moduli coinvolti**: users, treasury - -```python -class FinancePermissionsFlowTestCase(BaseTestCase): - """ - Flusso: - 1. Aspirante senza permessi finanziari - 2. Board assegna can_manage_casse - 3. Aspirante può ora gestire transazioni - """ - - def test_assign_finance_permissions(self): - from treasury.models import Account, Transaction - - # 1. Crea Aspirante - aspirante_profile = self.create_profile('aspirante@esnpolimi.it', is_esner=True) - aspirante = self.create_user(aspirante_profile, self.aspiranti_group) - - account = Account.objects.create(name='Cassa', type='cash', balance=100) - - # Senza permessi - non può creare transazioni - self.authenticate_user(aspirante) - - response = self.client.post('/backend/transactions/', { - 'account': account.pk, - 'type': 'other', - 'amount': '10.00' - }) - - self.assertEqual(response.status_code, 403) - - # 2. Board assegna permesso - board = self.create_board_user() - self.authenticate_user(board) - - response = self.client.patch( - f'/backend/users/{aspirante.pk}/finance_permissions/', - {'can_manage_casse': True} - ) - - self.assertEqual(response.status_code, 200) - - # 3. Ora può creare transazioni - aspirante.refresh_from_db() - self.assertTrue(aspirante.can_manage_casse) - - self.authenticate_user(aspirante) - - response = self.client.post('/backend/transactions/', { - 'account': account.pk, - 'type': 'other', - 'amount': '10.00', - 'description': 'Test' - }) - - self.assertEqual(response.status_code, 201) -``` - ---- - -## 🔐 Security Tests - -### Test Rate Limiting Login -```python -class LoginRateLimitTestCase(TestCase): - """Test rate limiting su login falliti""" - - def test_multiple_failed_logins_get_rate_limited(self): - # Effettua molti login falliti - for i in range(10): - self.client.post('/backend/api/login/', { - 'email': 'test@test.com', - 'password': 'wrong' - }) - - # L'11esimo dovrebbe essere rate limited - response = self.client.post('/backend/api/login/', { - 'email': 'test@test.com', - 'password': 'wrong' - }) - - # Verifica rate limiting (429 o messaggio specifico) - # Dipende dalla configurazione -``` - -### Test Token Expiration -```python -class TokenExpirationTestCase(BaseTestCase): - """Test scadenza token""" - - def test_expired_access_token_returns_401(self): - from rest_framework_simplejwt.tokens import AccessToken - from datetime import timedelta - - user = self.create_base_user() - - # Crea token già scaduto - token = AccessToken.for_user(user) - token.set_exp(lifetime=-timedelta(hours=1)) - - self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {str(token)}') - - response = self.client.get('/backend/erasmus_profiles/') - - self.assertEqual(response.status_code, 401) -``` - -### Test Permission Escalation Prevention -```python -class PermissionEscalationTestCase(BaseTestCase): - """Test prevenzione escalation permessi""" - - def test_attivi_cannot_promote_to_board(self): - """Attivi non può promuovere a Board""" - attivi_profile = self.create_profile('attivi@esnpolimi.it', is_esner=True) - attivi = self.create_user(attivi_profile, self.attivi_group) - - target_profile = self.create_profile('target@esnpolimi.it', is_esner=True) - self.create_user(target_profile, self.aspiranti_group) - - self.authenticate_user(attivi) - - response = self.client.patch(f'/backend/profile/{target_profile.pk}/', { - 'group': 'Board' - }) - - # Dovrebbe fallire - solo Board può promuovere a Board - self.assertIn(response.status_code, [400, 403]) -``` - ---- - -## Checklist Test Coverage - -### Flussi Registrazione -- [ ] ESNer registration -> verification -> login -- [ ] Erasmus registration -> verification -- [ ] Verifica email con token scaduto/invalido - -### Flussi Eventi -- [ ] Erasmus subscription via form -- [ ] Pagamento SumUp completo -- [ ] Move subscriptions tra liste -- [ ] Liberatorie PDF - -### Flussi Tesoreria -- [ ] ESNcard emission con quota -- [ ] Richiesta rimborso completa -- [ ] Rimborso deposito viaggio -- [ ] Export transazioni - -### Flussi Permessi -- [ ] Promozione gruppi -- [ ] Permessi finanziari custom -- [ ] Prevention escalation - -### Security -- [ ] Rate limiting -- [ ] Token expiration -- [ ] CORS -- [ ] CSRF (se applicabile) diff --git a/backend/events.md b/backend/events.md deleted file mode 100644 index 658c02d7e..000000000 --- a/backend/events.md +++ /dev/null @@ -1,39 +0,0 @@ - - -- **Event** - - Name - - tables - - Profile data - - With form - - Form open - - Form data - - Additional data - - RE - - RS - -Profile data is not directly editable (?) -Additional / form data must be in the following format: - -``` -{ - "name":"field_name", - "type":"text, number, choice, checkbox" - "choices":[{"name":"A","color":""}, ... ] -} -``` - -- **Table**. A table contains subscription objects. It also has the following properties - - Id - - Name - - Max entries - - Visible by office - - Editable by office - -- **Subscription**. Corresponds to a row in the tables - - Profile (FK) - - Event (FK) - - Color - - Type (form or office) - - Table - - Additional data - - Form data diff --git a/backend/events/models.py b/backend/events/models.py index d9f0d9922..57010cf43 100644 --- a/backend/events/models.py +++ b/backend/events/models.py @@ -149,7 +149,6 @@ def validate_selected_services(event_services, selected_services): svc = by_name.get(name) if not svc: errors.append(f"Unknown service for selection at index {idx}") - continue return errors diff --git a/backend/events/tests.py b/backend/events/tests.py index 4291c3859..98043e3ab 100644 --- a/backend/events/tests.py +++ b/backend/events/tests.py @@ -796,7 +796,7 @@ def test_event_form_submit_success(self): {"name": "tshirt", "type": "s", "field_type": "form", "choices": ["S", "M"], "required": True} ] ) - form_list = _create_event_list(event, name="Form List", is_main_list=False) + _create_event_list(event, name="Form List", is_main_list=False) profile = _create_profile("student@uni.it", is_esner=False) @@ -809,6 +809,36 @@ def test_event_form_submit_success(self): self.assertTrue(response.data["success"]) self.assertTrue(Subscription.objects.filter(profile=profile, event=event).exists()) + @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") + @patch("events.views.create_sumup_checkout", return_value=("chk_form_full_lists", {})) + def test_event_form_submit_online_payment_allows_form_list_when_main_waiting_full(self, _): + """Form submission must stay allowed even when Main/Waiting are full; payment remains a separate step.""" + event = _create_event(enable_form=True, allow_online_payment=True, cost=10) + form_list = _create_event_list(event, name="Form List", is_main_list=False, is_waiting_list=False) + main_list = _create_event_list(event, name="Main List", capacity=1, is_main_list=True, is_waiting_list=False) + waiting_list = _create_event_list(event, name="Waiting List", capacity=1, is_main_list=False, is_waiting_list=True) + + Subscription.objects.create(profile=_create_profile("main_full_form_submit@esnpolimi.it"), event=event, list=main_list) + Subscription.objects.create(profile=_create_profile("wait_full_form_submit@esnpolimi.it"), event=event, list=waiting_list) + + profile = _create_profile("form_only_submitter@esnpolimi.it") + + response = self.client.post(f"/backend/event/{event.pk}/formsubmit/", { + "email": profile.email, + "form_data": {}, + }, format="json") + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["success"]) + self.assertEqual(response.data["assigned_list"], "Form List") + self.assertTrue(response.data["payment_required"]) + self.assertTrue(response.data["capacity_blocked"]) + + sub = Subscription.objects.get(profile=profile, event=event) + self.assertEqual(sub.list, form_list) + # No checkout should be created when capacity is blocked (no live checkout exposed for sold-out) + self.assertIsNone(sub.sumup_checkout_id) + @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") def test_event_form_submit_invalid_email(self): """Invalid email should return 400.""" @@ -1021,7 +1051,7 @@ def test_sumup_webhook_marks_paid(self, mock_token, mock_get): event = _create_event(cost=10) list_main = _create_event_list(event) - sub = Subscription.objects.create(profile=profile, event=event, list=list_main, sumup_checkout_id="chk_1") + Subscription.objects.create(profile=profile, event=event, list=list_main, sumup_checkout_id="chk_1") response = self.client.post("/backend/sumup/webhook/", {"checkout_id": "chk_1"}, format="json") @@ -1518,6 +1548,33 @@ def test_payment_status_failed_flag(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["overall_status"], "failed") + def test_payment_status_reports_blocked_when_lists_full(self): + """Status should report payment as blocked when both Main and Waiting lists are full.""" + profile = _create_profile("blockedpayer@esnpolimi.it") + _create_user(profile) + + event = _create_event(cost=10, allow_online_payment=True) + form_list = _create_event_list(event, name="Form List", is_main_list=False, is_waiting_list=False) + main_list = _create_event_list(event, name="Main List", capacity=1, is_main_list=True, is_waiting_list=False) + waiting_list = _create_event_list(event, name="Waiting List", capacity=1, is_main_list=False, is_waiting_list=True) + + Subscription.objects.create(profile=_create_profile("ml-status@esnpolimi.it"), event=event, list=main_list) + Subscription.objects.create(profile=_create_profile("wl-status@esnpolimi.it"), event=event, list=waiting_list) + + sub = Subscription.objects.create( + profile=profile, + event=event, + list=form_list, + sumup_checkout_id="chk_blocked_status", + ) + + response = self.client.get(f"/backend/subscription/{sub.pk}/status/") + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["payment_blocked"]) + self.assertEqual(response.data["payment_blocked_reason"], "sold_out") + self.assertIn("sold out", response.data["payment_blocked_message"].lower()) + class SumUpWebhookEdgeCaseTests(EventsBaseTestCase): """Additional SumUp webhook edge cases.""" @@ -1585,6 +1642,38 @@ def test_subscription_process_payment_success(self, mock_ensure, mock_process): self.assertEqual(response.data["status"], "PAID") mock_ensure.assert_called_once() + @patch("events.views._process_sumup_checkout", return_value=("PENDING", {"status": "PENDING"})) + @patch("events.views._ensure_sumup_transactions") + def test_subscription_process_payment_blocked_when_lists_full(self, mock_ensure, mock_process): + """Process payment should be blocked when both Main and Waiting lists are full.""" + profile = _create_profile("formpayer@esnpolimi.it") + _create_user(profile) + + event = _create_event(cost=10, allow_online_payment=True) + form_list = _create_event_list(event, name="Form List", is_main_list=False, is_waiting_list=False) + main_list = _create_event_list(event, name="Main List", capacity=1, is_main_list=True, is_waiting_list=False) + waiting_list = _create_event_list(event, name="Waiting List", capacity=1, is_main_list=False, is_waiting_list=True) + + Subscription.objects.create(profile=_create_profile("ml-full@esnpolimi.it"), event=event, list=main_list) + Subscription.objects.create(profile=_create_profile("wl-full@esnpolimi.it"), event=event, list=waiting_list) + + sub = Subscription.objects.create( + profile=profile, + event=event, + list=form_list, + sumup_checkout_id="chk_blocked", + ) + + response = self.client.post(f"/backend/subscription/{sub.pk}/process_payment/", {}, format="json") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["status"], "BLOCKED") + self.assertEqual(response.data["error"], "sold_out") + self.assertIn("sold out", response.data["message"].lower()) + mock_ensure.assert_not_called() + # Reconciliation probe must still run to catch already-paid-remotely edge case + mock_process.assert_called_once() + class EventModelTests(EventsBaseTestCase): """Tests for Event model properties and methods.""" @@ -1650,7 +1739,7 @@ def test_subscription_to_closed_event_returns_400(self): def test_subscription_beyond_max_returns_400(self): """Subscription beyond max_subscriptions should fail.""" profile1 = _create_profile("first@esnpolimi.it") - user1 = _create_user(profile1) + _create_user(profile1) profile2 = _create_profile("second@esnpolimi.it") user2 = _create_user(profile2) diff --git a/backend/events/views.py b/backend/events/views.py index c7a98ebf6..8854fcf24 100644 --- a/backend/events/views.py +++ b/backend/events/views.py @@ -23,6 +23,8 @@ from reportlab.lib.units import cm from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, PageBreak from rest_framework.decorators import api_view, permission_classes + +PERM_VIEW_EVENT = 'events.view_event' from rest_framework.exceptions import ValidationError as DRFValidationError from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import IsAuthenticated @@ -46,6 +48,11 @@ logger = logging.getLogger(__name__) +PAYMENT_SOLD_OUT_MESSAGE = ( + "All spots are currently sold out unfortunately, so we cannot accept your subscription at the moment. " + "Thank you for filling out the form. Please wait for any notifications from the ESN team." +) + # --- Allowed file types for 'link' form fields (mirrors treasury) --- FORM_UPLOAD_ALLOWED_MIMETYPES = [ 'application/pdf', @@ -134,7 +141,7 @@ def _get_or_create_event_folder(service, parent_folder_id, event_name, event_id, return event_folder_id -def _upload_form_file_to_drive(file_obj, event_id, field_name, event_name=None, event_date=None): +def _upload_form_file_to_drive(file_obj, event_id, _field_name, event_name=None, event_date=None): """ Uploads file for form 'l' field to Drive and returns public link. Creates a dedicated folder for the event if event_name is provided. @@ -290,6 +297,49 @@ def _services_total(selected_services): return total +def _list_has_space(target_list): + if not target_list: + return False + if target_list.capacity == 0: + return True + return target_list.subscription_count < target_list.capacity + + +def _get_main_waiting_lists(event, lock=False): + lists_qs = event.lists.all() + if lock: + # Evaluate to acquire row-level locks on all involved lists + all_lists = list(lists_qs.select_for_update().order_by('id')) + main_list = next((lst for lst in all_lists if lst.is_main_list), None) + waiting_list = next((lst for lst in all_lists if lst.is_waiting_list), None) + return main_list, waiting_list + + main_list = lists_qs.filter(is_main_list=True).first() + waiting_list = lists_qs.filter(is_waiting_list=True).first() + return main_list, waiting_list + + +def _is_payment_blocked_by_full_lists(subscription): + """ + True when subscription is outside Main/Waiting and there is no space in both Main and Waiting. + Missing waiting list is treated as full. + """ + if not subscription or not subscription.event: + return False + + current_list = subscription.list + if current_list and (current_list.is_main_list or current_list.is_waiting_list): + return False + + with transaction.atomic(): + main_list, waiting_list = _get_main_waiting_lists(subscription.event, lock=True) + if not main_list: + return False + main_full = not _list_has_space(main_list) + waiting_full = not _list_has_space(waiting_list) if waiting_list else True + return main_full and waiting_full + + # --- Helper to auto-move from any non-ML/WL to ML/WL after payment --- def attempt_move_from_form_list(subscription): """ @@ -326,22 +376,14 @@ def attempt_move_from_form_list(subscription): if not paid_flag: return {'status': 'stayed', 'reason': 'not_paid'} - lists_qs = event.lists.all() - main_list = lists_qs.filter(is_main_list=True).first() - waiting_list = lists_qs.filter(is_waiting_list=True).first() - - def has_space(target): - if not target: - return False - if target.capacity == 0: - return True - return target.subscriptions.count() < target.capacity + with transaction.atomic(): + main_list, waiting_list = _get_main_waiting_lists(event, lock=True) - for target in [main_list, waiting_list]: - if has_space(target): - subscription.list = target - subscription.save(update_fields=['list']) - return {'status': 'moved', 'list': target.name} + for target in [main_list, waiting_list]: + if _list_has_space(target): + subscription.list = target + subscription.save(update_fields=['list']) + return {'status': 'moved', 'list': target.name} return {'status': 'stayed', 'reason': 'no_capacity'} except Exception as e: @@ -358,7 +400,7 @@ def get_action_permissions(request, action, default_perm=None): """ # Define permissions per action perms_map = { - 'event_detail_GET': 'events.view_event', + 'event_detail_GET': PERM_VIEW_EVENT, 'event_detail_PATCH': 'events.change_event', 'event_detail_DELETE': 'events.delete_event', 'subscription_detail_GET': 'events.view_subscription', @@ -367,11 +409,11 @@ def get_action_permissions(request, action, default_perm=None): 'event_creation_POST': 'events.add_event', 'subscription_create_POST': 'events.add_subscription', 'move_subscriptions_POST': 'events.change_subscription', - 'events_list_GET': 'events.view_event', + 'events_list_GET': PERM_VIEW_EVENT, 'generate_liberatorie_pdf_POST': None, # Special case: Board only (handled below) 'printable_liberatorie_GET': None, # Special case: Board only (handled below) 'link_event_to_lists_POST': 'events.change_event', - 'available_events_for_sharing_GET': 'events.view_event', + 'available_events_for_sharing_GET': PERM_VIEW_EVENT, } # Special case: allow Board group for liberatorie actions @@ -431,7 +473,7 @@ def _send_email(subject, html_content, to_email): return False -def _send_form_subscription_email(subscription, assigned_label, online_payment_required, payment_required): +def _send_form_subscription_email(subscription, assigned_label, online_payment_required, payment_required, capacity_blocked=False): # Only for form-created subscriptions; send once if not getattr(subscription, 'created_by_form', False): return @@ -443,7 +485,9 @@ def _send_form_subscription_email(subscription, assigned_label, online_payment_r return event = subscription.event - waiting = 'wait' in (assigned_label or '').lower() + assigned_label_lower = (assigned_label or '').lower() + waiting = 'wait' in assigned_label_lower + main_available = 'main' in assigned_label_lower notify_lists = getattr(event, 'notify_list', True) subject = f"{event.name} - Subscription received" html_parts = [ @@ -451,7 +495,9 @@ def _send_form_subscription_email(subscription, assigned_label, online_payment_r "

We received your subscription!

", ] - if payment_required: + if capacity_blocked: + html_parts.append("

All spots are currently sold out, so we cannot accept payment at the moment. Please wait for a notification from the ESN team in case new spots open up.

") + elif payment_required: if online_payment_required: base = settings.SCHEME_HOST pay_link = f"{base}/event/{event.id}/pay?subscriptionId={subscription.id}" @@ -460,7 +506,7 @@ def _send_form_subscription_email(subscription, assigned_label, online_payment_r if notify_lists: if waiting: html_parts.append("

Spots are only available in the Waiting List at the moment.

") - else: + elif main_available: html_parts.append("

Spots are available in the Main List at the moment.

") html_parts.append( "

Use the link below to complete your payment:
" @@ -2049,11 +2095,28 @@ def event_form_submit(request, event_id): external_whatsapp_number=external_whatsapp_number ) - # --- SumUp integration (widget-only) --- - payment_error = None total_cost = (event.cost or Decimal('0')) + (event.deposit or Decimal('0')) + _services_total(normalized_selected) + # --- Capacity check (must happen before SumUp checkout creation) --- + assigned_label = '' + capacity_blocked = False + + # Subscription is always created in Form List and is never rejected here due to Main/Waiting capacity. + # Capacity information is used to decide whether to create a live payment checkout. if event.allow_online_payment and total_cost > 0: + main_list, waiting_list = _get_main_waiting_lists(event) + + if _list_has_space(main_list): + assigned_label = "Main List" + elif _list_has_space(waiting_list): + assigned_label = "Waiting List" + else: + assigned_label = "Form List" + capacity_blocked = True + + # --- SumUp integration (widget-only) — only create checkout when capacity is available --- + payment_error = None + if event.allow_online_payment and total_cost > 0 and not capacity_blocked: try: checkout_id, _ = create_sumup_checkout(sub, total_cost, currency="EUR") sub.sumup_checkout_id = checkout_id @@ -2063,32 +2126,16 @@ def event_form_submit(request, event_id): print(f"[ERROR] Failed SumUp checkout for subscription {sub.pk}: {e}") logger.error(f"Failed SumUp checkout for subscription {sub.pk}: {e}") - online_payment_required = bool(event.allow_online_payment and total_cost > 0 and not payment_error) + online_payment_required = bool(event.allow_online_payment and total_cost > 0 and not payment_error and not capacity_blocked) payment_required = online_payment_required or total_cost > 0 - assigned_label = '' - - # Determine available list for only online payments (main, else waiting) - if online_payment_required: - event_lists = EventList.objects.filter(events=event) - main_list = event_lists.filter(is_main_list=True).first() - waiting_list = event_lists.filter(is_waiting_list=True).first() - - def has_space(lst): - if not lst: - return False - if lst.capacity == 0: - return True - return lst.subscription_count < lst.capacity - - if has_space(main_list): - assigned_label = "Main List" - elif has_space(waiting_list): - assigned_label = "Waiting List" - else: - return Response({"error": "No available spot for subscription. All lists are full."}, status=400) - - _send_form_subscription_email(sub, assigned_label, online_payment_required, payment_required) + _send_form_subscription_email( + sub, + assigned_label, + online_payment_required, + payment_required, + capacity_blocked=capacity_blocked + ) return Response({ "success": True, @@ -2096,7 +2143,8 @@ def has_space(lst): "assigned_list": assigned_label, "payment_required": bool(event.allow_online_payment and total_cost > 0 and not payment_error), "checkout_id": sub.sumup_checkout_id, - "payment_error": payment_error + "payment_error": payment_error, + "capacity_blocked": capacity_blocked }, status=200) except Event.DoesNotExist: return Response({"error": "Event not found"}, status=404) @@ -2140,6 +2188,14 @@ def subscription_payment_status(_, pk): else: overall = 'none' + payment_required = cost_needed or dep_needed or services_needed + payment_blocked = bool( + sub.event.allow_online_payment + and payment_required + and not _subscription_payment_already_registered(sub) + and _is_payment_blocked_by_full_lists(sub) + ) + return Response({ "subscription_id": sub.pk, "overall_status": overall, @@ -2148,6 +2204,9 @@ def subscription_payment_status(_, pk): "services_status": services_status, "sumup_checkout_id": sub.sumup_checkout_id, "sumup_transaction_id": sub.sumup_transaction_id, + "payment_blocked": payment_blocked, + "payment_blocked_reason": "sold_out" if payment_blocked else None, + "payment_blocked_message": PAYMENT_SOLD_OUT_MESSAGE if payment_blocked else "", }, status=200) @@ -2162,6 +2221,14 @@ def subscription_process_payment(request, pk): except Subscription.DoesNotExist: return Response({"error": "Subscription not found"}, status=404) + event = sub.event + has_payable_amount = bool( + Decimal(event.cost or 0) > 0 + or Decimal(event.deposit or 0) > 0 + or _services_total(sub.selected_services or []) > 0 + ) + + # Parse token from request before entering the DB transaction (no DB access here). payload = request.data or {} widget_payload = payload.get('widget_payload') or {} @@ -2179,16 +2246,47 @@ def subscription_process_payment(request, pk): candidates.append(card_obj.get('token') or card_obj.get('id')) token = next((c for c in candidates if isinstance(c, str) and c.strip()), None) - # Proceed even if token is None - status_flag, remote = _process_sumup_checkout(sub, token) + with transaction.atomic(): + # Re-fetch the subscription under a row-level lock so concurrent requests + # serialize here and the capacity re-check below is race-free. + sub = Subscription.objects.select_related('event').select_for_update().get(pk=pk) + event = sub.event + + should_block_payment = bool( + event.allow_online_payment + and has_payable_amount + and not _subscription_payment_already_registered(sub) + and _is_payment_blocked_by_full_lists(sub) + ) + if should_block_payment: + # Double check if the sumup checkout is actually already completed before blocking + status_flag, remote = _process_sumup_checkout(sub, None) + if status_flag == 'PAID': + _ensure_sumup_transactions(sub) + return Response( + {"status": status_flag, **remote}, + status=200 + ) + + return Response( + { + "status": "BLOCKED", + "error": "sold_out", + "message": PAYMENT_SOLD_OUT_MESSAGE, + }, + status=409, + ) - if status_flag == 'PAID': - _ensure_sumup_transactions(sub) + # Proceed even if token is None + status_flag, remote = _process_sumup_checkout(sub, token) - return Response( - {"status": status_flag, **remote}, - status=200 if status_flag not in ['ERROR'] else 500 - ) + if status_flag == 'PAID': + _ensure_sumup_transactions(sub) + + return Response( + {"status": status_flag, **remote}, + status=200 if status_flag not in ['ERROR'] else 500 + ) @api_view(["POST"]) diff --git a/backend/profiles/tests.py b/backend/profiles/tests.py index 65ec6d7e6..dd329c490 100644 --- a/backend/profiles/tests.py +++ b/backend/profiles/tests.py @@ -164,9 +164,9 @@ def test_profile_list_esncard_validity_filters(self): valid_profile = _create_profile("valid@uni.it", is_esner=False) expired_profile = _create_profile("expired@uni.it", is_esner=False) - absent_profile = _create_profile("absent@uni.it", is_esner=False) + _create_profile("absent@uni.it", is_esner=False) - valid_card = ESNcard.objects.create(profile=valid_profile, number="VALID123") + _valid_card = ESNcard.objects.create(profile=valid_profile, number="VALID123") expired_card = ESNcard.objects.create(profile=expired_profile, number="EXPIRED123") past_date = timezone.now() - timedelta(days=800) @@ -388,7 +388,7 @@ def test_verify_email_esner_activates_user_and_document(self): expiration=timezone.now().date() + timedelta(days=365), enabled=False, ) - user = _create_user(profile) + _create_user(profile) uid = urlsafe_base64_encode(force_bytes(profile.pk)) token = email_verification_token.make_token(profile) @@ -1026,7 +1026,7 @@ def test_latest_esncard_returns_most_recent(self): profile = _create_profile("test@uni.it", is_esner=False) # Create two ESNcards with different creation times - old_card = ESNcard.objects.create(profile=profile, number="OLD-123", enabled=True) + ESNcard.objects.create(profile=profile, number="OLD-123", enabled=True) sleep(0.01) # Small delay to ensure different created_at timestamps newer_card = ESNcard.objects.create(profile=profile, number="NEW-456", enabled=True) diff --git a/backend/profiles/views.py b/backend/profiles/views.py index 9a74a0b6e..a688dc076 100644 --- a/backend/profiles/views.py +++ b/backend/profiles/views.py @@ -19,6 +19,8 @@ from rest_framework.exceptions import NotFound from django.core.paginator import InvalidPage +MSG_INTERNAL_ERROR = 'Errore interno del server.' + from events.models import Subscription, EventOrganizer from events.serializers import SubscriptionSerializer, OrganizedEventSerializer from profiles.models import Profile, Document @@ -158,7 +160,7 @@ def profile_list(request, is_esner): except Exception as e: logger.error(str(e)) sentry_sdk.capture_exception(e) - return Response({'error': 'Errore interno del server.'}, status=500) + return Response({'error': MSG_INTERNAL_ERROR}, status=500) @api_view(['POST']) @@ -286,7 +288,7 @@ def initiate_profile_creation(request): except Exception as e: logger.error(str(e)) sentry_sdk.capture_exception(e) - return Response({'error': 'Errore interno del server.'}, status=500) + return Response({'error': MSG_INTERNAL_ERROR}, status=500) @api_view(['GET']) @@ -351,7 +353,7 @@ def verify_email_and_enable_profile(request, uid, token): except Exception as e: logger.error(str(e)) sentry_sdk.capture_exception(e) - return Response({'error': 'Errore interno del server.'}, status=500) + return Response({'error': MSG_INTERNAL_ERROR}, status=500) @api_view(['POST']) @@ -397,7 +399,7 @@ def manual_verify_profile_email(request, pk): except Exception as e: logger.error(str(e)) sentry_sdk.capture_exception(e) - return Response({'error': 'Errore interno del server.'}, status=500) + return Response({'error': MSG_INTERNAL_ERROR}, status=500) # Endpoint to view in detail, edit, delete a profile @@ -483,7 +485,7 @@ def profile_detail(request, pk): except Exception as e: logger.error(str(e)) sentry_sdk.capture_exception(e) - return Response({'error': 'Errore interno del server.'}, status=500) + return Response({'error': MSG_INTERNAL_ERROR}, status=500) # Endpoint to create document @@ -502,7 +504,7 @@ def document_creation(request): except Exception as e: logger.error(str(e)) sentry_sdk.capture_exception(e) - return Response({'error': 'Errore interno del server.'}, status=500) + return Response({'error': MSG_INTERNAL_ERROR}, status=500) @api_view(['PATCH', 'DELETE']) @@ -531,7 +533,7 @@ def document_detail(request, pk): except Exception as e: logger.error(str(e)) sentry_sdk.capture_exception(e) - return Response({'error': 'Errore interno del server.'}, status=500) + return Response({'error': MSG_INTERNAL_ERROR}, status=500) @api_view(['GET']) @@ -573,7 +575,7 @@ def search_profiles(request): except Exception as e: logger.error(str(e)) sentry_sdk.capture_exception(e) - return Response({'error': 'Errore interno del server.'}, status=500) + return Response({'error': MSG_INTERNAL_ERROR}, status=500) @api_view(['GET']) @@ -611,7 +613,7 @@ def profile_subscriptions(request, pk): except Exception as e: logger.error(str(e)) sentry_sdk.capture_exception(e) - return Response({'error': 'Errore interno del server.'}, status=500) + return Response({'error': MSG_INTERNAL_ERROR}, status=500) @api_view(['POST']) diff --git a/backend/treasury/models.py b/backend/treasury/models.py index a584f5d87..3a3a6b77e 100644 --- a/backend/treasury/models.py +++ b/backend/treasury/models.py @@ -76,6 +76,7 @@ class Transaction(BaseEntity): class TransactionType(models.TextChoices): SUBSCRIPTION = "subscription", _("Subscription") ESNCARD = "esncard", _("ESNcard") + RIMBORSO_ESNCARD = "rimborso_esncard", _("Rimborso ESNcard") DEPOSIT = "deposit", _("Deposit") # Manual deposit (not cauzione) WITHDRAWAL = "withdrawal", _("Withdrawal") REIMBURSEMENT = "reimbursement", _("Reimbursement"), @@ -110,6 +111,11 @@ def clean(self): raise ValueError("Le transazioni di Rimborso Servizi devono avere un'Iscrizione.") if self.type == self.TransactionType.ESNCARD and not self.esncard: raise ValueError("Le transazioni di Emissione ESNcard devono avere una ESNcard.") + if self.type == self.TransactionType.RIMBORSO_ESNCARD: + if self.subscription: + raise ValueError("Le transazioni di Rimborso ESNcard non devono avere un'Iscrizione.") + if self.esncard: + raise ValueError("Le transazioni di Rimborso ESNcard non devono avere una ESNcard associata.") if self.type == self.TransactionType.CAUZIONE: if not self.subscription: raise ValueError("Le transazioni di Cauzione devono avere un'Iscrizione.") diff --git a/backend/treasury/serializers.py b/backend/treasury/serializers.py index 2a2f84a94..8a8024599 100644 --- a/backend/treasury/serializers.py +++ b/backend/treasury/serializers.py @@ -13,6 +13,9 @@ from treasury.models import ESNcard, Transaction, Account, ReimbursementRequest from events.models import Event +GOOGLE_DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive' +DEFAULT_MIMETYPE = 'application/octet-stream' + # --- Helper function to find or create a folder in Google Drive --- def find_or_create_drive_folder(service, folder_name, parent_id): @@ -58,11 +61,11 @@ def upload_receipt_to_drive(receipt_file, user, instance_time, prefix): SERVICE_ACCOUNT_FILE = settings.GOOGLE_SERVICE_ACCOUNT_FILE credentials = service_account.Credentials.from_service_account_file( SERVICE_ACCOUNT_FILE, - scopes=['https://www.googleapis.com/auth/drive'] + scopes=[GOOGLE_DRIVE_SCOPE] ) service = build('drive', 'v3', credentials=credentials) receipt_file.seek(0) - mimetype = getattr(receipt_file, 'content_type', 'application/octet-stream') + mimetype = getattr(receipt_file, 'content_type', DEFAULT_MIMETYPE) media = MediaIoBaseUpload(receipt_file, mimetype=mimetype) ext = os.path.splitext(receipt_file.name)[1].lower() filename = f"{prefix}_{user.profile.name}_{user.profile.surname}_{instance_time.strftime('%Y%m%d_%H%M%S')}{ext}" @@ -95,7 +98,7 @@ def upload_reimbursement_receipt_to_drive(receipt_file, user, instance_time, eve SERVICE_ACCOUNT_FILE = settings.GOOGLE_SERVICE_ACCOUNT_FILE credentials = service_account.Credentials.from_service_account_file( SERVICE_ACCOUNT_FILE, - scopes=['https://www.googleapis.com/auth/drive'] + scopes=[GOOGLE_DRIVE_SCOPE] ) service = build('drive', 'v3', credentials=credentials) @@ -120,7 +123,7 @@ def upload_reimbursement_receipt_to_drive(receipt_file, user, instance_time, eve # Upload file to the final folder receipt_file.seek(0) - mimetype = getattr(receipt_file, 'content_type', 'application/octet-stream') + mimetype = getattr(receipt_file, 'content_type', DEFAULT_MIMETYPE) media = MediaIoBaseUpload(receipt_file, mimetype=mimetype) ext = os.path.splitext(receipt_file.name)[1].lower() filename = f"rimborso_{user.profile.name}_{user.profile.surname}_{instance_time.strftime('%Y%m%d_%H%M%S')}{ext}" @@ -157,7 +160,7 @@ def upload_transaction_receipt_to_drive(receipt_file, user, instance_time, event SERVICE_ACCOUNT_FILE = settings.GOOGLE_SERVICE_ACCOUNT_FILE credentials = service_account.Credentials.from_service_account_file( SERVICE_ACCOUNT_FILE, - scopes=['https://www.googleapis.com/auth/drive'] + scopes=[GOOGLE_DRIVE_SCOPE] ) service = build('drive', 'v3', credentials=credentials) @@ -180,7 +183,7 @@ def upload_transaction_receipt_to_drive(receipt_file, user, instance_time, event # Upload file to the final folder receipt_file.seek(0) - mimetype = getattr(receipt_file, 'content_type', 'application/octet-stream') + mimetype = getattr(receipt_file, 'content_type', DEFAULT_MIMETYPE) media = MediaIoBaseUpload(receipt_file, mimetype=mimetype) ext = os.path.splitext(receipt_file.name)[1].lower() filename = f"transazione_{user.profile.name}_{user.profile.surname}_{instance_time.strftime('%Y%m%d_%H%M%S')}{ext}" diff --git a/backend/treasury/tests.py b/backend/treasury/tests.py index 9619e3da3..62be11812 100644 --- a/backend/treasury/tests.py +++ b/backend/treasury/tests.py @@ -136,7 +136,7 @@ def test_account_creation_requires_board(self): "name": "New Account", }) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 403) def test_account_detail_patch_status_only_allowed_for_casse_manager(self): """Status-only changes should be allowed for casse managers.""" @@ -165,7 +165,7 @@ def test_account_detail_patch_requires_permission_for_full_edit(self): "name": "Updated", }) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 403) def test_account_detail_get_masks_balance_for_non_viewers(self): """Balance should be masked for non-allowed users (e.g., Aspiranti).""" @@ -274,7 +274,7 @@ def test_esncard_detail_patch_requires_permission(self): response = self.client.patch(f"/backend/esncard/{card.pk}/", {"number": "ESN-00000"}) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 403) def test_esncard_detail_patch_success(self): """ESNcard update should work with permission.""" @@ -292,6 +292,202 @@ def test_esncard_detail_patch_success(self): card.refresh_from_db() self.assertEqual(card.number, "ESN-00000") + def test_esncard_detail_delete_requires_board(self): + """ESNcard revoke must be restricted to Board members.""" + profile = _create_profile("editor@esnpolimi.it") + user = _create_user(profile) + self.authenticate(user) + + card_owner = _create_profile("owner@uni.it", is_esner=False) + card = ESNcard.objects.create(profile=card_owner, number="ESN-DEL-001") + + response = self.client.delete(f"/backend/esncard/{card.pk}/") + + self.assertEqual(response.status_code, 403) + self.assertTrue(ESNcard.objects.filter(pk=card.pk).exists()) + + def test_esncard_detail_delete_success_creates_refund_and_reverts_balance(self): + """Revoking a card should keep emission tx and create a refund tx restoring balance.""" + profile = _create_profile("board@esnpolimi.it") + user = _create_user(profile) + user.groups.add(self.group_board) + self.authenticate(user) + + account = _create_account("Main", user=user, balance="0.00") + card_owner = _create_profile("owner@uni.it", is_esner=False) + card = ESNcard.objects.create(profile=card_owner, number="ESN-DEL-002") + tx = Transaction.objects.create( + account=account, + executor=user, + type=Transaction.TransactionType.ESNCARD, + esncard=card, + amount=Decimal("10.00"), + description="Emissione ESNcard test", + ) + + account.refresh_from_db() + self.assertEqual(account.balance, Decimal("10.00")) + + response = self.client.delete(f"/backend/esncard/{card.pk}/") + + self.assertEqual(response.status_code, 200) + self.assertFalse(ESNcard.objects.filter(pk=card.pk).exists()) + self.assertTrue(Transaction.objects.filter(pk=tx.pk).exists()) + + refund_tx = Transaction.objects.filter( + type=Transaction.TransactionType.RIMBORSO_ESNCARD, + account=account, + ).first() + self.assertIsNotNone(refund_tx) + self.assertEqual(refund_tx.amount, Decimal("-10.00")) + self.assertIn("ESN-DEL-002", refund_tx.description) + self.assertEqual(response.data.get("original_transaction_id"), tx.pk) + self.assertEqual(response.data.get("refund_transaction_id"), refund_tx.pk) + + account.refresh_from_db() + self.assertEqual(account.balance, Decimal("0.00")) + + def test_esncard_detail_delete_success_without_linked_emission(self): + """Revoking should still work when no linked ESNcard emission tx is present.""" + profile = _create_profile("board@esnpolimi.it") + user = _create_user(profile) + user.groups.add(self.group_board) + self.authenticate(user) + + card_owner = _create_profile("owner@uni.it", is_esner=False) + card = ESNcard.objects.create(profile=card_owner, number="ESN-DEL-003") + + response = self.client.delete(f"/backend/esncard/{card.pk}/") + + self.assertEqual(response.status_code, 200) + self.assertFalse(ESNcard.objects.filter(pk=card.pk).exists()) + self.assertIsNone(response.data.get("original_transaction_id")) + self.assertIsNone(response.data.get("refund_transaction_id")) + + def test_esncard_detail_delete_rejects_insufficient_balance_for_refund(self): + """Revoking should fail if account balance is insufficient for ESNcard refund.""" + profile = _create_profile("board@esnpolimi.it") + user = _create_user(profile) + user.groups.add(self.group_board) + self.authenticate(user) + + account = _create_account("Main", user=user, balance="0.00") + card_owner = _create_profile("owner@uni.it", is_esner=False) + card = ESNcard.objects.create(profile=card_owner, number="ESN-DEL-006") + emission_tx = Transaction.objects.create( + account=account, + executor=user, + type=Transaction.TransactionType.ESNCARD, + esncard=card, + amount=Decimal("10.00"), + description="Emissione ESNcard test", + ) + Transaction.objects.create( + account=account, + executor=user, + type=Transaction.TransactionType.WITHDRAWAL, + amount=Decimal("-10.00"), + description="Prelievo test", + ) + + account.refresh_from_db() + self.assertEqual(account.balance, Decimal("0.00")) + + response = self.client.delete(f"/backend/esncard/{card.pk}/") + + self.assertEqual(response.status_code, 409) + self.assertTrue(ESNcard.objects.filter(pk=card.pk).exists()) + self.assertTrue(Transaction.objects.filter(pk=emission_tx.pk).exists()) + self.assertFalse(Transaction.objects.filter(type=Transaction.TransactionType.RIMBORSO_ESNCARD).exists()) + + def test_esncard_detail_delete_rejects_closed_account_for_refund(self): + """Revoking should fail if emission account is closed before refund.""" + profile = _create_profile("board@esnpolimi.it") + user = _create_user(profile) + user.groups.add(self.group_board) + self.authenticate(user) + + account = _create_account("Main", user=user, balance="0.00") + card_owner = _create_profile("owner@uni.it", is_esner=False) + card = ESNcard.objects.create(profile=card_owner, number="ESN-DEL-007") + emission_tx = Transaction.objects.create( + account=account, + executor=user, + type=Transaction.TransactionType.ESNCARD, + esncard=card, + amount=Decimal("10.00"), + description="Emissione ESNcard test", + ) + account.status = Account.Status.closed + account.save(update_fields=["status"]) + + response = self.client.delete(f"/backend/esncard/{card.pk}/") + + self.assertEqual(response.status_code, 409) + self.assertTrue(ESNcard.objects.filter(pk=card.pk).exists()) + self.assertTrue(Transaction.objects.filter(pk=emission_tx.pk).exists()) + self.assertFalse(Transaction.objects.filter(type=Transaction.TransactionType.RIMBORSO_ESNCARD).exists()) + + def test_esncard_detail_delete_rejects_multiple_linked_emissions(self): + """Revoking should fail if more than one ESNcard emission tx is linked.""" + profile = _create_profile("board@esnpolimi.it") + user = _create_user(profile) + user.groups.add(self.group_board) + self.authenticate(user) + + account = _create_account("Main", user=user, balance="0.00") + card_owner = _create_profile("owner@uni.it", is_esner=False) + card = ESNcard.objects.create(profile=card_owner, number="ESN-DEL-004") + Transaction.objects.create( + account=account, + executor=user, + type=Transaction.TransactionType.ESNCARD, + esncard=card, + amount=Decimal("10.00"), + description="Emissione ESNcard 1", + ) + Transaction.objects.create( + account=account, + executor=user, + type=Transaction.TransactionType.ESNCARD, + esncard=card, + amount=Decimal("10.00"), + description="Emissione ESNcard 2", + ) + + response = self.client.delete(f"/backend/esncard/{card.pk}/") + + self.assertEqual(response.status_code, 409) + self.assertTrue(ESNcard.objects.filter(pk=card.pk).exists()) + self.assertEqual(Transaction.objects.filter(esncard=card, type=Transaction.TransactionType.ESNCARD).count(), 2) + + def test_esncard_detail_delete_rejects_unexpected_transaction_types(self): + """Revoking should fail if the card is linked to non-ESNcard transaction types.""" + profile = _create_profile("board@esnpolimi.it") + user = _create_user(profile) + user.groups.add(self.group_board) + self.authenticate(user) + + account = _create_account("Main", user=user, balance="0.00") + card_owner = _create_profile("owner@uni.it", is_esner=False) + card = ESNcard.objects.create(profile=card_owner, number="ESN-DEL-005") + + tx = Transaction.objects.create( + account=account, + executor=user, + type=Transaction.TransactionType.DEPOSIT, + amount=Decimal("10.00"), + description="Deposito test", + ) + # Simulate legacy/corrupted data by linking the ESNcard post-save. + Transaction.objects.filter(pk=tx.pk).update(esncard=card) + + response = self.client.delete(f"/backend/esncard/{card.pk}/") + + self.assertEqual(response.status_code, 409) + self.assertTrue(ESNcard.objects.filter(pk=card.pk).exists()) + self.assertTrue(Transaction.objects.filter(pk=tx.pk).exists()) + class TransactionTests(TreasuryBaseTestCase): """Tests for transaction endpoints.""" @@ -310,7 +506,7 @@ def test_transaction_add_requires_permission(self): "description": "Test", }, format="json") - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 403) def test_transaction_add_success(self): """Adding transaction should update account balance.""" @@ -375,7 +571,7 @@ def test_transaction_detail_patch_requires_permission(self): response = self.client.patch(f"/backend/transaction/{tx.pk}/", {"description": "Updated"}) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 403) def test_transaction_detail_delete_only_allowed_types(self): """Deleting non-refund transactions should be blocked.""" @@ -584,7 +780,7 @@ def test_reimbursement_request_patch_requires_board(self): response = self.client.patch(f"/backend/reimbursement_request/{req.pk}/", {"description": "Updated"}) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 403) def test_reimbursement_request_delete_requires_board(self): """DELETE should be restricted to Board or delete permission.""" @@ -601,7 +797,7 @@ def test_reimbursement_request_delete_requires_board(self): response = self.client.delete(f"/backend/reimbursement_request/{req.pk}/") - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 403) def test_reimbursement_request_patch_creates_transaction(self): """Board with change permission can assign account and create reimbursement tx.""" @@ -1031,6 +1227,38 @@ def test_reimbursable_deposits_excludes_reimbursed(self): self.assertEqual(response.status_code, 200) self.assertFalse(any(r["id"] == sub.pk for r in response.data)) + def test_reimbursable_deposits_supports_external_subscription(self): + """External subscriptions (without profile) should not crash endpoint.""" + profile = _create_profile("board@esnpolimi.it") + user = _create_user(profile) + self.authenticate(user) + + event = _create_event(cost=10, deposit=5) + list_main = _create_event_list(event) + account = _create_account("Main", user=user) + sub = Subscription.objects.create( + profile=None, + external_name="John External", + event=event, + list=list_main, + ) + Transaction.objects.create( + subscription=sub, + account=account, + executor=user, + type=Transaction.TransactionType.CAUZIONE, + amount=5, + description="Cauzione" + ) + + response = self.client.get(f"/backend/reimbursable_deposits/?event={event.pk}&list={list_main.pk}") + + self.assertEqual(response.status_code, 200) + entry = next((r for r in response.data if r["id"] == sub.pk), None) + self.assertIsNotNone(entry) + self.assertIsNone(entry["profile_id"]) + self.assertEqual(entry["profile_name"], "John External") + class TransactionsExportTests(TreasuryBaseTestCase): """Tests for transactions export endpoint.""" @@ -1531,7 +1759,7 @@ def test_esncard_patch_to_duplicate_number_rejected(self): profile1 = _create_profile("owner1@uni.it", is_esner=False) profile2 = _create_profile("owner2@uni.it", is_esner=False) - card1 = ESNcard.objects.create(profile=profile1, number="ESN-FIRST") + ESNcard.objects.create(profile=profile1, number="ESN-FIRST") card2 = ESNcard.objects.create(profile=profile2, number="ESN-SECOND") response = self.client.patch(f"/backend/esncard/{card2.pk}/", { @@ -1744,7 +1972,7 @@ def test_reimburse_deposit_twice_prevented(self): self.assertIn(response1.status_code, [200, 201]) # Second reimbursement (should fail) - response2 = self.client.post("/backend/reimburse_deposits/", { + self.client.post("/backend/reimburse_deposits/", { "event": event.pk, "subscription_ids": [sub.pk], "account": account.pk, @@ -1806,7 +2034,7 @@ def test_account_visible_to_all_when_no_groups(self): board_user = _create_user(board_profile) board_user.groups.add(self.group_board) - account = _create_account("Public", user=board_user) + _create_account("Public", user=board_user) # No groups assigned = visible to all viewer_profile = _create_profile("aspiranti@esnpolimi.it") diff --git a/backend/treasury/views.py b/backend/treasury/views.py index 88ea86b00..a1287a570 100644 --- a/backend/treasury/views.py +++ b/backend/treasury/views.py @@ -5,7 +5,7 @@ import sentry_sdk from django.core.exceptions import PermissionDenied, ObjectDoesNotExist from django.db import transaction, IntegrityError -from django.db.models import Q +from django.db.models import Q, Prefetch from django.http import HttpResponse from django.utils.dateparse import parse_datetime from openpyxl import Workbook @@ -34,6 +34,8 @@ except Exception: ZoneInfo = None +MSG_UNAUTHORIZED = 'Non autorizzato.' + logger = logging.getLogger(__name__) @@ -59,6 +61,8 @@ def get_action_permissions(action, user): return user.has_perm('treasury.delete_transaction') or getattr(user, 'can_manage_casse', False) if action == 'esncard_detail_patch': return user.has_perm('treasury.change_esncard') + if action == 'esncard_detail_delete': + return user_is_board(user) if action == 'reimbursement_request_detail_patch': return user_is_board(user) if action == 'reimbursement_request_detail_delete': @@ -161,14 +165,14 @@ def esncard_emission(request): return Response({'error': 'Errore interno del server.'}, status=500) -@api_view(['PATCH']) +@api_view(['PATCH', 'DELETE']) @permission_classes([IsAuthenticated]) def esncard_detail(request, pk): try: - esncard = ESNcard.objects.get(pk=pk) if request.method == 'PATCH': + esncard = ESNcard.objects.get(pk=pk) if not get_action_permissions('esncard_detail_patch', request.user): - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) update_data = {} if 'number' in request.data: update_data['number'] = request.data['number'] @@ -178,10 +182,96 @@ def esncard_detail(request, pk): return Response(esncard_serializer.data, status=200) # Return updated data else: return Response(esncard_serializer.errors, status=400) + + elif request.method == 'DELETE': + if not get_action_permissions('esncard_detail_delete', request.user): + return Response({'error': MSG_UNAUTHORIZED}, status=403) + + with transaction.atomic(): + esncard = ESNcard.objects.select_for_update().select_related('profile').get(pk=pk) + + # Defensive guard: this flow only reverts ESNcard emission transactions. + # If the card is linked to unexpected transaction types, stop and require manual fix. + non_esncard_refs = Transaction.objects.select_for_update().filter(esncard=esncard).exclude( + type=Transaction.TransactionType.ESNCARD + ) + if non_esncard_refs.exists(): + return Response( + {'error': 'La ESNcard è collegata a transazioni non revocabili automaticamente.'}, + status=409 + ) + + linked_emissions = list( + Transaction.objects.select_for_update().filter( + esncard=esncard, + type=Transaction.TransactionType.ESNCARD + ).order_by('-created_at', '-id')[:2] + ) + if len(linked_emissions) > 1: + return Response( + {'error': 'Sono state trovate più transazioni ESNcard collegate. Revoca annullata.'}, + status=409 + ) + + original_transaction_id = None + refund_transaction_id = None + if linked_emissions: + emission_tx = linked_emissions[0] + original_transaction_id = emission_tx.id + + emission_amount = Decimal(str(emission_tx.amount or 0)) + if emission_amount <= 0: + return Response( + {'error': 'Importo della transazione ESNcard non valido per il rimborso automatico.'}, + status=409 + ) + + refund_account = Account.objects.select_for_update().get(pk=emission_tx.account_id) + if refund_account.status == Account.Status.closed: + return Response({'error': 'La cassa della transazione ESNcard è chiusa.'}, status=409) + + if refund_account.balance - emission_amount < 0: + return Response({'error': 'Saldo insufficiente per effettuare il rimborso ESNcard.'}, status=409) + + refund_tx = Transaction( + type=Transaction.TransactionType.RIMBORSO_ESNCARD, + account=refund_account, + executor=request.user, + amount=-emission_amount, + description=( + f"Rimborso ESNcard revocata {esncard.number}: " + f"{esncard.profile.name} {esncard.profile.surname} " + f"(emissione #{emission_tx.id})" + ) + ) + try: + refund_tx.save() + except (PermissionDenied, ValueError) as refund_error: + logger.exception("Errore durante il salvataggio della transazione di rimborso ESNcard: %s", refund_error) + return Response({'error': "Errore durante il salvataggio della transazione di rimborso ESNcard."}, status=409) + + refund_transaction_id = refund_tx.id + + revoked_number = esncard.number + esncard.delete() + + msg = 'ESNcard revocata con successo.' + if original_transaction_id and refund_transaction_id: + msg += ' Creata transazione di rimborso in tesoreria.' + else: + msg += ' Nessuna transazione di emissione trovata da rimborsare.' + + return Response({ + 'message': msg, + 'revoked_esncard_number': revoked_number, + 'original_transaction_id': original_transaction_id, + 'refund_transaction_id': refund_transaction_id, + }, status=200) else: return Response({'error': "Metodo non consentito"}, status=405) except ESNcard.DoesNotExist: - return Response({'error': 'La ESNcard non esiste'}, status=400) + not_found_status = 404 if request.method == 'DELETE' else 400 + return Response({'error': 'La ESNcard non esiste'}, status=not_found_status) except Exception as e: logger.error(str(e)) sentry_sdk.capture_exception(e) @@ -212,7 +302,7 @@ def esncard_fees(_): def transaction_add(request): try: if not get_action_permissions('transaction_add', request.user): - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) transaction_serializer = TransactionCreateSerializer(data=request.data, context={'request': request}) if not transaction_serializer.is_valid(): @@ -221,7 +311,7 @@ def transaction_add(request): transaction_type = transaction_serializer.validated_data['type'] if transaction_type in [Transaction.TransactionType.DEPOSIT, Transaction.TransactionType.WITHDRAWAL]: if not request.user.has_perm('treasury.add_transaction'): - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) try: tx = transaction_serializer.save() @@ -311,7 +401,7 @@ def transaction_detail(request, pk): elif request.method == 'PATCH': if not get_action_permissions('transaction_detail_patch', request.user): - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) # Executor handling: optional & nullable executor_raw = request.data.get('executor', '__not_provided__') @@ -361,10 +451,11 @@ def transaction_detail(request, pk): elif request.method == 'DELETE': if not get_action_permissions('transaction_detail_delete', request.user): - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) # Allow deletion only for specific transaction types list_deleteable = [ Transaction.TransactionType.RIMBORSO_CAUZIONE, + Transaction.TransactionType.RIMBORSO_ESNCARD, Transaction.TransactionType.REIMBURSEMENT, Transaction.TransactionType.RIMBORSO_QUOTA, Transaction.TransactionType.DEPOSIT, @@ -372,7 +463,7 @@ def transaction_detail(request, pk): ] if transaction_obj.type in list_deleteable: if not (request.user.has_perm('treasury.delete_transaction') or request.user.can_manage_casse): - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) transaction_obj.delete() return Response(status=204) else: @@ -408,7 +499,7 @@ def accounts_list(request): def account_creation(request): try: if not get_action_permissions('account_creation', request.user): - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) account_serializer = AccountCreateSerializer(data=request.data) if not account_serializer.is_valid(): @@ -446,10 +537,10 @@ def account_detail(request, pk): if not status_only: if not get_action_permissions('account_detail_patch', request.user): - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) else: if not is_casse_manager: - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) data = request.data.copy() data['changed_by'] = request.user @@ -525,7 +616,7 @@ def reimbursement_request_detail(request, pk): elif request.method == 'PATCH': if not get_action_permissions('reimbursement_request_detail_patch', request.user): - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) if request.user.has_perm('treasury.change_reimbursementrequest'): allowed_fields = {'description', 'receipt_link', 'account', 'amount'} data = {k: v for k, v in request.data.items() if k in allowed_fields} @@ -542,11 +633,11 @@ def reimbursement_request_detail(request, pk): return Response(ve.detail, status=400) return Response(serializer.errors, status=400) else: - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) elif request.method == 'DELETE': if not get_action_permissions('reimbursement_request_detail_delete', request.user): - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) try: instance.delete() return Response(status=204) @@ -576,7 +667,7 @@ def reimbursement_requests_list(request): # Allow only board or the profile owner req_profile_id = getattr(getattr(request.user, 'profile', None), 'id', None) if not user_is_board(request.user) and req_profile_id != profile_id_int: - return Response({'error': 'Non autorizzato.'}, status=403) + return Response({'error': MSG_UNAUTHORIZED}, status=403) requests = requests.filter(user__profile__id=profile_id_int) search = request.GET.get('search', '').strip() if search: @@ -628,7 +719,7 @@ def reimburse_deposits(request): """ try: if not get_action_permissions('reimburse_deposits', request.user): - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) event_id = request.data.get('event') subscription_ids = request.data.get('subscription_ids', []) @@ -719,17 +810,32 @@ def reimbursable_deposits(request): event = Event.objects.get(id=event_id) # Subscriptions in this list, with a paid cauzione transaction, not yet reimbursed - subs = Subscription.objects.filter(event=event, list__id=list_id) + subs = Subscription.objects.filter( + event=event, list__id=list_id + ).select_related('profile').prefetch_related( + Prefetch('transaction_set', queryset=Transaction.objects.select_related('account')) + ) result = [] for sub in subs: - deposit_tx = Transaction.objects.filter(subscription=sub, type=Transaction.TransactionType.CAUZIONE).first() - reimbursed = Transaction.objects.filter(subscription=sub, - type=Transaction.TransactionType.RIMBORSO_CAUZIONE).exists() + sub_transactions = sub.transaction_set.all() + deposit_tx = next( + (tx for tx in sub_transactions if tx.type == Transaction.TransactionType.CAUZIONE), None + ) + reimbursed = any( + tx.type == Transaction.TransactionType.RIMBORSO_CAUZIONE for tx in sub_transactions + ) if deposit_tx and not reimbursed: + profile_id = sub.profile.id if sub.profile else None + if sub.profile: + profile_name = f"{sub.profile.name} {sub.profile.surname}" + elif sub.external_name: + profile_name = sub.external_name + else: + profile_name = "Esterno" result.append({ "id": sub.pk, - "profile_id": sub.profile.id, - "profile_name": f"{sub.profile.name} {sub.profile.surname}", + "profile_id": profile_id, + "profile_name": profile_name, "account_name": deposit_tx.account.name if deposit_tx.account else None }) return Response(result, status=200) @@ -748,7 +854,7 @@ def reimburse_quota(request): """ try: if not get_action_permissions('reimburse_quota', request.user): - return Response({'error': 'Non autorizzato.'}, status=401) + return Response({'error': MSG_UNAUTHORIZED}, status=403) event_id = request.data.get('event') subscription_id = request.data.get('subscription_id') @@ -937,6 +1043,8 @@ def compute_amount(tx_obj): def build_attivita(tx_obj): if tx_obj.type == Transaction.TransactionType.ESNCARD: return "Quota Associativa" + if tx_obj.type == Transaction.TransactionType.RIMBORSO_ESNCARD: + return "Rimborso ESNcard" if tx_obj.type == Transaction.TransactionType.DEPOSIT: return "Deposito" if tx_obj.type == Transaction.TransactionType.WITHDRAWAL: @@ -966,6 +1074,8 @@ def build_descrizione(tx_obj): # New computed description column. if tx_obj.type == Transaction.TransactionType.ESNCARD: return f"Quota Associativa {_academic_year(tx_obj.created_at)}" + if tx_obj.type == Transaction.TransactionType.RIMBORSO_ESNCARD: + return f"Rimborso Quota Associativa {_academic_year(tx_obj.created_at)}" ev = None if getattr(tx_obj, 'subscription_id', None) and getattr(tx_obj.subscription, 'event', None): ev = tx_obj.subscription.event diff --git a/backend/users/models.py b/backend/users/models.py index 0a351c872..93322c4ae 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -15,7 +15,7 @@ class User(AbstractBaseUser, PermissionsMixin): last_login = models.DateTimeField(null=True, blank=True) # Override last_login, to be NULL initially can_manage_casse = models.BooleanField(default=False) # Board-granted for Aspiranti can_view_casse_import = models.BooleanField(default=False) # Board-granted for Aspiranti - can_manage_content = models.BooleanField(default=False) # Board-granted for Aspiranti/Attivi + can_manage_content = models.BooleanField(default=False) # Explicitly granted; Board has implicit access USERNAME_FIELD = 'profile' REQUIRED_FIELDS = [] diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 326a16d8b..db50b0927 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -70,11 +70,8 @@ def get_permissions(cls, obj): user_permissions = obj.user_permissions.values_list('codename', flat=True) group_permissions = obj.groups.values_list('permissions__codename', flat=True) permissions = set(user_permissions).union(set(group_permissions)) - # Virtual permission: manage_content — granted to Board, Attivi, or flagged users - if ( - obj.groups.filter(name__in=['Board', 'Attivi']).exists() - or getattr(obj, 'can_manage_content', False) - ): + # Virtual permission: manage_content — implicit for Board, explicit for flagged users. + if obj.groups.filter(name='Board').exists() or getattr(obj, 'can_manage_content', False): permissions.add('manage_content') return list(permissions) @@ -97,11 +94,7 @@ def get_effective_can_view_casse_import(self, obj): ) def get_effective_can_manage_content(self, obj): - return ( - obj.can_manage_content - or self._is_in(obj, 'Attivi') - or self._is_in(obj, 'Board') - ) + return obj.can_manage_content or self._is_in(obj, 'Board') def get_restricted_accounts(self, obj): if self._is_in(obj, 'Board'): diff --git a/backend/users/test_integration.py b/backend/users/test_integration.py index eeb684ad1..5f32053e5 100644 --- a/backend/users/test_integration.py +++ b/backend/users/test_integration.py @@ -373,7 +373,7 @@ def test_assign_finance_permissions(self): "description": "Attempt", }, format="json") - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 403) self.authenticate(board) response = self.client.patch( diff --git a/backend/users/views.py b/backend/users/views.py index c7f4bdd7e..83ed32c2b 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -49,7 +49,7 @@ def get_action_permissions(action, user): return True -def userinfo(claims, user): +def userinfo(_claims, user): """Return user info for DokuWiki OIDC""" ret = { "sub": str(user.id), @@ -335,7 +335,7 @@ def _in_group(user, name: str): def user_finance_permissions(request): """ GET: Return raw and effective finance permission flags. - PATCH: Board only. Allowed only if target is ESNer in group 'Aspiranti'. + PATCH: Board only. Allowed only if target is ESNer. """ email = request.query_params.get("email") if not email: @@ -352,7 +352,7 @@ def effective_view(u): return u.can_view_casse_import or _in_group(u, 'Attivi') or _in_group(u, 'Board') def effective_content(u): - return u.can_manage_content or _in_group(u, 'Attivi') or _in_group(u, 'Board') + return u.can_manage_content or _in_group(u, 'Board') if request.method == 'GET': return Response({ @@ -376,8 +376,8 @@ def effective_content(u): if finance_fields and not _in_group(target, 'Aspiranti'): return Response({'error': 'Permessi casse applicabili solo agli Aspiranti.'}, status=400) - if content_fields and not (_in_group(target, 'Aspiranti') or _in_group(target, 'Attivi')): - return Response({'error': 'Ruolo Content Manager applicabile solo ad Aspiranti e Attivi.'}, status=400) + if content_fields and not target.profile.is_esner: + return Response({'error': 'Ruolo Content Manager applicabile solo agli ESNer.'}, status=400) serializer = FinancePermissionSerializer(target, data=request.data, partial=True) if serializer.is_valid(): diff --git a/frontend/build/assets/index-BKm4YlQa.js b/frontend/build/assets/index-BKm4YlQa.js deleted file mode 100644 index 0ea7da0be..000000000 --- a/frontend/build/assets/index-BKm4YlQa.js +++ /dev/null @@ -1,318 +0,0 @@ -var A4=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var H1e=A4((Rs,ks)=>{function D4(e,t){for(var n=0;nr[o]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))r(o);new MutationObserver(o=>{for(const s of o)if(s.type==="childList")for(const l of s.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&r(l)}).observe(document,{childList:!0,subtree:!0});function n(o){const s={};return o.integrity&&(s.integrity=o.integrity),o.referrerPolicy&&(s.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?s.credentials="include":o.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function r(o){if(o.ep)return;o.ep=!0;const s=n(o);fetch(o.href,s)}})();var sc=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function bi(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var M0={exports:{}},Dp={};var bE;function O4(){if(bE)return Dp;bE=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.fragment");function n(r,o,s){var l=null;if(s!==void 0&&(l=""+s),o.key!==void 0&&(l=""+o.key),"key"in o){s={};for(var u in o)u!=="key"&&(s[u]=o[u])}else s=o;return o=s.ref,{$$typeof:e,type:r,key:l,ref:o!==void 0?o:null,props:s}}return Dp.Fragment=t,Dp.jsx=n,Dp.jsxs=n,Dp}var yE;function I4(){return yE||(yE=1,M0.exports=O4()),M0.exports}var a=I4();const mn=typeof __SENTRY_DEBUG__>"u"||__SENTRY_DEBUG__,Kn=globalThis,uu="10.36.0";function Ey(){return My(Kn),Kn}function My(e){const t=e.__SENTRY__=e.__SENTRY__||{};return t.version=t.version||uu,t[uu]=t[uu]||{}}function Ef(e,t,n=Kn){const r=n.__SENTRY__=n.__SENTRY__||{},o=r[uu]=r[uu]||{};return o[e]||(o[e]=t())}const L4=["debug","info","warn","error","log","assert","trace"],z4="Sentry Logger ",Gb={};function Mf(e){if(!("console"in Kn))return e();const t=Kn.console,n={},r=Object.keys(Gb);r.forEach(o=>{const s=Gb[o];n[o]=t[o],t[o]=s});try{return e()}finally{r.forEach(o=>{t[o]=n[o]})}}function N4(){vC().enabled=!0}function $4(){vC().enabled=!1}function DA(){return vC().enabled}function F4(...e){yC("log",...e)}function B4(...e){yC("warn",...e)}function V4(...e){yC("error",...e)}function yC(e,...t){mn&&DA()&&Mf(()=>{Kn.console[e](`${z4}[${e}]:`,...t)})}function vC(){return mn?Ef("loggerSettings",()=>({enabled:!1})):{enabled:!1}}const Qt={enable:N4,disable:$4,isEnabled:DA,log:F4,warn:B4,error:V4},OA=50,mu="?",vE=/\(error: (.*)\)/,xE=/captureMessage|captureException/;function IA(...e){const t=e.sort((n,r)=>n[0]-r[0]).map(n=>n[1]);return(n,r=0,o=0)=>{const s=[],l=n.split(` -`);for(let u=r;u1024&&(d=d.slice(0,1024));const p=vE.test(d)?d.replace(vE,"$1"):d;if(!p.match(/\S*Error: /)){for(const m of t){const b=m(p);if(b){s.push(b);break}}if(s.length>=OA+o)break}}return q4(s.slice(o))}}function H4(e){return Array.isArray(e)?IA(...e):e}function q4(e){if(!e.length)return[];const t=Array.from(e);return/sentryWrapped/.test(Eg(t).function||"")&&t.pop(),t.reverse(),xE.test(Eg(t).function||"")&&(t.pop(),xE.test(Eg(t).function||"")&&t.pop()),t.slice(0,OA).map(n=>({...n,filename:n.filename||Eg(t).filename,function:n.function||mu}))}function Eg(e){return e[e.length-1]||{}}const R0="";function pc(e){try{return!e||typeof e!="function"?R0:e.name||R0}catch{return R0}}function SE(e){const t=e.exception;if(t){const n=[];try{return t.values.forEach(r=>{r.stacktrace.frames&&n.push(...r.stacktrace.frames)}),n}catch{return}}}function LA(e){return"__v_isVNode"in e&&e.__v_isVNode?"[VueVNode]":"[VueViewModel]"}const gb={},CE={};function ju(e,t){gb[e]=gb[e]||[],gb[e].push(t)}function Eu(e,t){if(!CE[e]){CE[e]=!0;try{t()}catch(n){mn&&Qt.error(`Error while instrumenting ${e}`,n)}}}function Fi(e,t){const n=e&&gb[e];if(n)for(const r of n)try{r(t)}catch(o){mn&&Qt.error(`Error while triggering instrumentation handler. -Type: ${e} -Name: ${pc(r)} -Error:`,o)}}let k0=null;function U4(e){const t="error";ju(t,e),Eu(t,W4)}function W4(){k0=Kn.onerror,Kn.onerror=function(e,t,n,r,o){return Fi("error",{column:r,error:o,line:n,msg:e,url:t}),k0?k0.apply(this,arguments):!1},Kn.onerror.__SENTRY_INSTRUMENTED__=!0}let P0=null;function G4(e){const t="unhandledrejection";ju(t,e),Eu(t,Y4)}function Y4(){P0=Kn.onunhandledrejection,Kn.onunhandledrejection=function(e){return Fi("unhandledrejection",e),P0?P0.apply(this,arguments):!0},Kn.onunhandledrejection.__SENTRY_INSTRUMENTED__=!0}const zA=Object.prototype.toString;function qm(e){switch(zA.call(e)){case"[object Error]":case"[object Exception]":case"[object DOMException]":case"[object WebAssembly.Exception]":return!0;default:return mc(e,Error)}}function Rf(e,t){return zA.call(e)===`[object ${t}]`}function NA(e){return Rf(e,"ErrorEvent")}function wE(e){return Rf(e,"DOMError")}function K4(e){return Rf(e,"DOMException")}function il(e){return Rf(e,"String")}function xC(e){return typeof e=="object"&&e!==null&&"__sentry_template_string__"in e&&"__sentry_template_values__"in e}function Ry(e){return e===null||xC(e)||typeof e!="object"&&typeof e!="function"}function xm(e){return Rf(e,"Object")}function ky(e){return typeof Event<"u"&&mc(e,Event)}function X4(e){return typeof Element<"u"&&mc(e,Element)}function Q4(e){return Rf(e,"RegExp")}function Um(e){return!!(e?.then&&typeof e.then=="function")}function Z4(e){return xm(e)&&"nativeEvent"in e&&"preventDefault"in e&&"stopPropagation"in e}function mc(e,t){try{return e instanceof t}catch{return!1}}function $A(e){return!!(typeof e=="object"&&e!==null&&(e.__isVue||e._isVue||e.__v_isVNode))}function FA(e){return typeof Request<"u"&&mc(e,Request)}const SC=Kn,J4=80;function BA(e,t={}){if(!e)return"";try{let n=e;const r=5,o=[];let s=0,l=0;const u=" > ",d=u.length;let p;const m=Array.isArray(t)?t:t.keyAttrs,b=!Array.isArray(t)&&t.maxStringLength||J4;for(;n&&s++1&&l+o.length*d+p.length>=b));)o.push(p),l+=p.length,n=n.parentNode;return o.reverse().join(u)}catch{return""}}function eF(e,t){const n=e,r=[];if(!n?.tagName)return"";if(SC.HTMLElement&&n instanceof HTMLElement&&n.dataset){if(n.dataset.sentryComponent)return n.dataset.sentryComponent;if(n.dataset.sentryElement)return n.dataset.sentryElement}r.push(n.tagName.toLowerCase());const o=t?.length?t.filter(l=>n.getAttribute(l)).map(l=>[l,n.getAttribute(l)]):null;if(o?.length)o.forEach(l=>{r.push(`[${l[0]}="${l[1]}"]`)});else{n.id&&r.push(`#${n.id}`);const l=n.className;if(l&&il(l)){const u=l.split(/\s+/);for(const d of u)r.push(`.${d}`)}}const s=["aria-label","type","name","title","alt"];for(const l of s){const u=n.getAttribute(l);u&&r.push(`[${l}="${u}"]`)}return r.join("")}function CC(){try{return SC.document.location.href}catch{return""}}function tF(e){if(!SC.HTMLElement)return null;let t=e;const n=5;for(let r=0;r"}}function TE(e){if(typeof e=="object"&&e!==null){const t={};for(const n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n]);return t}else return{}}function nF(e){const t=Object.keys(HA(e));return t.sort(),t[0]?t.join(", "):"[object has no keys]"}let Sd;function Py(e){if(Sd!==void 0)return Sd?Sd(e):e();const t=Symbol.for("__SENTRY_SAFE_RANDOM_ID_WRAPPER__"),n=Kn;return t in n&&typeof n[t]=="function"?(Sd=n[t],Sd(e)):(Sd=null,e())}function Yb(){return Py(()=>Math.random())}function Ay(){return Py(()=>Date.now())}function W1(e,t=0){return typeof e!="string"||t===0||e.length<=t?e:`${e.slice(0,t)}...`}function jE(e,t){if(!Array.isArray(e))return"";const n=[];for(let r=0;rbb(e,r,n))}function rF(){const e=Kn;return e.crypto||e.msCrypto}let A0;function oF(){return Yb()*16}function di(e=rF()){try{if(e?.randomUUID)return Py(()=>e.randomUUID()).replace(/-/g,"")}catch{}return A0||(A0="10000000100040008000"+1e11),A0.replace(/[018]/g,t=>(t^(oF()&15)>>t/4).toString(16))}function qA(e){return e.exception?.values?.[0]}function su(e){const{message:t,event_id:n}=e;if(t)return t;const r=qA(e);return r?r.type&&r.value?`${r.type}: ${r.value}`:r.type||r.value||n||"":n||""}function G1(e,t,n){const r=e.exception=e.exception||{},o=r.values=r.values||[],s=o[0]=o[0]||{};s.value||(s.value=t||""),s.type||(s.type="Error")}function rf(e,t){const n=qA(e);if(!n)return;const r={type:"generic",handled:!0},o=n.mechanism;if(n.mechanism={...r,...o,...t},t&&"data"in t){const s={...o?.data,...t.data};n.mechanism.data=s}}function EE(e){if(sF(e))return!0;try{hc(e,"__sentry_captured__",!0)}catch{}return!1}function sF(e){try{return e.__sentry_captured__}catch{}}const UA=1e3;function Wm(){return Ay()/UA}function iF(){const{performance:e}=Kn;if(!e?.now||!e.timeOrigin)return Wm;const t=e.timeOrigin;return()=>(t+Py(()=>e.now()))/UA}let ME;function al(){return(ME??(ME=iF()))()}function aF(e){const t=al(),n={sid:di(),init:!0,timestamp:t,started:t,duration:0,status:"ok",errors:0,ignoreDuration:!1,toJSON:()=>cF(n)};return e&&of(n,e),n}function of(e,t={}){if(t.user&&(!e.ipAddress&&t.user.ip_address&&(e.ipAddress=t.user.ip_address),!e.did&&!t.did&&(e.did=t.user.id||t.user.email||t.user.username)),e.timestamp=t.timestamp||al(),t.abnormal_mechanism&&(e.abnormal_mechanism=t.abnormal_mechanism),t.ignoreDuration&&(e.ignoreDuration=t.ignoreDuration),t.sid&&(e.sid=t.sid.length===32?t.sid:di()),t.init!==void 0&&(e.init=t.init),!e.did&&t.did&&(e.did=`${t.did}`),typeof t.started=="number"&&(e.started=t.started),e.ignoreDuration)e.duration=void 0;else if(typeof t.duration=="number")e.duration=t.duration;else{const n=e.timestamp-e.started;e.duration=n>=0?n:0}t.release&&(e.release=t.release),t.environment&&(e.environment=t.environment),!e.ipAddress&&t.ipAddress&&(e.ipAddress=t.ipAddress),!e.userAgent&&t.userAgent&&(e.userAgent=t.userAgent),typeof t.errors=="number"&&(e.errors=t.errors),t.status&&(e.status=t.status)}function lF(e,t){let n={};e.status==="ok"&&(n={status:"exited"}),of(e,n)}function cF(e){return{sid:`${e.sid}`,init:e.init,started:new Date(e.started*1e3).toISOString(),timestamp:new Date(e.timestamp*1e3).toISOString(),status:e.status,errors:e.errors,did:typeof e.did=="number"||typeof e.did=="string"?`${e.did}`:void 0,duration:e.duration,abnormal_mechanism:e.abnormal_mechanism,attrs:{release:e.release,environment:e.environment,ip_address:e.ipAddress,user_agent:e.userAgent}}}function Gm(e,t,n=2){if(!t||typeof t!="object"||n<=0)return t;if(e&&Object.keys(t).length===0)return e;const r={...e};for(const o in t)Object.prototype.hasOwnProperty.call(t,o)&&(r[o]=Gm(r[o],t[o],n-1));return r}function RE(){return di()}function WA(){return di().substring(16)}const Y1="_sentrySpan";function kE(e,t){t?hc(e,Y1,t):delete e[Y1]}function PE(e){return e[Y1]}const uF=100;let hu=class K1{constructor(){this._notifyingListeners=!1,this._scopeListeners=[],this._eventProcessors=[],this._breadcrumbs=[],this._attachments=[],this._user={},this._tags={},this._attributes={},this._extra={},this._contexts={},this._sdkProcessingMetadata={},this._propagationContext={traceId:RE(),sampleRand:Yb()}}clone(){const t=new K1;return t._breadcrumbs=[...this._breadcrumbs],t._tags={...this._tags},t._attributes={...this._attributes},t._extra={...this._extra},t._contexts={...this._contexts},this._contexts.flags&&(t._contexts.flags={values:[...this._contexts.flags.values]}),t._user=this._user,t._level=this._level,t._session=this._session,t._transactionName=this._transactionName,t._fingerprint=this._fingerprint,t._eventProcessors=[...this._eventProcessors],t._attachments=[...this._attachments],t._sdkProcessingMetadata={...this._sdkProcessingMetadata},t._propagationContext={...this._propagationContext},t._client=this._client,t._lastEventId=this._lastEventId,kE(t,PE(this)),t}setClient(t){this._client=t}setLastEventId(t){this._lastEventId=t}getClient(){return this._client}lastEventId(){return this._lastEventId}addScopeListener(t){this._scopeListeners.push(t)}addEventProcessor(t){return this._eventProcessors.push(t),this}setUser(t){return this._user=t||{email:void 0,id:void 0,ip_address:void 0,username:void 0},this._session&&of(this._session,{user:t}),this._notifyScopeListeners(),this}getUser(){return this._user}setTags(t){return this._tags={...this._tags,...t},this._notifyScopeListeners(),this}setTag(t,n){return this.setTags({[t]:n})}setAttributes(t){return this._attributes={...this._attributes,...t},this._notifyScopeListeners(),this}setAttribute(t,n){return this.setAttributes({[t]:n})}removeAttribute(t){return t in this._attributes&&(delete this._attributes[t],this._notifyScopeListeners()),this}setExtras(t){return this._extra={...this._extra,...t},this._notifyScopeListeners(),this}setExtra(t,n){return this._extra={...this._extra,[t]:n},this._notifyScopeListeners(),this}setFingerprint(t){return this._fingerprint=t,this._notifyScopeListeners(),this}setLevel(t){return this._level=t,this._notifyScopeListeners(),this}setTransactionName(t){return this._transactionName=t,this._notifyScopeListeners(),this}setContext(t,n){return n===null?delete this._contexts[t]:this._contexts[t]=n,this._notifyScopeListeners(),this}setSession(t){return t?this._session=t:delete this._session,this._notifyScopeListeners(),this}getSession(){return this._session}update(t){if(!t)return this;const n=typeof t=="function"?t(this):t,r=n instanceof K1?n.getScopeData():xm(n)?t:void 0,{tags:o,attributes:s,extra:l,user:u,contexts:d,level:p,fingerprint:m=[],propagationContext:b}=r||{};return this._tags={...this._tags,...o},this._attributes={...this._attributes,...s},this._extra={...this._extra,...l},this._contexts={...this._contexts,...d},u&&Object.keys(u).length&&(this._user=u),p&&(this._level=p),m.length&&(this._fingerprint=m),b&&(this._propagationContext=b),this}clear(){return this._breadcrumbs=[],this._tags={},this._attributes={},this._extra={},this._user={},this._contexts={},this._level=void 0,this._transactionName=void 0,this._fingerprint=void 0,this._session=void 0,kE(this,void 0),this._attachments=[],this.setPropagationContext({traceId:RE(),sampleRand:Yb()}),this._notifyScopeListeners(),this}addBreadcrumb(t,n){const r=typeof n=="number"?n:uF;if(r<=0)return this;const o={timestamp:Wm(),...t,message:t.message?W1(t.message,2048):t.message};return this._breadcrumbs.push(o),this._breadcrumbs.length>r&&(this._breadcrumbs=this._breadcrumbs.slice(-r),this._client?.recordDroppedEvent("buffer_overflow","log_item")),this._notifyScopeListeners(),this}getLastBreadcrumb(){return this._breadcrumbs[this._breadcrumbs.length-1]}clearBreadcrumbs(){return this._breadcrumbs=[],this._notifyScopeListeners(),this}addAttachment(t){return this._attachments.push(t),this}clearAttachments(){return this._attachments=[],this}getScopeData(){return{breadcrumbs:this._breadcrumbs,attachments:this._attachments,contexts:this._contexts,tags:this._tags,attributes:this._attributes,extra:this._extra,user:this._user,level:this._level,fingerprint:this._fingerprint||[],eventProcessors:this._eventProcessors,propagationContext:this._propagationContext,sdkProcessingMetadata:this._sdkProcessingMetadata,transactionName:this._transactionName,span:PE(this)}}setSDKProcessingMetadata(t){return this._sdkProcessingMetadata=Gm(this._sdkProcessingMetadata,t,2),this}setPropagationContext(t){return this._propagationContext=t,this}getPropagationContext(){return this._propagationContext}captureException(t,n){const r=n?.event_id||di();if(!this._client)return mn&&Qt.warn("No client configured on scope - will not capture exception!"),r;const o=new Error("Sentry syntheticException");return this._client.captureException(t,{originalException:t,syntheticException:o,...n,event_id:r},this),r}captureMessage(t,n,r){const o=r?.event_id||di();if(!this._client)return mn&&Qt.warn("No client configured on scope - will not capture message!"),o;const s=r?.syntheticException??new Error(t);return this._client.captureMessage(t,n,{originalException:t,syntheticException:s,...r,event_id:o},this),o}captureEvent(t,n){const r=n?.event_id||di();return this._client?(this._client.captureEvent(t,{...n,event_id:r},this),r):(mn&&Qt.warn("No client configured on scope - will not capture event!"),r)}_notifyScopeListeners(){this._notifyingListeners||(this._notifyingListeners=!0,this._scopeListeners.forEach(t=>{t(this)}),this._notifyingListeners=!1)}};function dF(){return Ef("defaultCurrentScope",()=>new hu)}function fF(){return Ef("defaultIsolationScope",()=>new hu)}class pF{constructor(t,n){let r;t?r=t:r=new hu;let o;n?o=n:o=new hu,this._stack=[{scope:r}],this._isolationScope=o}withScope(t){const n=this._pushScope();let r;try{r=t(n)}catch(o){throw this._popScope(),o}return Um(r)?r.then(o=>(this._popScope(),o),o=>{throw this._popScope(),o}):(this._popScope(),r)}getClient(){return this.getStackTop().client}getScope(){return this.getStackTop().scope}getIsolationScope(){return this._isolationScope}getStackTop(){return this._stack[this._stack.length-1]}_pushScope(){const t=this.getScope().clone();return this._stack.push({client:this.getClient(),scope:t}),t}_popScope(){return this._stack.length<=1?!1:!!this._stack.pop()}}function sf(){const e=Ey(),t=My(e);return t.stack=t.stack||new pF(dF(),fF())}function mF(e){return sf().withScope(e)}function hF(e,t){const n=sf();return n.withScope(()=>(n.getStackTop().scope=e,t(e)))}function AE(e){return sf().withScope(()=>e(sf().getIsolationScope()))}function gF(){return{withIsolationScope:AE,withScope:mF,withSetScope:hF,withSetIsolationScope:(e,t)=>AE(t),getCurrentScope:()=>sf().getScope(),getIsolationScope:()=>sf().getIsolationScope()}}function _C(e){const t=My(e);return t.acs?t.acs:gF()}function hl(){const e=Ey();return _C(e).getCurrentScope()}function Mu(){const e=Ey();return _C(e).getIsolationScope()}function bF(){return Ef("globalScope",()=>new hu)}function TC(...e){const t=Ey(),n=_C(t);if(e.length===2){const[r,o]=e;return r?n.withSetScope(r,o):n.withScope(o)}return n.withScope(e[0])}function ao(){return hl().getClient()}function yF(e){const t=e.getPropagationContext(),{traceId:n,parentSpanId:r,propagationSpanId:o}=t,s={trace_id:n,span_id:o||WA()};return r&&(s.parent_span_id=r),s}const vF="sentry.source",xF="sentry.sample_rate",SF="sentry.previous_trace_sample_rate",CF="sentry.op",wF="sentry.origin",GA="sentry.profile_id",YA="sentry.exclusive_time",_F=0,TF=1,jF="_sentryScope",EF="_sentryIsolationScope";function MF(e){if(e){if(typeof e=="object"&&"deref"in e&&typeof e.deref=="function")try{return e.deref()}catch{return}return e}}function KA(e){const t=e;return{scope:t[jF],isolationScope:MF(t[EF])}}const RF="sentry-",kF=/^sentry-/;function PF(e){const t=AF(e);if(!t)return;const n=Object.entries(t).reduce((r,[o,s])=>{if(o.match(kF)){const l=o.slice(RF.length);r[l]=s}return r},{});if(Object.keys(n).length>0)return n}function AF(e){if(!(!e||!il(e)&&!Array.isArray(e)))return Array.isArray(e)?e.reduce((t,n)=>{const r=DE(n);return Object.entries(r).forEach(([o,s])=>{t[o]=s}),t},{}):DE(e)}function DE(e){return e.split(",").map(t=>{const n=t.indexOf("=");if(n===-1)return[];const r=t.slice(0,n),o=t.slice(n+1);return[r,o].map(s=>{try{return decodeURIComponent(s.trim())}catch{return}})}).reduce((t,[n,r])=>(n&&r&&(t[n]=r),t),{})}const DF=/^o(\d+)\./,OF=/^(?:(\w+):)\/\/(?:(\w+)(?::(\w+)?)?@)((?:\[[:.%\w]+\]|[\w.-]+))(?::(\d+))?\/(.+)/;function IF(e){return e==="http"||e==="https"}function kf(e,t=!1){const{host:n,path:r,pass:o,port:s,projectId:l,protocol:u,publicKey:d}=e;return`${u}://${d}${t&&o?`:${o}`:""}@${n}${s?`:${s}`:""}/${r&&`${r}/`}${l}`}function LF(e){const t=OF.exec(e);if(!t){Mf(()=>{console.error(`Invalid Sentry Dsn: ${e}`)});return}const[n,r,o="",s="",l="",u=""]=t.slice(1);let d="",p=u;const m=p.split("/");if(m.length>1&&(d=m.slice(0,-1).join("/"),p=m.pop()),p){const b=p.match(/^\d+/);b&&(p=b[0])}return XA({host:s,pass:o,path:d,projectId:p,port:l,protocol:n,publicKey:r})}function XA(e){return{protocol:e.protocol,publicKey:e.publicKey||"",pass:e.pass||"",host:e.host,port:e.port||"",path:e.path||"",projectId:e.projectId}}function zF(e){if(!mn)return!0;const{port:t,projectId:n,protocol:r}=e;return["protocol","publicKey","host","projectId"].find(l=>e[l]?!1:(Qt.error(`Invalid Sentry Dsn: ${l} missing`),!0))?!1:n.match(/^\d+$/)?IF(r)?t&&isNaN(parseInt(t,10))?(Qt.error(`Invalid Sentry Dsn: Invalid port ${t}`),!1):!0:(Qt.error(`Invalid Sentry Dsn: Invalid protocol ${r}`),!1):(Qt.error(`Invalid Sentry Dsn: Invalid projectId ${n}`),!1)}function NF(e){return e.match(DF)?.[1]}function $F(e){const t=e.getOptions(),{host:n}=e.getDsn()||{};let r;return t.orgId?r=String(t.orgId):n&&(r=NF(n)),r}function QA(e){const t=typeof e=="string"?LF(e):XA(e);if(!(!t||!zF(t)))return t}function FF(e){if(typeof e=="boolean")return Number(e);const t=typeof e=="string"?parseFloat(e):e;if(!(typeof t!="number"||isNaN(t)||t<0||t>1))return t}const ZA=1;let OE=!1;function BF(e){const{spanId:t,traceId:n,isRemote:r}=e.spanContext(),o=r?t:jC(e).parent_span_id,s=KA(e).scope,l=r?s?.getPropagationContext().propagationSpanId||WA():t;return{parent_span_id:o,span_id:l,trace_id:n}}function VF(e){if(e&&e.length>0)return e.map(({context:{spanId:t,traceId:n,traceFlags:r,...o},attributes:s})=>({span_id:t,trace_id:n,sampled:r===ZA,attributes:s,...o}))}function IE(e){return typeof e=="number"?LE(e):Array.isArray(e)?e[0]+e[1]/1e9:e instanceof Date?LE(e.getTime()):al()}function LE(e){return e>9999999999?e/1e3:e}function jC(e){if(qF(e))return e.getSpanJSON();const{spanId:t,traceId:n}=e.spanContext();if(HF(e)){const{attributes:r,startTime:o,name:s,endTime:l,status:u,links:d}=e,p="parentSpanId"in e?e.parentSpanId:"parentSpanContext"in e?e.parentSpanContext?.spanId:void 0;return{span_id:t,trace_id:n,data:r,description:s,parent_span_id:p,start_timestamp:IE(o),timestamp:IE(l)||void 0,status:WF(u),op:r[CF],origin:r[wF],links:VF(d)}}return{span_id:t,trace_id:n,start_timestamp:0,data:{}}}function HF(e){const t=e;return!!t.attributes&&!!t.startTime&&!!t.name&&!!t.endTime&&!!t.status}function qF(e){return typeof e.getSpanJSON=="function"}function UF(e){const{traceFlags:t}=e.spanContext();return t===ZA}function WF(e){if(!(!e||e.code===_F))return e.code===TF?"ok":e.message||"internal_error"}const GF="_sentryRootSpan";function JA(e){return e[GF]||e}function zE(){OE||(Mf(()=>{console.warn("[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly or use `ignoreSpans`.")}),OE=!0)}function YF(e){if(typeof __SENTRY_TRACING__=="boolean"&&!__SENTRY_TRACING__)return!1;const t=ao()?.getOptions();return!!t&&(t.tracesSampleRate!=null||!!t.tracesSampler)}function NE(e){Qt.log(`Ignoring span ${e.op} - ${e.description} because it matches \`ignoreSpans\`.`)}function $E(e,t){if(!t?.length||!e.description)return!1;for(const n of t){if(XF(n)){if(bb(e.description,n))return mn&&NE(e),!0;continue}if(!n.name&&!n.op)continue;const r=n.name?bb(e.description,n.name):!0,o=n.op?e.op&&bb(e.op,n.op):!0;if(r&&o)return mn&&NE(e),!0}return!1}function KF(e,t){const n=t.parent_span_id,r=t.span_id;if(n)for(const o of e)o.parent_span_id===r&&(o.parent_span_id=n)}function XF(e){return typeof e=="string"||e instanceof RegExp}const EC="production",QF="_frozenDsc";function eD(e,t){const n=t.getOptions(),{publicKey:r}=t.getDsn()||{},o={environment:n.environment||EC,release:n.release,public_key:r,trace_id:e,org_id:$F(t)};return t.emit("createDsc",o),o}function ZF(e,t){const n=t.getPropagationContext();return n.dsc||eD(n.traceId,e)}function JF(e){const t=ao();if(!t)return{};const n=JA(e),r=jC(n),o=r.data,s=n.spanContext().traceState,l=s?.get("sentry.sample_rate")??o[xF]??o[SF];function u(x){return(typeof l=="number"||typeof l=="string")&&(x.sample_rate=`${l}`),x}const d=n[QF];if(d)return u(d);const p=s?.get("sentry.dsc"),m=p&&PF(p);if(m)return u(m);const b=eD(e.spanContext().traceId,t),y=o[vF],h=r.description;return y!=="url"&&h&&(b.transaction=h),YF()&&(b.sampled=String(UF(n)),b.sample_rand=s?.get("sentry.sample_rand")??KA(n).scope?.getPropagationContext().sampleRand.toString()),u(b),t.emit("createDsc",b,n),b}function Za(e,t=100,n=1/0){try{return X1("",e,t,n)}catch(r){return{ERROR:`**non-serializable** (${r})`}}}function tD(e,t=3,n=100*1024){const r=Za(e,t);return r3(r)>n?tD(e,t-1,n):r}function X1(e,t,n=1/0,r=1/0,o=o3()){const[s,l]=o;if(t==null||["boolean","string"].includes(typeof t)||typeof t=="number"&&Number.isFinite(t))return t;const u=e3(e,t);if(!u.startsWith("[object "))return u;if(t.__sentry_skip_normalization__)return t;const d=typeof t.__sentry_override_normalization_depth__=="number"?t.__sentry_override_normalization_depth__:n;if(d===0)return u.replace("object ","");if(s(t))return"[Circular ~]";const p=t;if(p&&typeof p.toJSON=="function")try{const h=p.toJSON();return X1("",h,d-1,r,o)}catch{}const m=Array.isArray(t)?[]:{};let b=0;const y=HA(t);for(const h in y){if(!Object.prototype.hasOwnProperty.call(y,h))continue;if(b>=r){m[h]="[MaxProperties ~]";break}const x=y[h];m[h]=X1(h,x,d-1,r,o),b++}return l(t),m}function e3(e,t){try{if(e==="domain"&&t&&typeof t=="object"&&t._events)return"[Domain]";if(e==="domainEmitter")return"[DomainEmitter]";if(typeof global<"u"&&t===global)return"[Global]";if(typeof window<"u"&&t===window)return"[Window]";if(typeof document<"u"&&t===document)return"[Document]";if($A(t))return LA(t);if(Z4(t))return"[SyntheticEvent]";if(typeof t=="number"&&!Number.isFinite(t))return`[${t}]`;if(typeof t=="function")return`[Function: ${pc(t)}]`;if(typeof t=="symbol")return`[${String(t)}]`;if(typeof t=="bigint")return`[BigInt: ${String(t)}]`;const n=t3(t);return/^HTML(\w*)Element$/.test(n)?`[HTMLElement: ${n}]`:`[object ${n}]`}catch(n){return`**non-serializable** (${n})`}}function t3(e){const t=Object.getPrototypeOf(e);return t?.constructor?t.constructor.name:"null prototype"}function n3(e){return~-encodeURI(e).split(/%..|./).length}function r3(e){return n3(JSON.stringify(e))}function o3(){const e=new WeakSet;function t(r){return e.has(r)?!0:(e.add(r),!1)}function n(r){e.delete(r)}return[t,n]}function Pf(e,t=[]){return[e,t]}function s3(e,t){const[n,r]=e;return[n,[...r,t]]}function Q1(e,t){const n=e[1];for(const r of n){const o=r[0].type;if(t(r,o))return!0}return!1}function i3(e,t){return Q1(e,(n,r)=>t.includes(r))}function Z1(e){const t=My(Kn);return t.encodePolyfill?t.encodePolyfill(e):new TextEncoder().encode(e)}function a3(e){const[t,n]=e;let r=JSON.stringify(t);function o(s){typeof r=="string"?r=typeof s=="string"?r+s:[Z1(r),s]:r.push(typeof s=="string"?Z1(s):s)}for(const s of n){const[l,u]=s;if(o(` -${JSON.stringify(l)} -`),typeof u=="string"||u instanceof Uint8Array)o(u);else{let d;try{d=JSON.stringify(u)}catch{d=JSON.stringify(Za(u))}o(d)}}return typeof r=="string"?r:l3(r)}function l3(e){const t=e.reduce((o,s)=>o+s.length,0),n=new Uint8Array(t);let r=0;for(const o of e)n.set(o,r),r+=o.length;return n}function c3(e){const t=typeof e.data=="string"?Z1(e.data):e.data;return[{type:"attachment",length:t.length,filename:e.filename,content_type:e.contentType,attachment_type:e.attachmentType},t]}const u3={session:"session",sessions:"session",attachment:"attachment",transaction:"transaction",event:"error",client_report:"internal",user_report:"default",profile:"profile",profile_chunk:"profile",replay_event:"replay",replay_recording:"replay",check_in:"monitor",feedback:"feedback",span:"span",raw_security:"security",log:"log_item",metric:"metric",trace_metric:"metric"};function FE(e){return u3[e]}function nD(e){if(!e?.sdk)return;const{name:t,version:n}=e.sdk;return{name:t,version:n}}function d3(e,t,n,r){const o=e.sdkProcessingMetadata?.dynamicSamplingContext;return{event_id:e.event_id,sent_at:new Date().toISOString(),...t&&{sdk:t},...!!n&&r&&{dsn:kf(r)},...o&&{trace:o}}}function f3(e,t){if(!t)return e;const n=e.sdk||{};return e.sdk={...n,name:n.name||t.name,version:n.version||t.version,integrations:[...e.sdk?.integrations||[],...t.integrations||[]],packages:[...e.sdk?.packages||[],...t.packages||[]],settings:e.sdk?.settings||t.settings?{...e.sdk?.settings,...t.settings}:void 0},e}function p3(e,t,n,r){const o=nD(n),s={sent_at:new Date().toISOString(),...o&&{sdk:o},...!!r&&t&&{dsn:kf(t)}},l="aggregates"in e?[{type:"sessions"},e]:[{type:"session"},e.toJSON()];return Pf(s,[l])}function m3(e,t,n,r){const o=nD(n),s=e.type&&e.type!=="replay_event"?e.type:"event";f3(e,n?.sdk);const l=d3(e,o,r,t);return delete e.sdkProcessingMetadata,Pf(l,[[{type:s},e]])}const D0=0,BE=1,VE=2;function Oy(e){return new Sm(t=>{t(e)})}function MC(e){return new Sm((t,n)=>{n(e)})}class Sm{constructor(t){this._state=D0,this._handlers=[],this._runExecutor(t)}then(t,n){return new Sm((r,o)=>{this._handlers.push([!1,s=>{if(!t)r(s);else try{r(t(s))}catch(l){o(l)}},s=>{if(!n)o(s);else try{r(n(s))}catch(l){o(l)}}]),this._executeHandlers()})}catch(t){return this.then(n=>n,t)}finally(t){return new Sm((n,r)=>{let o,s;return this.then(l=>{s=!1,o=l,t&&t()},l=>{s=!0,o=l,t&&t()}).then(()=>{if(s){r(o);return}n(o)})})}_executeHandlers(){if(this._state===D0)return;const t=this._handlers.slice();this._handlers=[],t.forEach(n=>{n[0]||(this._state===BE&&n[1](this._value),this._state===VE&&n[2](this._value),n[0]=!0)})}_runExecutor(t){const n=(s,l)=>{if(this._state===D0){if(Um(l)){l.then(r,o);return}this._state=s,this._value=l,this._executeHandlers()}},r=s=>{n(BE,s)},o=s=>{n(VE,s)};try{t(r,o)}catch(s){o(s)}}}function h3(e,t,n,r=0){try{const o=J1(t,n,e,r);return Um(o)?o:Oy(o)}catch(o){return MC(o)}}function J1(e,t,n,r){const o=n[r];if(!e||!o)return e;const s=o({...e},t);return mn&&s===null&&Qt.log(`Event processor "${o.id||"?"}" dropped event`),Um(s)?s.then(l=>J1(l,t,n,r+1)):J1(s,t,n,r+1)}let Qc,HE,qE,Jl;function g3(e){const t=Kn._sentryDebugIds,n=Kn._debugIds;if(!t&&!n)return{};const r=t?Object.keys(t):[],o=n?Object.keys(n):[];if(Jl&&r.length===HE&&o.length===qE)return Jl;HE=r.length,qE=o.length,Jl={},Qc||(Qc={});const s=(l,u)=>{for(const d of l){const p=u[d],m=Qc?.[d];if(m&&Jl&&p)Jl[m[0]]=p,Qc&&(Qc[d]=[m[0],p]);else if(p){const b=e(d);for(let y=b.length-1;y>=0;y--){const x=b[y]?.filename;if(x&&Jl&&Qc){Jl[x]=p,Qc[d]=[x,p];break}}}}};return t&&s(r,t),n&&s(o,n),Jl}function b3(e,t){const{fingerprint:n,span:r,breadcrumbs:o,sdkProcessingMetadata:s}=t;v3(e,t),r&&C3(e,r),w3(e,n),x3(e,o),S3(e,s)}function UE(e,t){const{extra:n,tags:r,attributes:o,user:s,contexts:l,level:u,sdkProcessingMetadata:d,breadcrumbs:p,fingerprint:m,eventProcessors:b,attachments:y,propagationContext:h,transactionName:x,span:C}=t;Op(e,"extra",n),Op(e,"tags",r),Op(e,"attributes",o),Op(e,"user",s),Op(e,"contexts",l),e.sdkProcessingMetadata=Gm(e.sdkProcessingMetadata,d,2),u&&(e.level=u),x&&(e.transactionName=x),C&&(e.span=C),p.length&&(e.breadcrumbs=[...e.breadcrumbs,...p]),m.length&&(e.fingerprint=[...e.fingerprint,...m]),b.length&&(e.eventProcessors=[...e.eventProcessors,...b]),y.length&&(e.attachments=[...e.attachments,...y]),e.propagationContext={...e.propagationContext,...h}}function Op(e,t,n){e[t]=Gm(e[t],n,1)}function y3(e,t){const n=bF().getScopeData();return e&&UE(n,e.getScopeData()),t&&UE(n,t.getScopeData()),n}function v3(e,t){const{extra:n,tags:r,user:o,contexts:s,level:l,transactionName:u}=t;Object.keys(n).length&&(e.extra={...n,...e.extra}),Object.keys(r).length&&(e.tags={...r,...e.tags}),Object.keys(o).length&&(e.user={...o,...e.user}),Object.keys(s).length&&(e.contexts={...s,...e.contexts}),l&&(e.level=l),u&&e.type!=="transaction"&&(e.transaction=u)}function x3(e,t){const n=[...e.breadcrumbs||[],...t];e.breadcrumbs=n.length?n:void 0}function S3(e,t){e.sdkProcessingMetadata={...e.sdkProcessingMetadata,...t}}function C3(e,t){e.contexts={trace:BF(t),...e.contexts},e.sdkProcessingMetadata={dynamicSamplingContext:JF(t),...e.sdkProcessingMetadata};const n=JA(t),r=jC(n).description;r&&!e.transaction&&e.type==="transaction"&&(e.transaction=r)}function w3(e,t){e.fingerprint=e.fingerprint?Array.isArray(e.fingerprint)?e.fingerprint:[e.fingerprint]:[],t&&(e.fingerprint=e.fingerprint.concat(t)),e.fingerprint.length||delete e.fingerprint}function _3(e,t,n,r,o,s){const{normalizeDepth:l=3,normalizeMaxBreadth:u=1e3}=e,d={...t,event_id:t.event_id||n.event_id||di(),timestamp:t.timestamp||Wm()},p=n.integrations||e.integrations.map(S=>S.name);T3(d,e),M3(d,p),o&&o.emit("applyFrameMetadata",t),t.type===void 0&&j3(d,e.stackParser);const m=k3(r,n.captureContext);n.mechanism&&rf(d,n.mechanism);const b=o?o.getEventProcessors():[],y=y3(s,m),h=[...n.attachments||[],...y.attachments];h.length&&(n.attachments=h),b3(d,y);const x=[...b,...y.eventProcessors];return h3(x,d,n).then(S=>(S&&E3(S),typeof l=="number"&&l>0?R3(S,l,u):S))}function T3(e,t){const{environment:n,release:r,dist:o,maxValueLength:s}=t;e.environment=e.environment||n||EC,!e.release&&r&&(e.release=r),!e.dist&&o&&(e.dist=o);const l=e.request;l?.url&&s&&(l.url=W1(l.url,s)),s&&e.exception?.values?.forEach(u=>{u.value&&(u.value=W1(u.value,s))})}function j3(e,t){const n=g3(t);e.exception?.values?.forEach(r=>{r.stacktrace?.frames?.forEach(o=>{o.filename&&(o.debug_id=n[o.filename])})})}function E3(e){const t={};if(e.exception?.values?.forEach(r=>{r.stacktrace?.frames?.forEach(o=>{o.debug_id&&(o.abs_path?t[o.abs_path]=o.debug_id:o.filename&&(t[o.filename]=o.debug_id),delete o.debug_id)})}),Object.keys(t).length===0)return;e.debug_meta=e.debug_meta||{},e.debug_meta.images=e.debug_meta.images||[];const n=e.debug_meta.images;Object.entries(t).forEach(([r,o])=>{n.push({type:"sourcemap",code_file:r,debug_id:o})})}function M3(e,t){t.length>0&&(e.sdk=e.sdk||{},e.sdk.integrations=[...e.sdk.integrations||[],...t])}function R3(e,t,n){if(!e)return null;const r={...e,...e.breadcrumbs&&{breadcrumbs:e.breadcrumbs.map(o=>({...o,...o.data&&{data:Za(o.data,t,n)}}))},...e.user&&{user:Za(e.user,t,n)},...e.contexts&&{contexts:Za(e.contexts,t,n)},...e.extra&&{extra:Za(e.extra,t,n)}};return e.contexts?.trace&&r.contexts&&(r.contexts.trace=e.contexts.trace,e.contexts.trace.data&&(r.contexts.trace.data=Za(e.contexts.trace.data,t,n))),e.spans&&(r.spans=e.spans.map(o=>({...o,...o.data&&{data:Za(o.data,t,n)}}))),e.contexts?.flags&&r.contexts&&(r.contexts.flags=Za(e.contexts.flags,3,n)),r}function k3(e,t){if(!t)return e;const n=e?e.clone():new hu;return n.update(t),n}function P3(e){if(e)return A3(e)?{captureContext:e}:O3(e)?{captureContext:e}:e}function A3(e){return e instanceof hu||typeof e=="function"}const D3=["user","level","extra","contexts","tags","fingerprint","propagationContext"];function O3(e){return Object.keys(e).some(t=>D3.includes(t))}function Wd(e,t){return hl().captureException(e,P3(t))}function rD(e,t){return hl().captureEvent(e,t)}function I3(e,t){Mu().setContext(e,t)}function L3(){return Mu().lastEventId()}function WE(e){const t=Mu(),n=hl(),{userAgent:r}=Kn.navigator||{},o=aF({user:n.getUser()||t.getUser(),...r&&{userAgent:r},...e}),s=t.getSession();return s?.status==="ok"&&of(s,{status:"exited"}),oD(),t.setSession(o),o}function oD(){const e=Mu(),n=hl().getSession()||e.getSession();n&&lF(n),sD(),e.setSession()}function sD(){const e=Mu(),t=ao(),n=e.getSession();n&&t&&t.captureSession(n)}function GE(e=!1){if(e){oD();return}sD()}const z3="7";function iD(e){const t=e.protocol?`${e.protocol}:`:"",n=e.port?`:${e.port}`:"";return`${t}//${e.host}${n}${e.path?`/${e.path}`:""}/api/`}function N3(e){return`${iD(e)}${e.projectId}/envelope/`}function $3(e,t){const n={sentry_version:z3};return e.publicKey&&(n.sentry_key=e.publicKey),t&&(n.sentry_client=`${t.name}/${t.version}`),new URLSearchParams(n).toString()}function F3(e,t,n){return t||`${N3(e)}?${$3(e,n)}`}function B3(e,t){const n=QA(e);if(!n)return"";const r=`${iD(n)}embed/error-page/`;let o=`dsn=${kf(n)}`;for(const s in t)if(s!=="dsn"&&s!=="onClose")if(s==="user"){const l=t.user;if(!l)continue;l.name&&(o+=`&name=${encodeURIComponent(l.name)}`),l.email&&(o+=`&email=${encodeURIComponent(l.email)}`)}else o+=`&${encodeURIComponent(s)}=${encodeURIComponent(t[s])}`;return`${r}?${o}`}const YE=[];function V3(e){const t={};return e.forEach(n=>{const{name:r}=n,o=t[r];o&&!o.isDefaultInstance&&n.isDefaultInstance||(t[r]=n)}),Object.values(t)}function H3(e){const t=e.defaultIntegrations||[],n=e.integrations;t.forEach(o=>{o.isDefaultInstance=!0});let r;if(Array.isArray(n))r=[...t,...n];else if(typeof n=="function"){const o=n(t);r=Array.isArray(o)?o:[o]}else r=t;return V3(r)}function q3(e,t){const n={};return t.forEach(r=>{r&&aD(e,r,n)}),n}function KE(e,t){for(const n of t)n?.afterAllSetup&&n.afterAllSetup(e)}function aD(e,t,n){if(n[t.name]){mn&&Qt.log(`Integration skipped because it was already installed: ${t.name}`);return}if(n[t.name]=t,!YE.includes(t.name)&&typeof t.setupOnce=="function"&&(t.setupOnce(),YE.push(t.name)),t.setup&&typeof t.setup=="function"&&t.setup(e),typeof t.preprocessEvent=="function"){const r=t.preprocessEvent.bind(t);e.on("preprocessEvent",(o,s)=>r(o,s,e))}if(typeof t.processEvent=="function"){const r=t.processEvent.bind(t),o=Object.assign((s,l)=>r(s,l,e),{id:t.name});e.addEventProcessor(o)}mn&&Qt.log(`Integration installed: ${t.name}`)}function U3(e){return[{type:"log",item_count:e.length,content_type:"application/vnd.sentry.items.log+json"},{items:e}]}function W3(e,t,n,r){const o={};return t?.sdk&&(o.sdk={name:t.sdk.name,version:t.sdk.version}),n&&r&&(o.dsn=kf(r)),Pf(o,[U3(e)])}function lD(e,t){const n=t??G3(e)??[];if(n.length===0)return;const r=e.getOptions(),o=W3(n,r._metadata,r.tunnel,e.getDsn());cD().set(e,[]),e.emit("flushLogs"),e.sendEnvelope(o)}function G3(e){return cD().get(e)}function cD(){return Ef("clientToLogBufferMap",()=>new WeakMap)}function Y3(e){return[{type:"trace_metric",item_count:e.length,content_type:"application/vnd.sentry.items.trace-metric+json"},{items:e}]}function K3(e,t,n,r){const o={};return t?.sdk&&(o.sdk={name:t.sdk.name,version:t.sdk.version}),n&&r&&(o.dsn=kf(r)),Pf(o,[Y3(e)])}function uD(e,t){const n=t??X3(e)??[];if(n.length===0)return;const r=e.getOptions(),o=K3(n,r._metadata,r.tunnel,e.getDsn());dD().set(e,[]),e.emit("flushMetrics"),e.sendEnvelope(o)}function X3(e){return dD().get(e)}function dD(){return Ef("clientToMetricBufferMap",()=>new WeakMap)}const RC=Symbol.for("SentryBufferFullError");function kC(e=100){const t=new Set;function n(){return t.sizer(u),()=>r(u)),u}function s(l){if(!t.size)return Oy(!0);const u=Promise.allSettled(Array.from(t)).then(()=>!0);if(!l)return u;const d=[u,new Promise(p=>setTimeout(()=>p(!1),l))];return Promise.race(d)}return{get $(){return Array.from(t)},add:o,drain:s}}const Q3=60*1e3;function Z3(e,t=Ay()){const n=parseInt(`${e}`,10);if(!isNaN(n))return n*1e3;const r=Date.parse(`${e}`);return isNaN(r)?Q3:r-t}function J3(e,t){return e[t]||e.all||0}function eB(e,t,n=Ay()){return J3(e,t)>n}function tB(e,{statusCode:t,headers:n},r=Ay()){const o={...e},s=n?.["x-sentry-rate-limits"],l=n?.["retry-after"];if(s)for(const u of s.trim().split(",")){const[d,p,,,m]=u.split(":",5),b=parseInt(d,10),y=(isNaN(b)?60:b)*1e3;if(!p)o.all=r+y;else for(const h of p.split(";"))h==="metric_bucket"?(!m||m.split(";").includes("custom"))&&(o[h]=r+y):o[h]=r+y}else l?o.all=r+Z3(l,r):t===429&&(o.all=r+60*1e3);return o}const fD=64;function nB(e,t,n=kC(e.bufferSize||fD)){let r={};const o=l=>n.drain(l);function s(l){const u=[];if(Q1(l,(b,y)=>{const h=FE(y);eB(r,h)?e.recordDroppedEvent("ratelimit_backoff",h):u.push(b)}),u.length===0)return Promise.resolve({});const d=Pf(l[0],u),p=b=>{if(i3(d,["client_report"])){mn&&Qt.warn(`Dropping client report. Will not send outcomes (reason: ${b}).`);return}Q1(d,(y,h)=>{e.recordDroppedEvent(b,FE(h))})},m=()=>t({body:a3(d)}).then(b=>(b.statusCode!==void 0&&(b.statusCode<200||b.statusCode>=300)&&mn&&Qt.warn(`Sentry responded with status code ${b.statusCode} to sent event.`),r=tB(r,b),b),b=>{throw p("network_error"),mn&&Qt.error("Encountered error running transport request:",b),b});return n.add(m).then(b=>b,b=>{if(b===RC)return mn&&Qt.error("Skipped sending event because buffer is full."),p("queue_overflow"),Promise.resolve({});throw b})}return{send:s,flush:o}}function rB(e,t,n){const r=[{type:"client_report"},{timestamp:Wm(),discarded_events:e}];return Pf(t?{dsn:t}:{},[r])}function pD(e){const t=[];e.message&&t.push(e.message);try{const n=e.exception.values[e.exception.values.length-1];n?.value&&(t.push(n.value),n.type&&t.push(`${n.type}: ${n.value}`))}catch{}return t}function oB(e){const{trace_id:t,parent_span_id:n,span_id:r,status:o,origin:s,data:l,op:u}=e.contexts?.trace??{};return{data:l??{},description:e.transaction,op:u,parent_span_id:n,span_id:r??"",start_timestamp:e.start_timestamp??0,status:o,timestamp:e.timestamp,trace_id:t??"",origin:s,profile_id:l?.[GA],exclusive_time:l?.[YA],measurements:e.measurements,is_segment:!0}}function sB(e){return{type:"transaction",timestamp:e.timestamp,start_timestamp:e.start_timestamp,transaction:e.description,contexts:{trace:{trace_id:e.trace_id,span_id:e.span_id,parent_span_id:e.parent_span_id,op:e.op,status:e.status,origin:e.origin,data:{...e.data,...e.profile_id&&{[GA]:e.profile_id},...e.exclusive_time&&{[YA]:e.exclusive_time}}}},measurements:e.measurements}}const XE="Not capturing exception because it's already been captured.",QE="Discarded session because of missing or non-string release",mD=Symbol.for("SentryInternalError"),hD=Symbol.for("SentryDoNotSendEventError"),iB=5e3;function yb(e){return{message:e,[mD]:!0}}function O0(e){return{message:e,[hD]:!0}}function ZE(e){return!!e&&typeof e=="object"&&mD in e}function JE(e){return!!e&&typeof e=="object"&&hD in e}function eM(e,t,n,r,o){let s=0,l,u=!1;e.on(n,()=>{s=0,clearTimeout(l),u=!1}),e.on(t,d=>{s+=r(d),s>=8e5?o(e):u||(u=!0,l=setTimeout(()=>{o(e)},iB))}),e.on("flush",()=>{o(e)})}class aB{constructor(t){if(this._options=t,this._integrations={},this._numProcessing=0,this._outcomes={},this._hooks={},this._eventProcessors=[],this._promiseBuffer=kC(t.transportOptions?.bufferSize??fD),t.dsn?this._dsn=QA(t.dsn):mn&&Qt.warn("No DSN provided, client will not send events."),this._dsn){const r=F3(this._dsn,t.tunnel,t._metadata?t._metadata.sdk:void 0);this._transport=t.transport({tunnel:this._options.tunnel,recordDroppedEvent:this.recordDroppedEvent.bind(this),...t.transportOptions,url:r})}this._options.enableLogs=this._options.enableLogs??this._options._experiments?.enableLogs,this._options.enableLogs&&eM(this,"afterCaptureLog","flushLogs",dB,lD),(this._options.enableMetrics??this._options._experiments?.enableMetrics??!0)&&eM(this,"afterCaptureMetric","flushMetrics",uB,uD)}captureException(t,n,r){const o=di();if(EE(t))return mn&&Qt.log(XE),o;const s={event_id:o,...n};return this._process(()=>this.eventFromException(t,s).then(l=>this._captureEvent(l,s,r)).then(l=>l),"error"),s.event_id}captureMessage(t,n,r,o){const s={event_id:di(),...r},l=xC(t)?t:String(t),u=Ry(t),d=u?this.eventFromMessage(l,n,s):this.eventFromException(t,s);return this._process(()=>d.then(p=>this._captureEvent(p,s,o)),u?"unknown":"error"),s.event_id}captureEvent(t,n,r){const o=di();if(n?.originalException&&EE(n.originalException))return mn&&Qt.log(XE),o;const s={event_id:o,...n},l=t.sdkProcessingMetadata||{},u=l.capturedSpanScope,d=l.capturedSpanIsolationScope,p=tM(t.type);return this._process(()=>this._captureEvent(t,s,u||r,d),p),s.event_id}captureSession(t){this.sendSession(t),of(t,{init:!1})}getDsn(){return this._dsn}getOptions(){return this._options}getSdkMetadata(){return this._options._metadata}getTransport(){return this._transport}async flush(t){const n=this._transport;if(!n)return!0;this.emit("flush");const r=await this._isClientDoneProcessing(t),o=await n.flush(t);return r&&o}async close(t){const n=await this.flush(t);return this.getOptions().enabled=!1,this.emit("close"),n}getEventProcessors(){return this._eventProcessors}addEventProcessor(t){this._eventProcessors.push(t)}init(){(this._isEnabled()||this._options.integrations.some(({name:t})=>t.startsWith("Spotlight")))&&this._setupIntegrations()}getIntegrationByName(t){return this._integrations[t]}addIntegration(t){const n=this._integrations[t.name];aD(this,t,this._integrations),n||KE(this,[t])}sendEvent(t,n={}){this.emit("beforeSendEvent",t,n);let r=m3(t,this._dsn,this._options._metadata,this._options.tunnel);for(const o of n.attachments||[])r=s3(r,c3(o));this.sendEnvelope(r).then(o=>this.emit("afterSendEvent",t,o))}sendSession(t){const{release:n,environment:r=EC}=this._options;if("aggregates"in t){const s=t.attrs||{};if(!s.release&&!n){mn&&Qt.warn(QE);return}s.release=s.release||n,s.environment=s.environment||r,t.attrs=s}else{if(!t.release&&!n){mn&&Qt.warn(QE);return}t.release=t.release||n,t.environment=t.environment||r}this.emit("beforeSendSession",t);const o=p3(t,this._dsn,this._options._metadata,this._options.tunnel);this.sendEnvelope(o)}recordDroppedEvent(t,n,r=1){if(this._options.sendClientReports){const o=`${t}:${n}`;mn&&Qt.log(`Recording outcome: "${o}"${r>1?` (${r} times)`:""}`),this._outcomes[o]=(this._outcomes[o]||0)+r}}on(t,n){const r=this._hooks[t]=this._hooks[t]||new Set,o=(...s)=>n(...s);return r.add(o),()=>{r.delete(o)}}emit(t,...n){const r=this._hooks[t];r&&r.forEach(o=>o(...n))}async sendEnvelope(t){if(this.emit("beforeEnvelope",t),this._isEnabled()&&this._transport)try{return await this._transport.send(t)}catch(n){return mn&&Qt.error("Error while sending envelope:",n),{}}return mn&&Qt.error("Transport disabled"),{}}_setupIntegrations(){const{integrations:t}=this._options;this._integrations=q3(this,t),KE(this,t)}_updateSessionFromEvent(t,n){let r=n.level==="fatal",o=!1;const s=n.exception?.values;if(s){o=!0,r=!1;for(const d of s)if(d.mechanism?.handled===!1){r=!0;break}}const l=t.status==="ok";(l&&t.errors===0||l&&r)&&(of(t,{...r&&{status:"crashed"},errors:t.errors||Number(o||r)}),this.captureSession(t))}async _isClientDoneProcessing(t){let n=0;for(;!t||nsetTimeout(r,1)),!this._numProcessing)return!0;n++}return!1}_isEnabled(){return this.getOptions().enabled!==!1&&this._transport!==void 0}_prepareEvent(t,n,r,o){const s=this.getOptions(),l=Object.keys(this._integrations);return!n.integrations&&l?.length&&(n.integrations=l),this.emit("preprocessEvent",t,n),t.type||o.setLastEventId(t.event_id||n.event_id),_3(s,t,n,r,this,o).then(u=>{if(u===null)return u;this.emit("postprocessEvent",u,n),u.contexts={trace:yF(r),...u.contexts};const d=ZF(this,r);return u.sdkProcessingMetadata={dynamicSamplingContext:d,...u.sdkProcessingMetadata},u})}_captureEvent(t,n={},r=hl(),o=Mu()){return mn&&eS(t)&&Qt.log(`Captured error event \`${pD(t)[0]||""}\``),this._processEvent(t,n,r,o).then(s=>s.event_id,s=>{mn&&(JE(s)?Qt.log(s.message):ZE(s)?Qt.warn(s.message):Qt.warn(s))})}_processEvent(t,n,r,o){const s=this.getOptions(),{sampleRate:l}=s,u=gD(t),d=eS(t),m=`before send for type \`${t.type||"error"}\``,b=typeof l>"u"?void 0:FF(l);if(d&&typeof b=="number"&&Yb()>b)return this.recordDroppedEvent("sample_rate","error"),MC(O0(`Discarding event because it's not included in the random sample (sampling rate = ${l})`));const y=tM(t.type);return this._prepareEvent(t,n,r,o).then(h=>{if(h===null)throw this.recordDroppedEvent("event_processor",y),O0("An event processor returned `null`, will not send event.");if(n.data&&n.data.__sentry__===!0)return h;const C=cB(this,s,h,n);return lB(C,m)}).then(h=>{if(h===null){if(this.recordDroppedEvent("before_send",y),u){const w=1+(t.spans||[]).length;this.recordDroppedEvent("before_send","span",w)}throw O0(`${m} returned \`null\`, will not send event.`)}const x=r.getSession()||o.getSession();if(d&&x&&this._updateSessionFromEvent(x,h),u){const S=h.sdkProcessingMetadata?.spanCountBeforeProcessing||0,w=h.spans?h.spans.length:0,_=S-w;_>0&&this.recordDroppedEvent("before_send","span",_)}const C=h.transaction_info;if(u&&C&&h.transaction!==t.transaction){const S="custom";h.transaction_info={...C,source:S}}return this.sendEvent(h,n),h}).then(null,h=>{throw JE(h)||ZE(h)?h:(this.captureException(h,{mechanism:{handled:!1,type:"internal"},data:{__sentry__:!0},originalException:h}),yb(`Event processing pipeline threw an error, original event will not be sent. Details have been sent as a new event. -Reason: ${h}`))})}_process(t,n){this._numProcessing++,this._promiseBuffer.add(t).then(r=>(this._numProcessing--,r),r=>(this._numProcessing--,r===RC&&this.recordDroppedEvent("queue_overflow",n),r))}_clearOutcomes(){const t=this._outcomes;return this._outcomes={},Object.entries(t).map(([n,r])=>{const[o,s]=n.split(":");return{reason:o,category:s,quantity:r}})}_flushOutcomes(){mn&&Qt.log("Flushing outcomes...");const t=this._clearOutcomes();if(t.length===0){mn&&Qt.log("No outcomes to send");return}if(!this._dsn){mn&&Qt.log("No dsn provided, will not send outcomes");return}mn&&Qt.log("Sending outcomes:",t);const n=rB(t,this._options.tunnel&&kf(this._dsn));this.sendEnvelope(n)}}function tM(e){return e==="replay_event"?"replay":e||"error"}function lB(e,t){const n=`${t} must return \`null\` or a valid event.`;if(Um(e))return e.then(r=>{if(!xm(r)&&r!==null)throw yb(n);return r},r=>{throw yb(`${t} rejected with ${r}`)});if(!xm(e)&&e!==null)throw yb(n);return e}function cB(e,t,n,r){const{beforeSend:o,beforeSendTransaction:s,beforeSendSpan:l,ignoreSpans:u}=t;let d=n;if(eS(d)&&o)return o(d,r);if(gD(d)){if(l||u){const p=oB(d);if(u?.length&&$E(p,u))return null;if(l){const m=l(p);m?d=Gm(n,sB(m)):zE()}if(d.spans){const m=[],b=d.spans;for(const h of b){if(u?.length&&$E(h,u)){KF(b,h);continue}if(l){const x=l(h);x?m.push(x):(zE(),m.push(h))}else m.push(h)}const y=d.spans.length-m.length;y&&e.recordDroppedEvent("before_send","span",y),d.spans=m}}if(s){if(d.spans){const p=d.spans.length;d.sdkProcessingMetadata={...n.sdkProcessingMetadata,spanCountBeforeProcessing:p}}return s(d,r)}}return d}function eS(e){return e.type===void 0}function gD(e){return e.type==="transaction"}function uB(e){let t=0;return e.name&&(t+=e.name.length*2),t+=8,t+bD(e.attributes)}function dB(e){let t=0;return e.message&&(t+=e.message.length*2),t+bD(e.attributes)}function bD(e){if(!e)return 0;let t=0;return Object.values(e).forEach(n=>{Array.isArray(n)?t+=n.length*nM(n[0]):Ry(n)?t+=nM(n):t+=100}),t}function nM(e){return typeof e=="string"?e.length*2:typeof e=="number"?8:typeof e=="boolean"?4:0}function fB(e){return qm(e)&&"__sentry_fetch_url_host__"in e&&typeof e.__sentry_fetch_url_host__=="string"}function rM(e){return fB(e)?`${e.message} (${e.__sentry_fetch_url_host__})`:e.message}function pB(e,t){t.debug===!0&&(mn?Qt.enable():Mf(()=>{console.warn("[Sentry] Cannot initialize SDK with `debug` option using a non-debug bundle.")})),hl().update(t.initialScope);const r=new e(t);return mB(r),r.init(),r}function mB(e){hl().setClient(e)}function I0(e){if(!e)return{};const t=e.match(/^(([^:/?#]+):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/);if(!t)return{};const n=t[6]||"",r=t[8]||"";return{host:t[4],path:t[5],protocol:t[2],search:n,hash:r,relative:t[5]+n+r}}function hB(e,t=!0){if(e.startsWith("data:")){const n=e.match(/^data:([^;,]+)/),r=n?n[1]:"text/plain",o=e.includes(";base64,"),s=e.indexOf(",");let l="";if(t&&s!==-1){const u=e.slice(s+1);l=u.length>10?`${u.slice(0,10)}... [truncated]`:u}return`data:${r}${o?",base64":""}${l?`,${l}`:""}`}return e}function gB(e){"aggregates"in e?e.attrs?.ip_address===void 0&&(e.attrs={...e.attrs,ip_address:"{{auto}}"}):e.ipAddress===void 0&&(e.ipAddress="{{auto}}")}function yD(e,t,n=[t],r="npm"){const o=e._metadata||{};o.sdk||(o.sdk={name:`sentry.javascript.${t}`,packages:n.map(s=>({name:`${r}:@sentry/${s}`,version:uu})),version:uu}),e._metadata=o}const bB=100;function gu(e,t){const n=ao(),r=Mu();if(!n)return;const{beforeBreadcrumb:o=null,maxBreadcrumbs:s=bB}=n.getOptions();if(s<=0)return;const u={timestamp:Wm(),...e},d=o?Mf(()=>o(u,t)):u;d!==null&&(n.emit&&n.emit("beforeAddBreadcrumb",d,t),r.addBreadcrumb(d,s))}let oM;const yB="FunctionToString",sM=new WeakMap,vB=(()=>({name:yB,setupOnce(){oM=Function.prototype.toString;try{Function.prototype.toString=function(...e){const t=wC(this),n=sM.has(ao())&&t!==void 0?t:this;return oM.apply(n,e)}}catch{}},setup(e){sM.set(e,!0)}})),xB=vB,SB=[/^Script error\.?$/,/^Javascript error: Script error\.? on line 0$/,/^ResizeObserver loop completed with undelivered notifications.$/,/^Cannot redefine property: googletag$/,/^Can't find variable: gmo$/,/^undefined is not an object \(evaluating 'a\.[A-Z]'\)$/,`can't redefine non-configurable property "solana"`,"vv().getRestrictions is not a function. (In 'vv().getRestrictions(1,a)', 'vv().getRestrictions' is undefined)","Can't find variable: _AutofillCallbackHandler",/^Non-Error promise rejection captured with value: Object Not Found Matching Id:\d+, MethodName:simulateEvent, ParamCount:\d+$/,/^Java exception was raised during method invocation$/],CB="EventFilters",wB=(e={})=>{let t;return{name:CB,setup(n){const r=n.getOptions();t=iM(e,r)},processEvent(n,r,o){if(!t){const s=o.getOptions();t=iM(e,s)}return TB(n,t)?null:n}}},_B=((e={})=>({...wB(e),name:"InboundFilters"}));function iM(e={},t={}){return{allowUrls:[...e.allowUrls||[],...t.allowUrls||[]],denyUrls:[...e.denyUrls||[],...t.denyUrls||[]],ignoreErrors:[...e.ignoreErrors||[],...t.ignoreErrors||[],...e.disableErrorDefaults?[]:SB],ignoreTransactions:[...e.ignoreTransactions||[],...t.ignoreTransactions||[]]}}function TB(e,t){if(e.type){if(e.type==="transaction"&&EB(e,t.ignoreTransactions))return mn&&Qt.warn(`Event dropped due to being matched by \`ignoreTransactions\` option. -Event: ${su(e)}`),!0}else{if(jB(e,t.ignoreErrors))return mn&&Qt.warn(`Event dropped due to being matched by \`ignoreErrors\` option. -Event: ${su(e)}`),!0;if(PB(e))return mn&&Qt.warn(`Event dropped due to not having an error message, error type or stacktrace. -Event: ${su(e)}`),!0;if(MB(e,t.denyUrls))return mn&&Qt.warn(`Event dropped due to being matched by \`denyUrls\` option. -Event: ${su(e)}. -Url: ${Kb(e)}`),!0;if(!RB(e,t.allowUrls))return mn&&Qt.warn(`Event dropped due to not being matched by \`allowUrls\` option. -Event: ${su(e)}. -Url: ${Kb(e)}`),!0}return!1}function jB(e,t){return t?.length?pD(e).some(n=>Dy(n,t)):!1}function EB(e,t){if(!t?.length)return!1;const n=e.transaction;return n?Dy(n,t):!1}function MB(e,t){if(!t?.length)return!1;const n=Kb(e);return n?Dy(n,t):!1}function RB(e,t){if(!t?.length)return!0;const n=Kb(e);return n?Dy(n,t):!0}function kB(e=[]){for(let t=e.length-1;t>=0;t--){const n=e[t];if(n&&n.filename!==""&&n.filename!=="[native code]")return n.filename||null}return null}function Kb(e){try{const n=[...e.exception?.values??[]].reverse().find(r=>r.mechanism?.parent_id===void 0&&r.stacktrace?.frames?.length)?.stacktrace?.frames;return n?kB(n):null}catch{return mn&&Qt.error(`Cannot extract url for event ${su(e)}`),null}}function PB(e){return e.exception?.values?.length?!e.message&&!e.exception.values.some(t=>t.stacktrace||t.type&&t.type!=="Error"||t.value):!1}function AB(e,t,n,r,o,s){if(!o.exception?.values||!s||!mc(s.originalException,Error))return;const l=o.exception.values.length>0?o.exception.values[o.exception.values.length-1]:void 0;l&&(o.exception.values=tS(e,t,r,s.originalException,n,o.exception.values,l,0))}function tS(e,t,n,r,o,s,l,u){if(s.length>=n+1)return s;let d=[...s];if(mc(r[o],Error)){aM(l,u);const p=e(t,r[o]),m=d.length;lM(p,o,m,u),d=tS(e,t,n,r[o],o,[p,...d],p,m)}return Array.isArray(r.errors)&&r.errors.forEach((p,m)=>{if(mc(p,Error)){aM(l,u);const b=e(t,p),y=d.length;lM(b,`errors[${m}]`,y,u),d=tS(e,t,n,p,o,[b,...d],b,y)}}),d}function aM(e,t){e.mechanism={handled:!0,type:"auto.core.linked_errors",...e.mechanism,...e.type==="AggregateError"&&{is_exception_group:!0},exception_id:t}}function lM(e,t,n,r){e.mechanism={handled:!0,...e.mechanism,type:"chained",source:t,exception_id:n,parent_id:r}}function DB(e){const t="console";ju(t,e),Eu(t,OB)}function OB(){"console"in Kn&&L4.forEach(function(e){e in Kn.console&&Es(Kn.console,e,function(t){return Gb[e]=t,function(...n){Fi("console",{args:n,level:e}),Gb[e]?.apply(Kn.console,n)}})})}function IB(e){return e==="warn"?"warning":["fatal","error","warning","log","info","debug"].includes(e)?e:"log"}const LB="Dedupe",zB=(()=>{let e;return{name:LB,processEvent(t){if(t.type)return t;try{if($B(t,e))return mn&&Qt.warn("Event dropped due to being a duplicate of previously captured event."),null}catch{}return e=t}}}),NB=zB;function $B(e,t){return t?!!(FB(e,t)||BB(e,t)):!1}function FB(e,t){const n=e.message,r=t.message;return!(!n&&!r||n&&!r||!n&&r||n!==r||!xD(e,t)||!vD(e,t))}function BB(e,t){const n=cM(t),r=cM(e);return!(!n||!r||n.type!==r.type||n.value!==r.value||!xD(e,t)||!vD(e,t))}function vD(e,t){let n=SE(e),r=SE(t);if(!n&&!r)return!0;if(n&&!r||!n&&r||(n=n,r=r,r.length!==n.length))return!1;for(let o=0;o=400&&e<500?"warning":e>=500?"error":void 0}const Cm=Kn;function VB(){return"history"in Cm&&!!Cm.history}function HB(){if(!("fetch"in Cm))return!1;try{return new Headers,new Request("data:,"),new Response,!0}catch{return!1}}function nS(e){return e&&/^function\s+\w+\(\)\s+\{\s+\[native code\]\s+\}$/.test(e.toString())}function qB(){if(typeof EdgeRuntime=="string")return!0;if(!HB())return!1;if(nS(Cm.fetch))return!0;let e=!1;const t=Cm.document;if(t&&typeof t.createElement=="function")try{const n=t.createElement("iframe");n.hidden=!0,t.head.appendChild(n),n.contentWindow?.fetch&&(e=nS(n.contentWindow.fetch)),t.head.removeChild(n)}catch(n){mn&&Qt.warn("Could not create sandbox iframe for pure fetch check, bailing to window.fetch: ",n)}return e}function UB(e,t){const n="fetch";ju(n,e),Eu(n,()=>WB(void 0,t))}function WB(e,t=!1){t&&!qB()||Es(Kn,"fetch",function(n){return function(...r){const o=new Error,{method:s,url:l}=GB(r),u={args:r,fetchData:{method:s,url:l},startTimestamp:al()*1e3,virtualError:o,headers:YB(r)};return Fi("fetch",{...u}),n.apply(Kn,r).then(async d=>(Fi("fetch",{...u,endTimestamp:al()*1e3,response:d}),d),d=>{Fi("fetch",{...u,endTimestamp:al()*1e3,error:d}),qm(d)&&d.stack===void 0&&(d.stack=o.stack,hc(d,"framesToPop",1));const m=ao()?.getOptions().enhanceFetchErrorMessages??"always";if(m!==!1&&d instanceof TypeError&&(d.message==="Failed to fetch"||d.message==="Load failed"||d.message==="NetworkError when attempting to fetch resource."))try{const h=new URL(u.fetchData.url).host;m==="always"?d.message=`${d.message} (${h})`:hc(d,"__sentry_fetch_url_host__",h)}catch{}throw d})}})}function vb(e,t){return!!e&&typeof e=="object"&&!!e[t]}function uM(e){return typeof e=="string"?e:e?vb(e,"url")?e.url:e.toString?e.toString():"":""}function GB(e){if(e.length===0)return{method:"GET",url:""};if(e.length===2){const[n,r]=e;return{url:uM(n),method:vb(r,"method")?String(r.method).toUpperCase():FA(n)&&vb(n,"method")?String(n.method).toUpperCase():"GET"}}const t=e[0];return{url:uM(t),method:vb(t,"method")?String(t.method).toUpperCase():"GET"}}function YB(e){const[t,n]=e;try{if(typeof n=="object"&&n!==null&&"headers"in n&&n.headers)return new Headers(n.headers);if(FA(t))return new Headers(t.headers)}catch{}}function KB(){return"npm"}const pr=Kn;let rS=0;function CD(){return rS>0}function XB(){rS++,setTimeout(()=>{rS--})}function af(e,t={}){function n(o){return typeof o=="function"}if(!n(e))return e;try{const o=e.__sentry_wrapped__;if(o)return typeof o=="function"?o:e;if(wC(e))return e}catch{return e}const r=function(...o){try{const s=o.map(l=>af(l,t));return e.apply(this,s)}catch(s){throw XB(),TC(l=>{l.addEventProcessor(u=>(t.mechanism&&(G1(u,void 0),rf(u,t.mechanism)),u.extra={...u.extra,arguments:o},u)),Wd(s)}),s}};try{for(const o in e)Object.prototype.hasOwnProperty.call(e,o)&&(r[o]=e[o])}catch{}VA(r,e),hc(e,"__sentry_wrapped__",r);try{Object.getOwnPropertyDescriptor(r,"name").configurable&&Object.defineProperty(r,"name",{get(){return e.name}})}catch{}return r}function QB(){const e=CC(),{referrer:t}=pr.document||{},{userAgent:n}=pr.navigator||{},r={...t&&{Referer:t},...n&&{"User-Agent":n}};return{url:e,headers:r}}function PC(e,t){const n=AC(e,t),r={type:n6(t),value:r6(t)};return n.length&&(r.stacktrace={frames:n}),r.type===void 0&&r.value===""&&(r.value="Unrecoverable error caught"),r}function ZB(e,t,n,r){const s=ao()?.getOptions().normalizeDepth,l=l6(t),u={__serialized__:tD(t,s)};if(l)return{exception:{values:[PC(e,l)]},extra:u};const d={exception:{values:[{type:ky(t)?t.constructor.name:r?"UnhandledRejection":"Error",value:i6(t,{isUnhandledRejection:r})}]},extra:u};if(n){const p=AC(e,n);p.length&&(d.exception.values[0].stacktrace={frames:p})}return d}function L0(e,t){return{exception:{values:[PC(e,t)]}}}function AC(e,t){const n=t.stacktrace||t.stack||"",r=e6(t),o=t6(t);try{return e(n,r,o)}catch{}return[]}const JB=/Minified React error #\d+;/i;function e6(e){return e&&JB.test(e.message)?1:0}function t6(e){return typeof e.framesToPop=="number"?e.framesToPop:0}function wD(e){return typeof WebAssembly<"u"&&typeof WebAssembly.Exception<"u"?e instanceof WebAssembly.Exception:!1}function n6(e){const t=e?.name;return!t&&wD(e)?e.message&&Array.isArray(e.message)&&e.message.length==2?e.message[0]:"WebAssembly.Exception":t}function r6(e){const t=e?.message;return wD(e)?Array.isArray(e.message)&&e.message.length==2?e.message[1]:"wasm exception":t?t.error&&typeof t.error.message=="string"?rM(t.error):rM(e):"No error message"}function o6(e,t,n,r){const o=n?.syntheticException||void 0,s=DC(e,t,o,r);return rf(s),s.level="error",n?.event_id&&(s.event_id=n.event_id),Oy(s)}function s6(e,t,n="info",r,o){const s=r?.syntheticException||void 0,l=oS(e,t,s,o);return l.level=n,r?.event_id&&(l.event_id=r.event_id),Oy(l)}function DC(e,t,n,r,o){let s;if(NA(t)&&t.error)return L0(e,t.error);if(wE(t)||K4(t)){const l=t;if("stack"in t)s=L0(e,t);else{const u=l.name||(wE(l)?"DOMError":"DOMException"),d=l.message?`${u}: ${l.message}`:u;s=oS(e,d,n,r),G1(s,d)}return"code"in l&&(s.tags={...s.tags,"DOMException.code":`${l.code}`}),s}return qm(t)?L0(e,t):xm(t)||ky(t)?(s=ZB(e,t,n,o),rf(s,{synthetic:!0}),s):(s=oS(e,t,n,r),G1(s,`${t}`),rf(s,{synthetic:!0}),s)}function oS(e,t,n,r){const o={};if(r&&n){const s=AC(e,n);s.length&&(o.exception={values:[{value:t,stacktrace:{frames:s}}]}),rf(o,{synthetic:!0})}if(xC(t)){const{__sentry_template_string__:s,__sentry_template_values__:l}=t;return o.logentry={message:s,params:l},o}return o.message=t,o}function i6(e,{isUnhandledRejection:t}){const n=nF(e),r=t?"promise rejection":"exception";return NA(e)?`Event \`ErrorEvent\` captured as ${r} with message \`${e.message}\``:ky(e)?`Event \`${a6(e)}\` (type=${e.type}) captured as ${r}`:`Object captured as ${r} with keys: ${n}`}function a6(e){try{const t=Object.getPrototypeOf(e);return t?t.constructor.name:void 0}catch{}}function l6(e){for(const t in e)if(Object.prototype.hasOwnProperty.call(e,t)){const n=e[t];if(n instanceof Error)return n}}class c6 extends aB{constructor(t){const n=u6(t),r=pr.SENTRY_SDK_SOURCE||KB();yD(n,"browser",["browser"],r),n._metadata?.sdk&&(n._metadata.sdk.settings={infer_ip:n.sendDefaultPii?"auto":"never",...n._metadata.sdk.settings}),super(n);const{sendDefaultPii:o,sendClientReports:s,enableLogs:l,_experiments:u,enableMetrics:d}=this._options,p=d??u?.enableMetrics??!0;pr.document&&(s||l||p)&&pr.document.addEventListener("visibilitychange",()=>{pr.document.visibilityState==="hidden"&&(s&&this._flushOutcomes(),l&&lD(this),p&&uD(this))}),o&&this.on("beforeSendSession",gB)}eventFromException(t,n){return o6(this._options.stackParser,t,n,this._options.attachStacktrace)}eventFromMessage(t,n="info",r){return s6(this._options.stackParser,t,n,r,this._options.attachStacktrace)}_prepareEvent(t,n,r,o){return t.platform=t.platform||"javascript",super._prepareEvent(t,n,r,o)}}function u6(e){return{release:typeof __SENTRY_RELEASE__=="string"?__SENTRY_RELEASE__:pr.SENTRY_RELEASE?.id,sendClientReports:!0,parentSpanIsAlwaysRootSpan:!0,...e}}const d6=typeof __SENTRY_DEBUG__>"u"||__SENTRY_DEBUG__,Fo=Kn,f6=1e3;let dM,sS,iS;function p6(e){ju("dom",e),Eu("dom",m6)}function m6(){if(!Fo.document)return;const e=Fi.bind(null,"dom"),t=fM(e,!0);Fo.document.addEventListener("click",t,!1),Fo.document.addEventListener("keypress",t,!1),["EventTarget","Node"].forEach(n=>{const o=Fo[n]?.prototype;o?.hasOwnProperty?.("addEventListener")&&(Es(o,"addEventListener",function(s){return function(l,u,d){if(l==="click"||l=="keypress")try{const p=this.__sentry_instrumentation_handlers__=this.__sentry_instrumentation_handlers__||{},m=p[l]=p[l]||{refCount:0};if(!m.handler){const b=fM(e);m.handler=b,s.call(this,l,b,d)}m.refCount++}catch{}return s.call(this,l,u,d)}}),Es(o,"removeEventListener",function(s){return function(l,u,d){if(l==="click"||l=="keypress")try{const p=this.__sentry_instrumentation_handlers__||{},m=p[l];m&&(m.refCount--,m.refCount<=0&&(s.call(this,l,m.handler,d),m.handler=void 0,delete p[l]),Object.keys(p).length===0&&delete this.__sentry_instrumentation_handlers__)}catch{}return s.call(this,l,u,d)}}))})}function h6(e){if(e.type!==sS)return!1;try{if(!e.target||e.target._sentryId!==iS)return!1}catch{}return!0}function g6(e,t){return e!=="keypress"?!1:t?.tagName?!(t.tagName==="INPUT"||t.tagName==="TEXTAREA"||t.isContentEditable):!0}function fM(e,t=!1){return n=>{if(!n||n._sentryCaptured)return;const r=b6(n);if(g6(n.type,r))return;hc(n,"_sentryCaptured",!0),r&&!r._sentryId&&hc(r,"_sentryId",di());const o=n.type==="keypress"?"input":n.type;h6(n)||(e({event:n,name:o,global:t}),sS=n.type,iS=r?r._sentryId:void 0),clearTimeout(dM),dM=Fo.setTimeout(()=>{iS=void 0,sS=void 0},f6)}}function b6(e){try{return e.target}catch{return null}}let Mg;function _D(e){const t="history";ju(t,e),Eu(t,y6)}function y6(){if(Fo.addEventListener("popstate",()=>{const t=Fo.location.href,n=Mg;if(Mg=t,n===t)return;Fi("history",{from:n,to:t})}),!VB())return;function e(t){return function(...n){const r=n.length>2?n[2]:void 0;if(r){const o=Mg,s=v6(String(r));if(Mg=s,o===s)return t.apply(this,n);Fi("history",{from:o,to:s})}return t.apply(this,n)}}Es(Fo.history,"pushState",e),Es(Fo.history,"replaceState",e)}function v6(e){try{return new URL(e,Fo.location.origin).toString()}catch{return e}}const xb={};function x6(e){const t=xb[e];if(t)return t;let n=Fo[e];if(nS(n))return xb[e]=n.bind(Fo);const r=Fo.document;if(r&&typeof r.createElement=="function")try{const o=r.createElement("iframe");o.hidden=!0,r.head.appendChild(o);const s=o.contentWindow;s?.[e]&&(n=s[e]),r.head.removeChild(o)}catch(o){d6&&Qt.warn(`Could not create sandbox iframe for ${e} check, bailing to window.${e}: `,o)}return n&&(xb[e]=n.bind(Fo))}function S6(e){xb[e]=void 0}const Xp="__sentry_xhr_v3__";function C6(e){ju("xhr",e),Eu("xhr",w6)}function w6(){if(!Fo.XMLHttpRequest)return;const e=XMLHttpRequest.prototype;e.open=new Proxy(e.open,{apply(t,n,r){const o=new Error,s=al()*1e3,l=il(r[0])?r[0].toUpperCase():void 0,u=_6(r[1]);if(!l||!u)return t.apply(n,r);n[Xp]={method:l,url:u,request_headers:{}},l==="POST"&&u.match(/sentry_key/)&&(n.__sentry_own_request__=!0);const d=()=>{const p=n[Xp];if(p&&n.readyState===4){try{p.status_code=n.status}catch{}const m={endTimestamp:al()*1e3,startTimestamp:s,xhr:n,virtualError:o};Fi("xhr",m)}};return"onreadystatechange"in n&&typeof n.onreadystatechange=="function"?n.onreadystatechange=new Proxy(n.onreadystatechange,{apply(p,m,b){return d(),p.apply(m,b)}}):n.addEventListener("readystatechange",d),n.setRequestHeader=new Proxy(n.setRequestHeader,{apply(p,m,b){const[y,h]=b,x=m[Xp];return x&&il(y)&&il(h)&&(x.request_headers[y.toLowerCase()]=h),p.apply(m,b)}}),t.apply(n,r)}}),e.send=new Proxy(e.send,{apply(t,n,r){const o=n[Xp];if(!o)return t.apply(n,r);r[0]!==void 0&&(o.body=r[0]);const s={startTimestamp:al()*1e3,xhr:n};return Fi("xhr",s),t.apply(n,r)}})}function _6(e){if(il(e))return e;try{return e.toString()}catch{}}const T6=40;function j6(e,t=x6("fetch")){let n=0,r=0;async function o(s){const l=s.body.length;n+=l,r++;const u={body:s.body,method:"POST",referrerPolicy:"strict-origin",headers:e.headers,keepalive:n<=6e4&&r<15,...e.fetchOptions};try{const d=await t(e.url,u);return{statusCode:d.status,headers:{"x-sentry-rate-limits":d.headers.get("X-Sentry-Rate-Limits"),"retry-after":d.headers.get("Retry-After")}}}catch(d){throw S6("fetch"),d}finally{n-=l,r--}}return nB(e,o,kC(e.bufferSize||T6))}const lf=typeof __SENTRY_DEBUG__>"u"||__SENTRY_DEBUG__,E6=30,M6=50;function aS(e,t,n,r){const o={filename:e,function:t===""?mu:t,in_app:!0};return n!==void 0&&(o.lineno=n),r!==void 0&&(o.colno=r),o}const R6=/^\s*at (\S+?)(?::(\d+))(?::(\d+))\s*$/i,k6=/^\s*at (?:(.+?\)(?: \[.+\])?|.*?) ?\((?:address at )?)?(?:async )?((?:|[-a-z]+:|.*bundle|\/)?.*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i,P6=/\((\S*)(?::(\d+))(?::(\d+))\)/,A6=/at (.+?) ?\(data:(.+?),/,D6=e=>{const t=e.match(A6);if(t)return{filename:``,function:t[1]};const n=R6.exec(e);if(n){const[,o,s,l]=n;return aS(o,mu,+s,+l)}const r=k6.exec(e);if(r){if(r[2]&&r[2].indexOf("eval")===0){const u=P6.exec(r[2]);u&&(r[2]=u[1],r[3]=u[2],r[4]=u[3])}const[s,l]=TD(r[1]||mu,r[2]);return aS(l,s,r[3]?+r[3]:void 0,r[4]?+r[4]:void 0)}},O6=[E6,D6],I6=/^\s*(.*?)(?:\((.*?)\))?(?:^|@)?((?:[-a-z]+)?:\/.*?|\[native code\]|[^@]*(?:bundle|\d+\.js)|\/[\w\-. /=]+)(?::(\d+))?(?::(\d+))?\s*$/i,L6=/(\S+) line (\d+)(?: > eval line \d+)* > eval/i,z6=e=>{const t=I6.exec(e);if(t){if(t[3]&&t[3].indexOf(" > eval")>-1){const s=L6.exec(t[3]);s&&(t[1]=t[1]||"eval",t[3]=s[1],t[4]=s[2],t[5]="")}let r=t[3],o=t[1]||mu;return[o,r]=TD(o,r),aS(r,o,t[4]?+t[4]:void 0,t[5]?+t[5]:void 0)}},N6=[M6,z6],$6=[O6,N6],F6=IA(...$6),TD=(e,t)=>{const n=e.indexOf("safari-extension")!==-1,r=e.indexOf("safari-web-extension")!==-1;return n||r?[e.indexOf("@")!==-1?e.split("@")[0]:mu,n?`safari-extension:${t}`:`safari-web-extension:${t}`]:[e,t]},Rg=1024,B6="Breadcrumbs",V6=((e={})=>{const t={console:!0,dom:!0,fetch:!0,history:!0,sentry:!0,xhr:!0,...e};return{name:B6,setup(n){t.console&&DB(W6(n)),t.dom&&p6(U6(n,t.dom)),t.xhr&&C6(G6(n)),t.fetch&&UB(Y6(n)),t.history&&_D(K6(n)),t.sentry&&n.on("beforeSendEvent",q6(n))}}}),H6=V6;function q6(e){return function(n){ao()===e&&gu({category:`sentry.${n.type==="transaction"?"transaction":"event"}`,event_id:n.event_id,level:n.level,message:su(n)},{event:n})}}function U6(e,t){return function(r){if(ao()!==e)return;let o,s,l=typeof t=="object"?t.serializeAttribute:void 0,u=typeof t=="object"&&typeof t.maxStringLength=="number"?t.maxStringLength:void 0;u&&u>Rg&&(lf&&Qt.warn(`\`dom.maxStringLength\` cannot exceed ${Rg}, but a value of ${u} was configured. Sentry will use ${Rg} instead.`),u=Rg),typeof l=="string"&&(l=[l]);try{const p=r.event,m=X6(p)?p.target:p;o=BA(m,{keyAttrs:l,maxStringLength:u}),s=tF(m)}catch{o=""}if(o.length===0)return;const d={category:`ui.${r.name}`,message:o};s&&(d.data={"ui.component_name":s}),gu(d,{event:r.event,name:r.name,global:r.global})}}function W6(e){return function(n){if(ao()!==e)return;const r={category:"console",data:{arguments:n.args,logger:"console"},level:IB(n.level),message:jE(n.args," ")};if(n.level==="assert")if(n.args[0]===!1)r.message=`Assertion failed: ${jE(n.args.slice(1)," ")||"console.assert"}`,r.data.arguments=n.args.slice(1);else return;gu(r,{input:n.args,level:n.level})}}function G6(e){return function(n){if(ao()!==e)return;const{startTimestamp:r,endTimestamp:o}=n,s=n.xhr[Xp];if(!r||!o||!s)return;const{method:l,url:u,status_code:d,body:p}=s,m={method:l,url:u,status_code:d},b={xhr:n.xhr,input:p,startTimestamp:r,endTimestamp:o},y={category:"xhr",data:m,type:"http",level:SD(d)};e.emit("beforeOutgoingRequestBreadcrumb",y,b),gu(y,b)}}function Y6(e){return function(n){if(ao()!==e)return;const{startTimestamp:r,endTimestamp:o}=n;if(o&&!(n.fetchData.url.match(/sentry_key/)&&n.fetchData.method==="POST"))if(n.fetchData.method,n.fetchData.url,n.error){const s=n.fetchData,l={data:n.error,input:n.args,startTimestamp:r,endTimestamp:o},u={category:"fetch",data:s,level:"error",type:"http"};e.emit("beforeOutgoingRequestBreadcrumb",u,l),gu(u,l)}else{const s=n.response,l={...n.fetchData,status_code:s?.status};n.fetchData.request_body_size,n.fetchData.response_body_size,s?.status;const u={input:n.args,response:s,startTimestamp:r,endTimestamp:o},d={category:"fetch",data:l,type:"http",level:SD(l.status_code)};e.emit("beforeOutgoingRequestBreadcrumb",d,u),gu(d,u)}}}function K6(e){return function(n){if(ao()!==e)return;let r=n.from,o=n.to;const s=I0(pr.location.href);let l=r?I0(r):void 0;const u=I0(o);l?.path||(l=s),s.protocol===u.protocol&&s.host===u.host&&(o=u.relative),s.protocol===l.protocol&&s.host===l.host&&(r=l.relative),gu({category:"navigation",data:{from:r,to:o}})}}function X6(e){return!!e&&!!e.target}const Q6=["EventTarget","Window","Node","ApplicationCache","AudioTrackList","BroadcastChannel","ChannelMergerNode","CryptoOperation","EventSource","FileReader","HTMLUnknownElement","IDBDatabase","IDBRequest","IDBTransaction","KeyOperation","MediaController","MessagePort","ModalWindow","Notification","SVGElementInstance","Screen","SharedWorker","TextTrack","TextTrackCue","TextTrackList","WebSocket","WebSocketWorker","Worker","XMLHttpRequest","XMLHttpRequestEventTarget","XMLHttpRequestUpload"],Z6="BrowserApiErrors",J6=((e={})=>{const t={XMLHttpRequest:!0,eventTarget:!0,requestAnimationFrame:!0,setInterval:!0,setTimeout:!0,unregisterOriginalCallbacks:!1,...e};return{name:Z6,setupOnce(){t.setTimeout&&Es(pr,"setTimeout",pM),t.setInterval&&Es(pr,"setInterval",pM),t.requestAnimationFrame&&Es(pr,"requestAnimationFrame",t8),t.XMLHttpRequest&&"XMLHttpRequest"in pr&&Es(XMLHttpRequest.prototype,"send",n8);const n=t.eventTarget;n&&(Array.isArray(n)?n:Q6).forEach(o=>r8(o,t))}}}),e8=J6;function pM(e){return function(...t){const n=t[0];return t[0]=af(n,{mechanism:{handled:!1,type:`auto.browser.browserapierrors.${pc(e)}`}}),e.apply(this,t)}}function t8(e){return function(t){return e.apply(this,[af(t,{mechanism:{data:{handler:pc(e)},handled:!1,type:"auto.browser.browserapierrors.requestAnimationFrame"}})])}}function n8(e){return function(...t){const n=this;return["onload","onerror","onprogress","onreadystatechange"].forEach(o=>{o in n&&typeof n[o]=="function"&&Es(n,o,function(s){const l={mechanism:{data:{handler:pc(s)},handled:!1,type:`auto.browser.browserapierrors.xhr.${o}`}},u=wC(s);return u&&(l.mechanism.data.handler=pc(u)),af(s,l)})}),e.apply(this,t)}}function r8(e,t){const r=pr[e]?.prototype;r?.hasOwnProperty?.("addEventListener")&&(Es(r,"addEventListener",function(o){return function(s,l,u){try{o8(l)&&(l.handleEvent=af(l.handleEvent,{mechanism:{data:{handler:pc(l),target:e},handled:!1,type:"auto.browser.browserapierrors.handleEvent"}}))}catch{}return t.unregisterOriginalCallbacks&&s8(this,s,l),o.apply(this,[s,af(l,{mechanism:{data:{handler:pc(l),target:e},handled:!1,type:"auto.browser.browserapierrors.addEventListener"}}),u])}}),Es(r,"removeEventListener",function(o){return function(s,l,u){try{const d=l.__sentry_wrapped__;d&&o.call(this,s,d,u)}catch{}return o.call(this,s,l,u)}}))}function o8(e){return typeof e.handleEvent=="function"}function s8(e,t,n){e&&typeof e=="object"&&"removeEventListener"in e&&typeof e.removeEventListener=="function"&&e.removeEventListener(t,n)}const i8=()=>({name:"BrowserSession",setupOnce(){if(typeof pr.document>"u"){lf&&Qt.warn("Using the `browserSessionIntegration` in non-browser environments is not supported.");return}WE({ignoreDuration:!0}),GE(),_D(({from:e,to:t})=>{e!==void 0&&e!==t&&(WE({ignoreDuration:!0}),GE())})}}),a8="GlobalHandlers",l8=((e={})=>{const t={onerror:!0,onunhandledrejection:!0,...e};return{name:a8,setupOnce(){Error.stackTraceLimit=50},setup(n){t.onerror&&(u8(n),mM("onerror")),t.onunhandledrejection&&(d8(n),mM("onunhandledrejection"))}}}),c8=l8;function u8(e){U4(t=>{const{stackParser:n,attachStacktrace:r}=jD();if(ao()!==e||CD())return;const{msg:o,url:s,line:l,column:u,error:d}=t,p=m8(DC(n,d||o,void 0,r,!1),s,l,u);p.level="error",rD(p,{originalException:d,mechanism:{handled:!1,type:"auto.browser.global_handlers.onerror"}})})}function d8(e){G4(t=>{const{stackParser:n,attachStacktrace:r}=jD();if(ao()!==e||CD())return;const o=f8(t),s=Ry(o)?p8(o):DC(n,o,void 0,r,!0);s.level="error",rD(s,{originalException:o,mechanism:{handled:!1,type:"auto.browser.global_handlers.onunhandledrejection"}})})}function f8(e){if(Ry(e))return e;try{if("reason"in e)return e.reason;if("detail"in e&&"reason"in e.detail)return e.detail.reason}catch{}return e}function p8(e){return{exception:{values:[{type:"UnhandledRejection",value:`Non-Error promise rejection captured with value: ${String(e)}`}]}}}function m8(e,t,n,r){const o=e.exception=e.exception||{},s=o.values=o.values||[],l=s[0]=s[0]||{},u=l.stacktrace=l.stacktrace||{},d=u.frames=u.frames||[],p=r,m=n,b=h8(t)??CC();return d.length===0&&d.push({colno:p,filename:b,function:mu,in_app:!0,lineno:m}),e}function mM(e){lf&&Qt.log(`Global Handler attached: ${e}`)}function jD(){return ao()?.getOptions()||{stackParser:()=>[],attachStacktrace:!1}}function h8(e){if(!(!il(e)||e.length===0))return e.startsWith("data:")?`<${hB(e,!1)}>`:e}const g8=()=>({name:"HttpContext",preprocessEvent(e){if(!pr.navigator&&!pr.location&&!pr.document)return;const t=QB(),n={...t.headers,...e.request?.headers};e.request={...t,...e.request,headers:n}}}),b8="cause",y8=5,v8="LinkedErrors",x8=((e={})=>{const t=e.limit||y8,n=e.key||b8;return{name:v8,preprocessEvent(r,o,s){const l=s.getOptions();AB(PC,l.stackParser,n,t,r,o)}}}),S8=x8;function C8(){return w8()?(lf&&Mf(()=>{console.error("[Sentry] You cannot use Sentry.init() in a browser extension, see: https://docs.sentry.io/platforms/javascript/best-practices/browser-extensions/")}),!0):!1}function w8(){if(typeof pr.window>"u")return!1;const e=pr;if(e.nw||!(e.chrome||e.browser)?.runtime?.id)return!1;const n=CC(),r=["chrome-extension","moz-extension","ms-browser-extension","safari-web-extension"];return!(pr===pr.top&&r.some(s=>n.startsWith(`${s}://`)))}function _8(e){return[_B(),xB(),e8(),H6(),c8(),S8(),NB(),g8(),i8()]}function T8(e={}){const t=!e.skipBrowserExtensionCheck&&C8();let n=e.defaultIntegrations==null?_8():e.defaultIntegrations;const r={...e,enabled:t?!1:e.enabled,stackParser:H4(e.stackParser||F6),integrations:H3({integrations:e.integrations,defaultIntegrations:n}),transport:e.transport||j6};return pB(c6,r)}function hM(e={}){const t=pr.document,n=t?.head||t?.body;if(!n){lf&&Qt.error("[showReportDialog] Global document not defined");return}const r=hl(),s=ao()?.getDsn();if(!s){lf&&Qt.error("[showReportDialog] DSN not configured");return}const l={...e,user:{...r.getUser(),...e.user},eventId:e.eventId||L3()},u=pr.document.createElement("script");u.async=!0,u.crossOrigin="anonymous",u.src=B3(s,l);const{onLoad:d,onClose:p}=l;if(d&&(u.onload=d),p){const m=b=>{if(b.data==="__sentry_reportdialog_closed__")try{p()}finally{pr.removeEventListener("message",m)}};pr.addEventListener("message",m)}n.appendChild(u)}var z0={exports:{}},ln={};var gM;function j8(){if(gM)return ln;gM=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),o=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),l=Symbol.for("react.context"),u=Symbol.for("react.forward_ref"),d=Symbol.for("react.suspense"),p=Symbol.for("react.memo"),m=Symbol.for("react.lazy"),b=Symbol.for("react.activity"),y=Symbol.iterator;function h(q){return q===null||typeof q!="object"?null:(q=y&&q[y]||q["@@iterator"],typeof q=="function"?q:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},C=Object.assign,S={};function w(q,K,ee){this.props=q,this.context=K,this.refs=S,this.updater=ee||x}w.prototype.isReactComponent={},w.prototype.setState=function(q,K){if(typeof q!="object"&&typeof q!="function"&&q!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,q,K,"setState")},w.prototype.forceUpdate=function(q){this.updater.enqueueForceUpdate(this,q,"forceUpdate")};function _(){}_.prototype=w.prototype;function T(q,K,ee){this.props=q,this.context=K,this.refs=S,this.updater=ee||x}var M=T.prototype=new _;M.constructor=T,C(M,w.prototype),M.isPureReactComponent=!0;var R=Array.isArray;function I(){}var N={H:null,A:null,T:null,S:null},D=Object.prototype.hasOwnProperty;function F(q,K,ee){var se=ee.ref;return{$$typeof:e,type:q,key:K,ref:se!==void 0?se:null,props:ee}}function B(q,K){return F(q.type,K,q.props)}function A(q){return typeof q=="object"&&q!==null&&q.$$typeof===e}function E(q){var K={"=":"=0",":":"=2"};return"$"+q.replace(/[=:]/g,function(ee){return K[ee]})}var L=/\/+/g;function $(q,K){return typeof q=="object"&&q!==null&&q.key!=null?E(""+q.key):K.toString(36)}function z(q){switch(q.status){case"fulfilled":return q.value;case"rejected":throw q.reason;default:switch(typeof q.status=="string"?q.then(I,I):(q.status="pending",q.then(function(K){q.status==="pending"&&(q.status="fulfilled",q.value=K)},function(K){q.status==="pending"&&(q.status="rejected",q.reason=K)})),q.status){case"fulfilled":return q.value;case"rejected":throw q.reason}}throw q}function P(q,K,ee,se,J){var X=typeof q;(X==="undefined"||X==="boolean")&&(q=null);var ue=!1;if(q===null)ue=!0;else switch(X){case"bigint":case"string":case"number":ue=!0;break;case"object":switch(q.$$typeof){case e:case t:ue=!0;break;case m:return ue=q._init,P(ue(q._payload),K,ee,se,J)}}if(ue)return J=J(q),ue=se===""?"."+$(q,0):se,R(J)?(ee="",ue!=null&&(ee=ue.replace(L,"$&/")+"/"),P(J,K,ee,"",function(ve){return ve})):J!=null&&(A(J)&&(J=B(J,ee+(J.key==null||q&&q.key===J.key?"":(""+J.key).replace(L,"$&/")+"/")+ue)),K.push(J)),1;ue=0;var Ce=se===""?".":se+":";if(R(q))for(var pe=0;pe=17}function R8(e,t){const n=new WeakSet;function r(o,s){if(!n.has(o)){if(o.cause)return n.add(o),r(o.cause,s);o.cause=s}}r(e,t)}function k8(e,{componentStack:t},n){if(M8(v.version)&&qm(e)&&t){const r=new Error(e.message);r.name=`React ErrorBoundary ${e.name}`,r.stack=t,R8(e,r)}return TC(r=>(r.setContext("react",{componentStack:t}),Wd(e,n)))}const P8=typeof __SENTRY_DEBUG__>"u"||__SENTRY_DEBUG__,N0={componentStack:null,error:null,eventId:null};class A8 extends v.Component{constructor(t){super(t),this.state=N0,this._openFallbackReportDialog=!0;const n=ao();n&&t.showDialog&&(this._openFallbackReportDialog=!1,this._cleanupHook=n.on("afterSendEvent",r=>{!r.type&&this._lastEventId&&r.event_id===this._lastEventId&&hM({...t.dialogOptions,eventId:this._lastEventId})}))}componentDidCatch(t,n){const{componentStack:r}=n,{beforeCapture:o,onError:s,showDialog:l,dialogOptions:u}=this.props;TC(d=>{o&&o(d,t,r);const p=this.props.handled!=null?this.props.handled:!!this.props.fallback,m=k8(t,n,{mechanism:{handled:p,type:"auto.function.react.error_boundary"}});s&&s(t,r,m),l&&(this._lastEventId=m,this._openFallbackReportDialog&&hM({...u,eventId:m})),this.setState({error:t,componentStack:r,eventId:m})})}componentDidMount(){const{onMount:t}=this.props;t&&t()}componentWillUnmount(){const{error:t,componentStack:n,eventId:r}=this.state,{onUnmount:o}=this.props;o&&(this.state===N0?o(null,null,null):o(t,n,r)),this._cleanupHook&&(this._cleanupHook(),this._cleanupHook=void 0)}resetErrorBoundary(){const{onReset:t}=this.props,{error:n,componentStack:r,eventId:o}=this.state;t&&t(n,r,o),this.setState(N0)}render(){const{fallback:t,children:n}=this.props,r=this.state;if(r.componentStack===null)return typeof n=="function"?n():n;const o=typeof t=="function"?v.createElement(t,{error:r.error,componentStack:r.componentStack,resetError:()=>this.resetErrorBoundary(),eventId:r.eventId}):t;return v.isValidElement(o)?o:(t&&P8&&Qt.warn("fallback did not produce a valid ReactElement"),null)}}var $0={exports:{}},Ip={},F0={exports:{}},B0={};var yM;function D8(){return yM||(yM=1,(function(e){function t(P,O){var V=P.length;P.push(O);e:for(;0>>1,Y=P[H];if(0>>1;Ho(ee,V))seo(J,ee)?(P[H]=J,P[se]=V,H=se):(P[H]=ee,P[K]=V,H=K);else if(seo(J,V))P[H]=J,P[se]=V,H=se;else break e}}return O}function o(P,O){var V=P.sortIndex-O.sortIndex;return V!==0?V:P.id-O.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;e.unstable_now=function(){return s.now()}}else{var l=Date,u=l.now();e.unstable_now=function(){return l.now()-u}}var d=[],p=[],m=1,b=null,y=3,h=!1,x=!1,C=!1,S=!1,w=typeof setTimeout=="function"?setTimeout:null,_=typeof clearTimeout=="function"?clearTimeout:null,T=typeof setImmediate<"u"?setImmediate:null;function M(P){for(var O=n(p);O!==null;){if(O.callback===null)r(p);else if(O.startTime<=P)r(p),O.sortIndex=O.expirationTime,t(d,O);else break;O=n(p)}}function R(P){if(C=!1,M(P),!x)if(n(d)!==null)x=!0,I||(I=!0,E());else{var O=n(p);O!==null&&z(R,O.startTime-P)}}var I=!1,N=-1,D=5,F=-1;function B(){return S?!0:!(e.unstable_now()-FP&&B());){var H=b.callback;if(typeof H=="function"){b.callback=null,y=b.priorityLevel;var Y=H(b.expirationTime<=P);if(P=e.unstable_now(),typeof Y=="function"){b.callback=Y,M(P),O=!0;break t}b===n(d)&&r(d),M(P)}else r(d);b=n(d)}if(b!==null)O=!0;else{var q=n(p);q!==null&&z(R,q.startTime-P),O=!1}}break e}finally{b=null,y=V,h=!1}O=void 0}}finally{O?E():I=!1}}}var E;if(typeof T=="function")E=function(){T(A)};else if(typeof MessageChannel<"u"){var L=new MessageChannel,$=L.port2;L.port1.onmessage=A,E=function(){$.postMessage(null)}}else E=function(){w(A,0)};function z(P,O){N=w(function(){P(e.unstable_now())},O)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(P){P.callback=null},e.unstable_forceFrameRate=function(P){0>P||125H?(P.sortIndex=V,t(p,P),n(d)===null&&P===n(p)&&(C?(_(N),N=-1):C=!0,z(R,V-H))):(P.sortIndex=Y,t(d,P),x||h||(x=!0,I||(I=!0,E()))),P},e.unstable_shouldYield=B,e.unstable_wrapCallback=function(P){var O=y;return function(){var V=y;y=O;try{return P.apply(this,arguments)}finally{y=V}}}})(B0)),B0}var vM;function O8(){return vM||(vM=1,F0.exports=D8()),F0.exports}var V0={exports:{}},_o={};var xM;function I8(){if(xM)return _o;xM=1;var e=OC();function t(d){var p="https://react.dev/errors/"+d;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}return e(),V0.exports=I8(),V0.exports}var CM;function L8(){if(CM)return Ip;CM=1;var e=O8(),t=OC(),n=ED();function r(i){var c="https://react.dev/errors/"+i;if(1Y||(i.current=H[Y],H[Y]=null,Y--)}function ee(i,c){Y++,H[Y]=i.current,i.current=c}var se=q(null),J=q(null),X=q(null),ue=q(null);function Ce(i,c){switch(ee(X,c),ee(J,i),ee(se,null),c.nodeType){case 9:case 11:i=(i=c.documentElement)&&(i=i.namespaceURI)?Fj(i):0;break;default:if(i=c.tagName,c=c.namespaceURI)c=Fj(c),i=Bj(c,i);else switch(i){case"svg":i=1;break;case"math":i=2;break;default:i=0}}K(se),ee(se,i)}function pe(){K(se),K(J),K(X)}function ve(i){i.memoizedState!==null&&ee(ue,i);var c=se.current,f=Bj(c,i.type);c!==f&&(ee(J,i),ee(se,f))}function be(i){J.current===i&&(K(se),K(J)),ue.current===i&&(K(ue),Rp._currentValue=V)}var xe,Re;function le(i){if(xe===void 0)try{throw Error()}catch(f){var c=f.stack.trim().match(/\n( *(at )?)/);xe=c&&c[1]||"",Re=-1)":-1j||Ae[g]!==Be[j]){var Ze=` -`+Ae[g].replace(" at new "," at ");return i.displayName&&Ze.includes("")&&(Ze=Ze.replace("",i.displayName)),Ze}while(1<=g&&0<=j);break}}}finally{re=!1,Error.prepareStackTrace=f}return(f=i?i.displayName||i.name:"")?le(f):""}function te(i,c){switch(i.tag){case 26:case 27:case 5:return le(i.type);case 16:return le("Lazy");case 13:return i.child!==c&&c!==null?le("Suspense Fallback"):le("Suspense");case 19:return le("SuspenseList");case 0:case 15:return G(i.type,!1);case 11:return G(i.type.render,!1);case 1:return G(i.type,!0);case 31:return le("Activity");default:return""}}function oe(i){try{var c="",f=null;do c+=te(i,f),f=i,i=i.return;while(i);return c}catch(g){return` -Error generating stack: `+g.message+` -`+g.stack}}var de=Object.prototype.hasOwnProperty,ce=e.unstable_scheduleCallback,he=e.unstable_cancelCallback,Z=e.unstable_shouldYield,ye=e.unstable_requestPaint,Ee=e.unstable_now,Me=e.unstable_getCurrentPriorityLevel,fe=e.unstable_ImmediatePriority,Pe=e.unstable_UserBlockingPriority,ke=e.unstable_NormalPriority,Se=e.unstable_LowPriority,Te=e.unstable_IdlePriority,Oe=e.log,Ke=e.unstable_setDisableYieldValue,Ve=null,We=null;function He(i){if(typeof Oe=="function"&&Ke(i),We&&typeof We.setStrictMode=="function")try{We.setStrictMode(Ve,i)}catch{}}var lt=Math.clz32?Math.clz32:Rt,$e=Math.log,St=Math.LN2;function Rt(i){return i>>>=0,i===0?32:31-($e(i)/St|0)|0}var Wt=256,un=262144,Pt=4194304;function Tt(i){var c=i&42;if(c!==0)return c;switch(i&-i){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return i&261888;case 262144:case 524288:case 1048576:case 2097152:return i&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return i&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return i}}function dn(i,c,f){var g=i.pendingLanes;if(g===0)return 0;var j=0,k=i.suspendedLanes,W=i.pingedLanes;i=i.warmLanes;var ne=g&134217727;return ne!==0?(g=ne&~k,g!==0?j=Tt(g):(W&=ne,W!==0?j=Tt(W):f||(f=ne&~i,f!==0&&(j=Tt(f))))):(ne=g&~k,ne!==0?j=Tt(ne):W!==0?j=Tt(W):f||(f=g&~i,f!==0&&(j=Tt(f)))),j===0?0:c!==0&&c!==j&&(c&k)===0&&(k=j&-j,f=c&-c,k>=f||k===32&&(f&4194048)!==0)?c:j}function ut(i,c){return(i.pendingLanes&~(i.suspendedLanes&~i.pingedLanes)&c)===0}function Mt(i,c){switch(i){case 1:case 2:case 4:case 8:case 64:return c+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return c+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function ht(){var i=Pt;return Pt<<=1,(Pt&62914560)===0&&(Pt=4194304),i}function Yt(i){for(var c=[],f=0;31>f;f++)c.push(i);return c}function mt(i,c){i.pendingLanes|=c,c!==268435456&&(i.suspendedLanes=0,i.pingedLanes=0,i.warmLanes=0)}function Et(i,c,f,g,j,k){var W=i.pendingLanes;i.pendingLanes=f,i.suspendedLanes=0,i.pingedLanes=0,i.warmLanes=0,i.expiredLanes&=f,i.entangledLanes&=f,i.errorRecoveryDisabledLanes&=f,i.shellSuspendCounter=0;var ne=i.entanglements,Ae=i.expirationTimes,Be=i.hiddenUpdates;for(f=W&~f;0"u")return null;try{return i.activeElement||i.body}catch{return i.body}}var Gf=/[\n"\\]/g;function So(i){return i.replace(Gf,function(c){return"\\"+c.charCodeAt(0).toString(16)+" "})}function Dc(i,c,f,g,j,k,W,ne){i.name="",W!=null&&typeof W!="function"&&typeof W!="symbol"&&typeof W!="boolean"?i.type=W:i.removeAttribute("type"),c!=null?W==="number"?(c===0&&i.value===""||i.value!=c)&&(i.value=""+Kr(c)):i.value!==""+Kr(c)&&(i.value=""+Kr(c)):W!=="submit"&&W!=="reset"||i.removeAttribute("value"),c!=null?Oc(i,W,Kr(c)):f!=null?Oc(i,W,Kr(f)):g!=null&&i.removeAttribute("value"),j==null&&k!=null&&(i.defaultChecked=!!k),j!=null&&(i.checked=j&&typeof j!="function"&&typeof j!="symbol"),ne!=null&&typeof ne!="function"&&typeof ne!="symbol"&&typeof ne!="boolean"?i.name=""+Kr(ne):i.removeAttribute("name")}function Tl(i,c,f,g,j,k,W,ne){if(k!=null&&typeof k!="function"&&typeof k!="symbol"&&typeof k!="boolean"&&(i.type=k),c!=null||f!=null){if(!(k!=="submit"&&k!=="reset"||c!=null)){wl(i);return}f=f!=null?""+Kr(f):"",c=c!=null?""+Kr(c):f,ne||c===i.value||(i.value=c),i.defaultValue=c}g=g??j,g=typeof g!="function"&&typeof g!="symbol"&&!!g,i.checked=ne?i.checked:!!g,i.defaultChecked=!!g,W!=null&&typeof W!="function"&&typeof W!="symbol"&&typeof W!="boolean"&&(i.name=W),wl(i)}function Oc(i,c,f){c==="number"&&_l(i.ownerDocument)===i||i.defaultValue===""+f||(i.defaultValue=""+f)}function Qi(i,c,f,g){if(i=i.options,c){c={};for(var j=0;j"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Rl=!1;if(Oo)try{var wo={};Object.defineProperty(wo,"passive",{get:function(){Rl=!0}}),window.addEventListener("test",wo,wo),window.removeEventListener("test",wo,wo)}catch{Rl=!1}var ps=null,Aa=null,kl=null;function Hu(){if(kl)return kl;var i,c=Aa,f=c.length,g,j="value"in ps?ps.value:ps.textContent,k=j.length;for(i=0;i=Xf),G_=" ",Y_=!1;function K_(i,c){switch(i){case"keyup":return e$.indexOf(c.keyCode)!==-1;case"keydown":return c.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function X_(i){return i=i.detail,typeof i=="object"&&"data"in i?i.data:null}var Wu=!1;function n$(i,c){switch(i){case"compositionend":return X_(c);case"keypress":return c.which!==32?null:(Y_=!0,G_);case"textInput":return i=c.data,i===G_&&Y_?null:i;default:return null}}function r$(i,c){if(Wu)return i==="compositionend"||!Ov&&K_(i,c)?(i=Hu(),kl=Aa=ps=null,Wu=!1,i):null;switch(i){case"paste":return null;case"keypress":if(!(c.ctrlKey||c.altKey||c.metaKey)||c.ctrlKey&&c.altKey){if(c.char&&1=c)return{node:f,offset:c-i};i=g}e:{for(;f;){if(f.nextSibling){f=f.nextSibling;break e}f=f.parentNode}f=void 0}f=o2(f)}}function i2(i,c){return i&&c?i===c?!0:i&&i.nodeType===3?!1:c&&c.nodeType===3?i2(i,c.parentNode):"contains"in i?i.contains(c):i.compareDocumentPosition?!!(i.compareDocumentPosition(c)&16):!1:!1}function a2(i){i=i!=null&&i.ownerDocument!=null&&i.ownerDocument.defaultView!=null?i.ownerDocument.defaultView:window;for(var c=_l(i.document);c instanceof i.HTMLIFrameElement;){try{var f=typeof c.contentWindow.location.href=="string"}catch{f=!1}if(f)i=c.contentWindow;else break;c=_l(i.document)}return c}function zv(i){var c=i&&i.nodeName&&i.nodeName.toLowerCase();return c&&(c==="input"&&(i.type==="text"||i.type==="search"||i.type==="tel"||i.type==="url"||i.type==="password")||c==="textarea"||i.contentEditable==="true")}var d$=Oo&&"documentMode"in document&&11>=document.documentMode,Gu=null,Nv=null,ep=null,$v=!1;function l2(i,c,f){var g=f.window===f?f.document:f.nodeType===9?f:f.ownerDocument;$v||Gu==null||Gu!==_l(g)||(g=Gu,"selectionStart"in g&&zv(g)?g={start:g.selectionStart,end:g.selectionEnd}:(g=(g.ownerDocument&&g.ownerDocument.defaultView||window).getSelection(),g={anchorNode:g.anchorNode,anchorOffset:g.anchorOffset,focusNode:g.focusNode,focusOffset:g.focusOffset}),ep&&Jf(ep,g)||(ep=g,g=fg(Nv,"onSelect"),0>=W,j-=W,na=1<<32-lt(c)+j|f<hn?(En=zt,zt=null):En=zt.sibling;var In=qe(ze,zt,Fe[hn],et);if(In===null){zt===null&&(zt=En);break}i&&zt&&In.alternate===null&&c(ze,zt),Ie=k(In,Ie,hn),On===null?Vt=In:On.sibling=In,On=In,zt=En}if(hn===Fe.length)return f(ze,zt),Rn&&Ia(ze,hn),Vt;if(zt===null){for(;hnhn?(En=zt,zt=null):En=zt.sibling;var Zl=qe(ze,zt,In.value,et);if(Zl===null){zt===null&&(zt=En);break}i&&zt&&Zl.alternate===null&&c(ze,zt),Ie=k(Zl,Ie,hn),On===null?Vt=Zl:On.sibling=Zl,On=Zl,zt=En}if(In.done)return f(ze,zt),Rn&&Ia(ze,hn),Vt;if(zt===null){for(;!In.done;hn++,In=Fe.next())In=ot(ze,In.value,et),In!==null&&(Ie=k(In,Ie,hn),On===null?Vt=In:On.sibling=In,On=In);return Rn&&Ia(ze,hn),Vt}for(zt=g(zt);!In.done;hn++,In=Fe.next())In=Ye(zt,ze,hn,In.value,et),In!==null&&(i&&In.alternate!==null&&zt.delete(In.key===null?hn:In.key),Ie=k(In,Ie,hn),On===null?Vt=In:On.sibling=In,On=In);return i&&zt.forEach(function(P4){return c(ze,P4)}),Rn&&Ia(ze,hn),Vt}function Jn(ze,Ie,Fe,et){if(typeof Fe=="object"&&Fe!==null&&Fe.type===C&&Fe.key===null&&(Fe=Fe.props.children),typeof Fe=="object"&&Fe!==null){switch(Fe.$$typeof){case h:e:{for(var Vt=Fe.key;Ie!==null;){if(Ie.key===Vt){if(Vt=Fe.type,Vt===C){if(Ie.tag===7){f(ze,Ie.sibling),et=j(Ie,Fe.props.children),et.return=ze,ze=et;break e}}else if(Ie.elementType===Vt||typeof Vt=="object"&&Vt!==null&&Vt.$$typeof===D&&qc(Vt)===Ie.type){f(ze,Ie.sibling),et=j(Ie,Fe.props),ip(et,Fe),et.return=ze,ze=et;break e}f(ze,Ie);break}else c(ze,Ie);Ie=Ie.sibling}Fe.type===C?(et=$c(Fe.props.children,ze.mode,et,Fe.key),et.return=ze,ze=et):(et=Rh(Fe.type,Fe.key,Fe.props,null,ze.mode,et),ip(et,Fe),et.return=ze,ze=et)}return W(ze);case x:e:{for(Vt=Fe.key;Ie!==null;){if(Ie.key===Vt)if(Ie.tag===4&&Ie.stateNode.containerInfo===Fe.containerInfo&&Ie.stateNode.implementation===Fe.implementation){f(ze,Ie.sibling),et=j(Ie,Fe.children||[]),et.return=ze,ze=et;break e}else{f(ze,Ie);break}else c(ze,Ie);Ie=Ie.sibling}et=Wv(Fe,ze.mode,et),et.return=ze,ze=et}return W(ze);case D:return Fe=qc(Fe),Jn(ze,Ie,Fe,et)}if(z(Fe))return Dt(ze,Ie,Fe,et);if(E(Fe)){if(Vt=E(Fe),typeof Vt!="function")throw Error(r(150));return Fe=Vt.call(Fe),Xt(ze,Ie,Fe,et)}if(typeof Fe.then=="function")return Jn(ze,Ie,Lh(Fe),et);if(Fe.$$typeof===T)return Jn(ze,Ie,Ah(ze,Fe),et);zh(ze,Fe)}return typeof Fe=="string"&&Fe!==""||typeof Fe=="number"||typeof Fe=="bigint"?(Fe=""+Fe,Ie!==null&&Ie.tag===6?(f(ze,Ie.sibling),et=j(Ie,Fe),et.return=ze,ze=et):(f(ze,Ie),et=Uv(Fe,ze.mode,et),et.return=ze,ze=et),W(ze)):f(ze,Ie)}return function(ze,Ie,Fe,et){try{sp=0;var Vt=Jn(ze,Ie,Fe,et);return od=null,Vt}catch(zt){if(zt===rd||zt===Oh)throw zt;var On=gs(29,zt,null,ze.mode);return On.lanes=et,On.return=ze,On}}}var Wc=P2(!0),A2=P2(!1),Il=!1;function ox(i){i.updateQueue={baseState:i.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function sx(i,c){i=i.updateQueue,c.updateQueue===i&&(c.updateQueue={baseState:i.baseState,firstBaseUpdate:i.firstBaseUpdate,lastBaseUpdate:i.lastBaseUpdate,shared:i.shared,callbacks:null})}function Ll(i){return{lane:i,tag:0,payload:null,callback:null,next:null}}function zl(i,c,f){var g=i.updateQueue;if(g===null)return null;if(g=g.shared,(Ln&2)!==0){var j=g.pending;return j===null?c.next=c:(c.next=j.next,j.next=c),g.pending=c,c=Mh(i),h2(i,null,f),c}return Eh(i,g,c,f),Mh(i)}function ap(i,c,f){if(c=c.updateQueue,c!==null&&(c=c.shared,(f&4194048)!==0)){var g=c.lanes;g&=i.pendingLanes,f|=g,c.lanes=f,en(i,f)}}function ix(i,c){var f=i.updateQueue,g=i.alternate;if(g!==null&&(g=g.updateQueue,f===g)){var j=null,k=null;if(f=f.firstBaseUpdate,f!==null){do{var W={lane:f.lane,tag:f.tag,payload:f.payload,callback:null,next:null};k===null?j=k=W:k=k.next=W,f=f.next}while(f!==null);k===null?j=k=c:k=k.next=c}else j=k=c;f={baseState:g.baseState,firstBaseUpdate:j,lastBaseUpdate:k,shared:g.shared,callbacks:g.callbacks},i.updateQueue=f;return}i=f.lastBaseUpdate,i===null?f.firstBaseUpdate=c:i.next=c,f.lastBaseUpdate=c}var ax=!1;function lp(){if(ax){var i=nd;if(i!==null)throw i}}function cp(i,c,f,g){ax=!1;var j=i.updateQueue;Il=!1;var k=j.firstBaseUpdate,W=j.lastBaseUpdate,ne=j.shared.pending;if(ne!==null){j.shared.pending=null;var Ae=ne,Be=Ae.next;Ae.next=null,W===null?k=Be:W.next=Be,W=Ae;var Ze=i.alternate;Ze!==null&&(Ze=Ze.updateQueue,ne=Ze.lastBaseUpdate,ne!==W&&(ne===null?Ze.firstBaseUpdate=Be:ne.next=Be,Ze.lastBaseUpdate=Ae))}if(k!==null){var ot=j.baseState;W=0,Ze=Be=Ae=null,ne=k;do{var qe=ne.lane&-536870913,Ye=qe!==ne.lane;if(Ye?(jn&qe)===qe:(g&qe)===qe){qe!==0&&qe===td&&(ax=!0),Ze!==null&&(Ze=Ze.next={lane:0,tag:ne.tag,payload:ne.payload,callback:null,next:null});e:{var Dt=i,Xt=ne;qe=c;var Jn=f;switch(Xt.tag){case 1:if(Dt=Xt.payload,typeof Dt=="function"){ot=Dt.call(Jn,ot,qe);break e}ot=Dt;break e;case 3:Dt.flags=Dt.flags&-65537|128;case 0:if(Dt=Xt.payload,qe=typeof Dt=="function"?Dt.call(Jn,ot,qe):Dt,qe==null)break e;ot=b({},ot,qe);break e;case 2:Il=!0}}qe=ne.callback,qe!==null&&(i.flags|=64,Ye&&(i.flags|=8192),Ye=j.callbacks,Ye===null?j.callbacks=[qe]:Ye.push(qe))}else Ye={lane:qe,tag:ne.tag,payload:ne.payload,callback:ne.callback,next:null},Ze===null?(Be=Ze=Ye,Ae=ot):Ze=Ze.next=Ye,W|=qe;if(ne=ne.next,ne===null){if(ne=j.shared.pending,ne===null)break;Ye=ne,ne=Ye.next,Ye.next=null,j.lastBaseUpdate=Ye,j.shared.pending=null}}while(!0);Ze===null&&(Ae=ot),j.baseState=Ae,j.firstBaseUpdate=Be,j.lastBaseUpdate=Ze,k===null&&(j.shared.lanes=0),Vl|=W,i.lanes=W,i.memoizedState=ot}}function D2(i,c){if(typeof i!="function")throw Error(r(191,i));i.call(c)}function O2(i,c){var f=i.callbacks;if(f!==null)for(i.callbacks=null,i=0;ik?k:8;var W=P.T,ne={};P.T=ne,jx(i,!1,c,f);try{var Ae=j(),Be=P.S;if(Be!==null&&Be(ne,Ae),Ae!==null&&typeof Ae=="object"&&typeof Ae.then=="function"){var Ze=x$(Ae,g);fp(i,c,Ze,Ss(i))}else fp(i,c,g,Ss(i))}catch(ot){fp(i,c,{then:function(){},status:"rejected",reason:ot},Ss())}finally{O.p=k,W!==null&&ne.types!==null&&(W.types=ne.types),P.T=W}}function j$(){}function _x(i,c,f,g){if(i.tag!==5)throw Error(r(476));var j=fT(i).queue;dT(i,j,c,V,f===null?j$:function(){return pT(i),f(g)})}function fT(i){var c=i.memoizedState;if(c!==null)return c;c={memoizedState:V,baseState:V,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:$a,lastRenderedState:V},next:null};var f={};return c.next={memoizedState:f,baseState:f,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:$a,lastRenderedState:f},next:null},i.memoizedState=c,i=i.alternate,i!==null&&(i.memoizedState=c),c}function pT(i){var c=fT(i);c.next===null&&(c=i.alternate.memoizedState),fp(i,c.next.queue,{},Ss())}function Tx(){return po(Rp)}function mT(){return zr().memoizedState}function hT(){return zr().memoizedState}function E$(i){for(var c=i.return;c!==null;){switch(c.tag){case 24:case 3:var f=Ss();i=Ll(f);var g=zl(c,i,f);g!==null&&(Qo(g,c,f),ap(g,c,f)),c={cache:ex()},i.payload=c;return}c=c.return}}function M$(i,c,f){var g=Ss();f={lane:g,revertLane:0,gesture:null,action:f,hasEagerState:!1,eagerState:null,next:null},Gh(i)?bT(c,f):(f=Hv(i,c,f,g),f!==null&&(Qo(f,i,g),yT(f,c,g)))}function gT(i,c,f){var g=Ss();fp(i,c,f,g)}function fp(i,c,f,g){var j={lane:g,revertLane:0,gesture:null,action:f,hasEagerState:!1,eagerState:null,next:null};if(Gh(i))bT(c,j);else{var k=i.alternate;if(i.lanes===0&&(k===null||k.lanes===0)&&(k=c.lastRenderedReducer,k!==null))try{var W=c.lastRenderedState,ne=k(W,f);if(j.hasEagerState=!0,j.eagerState=ne,hs(ne,W))return Eh(i,c,j,0),rr===null&&jh(),!1}catch{}if(f=Hv(i,c,j,g),f!==null)return Qo(f,i,g),yT(f,c,g),!0}return!1}function jx(i,c,f,g){if(g={lane:2,revertLane:o0(),gesture:null,action:g,hasEagerState:!1,eagerState:null,next:null},Gh(i)){if(c)throw Error(r(479))}else c=Hv(i,f,g,2),c!==null&&Qo(c,i,2)}function Gh(i){var c=i.alternate;return i===pn||c!==null&&c===pn}function bT(i,c){id=Fh=!0;var f=i.pending;f===null?c.next=c:(c.next=f.next,f.next=c),i.pending=c}function yT(i,c,f){if((f&4194048)!==0){var g=c.lanes;g&=i.pendingLanes,f|=g,c.lanes=f,en(i,f)}}var pp={readContext:po,use:Hh,useCallback:Er,useContext:Er,useEffect:Er,useImperativeHandle:Er,useLayoutEffect:Er,useInsertionEffect:Er,useMemo:Er,useReducer:Er,useRef:Er,useState:Er,useDebugValue:Er,useDeferredValue:Er,useTransition:Er,useSyncExternalStore:Er,useId:Er,useHostTransitionStatus:Er,useFormState:Er,useActionState:Er,useOptimistic:Er,useMemoCache:Er,useCacheRefresh:Er};pp.useEffectEvent=Er;var vT={readContext:po,use:Hh,useCallback:function(i,c){return Io().memoizedState=[i,c===void 0?null:c],i},useContext:po,useEffect:nT,useImperativeHandle:function(i,c,f){f=f!=null?f.concat([i]):null,Uh(4194308,4,iT.bind(null,c,i),f)},useLayoutEffect:function(i,c){return Uh(4194308,4,i,c)},useInsertionEffect:function(i,c){Uh(4,2,i,c)},useMemo:function(i,c){var f=Io();c=c===void 0?null:c;var g=i();if(Gc){He(!0);try{i()}finally{He(!1)}}return f.memoizedState=[g,c],g},useReducer:function(i,c,f){var g=Io();if(f!==void 0){var j=f(c);if(Gc){He(!0);try{f(c)}finally{He(!1)}}}else j=c;return g.memoizedState=g.baseState=j,i={pending:null,lanes:0,dispatch:null,lastRenderedReducer:i,lastRenderedState:j},g.queue=i,i=i.dispatch=M$.bind(null,pn,i),[g.memoizedState,i]},useRef:function(i){var c=Io();return i={current:i},c.memoizedState=i},useState:function(i){i=vx(i);var c=i.queue,f=gT.bind(null,pn,c);return c.dispatch=f,[i.memoizedState,f]},useDebugValue:Cx,useDeferredValue:function(i,c){var f=Io();return wx(f,i,c)},useTransition:function(){var i=vx(!1);return i=dT.bind(null,pn,i.queue,!0,!1),Io().memoizedState=i,[!1,i]},useSyncExternalStore:function(i,c,f){var g=pn,j=Io();if(Rn){if(f===void 0)throw Error(r(407));f=f()}else{if(f=c(),rr===null)throw Error(r(349));(jn&127)!==0||F2(g,c,f)}j.memoizedState=f;var k={value:f,getSnapshot:c};return j.queue=k,nT(V2.bind(null,g,k,i),[i]),g.flags|=2048,ld(9,{destroy:void 0},B2.bind(null,g,k,f,c),null),f},useId:function(){var i=Io(),c=rr.identifierPrefix;if(Rn){var f=ra,g=na;f=(g&~(1<<32-lt(g)-1)).toString(32)+f,c="_"+c+"R_"+f,f=Bh++,0<\/script>",k=k.removeChild(k.firstChild);break;case"select":k=typeof g.is=="string"?W.createElement("select",{is:g.is}):W.createElement("select"),g.multiple?k.multiple=!0:g.size&&(k.size=g.size);break;default:k=typeof g.is=="string"?W.createElement(j,{is:g.is}):W.createElement(j)}}k[Bt]=c,k[fn]=g;e:for(W=c.child;W!==null;){if(W.tag===5||W.tag===6)k.appendChild(W.stateNode);else if(W.tag!==4&&W.tag!==27&&W.child!==null){W.child.return=W,W=W.child;continue}if(W===c)break e;for(;W.sibling===null;){if(W.return===null||W.return===c)break e;W=W.return}W.sibling.return=W.return,W=W.sibling}c.stateNode=k;e:switch(ho(k,j,g),j){case"button":case"input":case"select":case"textarea":g=!!g.autoFocus;break e;case"img":g=!0;break e;default:g=!1}g&&Ba(c)}}return cr(c),Fx(c,c.type,i===null?null:i.memoizedProps,c.pendingProps,f),null;case 6:if(i&&c.stateNode!=null)i.memoizedProps!==g&&Ba(c);else{if(typeof g!="string"&&c.stateNode===null)throw Error(r(166));if(i=X.current,Ju(c)){if(i=c.stateNode,f=c.memoizedProps,g=null,j=fo,j!==null)switch(j.tag){case 27:case 5:g=j.memoizedProps}i[Bt]=c,i=!!(i.nodeValue===f||g!==null&&g.suppressHydrationWarning===!0||Nj(i.nodeValue,f)),i||Dl(c,!0)}else i=pg(i).createTextNode(g),i[Bt]=c,c.stateNode=i}return cr(c),null;case 31:if(f=c.memoizedState,i===null||i.memoizedState!==null){if(g=Ju(c),f!==null){if(i===null){if(!g)throw Error(r(318));if(i=c.memoizedState,i=i!==null?i.dehydrated:null,!i)throw Error(r(557));i[Bt]=c}else Fc(),(c.flags&128)===0&&(c.memoizedState=null),c.flags|=4;cr(c),i=!1}else f=Xv(),i!==null&&i.memoizedState!==null&&(i.memoizedState.hydrationErrors=f),i=!0;if(!i)return c.flags&256?(ys(c),c):(ys(c),null);if((c.flags&128)!==0)throw Error(r(558))}return cr(c),null;case 13:if(g=c.memoizedState,i===null||i.memoizedState!==null&&i.memoizedState.dehydrated!==null){if(j=Ju(c),g!==null&&g.dehydrated!==null){if(i===null){if(!j)throw Error(r(318));if(j=c.memoizedState,j=j!==null?j.dehydrated:null,!j)throw Error(r(317));j[Bt]=c}else Fc(),(c.flags&128)===0&&(c.memoizedState=null),c.flags|=4;cr(c),j=!1}else j=Xv(),i!==null&&i.memoizedState!==null&&(i.memoizedState.hydrationErrors=j),j=!0;if(!j)return c.flags&256?(ys(c),c):(ys(c),null)}return ys(c),(c.flags&128)!==0?(c.lanes=f,c):(f=g!==null,i=i!==null&&i.memoizedState!==null,f&&(g=c.child,j=null,g.alternate!==null&&g.alternate.memoizedState!==null&&g.alternate.memoizedState.cachePool!==null&&(j=g.alternate.memoizedState.cachePool.pool),k=null,g.memoizedState!==null&&g.memoizedState.cachePool!==null&&(k=g.memoizedState.cachePool.pool),k!==j&&(g.flags|=2048)),f!==i&&f&&(c.child.flags|=8192),Zh(c,c.updateQueue),cr(c),null);case 4:return pe(),i===null&&l0(c.stateNode.containerInfo),cr(c),null;case 10:return za(c.type),cr(c),null;case 19:if(K(Lr),g=c.memoizedState,g===null)return cr(c),null;if(j=(c.flags&128)!==0,k=g.rendering,k===null)if(j)hp(g,!1);else{if(Mr!==0||i!==null&&(i.flags&128)!==0)for(i=c.child;i!==null;){if(k=$h(i),k!==null){for(c.flags|=128,hp(g,!1),i=k.updateQueue,c.updateQueue=i,Zh(c,i),c.subtreeFlags=0,i=f,f=c.child;f!==null;)g2(f,i),f=f.sibling;return ee(Lr,Lr.current&1|2),Rn&&Ia(c,g.treeForkCount),c.child}i=i.sibling}g.tail!==null&&Ee()>rg&&(c.flags|=128,j=!0,hp(g,!1),c.lanes=4194304)}else{if(!j)if(i=$h(k),i!==null){if(c.flags|=128,j=!0,i=i.updateQueue,c.updateQueue=i,Zh(c,i),hp(g,!0),g.tail===null&&g.tailMode==="hidden"&&!k.alternate&&!Rn)return cr(c),null}else 2*Ee()-g.renderingStartTime>rg&&f!==536870912&&(c.flags|=128,j=!0,hp(g,!1),c.lanes=4194304);g.isBackwards?(k.sibling=c.child,c.child=k):(i=g.last,i!==null?i.sibling=k:c.child=k,g.last=k)}return g.tail!==null?(i=g.tail,g.rendering=i,g.tail=i.sibling,g.renderingStartTime=Ee(),i.sibling=null,f=Lr.current,ee(Lr,j?f&1|2:f&1),Rn&&Ia(c,g.treeForkCount),i):(cr(c),null);case 22:case 23:return ys(c),cx(),g=c.memoizedState!==null,i!==null?i.memoizedState!==null!==g&&(c.flags|=8192):g&&(c.flags|=8192),g?(f&536870912)!==0&&(c.flags&128)===0&&(cr(c),c.subtreeFlags&6&&(c.flags|=8192)):cr(c),f=c.updateQueue,f!==null&&Zh(c,f.retryQueue),f=null,i!==null&&i.memoizedState!==null&&i.memoizedState.cachePool!==null&&(f=i.memoizedState.cachePool.pool),g=null,c.memoizedState!==null&&c.memoizedState.cachePool!==null&&(g=c.memoizedState.cachePool.pool),g!==f&&(c.flags|=2048),i!==null&&K(Hc),null;case 24:return f=null,i!==null&&(f=i.memoizedState.cache),c.memoizedState.cache!==f&&(c.flags|=2048),za(Vr),cr(c),null;case 25:return null;case 30:return null}throw Error(r(156,c.tag))}function D$(i,c){switch(Yv(c),c.tag){case 1:return i=c.flags,i&65536?(c.flags=i&-65537|128,c):null;case 3:return za(Vr),pe(),i=c.flags,(i&65536)!==0&&(i&128)===0?(c.flags=i&-65537|128,c):null;case 26:case 27:case 5:return be(c),null;case 31:if(c.memoizedState!==null){if(ys(c),c.alternate===null)throw Error(r(340));Fc()}return i=c.flags,i&65536?(c.flags=i&-65537|128,c):null;case 13:if(ys(c),i=c.memoizedState,i!==null&&i.dehydrated!==null){if(c.alternate===null)throw Error(r(340));Fc()}return i=c.flags,i&65536?(c.flags=i&-65537|128,c):null;case 19:return K(Lr),null;case 4:return pe(),null;case 10:return za(c.type),null;case 22:case 23:return ys(c),cx(),i!==null&&K(Hc),i=c.flags,i&65536?(c.flags=i&-65537|128,c):null;case 24:return za(Vr),null;case 25:return null;default:return null}}function HT(i,c){switch(Yv(c),c.tag){case 3:za(Vr),pe();break;case 26:case 27:case 5:be(c);break;case 4:pe();break;case 31:c.memoizedState!==null&&ys(c);break;case 13:ys(c);break;case 19:K(Lr);break;case 10:za(c.type);break;case 22:case 23:ys(c),cx(),i!==null&&K(Hc);break;case 24:za(Vr)}}function gp(i,c){try{var f=c.updateQueue,g=f!==null?f.lastEffect:null;if(g!==null){var j=g.next;f=j;do{if((f.tag&i)===i){g=void 0;var k=f.create,W=f.inst;g=k(),W.destroy=g}f=f.next}while(f!==j)}}catch(ne){Un(c,c.return,ne)}}function Fl(i,c,f){try{var g=c.updateQueue,j=g!==null?g.lastEffect:null;if(j!==null){var k=j.next;g=k;do{if((g.tag&i)===i){var W=g.inst,ne=W.destroy;if(ne!==void 0){W.destroy=void 0,j=c;var Ae=f,Be=ne;try{Be()}catch(Ze){Un(j,Ae,Ze)}}}g=g.next}while(g!==k)}}catch(Ze){Un(c,c.return,Ze)}}function qT(i){var c=i.updateQueue;if(c!==null){var f=i.stateNode;try{O2(c,f)}catch(g){Un(i,i.return,g)}}}function UT(i,c,f){f.props=Yc(i.type,i.memoizedProps),f.state=i.memoizedState;try{f.componentWillUnmount()}catch(g){Un(i,c,g)}}function bp(i,c){try{var f=i.ref;if(f!==null){switch(i.tag){case 26:case 27:case 5:var g=i.stateNode;break;case 30:g=i.stateNode;break;default:g=i.stateNode}typeof f=="function"?i.refCleanup=f(g):f.current=g}}catch(j){Un(i,c,j)}}function oa(i,c){var f=i.ref,g=i.refCleanup;if(f!==null)if(typeof g=="function")try{g()}catch(j){Un(i,c,j)}finally{i.refCleanup=null,i=i.alternate,i!=null&&(i.refCleanup=null)}else if(typeof f=="function")try{f(null)}catch(j){Un(i,c,j)}else f.current=null}function WT(i){var c=i.type,f=i.memoizedProps,g=i.stateNode;try{e:switch(c){case"button":case"input":case"select":case"textarea":f.autoFocus&&g.focus();break e;case"img":f.src?g.src=f.src:f.srcSet&&(g.srcset=f.srcSet)}}catch(j){Un(i,i.return,j)}}function Bx(i,c,f){try{var g=i.stateNode;t4(g,i.type,f,c),g[fn]=c}catch(j){Un(i,i.return,j)}}function GT(i){return i.tag===5||i.tag===3||i.tag===26||i.tag===27&&Gl(i.type)||i.tag===4}function Vx(i){e:for(;;){for(;i.sibling===null;){if(i.return===null||GT(i.return))return null;i=i.return}for(i.sibling.return=i.return,i=i.sibling;i.tag!==5&&i.tag!==6&&i.tag!==18;){if(i.tag===27&&Gl(i.type)||i.flags&2||i.child===null||i.tag===4)continue e;i.child.return=i,i=i.child}if(!(i.flags&2))return i.stateNode}}function Hx(i,c,f){var g=i.tag;if(g===5||g===6)i=i.stateNode,c?(f.nodeType===9?f.body:f.nodeName==="HTML"?f.ownerDocument.body:f).insertBefore(i,c):(c=f.nodeType===9?f.body:f.nodeName==="HTML"?f.ownerDocument.body:f,c.appendChild(i),f=f._reactRootContainer,f!=null||c.onclick!==null||(c.onclick=Wo));else if(g!==4&&(g===27&&Gl(i.type)&&(f=i.stateNode,c=null),i=i.child,i!==null))for(Hx(i,c,f),i=i.sibling;i!==null;)Hx(i,c,f),i=i.sibling}function Jh(i,c,f){var g=i.tag;if(g===5||g===6)i=i.stateNode,c?f.insertBefore(i,c):f.appendChild(i);else if(g!==4&&(g===27&&Gl(i.type)&&(f=i.stateNode),i=i.child,i!==null))for(Jh(i,c,f),i=i.sibling;i!==null;)Jh(i,c,f),i=i.sibling}function YT(i){var c=i.stateNode,f=i.memoizedProps;try{for(var g=i.type,j=c.attributes;j.length;)c.removeAttributeNode(j[0]);ho(c,g,f),c[Bt]=i,c[fn]=f}catch(k){Un(i,i.return,k)}}var Va=!1,Ur=!1,qx=!1,KT=typeof WeakSet=="function"?WeakSet:Set,ro=null;function O$(i,c){if(i=i.containerInfo,d0=xg,i=a2(i),zv(i)){if("selectionStart"in i)var f={start:i.selectionStart,end:i.selectionEnd};else e:{f=(f=i.ownerDocument)&&f.defaultView||window;var g=f.getSelection&&f.getSelection();if(g&&g.rangeCount!==0){f=g.anchorNode;var j=g.anchorOffset,k=g.focusNode;g=g.focusOffset;try{f.nodeType,k.nodeType}catch{f=null;break e}var W=0,ne=-1,Ae=-1,Be=0,Ze=0,ot=i,qe=null;t:for(;;){for(var Ye;ot!==f||j!==0&&ot.nodeType!==3||(ne=W+j),ot!==k||g!==0&&ot.nodeType!==3||(Ae=W+g),ot.nodeType===3&&(W+=ot.nodeValue.length),(Ye=ot.firstChild)!==null;)qe=ot,ot=Ye;for(;;){if(ot===i)break t;if(qe===f&&++Be===j&&(ne=W),qe===k&&++Ze===g&&(Ae=W),(Ye=ot.nextSibling)!==null)break;ot=qe,qe=ot.parentNode}ot=Ye}f=ne===-1||Ae===-1?null:{start:ne,end:Ae}}else f=null}f=f||{start:0,end:0}}else f=null;for(f0={focusedElem:i,selectionRange:f},xg=!1,ro=c;ro!==null;)if(c=ro,i=c.child,(c.subtreeFlags&1028)!==0&&i!==null)i.return=c,ro=i;else for(;ro!==null;){switch(c=ro,k=c.alternate,i=c.flags,c.tag){case 0:if((i&4)!==0&&(i=c.updateQueue,i=i!==null?i.events:null,i!==null))for(f=0;f title"))),ho(k,g,f),k[Bt]=i,kn(k),g=k;break e;case"link":var W=tE("link","href",j).get(g+(f.href||""));if(W){for(var ne=0;neJn&&(W=Jn,Jn=Xt,Xt=W);var ze=s2(ne,Xt),Ie=s2(ne,Jn);if(ze&&Ie&&(Ye.rangeCount!==1||Ye.anchorNode!==ze.node||Ye.anchorOffset!==ze.offset||Ye.focusNode!==Ie.node||Ye.focusOffset!==Ie.offset)){var Fe=ot.createRange();Fe.setStart(ze.node,ze.offset),Ye.removeAllRanges(),Xt>Jn?(Ye.addRange(Fe),Ye.extend(Ie.node,Ie.offset)):(Fe.setEnd(Ie.node,Ie.offset),Ye.addRange(Fe))}}}}for(ot=[],Ye=ne;Ye=Ye.parentNode;)Ye.nodeType===1&&ot.push({element:Ye,left:Ye.scrollLeft,top:Ye.scrollTop});for(typeof ne.focus=="function"&&ne.focus(),ne=0;nef?32:f,P.T=null,f=Qx,Qx=null;var k=ql,W=Ga;if(Jr=0,pd=ql=null,Ga=0,(Ln&6)!==0)throw Error(r(331));var ne=Ln;if(Ln|=4,ij(k.current),rj(k,k.current,W,f),Ln=ne,wp(0,!1),We&&typeof We.onPostCommitFiberRoot=="function")try{We.onPostCommitFiberRoot(Ve,k)}catch{}return!0}finally{O.p=j,P.T=g,_j(i,c)}}function jj(i,c,f){c=Ws(f,c),c=kx(i.stateNode,c,2),i=zl(i,c,2),i!==null&&(mt(i,2),sa(i))}function Un(i,c,f){if(i.tag===3)jj(i,i,f);else for(;c!==null;){if(c.tag===3){jj(c,i,f);break}else if(c.tag===1){var g=c.stateNode;if(typeof c.type.getDerivedStateFromError=="function"||typeof g.componentDidCatch=="function"&&(Hl===null||!Hl.has(g))){i=Ws(f,i),f=ET(2),g=zl(c,f,2),g!==null&&(MT(f,g,c,i),mt(g,2),sa(g));break}}c=c.return}}function t0(i,c,f){var g=i.pingCache;if(g===null){g=i.pingCache=new z$;var j=new Set;g.set(c,j)}else j=g.get(c),j===void 0&&(j=new Set,g.set(c,j));j.has(f)||(Gx=!0,j.add(f),i=V$.bind(null,i,c,f),c.then(i,i))}function V$(i,c,f){var g=i.pingCache;g!==null&&g.delete(c),i.pingedLanes|=i.suspendedLanes&f,i.warmLanes&=~f,rr===i&&(jn&f)===f&&(Mr===4||Mr===3&&(jn&62914560)===jn&&300>Ee()-ng?(Ln&2)===0&&md(i,0):Yx|=f,fd===jn&&(fd=0)),sa(i)}function Ej(i,c){c===0&&(c=ht()),i=Nc(i,c),i!==null&&(mt(i,c),sa(i))}function H$(i){var c=i.memoizedState,f=0;c!==null&&(f=c.retryLane),Ej(i,f)}function q$(i,c){var f=0;switch(i.tag){case 31:case 13:var g=i.stateNode,j=i.memoizedState;j!==null&&(f=j.retryLane);break;case 19:g=i.stateNode;break;case 22:g=i.stateNode._retryCache;break;default:throw Error(r(314))}g!==null&&g.delete(c),Ej(i,f)}function U$(i,c){return ce(i,c)}var cg=null,gd=null,n0=!1,ug=!1,r0=!1,Wl=0;function sa(i){i!==gd&&i.next===null&&(gd===null?cg=gd=i:gd=gd.next=i),ug=!0,n0||(n0=!0,G$())}function wp(i,c){if(!r0&&ug){r0=!0;do for(var f=!1,g=cg;g!==null;){if(i!==0){var j=g.pendingLanes;if(j===0)var k=0;else{var W=g.suspendedLanes,ne=g.pingedLanes;k=(1<<31-lt(42|i)+1)-1,k&=j&~(W&~ne),k=k&201326741?k&201326741|1:k?k|2:0}k!==0&&(f=!0,Pj(g,k))}else k=jn,k=dn(g,g===rr?k:0,g.cancelPendingCommit!==null||g.timeoutHandle!==-1),(k&3)===0||ut(g,k)||(f=!0,Pj(g,k));g=g.next}while(f);r0=!1}}function W$(){Mj()}function Mj(){ug=n0=!1;var i=0;Wl!==0&&r4()&&(i=Wl);for(var c=Ee(),f=null,g=cg;g!==null;){var j=g.next,k=Rj(g,c);k===0?(g.next=null,f===null?cg=j:f.next=j,j===null&&(gd=f)):(f=g,(i!==0||(k&3)!==0)&&(ug=!0)),g=j}Jr!==0&&Jr!==5||wp(i),Wl!==0&&(Wl=0)}function Rj(i,c){for(var f=i.suspendedLanes,g=i.pingedLanes,j=i.expirationTimes,k=i.pendingLanes&-62914561;0ne)break;var Ze=Ae.transferSize,ot=Ae.initiatorType;Ze&&$j(ot)&&(Ae=Ae.responseEnd,W+=Ze*(Ae"u"?null:document;function Qj(i,c,f){var g=bd;if(g&&typeof c=="string"&&c){var j=So(c);j='link[rel="'+i+'"][href="'+j+'"]',typeof f=="string"&&(j+='[crossorigin="'+f+'"]'),Xj.has(j)||(Xj.add(j),i={rel:i,crossOrigin:f,href:c},g.querySelector(j)===null&&(c=g.createElement("link"),ho(c,"link",i),kn(c),g.head.appendChild(c)))}}function f4(i){Ya.D(i),Qj("dns-prefetch",i,null)}function p4(i,c){Ya.C(i,c),Qj("preconnect",i,c)}function m4(i,c,f){Ya.L(i,c,f);var g=bd;if(g&&i&&c){var j='link[rel="preload"][as="'+So(c)+'"]';c==="image"&&f&&f.imageSrcSet?(j+='[imagesrcset="'+So(f.imageSrcSet)+'"]',typeof f.imageSizes=="string"&&(j+='[imagesizes="'+So(f.imageSizes)+'"]')):j+='[href="'+So(i)+'"]';var k=j;switch(c){case"style":k=yd(i);break;case"script":k=vd(i)}Zs.has(k)||(i=b({rel:"preload",href:c==="image"&&f&&f.imageSrcSet?void 0:i,as:c},f),Zs.set(k,i),g.querySelector(j)!==null||c==="style"&&g.querySelector(Ep(k))||c==="script"&&g.querySelector(Mp(k))||(c=g.createElement("link"),ho(c,"link",i),kn(c),g.head.appendChild(c)))}}function h4(i,c){Ya.m(i,c);var f=bd;if(f&&i){var g=c&&typeof c.as=="string"?c.as:"script",j='link[rel="modulepreload"][as="'+So(g)+'"][href="'+So(i)+'"]',k=j;switch(g){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":k=vd(i)}if(!Zs.has(k)&&(i=b({rel:"modulepreload",href:i},c),Zs.set(k,i),f.querySelector(j)===null)){switch(g){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(f.querySelector(Mp(k)))return}g=f.createElement("link"),ho(g,"link",i),kn(g),f.head.appendChild(g)}}}function g4(i,c,f){Ya.S(i,c,f);var g=bd;if(g&&i){var j=Dn(g).hoistableStyles,k=yd(i);c=c||"default";var W=j.get(k);if(!W){var ne={loading:0,preload:null};if(W=g.querySelector(Ep(k)))ne.loading=5;else{i=b({rel:"stylesheet",href:i,"data-precedence":c},f),(f=Zs.get(k))&&v0(i,f);var Ae=W=g.createElement("link");kn(Ae),ho(Ae,"link",i),Ae._p=new Promise(function(Be,Ze){Ae.onload=Be,Ae.onerror=Ze}),Ae.addEventListener("load",function(){ne.loading|=1}),Ae.addEventListener("error",function(){ne.loading|=2}),ne.loading|=4,hg(W,c,g)}W={type:"stylesheet",instance:W,count:1,state:ne},j.set(k,W)}}}function b4(i,c){Ya.X(i,c);var f=bd;if(f&&i){var g=Dn(f).hoistableScripts,j=vd(i),k=g.get(j);k||(k=f.querySelector(Mp(j)),k||(i=b({src:i,async:!0},c),(c=Zs.get(j))&&x0(i,c),k=f.createElement("script"),kn(k),ho(k,"link",i),f.head.appendChild(k)),k={type:"script",instance:k,count:1,state:null},g.set(j,k))}}function y4(i,c){Ya.M(i,c);var f=bd;if(f&&i){var g=Dn(f).hoistableScripts,j=vd(i),k=g.get(j);k||(k=f.querySelector(Mp(j)),k||(i=b({src:i,async:!0,type:"module"},c),(c=Zs.get(j))&&x0(i,c),k=f.createElement("script"),kn(k),ho(k,"link",i),f.head.appendChild(k)),k={type:"script",instance:k,count:1,state:null},g.set(j,k))}}function Zj(i,c,f,g){var j=(j=X.current)?mg(j):null;if(!j)throw Error(r(446));switch(i){case"meta":case"title":return null;case"style":return typeof f.precedence=="string"&&typeof f.href=="string"?(c=yd(f.href),f=Dn(j).hoistableStyles,g=f.get(c),g||(g={type:"style",instance:null,count:0,state:null},f.set(c,g)),g):{type:"void",instance:null,count:0,state:null};case"link":if(f.rel==="stylesheet"&&typeof f.href=="string"&&typeof f.precedence=="string"){i=yd(f.href);var k=Dn(j).hoistableStyles,W=k.get(i);if(W||(j=j.ownerDocument||j,W={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},k.set(i,W),(k=j.querySelector(Ep(i)))&&!k._p&&(W.instance=k,W.state.loading=5),Zs.has(i)||(f={rel:"preload",as:"style",href:f.href,crossOrigin:f.crossOrigin,integrity:f.integrity,media:f.media,hrefLang:f.hrefLang,referrerPolicy:f.referrerPolicy},Zs.set(i,f),k||v4(j,i,f,W.state))),c&&g===null)throw Error(r(528,""));return W}if(c&&g!==null)throw Error(r(529,""));return null;case"script":return c=f.async,f=f.src,typeof f=="string"&&c&&typeof c!="function"&&typeof c!="symbol"?(c=vd(f),f=Dn(j).hoistableScripts,g=f.get(c),g||(g={type:"script",instance:null,count:0,state:null},f.set(c,g)),g):{type:"void",instance:null,count:0,state:null};default:throw Error(r(444,i))}}function yd(i){return'href="'+So(i)+'"'}function Ep(i){return'link[rel="stylesheet"]['+i+"]"}function Jj(i){return b({},i,{"data-precedence":i.precedence,precedence:null})}function v4(i,c,f,g){i.querySelector('link[rel="preload"][as="style"]['+c+"]")?g.loading=1:(c=i.createElement("link"),g.preload=c,c.addEventListener("load",function(){return g.loading|=1}),c.addEventListener("error",function(){return g.loading|=2}),ho(c,"link",f),kn(c),i.head.appendChild(c))}function vd(i){return'[src="'+So(i)+'"]'}function Mp(i){return"script[async]"+i}function eE(i,c,f){if(c.count++,c.instance===null)switch(c.type){case"style":var g=i.querySelector('style[data-href~="'+So(f.href)+'"]');if(g)return c.instance=g,kn(g),g;var j=b({},f,{"data-href":f.href,"data-precedence":f.precedence,href:null,precedence:null});return g=(i.ownerDocument||i).createElement("style"),kn(g),ho(g,"style",j),hg(g,f.precedence,i),c.instance=g;case"stylesheet":j=yd(f.href);var k=i.querySelector(Ep(j));if(k)return c.state.loading|=4,c.instance=k,kn(k),k;g=Jj(f),(j=Zs.get(j))&&v0(g,j),k=(i.ownerDocument||i).createElement("link"),kn(k);var W=k;return W._p=new Promise(function(ne,Ae){W.onload=ne,W.onerror=Ae}),ho(k,"link",g),c.state.loading|=4,hg(k,f.precedence,i),c.instance=k;case"script":return k=vd(f.src),(j=i.querySelector(Mp(k)))?(c.instance=j,kn(j),j):(g=f,(j=Zs.get(k))&&(g=b({},f),x0(g,j)),i=i.ownerDocument||i,j=i.createElement("script"),kn(j),ho(j,"link",g),i.head.appendChild(j),c.instance=j);case"void":return null;default:throw Error(r(443,c.type))}else c.type==="stylesheet"&&(c.state.loading&4)===0&&(g=c.instance,c.state.loading|=4,hg(g,f.precedence,i));return c.instance}function hg(i,c,f){for(var g=f.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),j=g.length?g[g.length-1]:null,k=j,W=0;W title"):null)}function x4(i,c,f){if(f===1||c.itemProp!=null)return!1;switch(i){case"meta":case"title":return!0;case"style":if(typeof c.precedence!="string"||typeof c.href!="string"||c.href==="")break;return!0;case"link":if(typeof c.rel!="string"||typeof c.href!="string"||c.href===""||c.onLoad||c.onError)break;return c.rel==="stylesheet"?(i=c.disabled,typeof c.precedence=="string"&&i==null):!0;case"script":if(c.async&&typeof c.async!="function"&&typeof c.async!="symbol"&&!c.onLoad&&!c.onError&&c.src&&typeof c.src=="string")return!0}return!1}function rE(i){return!(i.type==="stylesheet"&&(i.state.loading&3)===0)}function S4(i,c,f,g){if(f.type==="stylesheet"&&(typeof g.media!="string"||matchMedia(g.media).matches!==!1)&&(f.state.loading&4)===0){if(f.instance===null){var j=yd(g.href),k=c.querySelector(Ep(j));if(k){c=k._p,c!==null&&typeof c=="object"&&typeof c.then=="function"&&(i.count++,i=bg.bind(i),c.then(i,i)),f.state.loading|=4,f.instance=k,kn(k);return}k=c.ownerDocument||c,g=Jj(g),(j=Zs.get(j))&&v0(g,j),k=k.createElement("link"),kn(k);var W=k;W._p=new Promise(function(ne,Ae){W.onload=ne,W.onerror=Ae}),ho(k,"link",g),f.instance=k}i.stylesheets===null&&(i.stylesheets=new Map),i.stylesheets.set(f,c),(c=f.state.preload)&&(f.state.loading&3)===0&&(i.count++,f=bg.bind(i),c.addEventListener("load",f),c.addEventListener("error",f))}}var S0=0;function C4(i,c){return i.stylesheets&&i.count===0&&vg(i,i.stylesheets),0S0?50:800)+c);return i.unsuspend=f,function(){i.unsuspend=null,clearTimeout(g),clearTimeout(j)}}:null}function bg(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)vg(this,this.stylesheets);else if(this.unsuspend){var i=this.unsuspend;this.unsuspend=null,i()}}}var yg=null;function vg(i,c){i.stylesheets=null,i.unsuspend!==null&&(i.count++,yg=new Map,c.forEach(w4,i),yg=null,bg.call(i))}function w4(i,c){if(!(c.state.loading&4)){var f=yg.get(i);if(f)var g=f.get(null);else{f=new Map,yg.set(i,f);for(var j=i.querySelectorAll("link[data-precedence],style[data-precedence]"),k=0;k"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}return e(),$0.exports=L8(),$0.exports}var N8=z8();const $8=bi(N8);var _M="popstate";function F8(e={}){function t(r,o){let{pathname:s,search:l,hash:u}=r.location;return lS("",{pathname:s,search:l,hash:u},o.state&&o.state.usr||null,o.state&&o.state.key||"default")}function n(r,o){return typeof o=="string"?o:wm(o)}return V8(t,n,null,e)}function Cr(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function Ca(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function B8(){return Math.random().toString(36).substring(2,10)}function TM(e,t){return{usr:e.state,key:e.key,idx:t}}function lS(e,t,n=null,r){return{pathname:typeof e=="string"?e:e.pathname,search:"",hash:"",...typeof t=="string"?Af(t):t,state:n,key:t&&t.key||r||B8()}}function wm({pathname:e="/",search:t="",hash:n=""}){return t&&t!=="?"&&(e+=t.charAt(0)==="?"?t:"?"+t),n&&n!=="#"&&(e+=n.charAt(0)==="#"?n:"#"+n),e}function Af(e){let t={};if(e){let n=e.indexOf("#");n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf("?");r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function V8(e,t,n,r={}){let{window:o=document.defaultView,v5Compat:s=!1}=r,l=o.history,u="POP",d=null,p=m();p==null&&(p=0,l.replaceState({...l.state,idx:p},""));function m(){return(l.state||{idx:null}).idx}function b(){u="POP";let S=m(),w=S==null?null:S-p;p=S,d&&d({action:u,location:C.location,delta:w})}function y(S,w){u="PUSH";let _=lS(C.location,S,w);p=m()+1;let T=TM(_,p),M=C.createHref(_);try{l.pushState(T,"",M)}catch(R){if(R instanceof DOMException&&R.name==="DataCloneError")throw R;o.location.assign(M)}s&&d&&d({action:u,location:C.location,delta:1})}function h(S,w){u="REPLACE";let _=lS(C.location,S,w);p=m();let T=TM(_,p),M=C.createHref(_);l.replaceState(T,"",M),s&&d&&d({action:u,location:C.location,delta:0})}function x(S){return H8(S)}let C={get action(){return u},get location(){return e(o,l)},listen(S){if(d)throw new Error("A history only accepts one active listener");return o.addEventListener(_M,b),d=S,()=>{o.removeEventListener(_M,b),d=null}},createHref(S){return t(o,S)},createURL:x,encodeLocation(S){let w=x(S);return{pathname:w.pathname,search:w.search,hash:w.hash}},push:y,replace:h,go(S){return l.go(S)}};return C}function H8(e,t=!1){let n="http://localhost";typeof window<"u"&&(n=window.location.origin!=="null"?window.location.origin:window.location.href),Cr(n,"No window.location.(origin|href) available to create URL");let r=typeof e=="string"?e:wm(e);return r=r.replace(/ $/,"%20"),!t&&r.startsWith("//")&&(r=n+r),new URL(r,n)}function MD(e,t,n="/"){return q8(e,t,n,!1)}function q8(e,t,n,r){let o=typeof t=="string"?Af(t):t,s=ul(o.pathname||"/",n);if(s==null)return null;let l=RD(e);U8(l);let u=null;for(let d=0;u==null&&d{let m={relativePath:p===void 0?l.path||"":p,caseSensitive:l.caseSensitive===!0,childrenIndex:u,route:l};if(m.relativePath.startsWith("/")){if(!m.relativePath.startsWith(r)&&d)return;Cr(m.relativePath.startsWith(r),`Absolute route path "${m.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),m.relativePath=m.relativePath.slice(r.length)}let b=ll([r,m.relativePath]),y=n.concat(m);l.children&&l.children.length>0&&(Cr(l.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${b}".`),RD(l.children,t,y,b,d)),!(l.path==null&&!l.index)&&t.push({path:b,score:Z8(b,l.index),routesMeta:y})};return e.forEach((l,u)=>{if(l.path===""||!l.path?.includes("?"))s(l,u);else for(let d of kD(l.path))s(l,u,!0,d)}),t}function kD(e){let t=e.split("/");if(t.length===0)return[];let[n,...r]=t,o=n.endsWith("?"),s=n.replace(/\?$/,"");if(r.length===0)return o?[s,""]:[s];let l=kD(r.join("/")),u=[];return u.push(...l.map(d=>d===""?s:[s,d].join("/"))),o&&u.push(...l),u.map(d=>e.startsWith("/")&&d===""?"/":d)}function U8(e){e.sort((t,n)=>t.score!==n.score?n.score-t.score:J8(t.routesMeta.map(r=>r.childrenIndex),n.routesMeta.map(r=>r.childrenIndex)))}var W8=/^:[\w-]+$/,G8=3,Y8=2,K8=1,X8=10,Q8=-2,jM=e=>e==="*";function Z8(e,t){let n=e.split("/"),r=n.length;return n.some(jM)&&(r+=Q8),t&&(r+=Y8),n.filter(o=>!jM(o)).reduce((o,s)=>o+(W8.test(s)?G8:s===""?K8:X8),r)}function J8(e,t){return e.length===t.length&&e.slice(0,-1).every((r,o)=>r===t[o])?e[e.length-1]-t[t.length-1]:0}function e9(e,t,n=!1){let{routesMeta:r}=e,o={},s="/",l=[];for(let u=0;u{if(m==="*"){let x=u[y]||"";l=s.slice(0,s.length-x.length).replace(/(.)\/+$/,"$1")}const h=u[y];return b&&!h?p[m]=void 0:p[m]=(h||"").replace(/%2F/g,"/"),p},{}),pathname:s,pathnameBase:l,pattern:e}}function t9(e,t=!1,n=!0){Ca(e==="*"||!e.endsWith("*")||e.endsWith("/*"),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,"/*")}".`);let r=[],o="^"+e.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(l,u,d)=>(r.push({paramName:u,isOptional:d!=null}),d?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return e.endsWith("*")?(r.push({paramName:"*"}),o+=e==="*"||e==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):n?o+="\\/*$":e!==""&&e!=="/"&&(o+="(?:(?=\\/|$))"),[new RegExp(o,t?void 0:"i"),r]}function n9(e){try{return e.split("/").map(t=>decodeURIComponent(t).replace(/\//g,"%2F")).join("/")}catch(t){return Ca(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function ul(e,t){if(t==="/")return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith("/")?t.length-1:t.length,r=e.charAt(n);return r&&r!=="/"?null:e.slice(n)||"/"}var r9=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function o9(e,t="/"){let{pathname:n,search:r="",hash:o=""}=typeof e=="string"?Af(e):e,s;return n?(n=n.replace(/\/\/+/g,"/"),n.startsWith("/")?s=EM(n.substring(1),"/"):s=EM(n,t)):s=t,{pathname:s,search:a9(r),hash:l9(o)}}function EM(e,t){let n=t.replace(/\/+$/,"").split("/");return e.split("/").forEach(o=>{o===".."?n.length>1&&n.pop():o!=="."&&n.push(o)}),n.length>1?n.join("/"):"/"}function H0(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function s9(e){return e.filter((t,n)=>n===0||t.route.path&&t.route.path.length>0)}function PD(e){let t=s9(e);return t.map((n,r)=>r===t.length-1?n.pathname:n.pathnameBase)}function AD(e,t,n,r=!1){let o;typeof e=="string"?o=Af(e):(o={...e},Cr(!o.pathname||!o.pathname.includes("?"),H0("?","pathname","search",o)),Cr(!o.pathname||!o.pathname.includes("#"),H0("#","pathname","hash",o)),Cr(!o.search||!o.search.includes("#"),H0("#","search","hash",o)));let s=e===""||o.pathname==="",l=s?"/":o.pathname,u;if(l==null)u=n;else{let b=t.length-1;if(!r&&l.startsWith("..")){let y=l.split("/");for(;y[0]==="..";)y.shift(),b-=1;o.pathname=y.join("/")}u=b>=0?t[b]:"/"}let d=o9(o,u),p=l&&l!=="/"&&l.endsWith("/"),m=(s||l===".")&&n.endsWith("/");return!d.pathname.endsWith("/")&&(p||m)&&(d.pathname+="/"),d}var ll=e=>e.join("/").replace(/\/\/+/g,"/"),i9=e=>e.replace(/\/+$/,"").replace(/^\/*/,"/"),a9=e=>!e||e==="?"?"":e.startsWith("?")?e:"?"+e,l9=e=>!e||e==="#"?"":e.startsWith("#")?e:"#"+e,c9=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||"",this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function u9(e){return e!=null&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.internal=="boolean"&&"data"in e}function d9(e){return e.map(t=>t.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var DD=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function OD(e,t){let n=e;if(typeof n!="string"||!r9.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,o=!1;if(DD)try{let s=new URL(window.location.href),l=n.startsWith("//")?new URL(s.protocol+n):new URL(n),u=ul(l.pathname,t);l.origin===s.origin&&u!=null?n=u+l.search+l.hash:o=!0}catch{Ca(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:o,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var ID=["POST","PUT","PATCH","DELETE"];new Set(ID);var f9=["GET",...ID];new Set(f9);var Df=v.createContext(null);Df.displayName="DataRouter";var Iy=v.createContext(null);Iy.displayName="DataRouterState";var p9=v.createContext(!1),LD=v.createContext({isTransitioning:!1});LD.displayName="ViewTransition";var m9=v.createContext(new Map);m9.displayName="Fetchers";var h9=v.createContext(null);h9.displayName="Await";var yi=v.createContext(null);yi.displayName="Navigation";var Ym=v.createContext(null);Ym.displayName="Location";var ja=v.createContext({outlet:null,matches:[],isDataRoute:!1});ja.displayName="Route";var IC=v.createContext(null);IC.displayName="RouteError";var zD="REACT_ROUTER_ERROR",g9="REDIRECT",b9="ROUTE_ERROR_RESPONSE";function y9(e){if(e.startsWith(`${zD}:${g9}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.location=="string"&&typeof t.reloadDocument=="boolean"&&typeof t.replace=="boolean")return t}catch{}}function v9(e){if(e.startsWith(`${zD}:${b9}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string")return new c9(t.status,t.statusText,t.data)}catch{}}function x9(e,{relative:t}={}){Cr(Km(),"useHref() may be used only in the context of a component.");let{basename:n,navigator:r}=v.useContext(yi),{hash:o,pathname:s,search:l}=Xm(e,{relative:t}),u=s;return n!=="/"&&(u=s==="/"?n:ll([n,s])),r.createHref({pathname:u,search:l,hash:o})}function Km(){return v.useContext(Ym)!=null}function vi(){return Cr(Km(),"useLocation() may be used only in the context of a component."),v.useContext(Ym).location}var ND="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function $D(e){v.useContext(yi).static||v.useLayoutEffect(e)}function Yr(){let{isDataRoute:e}=v.useContext(ja);return e?D9():S9()}function S9(){Cr(Km(),"useNavigate() may be used only in the context of a component.");let e=v.useContext(Df),{basename:t,navigator:n}=v.useContext(yi),{matches:r}=v.useContext(ja),{pathname:o}=vi(),s=JSON.stringify(PD(r)),l=v.useRef(!1);return $D(()=>{l.current=!0}),v.useCallback((d,p={})=>{if(Ca(l.current,ND),!l.current)return;if(typeof d=="number"){n.go(d);return}let m=AD(d,JSON.parse(s),o,p.relative==="path");e==null&&t!=="/"&&(m.pathname=m.pathname==="/"?t:ll([t,m.pathname])),(p.replace?n.replace:n.push)(m,p.state,p)},[t,n,s,o,e])}v.createContext(null);function gl(){let{matches:e}=v.useContext(ja),t=e[e.length-1];return t?t.params:{}}function Xm(e,{relative:t}={}){let{matches:n}=v.useContext(ja),{pathname:r}=vi(),o=JSON.stringify(PD(n));return v.useMemo(()=>AD(e,JSON.parse(o),r,t==="path"),[e,o,r,t])}function C9(e,t){return FD(e,t)}function FD(e,t,n,r,o){Cr(Km(),"useRoutes() may be used only in the context of a component.");let{navigator:s}=v.useContext(yi),{matches:l}=v.useContext(ja),u=l[l.length-1],d=u?u.params:{},p=u?u.pathname:"/",m=u?u.pathnameBase:"/",b=u&&u.route;{let _=b&&b.path||"";VD(p,!b||_.endsWith("*")||_.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${p}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. - -Please change the parent to .`)}let y=vi(),h;if(t){let _=typeof t=="string"?Af(t):t;Cr(m==="/"||_.pathname?.startsWith(m),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${m}" but pathname "${_.pathname}" was given in the \`location\` prop.`),h=_}else h=y;let x=h.pathname||"/",C=x;if(m!=="/"){let _=m.replace(/^\//,"").split("/");C="/"+x.replace(/^\//,"").split("/").slice(_.length).join("/")}let S=MD(e,{pathname:C});Ca(b||S!=null,`No routes matched location "${h.pathname}${h.search}${h.hash}" `),Ca(S==null||S[S.length-1].route.element!==void 0||S[S.length-1].route.Component!==void 0||S[S.length-1].route.lazy!==void 0,`Matched leaf route at location "${h.pathname}${h.search}${h.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let w=E9(S&&S.map(_=>Object.assign({},_,{params:Object.assign({},d,_.params),pathname:ll([m,s.encodeLocation?s.encodeLocation(_.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:_.pathname]),pathnameBase:_.pathnameBase==="/"?m:ll([m,s.encodeLocation?s.encodeLocation(_.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:_.pathnameBase])})),l,n,r,o);return t&&w?v.createElement(Ym.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...h},navigationType:"POP"}},w):w}function w9(){let e=A9(),t=u9(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r="rgba(200,200,200, 0.5)",o={padding:"0.5rem",backgroundColor:r},s={padding:"2px 4px",backgroundColor:r},l=null;return console.error("Error handled by React Router default ErrorBoundary:",e),l=v.createElement(v.Fragment,null,v.createElement("p",null,"💿 Hey developer 👋"),v.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",v.createElement("code",{style:s},"ErrorBoundary")," or"," ",v.createElement("code",{style:s},"errorElement")," prop on your route.")),v.createElement(v.Fragment,null,v.createElement("h2",null,"Unexpected Application Error!"),v.createElement("h3",{style:{fontStyle:"italic"}},t),n?v.createElement("pre",{style:o},n):null,l)}var _9=v.createElement(w9,null),BD=class extends v.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!=="idle"&&e.revalidation==="idle"?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error!==void 0?e.error:t.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error("React Router caught the following error during render",e)}render(){let e=this.state.error;if(this.context&&typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){const n=v9(e.digest);n&&(e=n)}let t=e!==void 0?v.createElement(ja.Provider,{value:this.props.routeContext},v.createElement(IC.Provider,{value:e,children:this.props.component})):this.props.children;return this.context?v.createElement(T9,{error:e},t):t}};BD.contextType=p9;var q0=new WeakMap;function T9({children:e,error:t}){let{basename:n}=v.useContext(yi);if(typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){let r=y9(t.digest);if(r){let o=q0.get(t);if(o)throw o;let s=OD(r.location,n);if(DD&&!q0.get(t))if(s.isExternal||r.reloadDocument)window.location.href=s.absoluteURL||s.to;else{const l=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(s.to,{replace:r.replace}));throw q0.set(t,l),l}return v.createElement("meta",{httpEquiv:"refresh",content:`0;url=${s.absoluteURL||s.to}`})}}return e}function j9({routeContext:e,match:t,children:n}){let r=v.useContext(Df);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),v.createElement(ja.Provider,{value:e},n)}function E9(e,t=[],n=null,r=null,o=null){if(e==null){if(!n)return null;if(n.errors)e=n.matches;else if(t.length===0&&!n.initialized&&n.matches.length>0)e=n.matches;else return null}let s=e,l=n?.errors;if(l!=null){let m=s.findIndex(b=>b.route.id&&l?.[b.route.id]!==void 0);Cr(m>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(l).join(",")}`),s=s.slice(0,Math.min(s.length,m+1))}let u=!1,d=-1;if(n)for(let m=0;m=0?s=s.slice(0,d+1):s=[s[0]];break}}}let p=n&&r?(m,b)=>{r(m,{location:n.location,params:n.matches?.[0]?.params??{},unstable_pattern:d9(n.matches),errorInfo:b})}:void 0;return s.reduceRight((m,b,y)=>{let h,x=!1,C=null,S=null;n&&(h=l&&b.route.id?l[b.route.id]:void 0,C=b.route.errorElement||_9,u&&(d<0&&y===0?(VD("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),x=!0,S=null):d===y&&(x=!0,S=b.route.hydrateFallbackElement||null)));let w=t.concat(s.slice(0,y+1)),_=()=>{let T;return h?T=C:x?T=S:b.route.Component?T=v.createElement(b.route.Component,null):b.route.element?T=b.route.element:T=m,v.createElement(j9,{match:b,routeContext:{outlet:m,matches:w,isDataRoute:n!=null},children:T})};return n&&(b.route.ErrorBoundary||b.route.errorElement||y===0)?v.createElement(BD,{location:n.location,revalidation:n.revalidation,component:C,error:h,children:_(),routeContext:{outlet:null,matches:w,isDataRoute:!0},onError:p}):_()},null)}function LC(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function M9(e){let t=v.useContext(Df);return Cr(t,LC(e)),t}function R9(e){let t=v.useContext(Iy);return Cr(t,LC(e)),t}function k9(e){let t=v.useContext(ja);return Cr(t,LC(e)),t}function zC(e){let t=k9(e),n=t.matches[t.matches.length-1];return Cr(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function P9(){return zC("useRouteId")}function A9(){let e=v.useContext(IC),t=R9("useRouteError"),n=zC("useRouteError");return e!==void 0?e:t.errors?.[n]}function D9(){let{router:e}=M9("useNavigate"),t=zC("useNavigate"),n=v.useRef(!1);return $D(()=>{n.current=!0}),v.useCallback(async(o,s={})=>{Ca(n.current,ND),n.current&&(typeof o=="number"?await e.navigate(o):await e.navigate(o,{fromRouteId:t,...s}))},[e,t])}var MM={};function VD(e,t,n){!t&&!MM[e]&&(MM[e]=!0,Ca(!1,n))}v.memo(O9);function O9({routes:e,future:t,state:n,onError:r}){return FD(e,void 0,n,r,t)}function xr(e){Cr(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function I9({basename:e="/",children:t=null,location:n,navigationType:r="POP",navigator:o,static:s=!1,unstable_useTransitions:l}){Cr(!Km(),"You cannot render a inside another . You should never have more than one in your app.");let u=e.replace(/^\/*/,"/"),d=v.useMemo(()=>({basename:u,navigator:o,static:s,unstable_useTransitions:l,future:{}}),[u,o,s,l]);typeof n=="string"&&(n=Af(n));let{pathname:p="/",search:m="",hash:b="",state:y=null,key:h="default"}=n,x=v.useMemo(()=>{let C=ul(p,u);return C==null?null:{location:{pathname:C,search:m,hash:b,state:y,key:h},navigationType:r}},[u,p,m,b,y,h,r]);return Ca(x!=null,` is not able to match the URL "${p}${m}${b}" because it does not start with the basename, so the won't render anything.`),x==null?null:v.createElement(yi.Provider,{value:d},v.createElement(Ym.Provider,{children:t,value:x}))}function L9({children:e,location:t}){return C9(cS(e),t)}function cS(e,t=[]){let n=[];return v.Children.forEach(e,(r,o)=>{if(!v.isValidElement(r))return;let s=[...t,o];if(r.type===v.Fragment){n.push.apply(n,cS(r.props.children,s));return}Cr(r.type===xr,`[${typeof r.type=="string"?r.type:r.type.name}] is not a component. All component children of must be a or `),Cr(!r.props.index||!r.props.children,"An index route cannot have child routes.");let l={id:r.props.id||s.join("-"),caseSensitive:r.props.caseSensitive,element:r.props.element,Component:r.props.Component,index:r.props.index,path:r.props.path,middleware:r.props.middleware,loader:r.props.loader,action:r.props.action,hydrateFallbackElement:r.props.hydrateFallbackElement,HydrateFallback:r.props.HydrateFallback,errorElement:r.props.errorElement,ErrorBoundary:r.props.ErrorBoundary,hasErrorBoundary:r.props.hasErrorBoundary===!0||r.props.ErrorBoundary!=null||r.props.errorElement!=null,shouldRevalidate:r.props.shouldRevalidate,handle:r.props.handle,lazy:r.props.lazy};r.props.children&&(l.children=cS(r.props.children,s)),n.push(l)}),n}var Sb="get",Cb="application/x-www-form-urlencoded";function Ly(e){return typeof HTMLElement<"u"&&e instanceof HTMLElement}function z9(e){return Ly(e)&&e.tagName.toLowerCase()==="button"}function N9(e){return Ly(e)&&e.tagName.toLowerCase()==="form"}function $9(e){return Ly(e)&&e.tagName.toLowerCase()==="input"}function F9(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function B9(e,t){return e.button===0&&(!t||t==="_self")&&!F9(e)}var kg=null;function V9(){if(kg===null)try{new FormData(document.createElement("form"),0),kg=!1}catch{kg=!0}return kg}var H9=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function U0(e){return e!=null&&!H9.has(e)?(Ca(!1,`"${e}" is not a valid \`encType\` for \`

\`/\`\` and will default to "${Cb}"`),null):e}function q9(e,t){let n,r,o,s,l;if(N9(e)){let u=e.getAttribute("action");r=u?ul(u,t):null,n=e.getAttribute("method")||Sb,o=U0(e.getAttribute("enctype"))||Cb,s=new FormData(e)}else if(z9(e)||$9(e)&&(e.type==="submit"||e.type==="image")){let u=e.form;if(u==null)throw new Error('Cannot submit a + + + + setConfirmDialog({open: false, action: null, message: ''})} + /> + {popup && } + + )} + + + ); +} diff --git a/frontend/src/Components/events/SubscriptionModal.jsx b/frontend/src/Components/events/SubscriptionModal.jsx index 328b9af4f..26f1bffc5 100644 --- a/frontend/src/Components/events/SubscriptionModal.jsx +++ b/frontend/src/Components/events/SubscriptionModal.jsx @@ -201,7 +201,10 @@ export default function SubscriptionModal({ // Fetch profile data to check matricola status fetchCustom("GET", `/profile/${profileId}/`, { onSuccess: (profileData) => { - if (!profileData.is_esner) { + if (profileData.is_esner) { + setMatricolaStatus({ isMissing: false, isExpired: false }); + setProfileHasEsncard(null); + } else { setProfileHasEsncard(Boolean(profileData.latest_esncard)); const matricolaMissing = !profileData.matricola_number || !profileData.person_code; const matricolaExpired = profileData.matricola_expiration ? @@ -210,8 +213,6 @@ export default function SubscriptionModal({ isMissing: matricolaMissing, isExpired: matricolaExpired }); - } else { - setMatricolaStatus({ isMissing: false, isExpired: false }); } }, onError: (responseOrError) => defaultErrorHandler(responseOrError, setPopup) diff --git a/frontend/src/Components/treasury/AccountsDash.jsx b/frontend/src/Components/treasury/AccountsDash.jsx index 9de254a0a..10d1a8466 100644 --- a/frontend/src/Components/treasury/AccountsDash.jsx +++ b/frontend/src/Components/treasury/AccountsDash.jsx @@ -34,7 +34,7 @@ export default function AccountsDash() { Cell: ({cell}) => ( {cell.getValue() !== null ? ( - ) : ( + ) : ( )} ), diff --git a/frontend/src/Components/treasury/TransactionModal.jsx b/frontend/src/Components/treasury/TransactionModal.jsx index e53bb8125..cf25e3ed7 100644 --- a/frontend/src/Components/treasury/TransactionModal.jsx +++ b/frontend/src/Components/treasury/TransactionModal.jsx @@ -13,7 +13,7 @@ import ReceiptFileUpload from '../common/ReceiptFileUpload'; import {ToggleButton, ToggleButtonGroup} from '@mui/material'; // List of transaction types that can be deleted -const deletableTranTypes = ['rimborso_cauzione', 'rimborso_quota', 'reimbursement', 'deposit', 'withdrawal']; +const deletableTranTypes = new Set(['rimborso_esncard', 'rimborso_cauzione', 'rimborso_quota', 'reimbursement', 'deposit', 'withdrawal']); export default function TransactionModal({open, onClose, transaction}) { const [isLoading, setLoading] = useState(true); @@ -22,7 +22,7 @@ export default function TransactionModal({open, onClose, transaction}) { const [submitting, setSubmitting] = useState(false); const [deleting, setDeleting] = useState(false); const [confirmDialog, setConfirmDialog] = useState({open: false, action: null, message: ''}); - const negative_types = ['withdrawal', 'rimborso_cauzione', 'rimborso_quota', 'reimbursement']; + const negative_types = new Set(['withdrawal', 'rimborso_esncard', 'rimborso_cauzione', 'rimborso_quota', 'reimbursement']); const [data, setData] = useState({ executor: null, @@ -62,7 +62,7 @@ export default function TransactionModal({open, onClose, transaction}) { formattedDate = `${year}-${month}-${day}T${hours}:${minutes}`; } let amount = transaction.amount || 0; - if (transaction.type && negative_types.includes(transaction.type)) + if (transaction.type && negative_types.has(transaction.type)) amount = -amount; setData({ executor: transaction.executor || null, @@ -115,7 +115,7 @@ export default function TransactionModal({open, onClose, transaction}) { if (!validate()) return; // Confirm dialog for account amount change let amount = data.amount; - if (negative_types.includes(data.type) && parseFloat(amount) > 0) + if (negative_types.has(data.type) && Number.parseFloat(amount) > 0) amount = -amount; const payload = { executor: data.executor.id || data.executor.email, @@ -164,7 +164,7 @@ export default function TransactionModal({open, onClose, transaction}) { }; const handleDelete = () => { - if (!transaction || !deletableTranTypes.includes(transaction.type)) { + if (!transaction || !deletableTranTypes.has(transaction.type)) { setPopup({message: 'Transazione non eliminabile', state: 'error', id: Date.now()}); return; } @@ -367,7 +367,7 @@ export default function TransactionModal({open, onClose, transaction}) { disabled={submitting || deleting}> {submitting ? : "Salva Modifiche"} - {transaction && deletableTranTypes.includes(transaction.type) && ( + {transaction && deletableTranTypes.has(transaction.type) && ( + + + )} {popup && } - )} + !revokingESNcard && setRevokeESNcardConfirmOpen(false)} + maxWidth="xs" + fullWidth + > + Conferma Revoca ESNcard + + + Questa azione cancellerà la ESNcard e creerà una transazione di rimborso in tesoreria. + + + ESNcard: {latestESNcard?.number || "N/D"} + + + + + + + setDeleteConfirmOpen(false)} maxWidth="xs" fullWidth> Conferma Eliminazione diff --git a/frontend/src/Pages/treasury/AccountsList.jsx b/frontend/src/Pages/treasury/AccountsList.jsx index b731d9bf3..d546e9872 100644 --- a/frontend/src/Pages/treasury/AccountsList.jsx +++ b/frontend/src/Pages/treasury/AccountsList.jsx @@ -71,7 +71,7 @@ export default function AccountsList() { Cell: ({cell}) => ( {cell.getValue() !== null ? ( - ) : ( + ) : ( )} ), diff --git a/frontend/src/data/transactionConfigs.js b/frontend/src/data/transactionConfigs.js index 5ab96b18f..454c914aa 100644 --- a/frontend/src/data/transactionConfigs.js +++ b/frontend/src/data/transactionConfigs.js @@ -7,6 +7,7 @@ export const TRANSACTION_CONFIGS = { withdrawal: {label: names.tran_type["withdrawal"], color: 'error'}, reimbursement: {label: names.tran_type["reimbursement"], color: 'info'}, cauzione: {label: names.tran_type["cauzione"], color: 'warning'}, + rimborso_esncard: {label: names.tran_type["rimborso_esncard"], color: 'default'}, rimborso_cauzione: {label: names.tran_type["rimborso_cauzione"], color: 'default'}, rimborso_quota: {label: names.tran_type["rimborso_quota"], color: 'default'}, service: {label: names.tran_type["service"], color: 'primary'}, diff --git a/frontend/src/utils/displayAttributes.jsx b/frontend/src/utils/displayAttributes.jsx index 2c2d3cc1c..a16ed62ff 100644 --- a/frontend/src/utils/displayAttributes.jsx +++ b/frontend/src/utils/displayAttributes.jsx @@ -65,6 +65,7 @@ export const transactionDisplayNames = { subscription: 'Iscrizione', cauzione: 'Cauzione', reimbursement: 'Richiesta Rimborso', + rimborso_esncard: 'Rimborso ESNcard', rimborso_cauzione: 'Rimborso Cauzione Evento', rimborso_quota: 'Rimborso Quota Evento', service: 'Acquisto Servizio', diff --git a/frontend/src/utils/useMaintenanceNotification.js b/frontend/src/utils/useMaintenanceNotification.js index 92be61214..37b42fa34 100644 --- a/frontend/src/utils/useMaintenanceNotification.js +++ b/frontend/src/utils/useMaintenanceNotification.js @@ -16,7 +16,7 @@ const useMaintenanceNotification = (accessToken) => { return; } - const apiHost = window.API_HOST || ''; + const apiHost = globalThis.API_HOST || ''; const url = `${apiHost}/maintenance/status/`; let lastNotificationId = null; @@ -51,8 +51,8 @@ const useMaintenanceNotification = (accessToken) => { // Check immediately checkStatus(); - // Then poll every 30 seconds - const intervalId = setInterval(checkStatus, 30000); + // Then poll every 5 minutes + const intervalId = setInterval(checkStatus, 300000); return () => { clearInterval(intervalId);