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.