mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-29 07:53:23 +00:00
Compare commits
66 Commits
WIP_IMAP
...
abb4b525b2
| Author | SHA1 | Date | |
|---|---|---|---|
| abb4b525b2 | |||
| b4f70e6bad | |||
| 8549699dec | |||
| a034149eae | |||
| 12f5fc7aa9 | |||
| 2dc0dfa268 | |||
| dd00cba385 | |||
| 7486f6c5ce | |||
| 1e5bc6ccba | |||
| 0fb668b212 | |||
| 5e62ee5100 | |||
| e89d2fc4c3 | |||
| 9481a0132d | |||
| 482e8c1357 | |||
| 0e0141d155 | |||
| 7f002e2e6a | |||
| 0064b8d35a | |||
| ec2c1daebc | |||
| 67cea2f1c6 | |||
| 5785bfae46 | |||
| a17078709b | |||
| d58155da06 | |||
| 043d93dcc4 | |||
| 6bc24055cd | |||
| 2f6d30b85b | |||
| c161fa7e75 | |||
| 789816e986 | |||
| 6bedf715cc | |||
| 59a0d40130 | |||
| 25e2799c0f | |||
| 017c0290dd | |||
| fe2d4d4513 | |||
| f93c428259 | |||
| e61cd51ce2 | |||
| 6a0b90e98f | |||
| 8a71fa1830 | |||
| f265540da2 | |||
| 5be5f9f70d | |||
| 68a6a63c4f | |||
| af30ae33b5 | |||
| e509625811 | |||
| 3a2455f918 | |||
| e74f9c98a2 | |||
| 8f0cf16f70 | |||
| 78d96f82f9 | |||
| c117f96e52 | |||
| e4668ef1e5 | |||
| ec2630a6e4 | |||
| d65b171da8 | |||
| 4a6b7ce379 | |||
| 170f7c4fa8 | |||
| ce83e02f7b | |||
| a69498dd06 | |||
| 23ab7d04ef | |||
| 8cf22905e5 | |||
| 314c31fab1 | |||
| be27fe1232 | |||
| 8b54cedcab | |||
| d37145b73e | |||
| e2df29d851 | |||
| eb48523f7d | |||
| e30753f1d6 | |||
| a42cf348a0 | |||
| 55cb20bf8c | |||
| 677cec1ec2 | |||
| 82573f1b23 |
7
.github/copilot-instructions.md
vendored
7
.github/copilot-instructions.md
vendored
@ -35,6 +35,13 @@ Corriger ou améliorer le projet N3WT-SCHOOL de manière minimaliste et fonction
|
||||
|
||||
## Exigences qualité
|
||||
|
||||
Pour le front-end, les exigences de qualité sont les suivantes :
|
||||
|
||||
- **Linting** : Utiliser ESLint pour le code JavaScript/TypeScript
|
||||
- **Formatage** : Utiliser Prettier pour le formatage du code
|
||||
- **Tests** : Utiliser Jest pour les tests unitaires et d'intégration
|
||||
- Référence : [frontend guideline](./instructions/frontend.instruction.md)
|
||||
|
||||
### Tests
|
||||
|
||||
- Tests unitaires obligatoires pour chaque nouvelle fonctionnalité
|
||||
|
||||
0
.github/instructions/frontend.instruction.md
vendored
Normal file
0
.github/instructions/frontend.instruction.md
vendored
Normal file
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
.venv/
|
||||
.env
|
||||
node_modules/
|
||||
hardcoded-strings-report.md
|
||||
hardcoded-strings-report.md
|
||||
backend.env
|
||||
@ -1 +1 @@
|
||||
node scripts/prepare-commit-msg.js "$1" "$2"
|
||||
#node scripts/prepare-commit-msg.js "$1" "$2"
|
||||
24
.vscode/tasks.json
vendored
24
.vscode/tasks.json
vendored
@ -1,13 +1,13 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "dev",
|
||||
"path": "Front-End",
|
||||
"problemMatcher": [],
|
||||
"label": "npm: dev - Front-End",
|
||||
"detail": "next dev"
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Start Frontend Dev Server",
|
||||
"type": "shell",
|
||||
"command": "npm run dev",
|
||||
"group": "build",
|
||||
"isBackground": true,
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
2
Back-End/.gitignore
vendored
2
Back-End/.gitignore
vendored
@ -4,4 +4,4 @@ documents
|
||||
data
|
||||
*.dmp
|
||||
staticfiles
|
||||
/*/Configuration/application.json
|
||||
/*/Configuration/application*.json
|
||||
75
Back-End/Auth/migrations/0001_initial.py
Normal file
75
Back-End/Auth/migrations/0001_initial.py
Normal file
@ -0,0 +1,75 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('Establishment', '0001_initial'),
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProfileRole',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role_type', models.IntegerField(choices=[(-1, 'NON DEFINI'), (0, 'ECOLE'), (1, 'ADMIN'), (2, 'PARENT')], default=-1)),
|
||||
('is_active', models.BooleanField(default=False)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_roles', to='Establishment.establishment')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Directeur',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('last_name', models.CharField(max_length=100)),
|
||||
('first_name', models.CharField(max_length=100)),
|
||||
('profile_role', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='directeur_profile', to='Auth.profilerole')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Profile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('email', models.EmailField(default='', max_length=255, unique=True, validators=[django.core.validators.EmailValidator()])),
|
||||
('roleIndexLoginDefault', models.IntegerField(default=0)),
|
||||
('code', models.CharField(blank=True, default='', max_length=200)),
|
||||
('datePeremption', models.CharField(blank=True, default='', max_length=200)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profilerole',
|
||||
name='profile',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roles', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
0
Back-End/Auth/migrations/__init__.py
Normal file
0
Back-End/Auth/migrations/__init__.py
Normal file
@ -2,7 +2,6 @@ from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.validators import EmailValidator
|
||||
from django_mailbox.models import Mailbox
|
||||
|
||||
class Profile(AbstractUser):
|
||||
email = models.EmailField(max_length=255, unique=True, default="", validators=[EmailValidator()])
|
||||
@ -12,13 +11,9 @@ class Profile(AbstractUser):
|
||||
roleIndexLoginDefault = models.IntegerField(default=0)
|
||||
code = models.CharField(max_length=200, default="", blank=True)
|
||||
datePeremption = models.CharField(max_length=200, default="", blank=True)
|
||||
mailbox = models.OneToOneField(
|
||||
Mailbox,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='profile'
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
class ProfileRole(models.Model):
|
||||
class RoleType(models.IntegerChoices):
|
||||
@ -27,11 +22,19 @@ class ProfileRole(models.Model):
|
||||
PROFIL_ADMIN = 1, _('ADMIN')
|
||||
PROFIL_PARENT = 2, _('PARENT')
|
||||
|
||||
profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='roles')
|
||||
profile = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='roles')
|
||||
role_type = models.IntegerField(choices=RoleType.choices, default=RoleType.PROFIL_UNDEFINED)
|
||||
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='profile_roles')
|
||||
is_active = models.BooleanField(default=False)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.profile.email} - {self.get_role_type_display()}"
|
||||
return f"{self.profile.email} - {self.get_role_type_display()}"
|
||||
|
||||
class Directeur(models.Model):
|
||||
profile_role = models.OneToOneField("ProfileRole", on_delete=models.CASCADE, related_name='directeur_profile')
|
||||
last_name = models.CharField(max_length=100)
|
||||
first_name = models.CharField(max_length=100)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.first_name} {self.last_name} ({self.profile_role.profile.email})"
|
||||
@ -1,61 +0,0 @@
|
||||
{% load static %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
||||
<title>Monteschool</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sidebar">
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="logo-circular centered">
|
||||
<img src="{% static 'img/logo_min.svg' %}" alt="">
|
||||
</div>
|
||||
<h1 class="login-heading">Authentification</h1>
|
||||
<form class="centered login-form" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="input-group">
|
||||
<label for="userInput">{{ form.email.label }}</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon-ctn">
|
||||
<i class="icon user"></i>
|
||||
</span>
|
||||
<input type="text" id="userInput" placeholder='Identifiant' name="email">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="userInput">{{ form.password.label }}</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon-ctn">
|
||||
<i class="icon key"></i>
|
||||
</span>
|
||||
<input type="password" id="userInput" placeholder="Mot de passe" name="password">
|
||||
</div>
|
||||
<p style="color:#FF0000">{{ message }}</p>
|
||||
{% if form.errors %}
|
||||
{% for field in form %}
|
||||
{% for error in field.errors %}
|
||||
<p style="color:#FF0000">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% for error in form.non_field_errors %}
|
||||
<p style="color:#FF0000">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<label><a class="right" href='/reset/{{code}}'>Mot de passe oublié ?</a></label>
|
||||
</div>
|
||||
<div class="form-group-submit">
|
||||
<button href="" class="btn primary" type="submit" name="connect">Se Connecter</button>
|
||||
<br>
|
||||
<h2>Pas de compte ?</h2>
|
||||
<br>
|
||||
<button href="" class="btn " name="register">S'inscrire</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,64 +0,0 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
||||
<title>Monteschool</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container negative full-size">
|
||||
<div class="logo-circular centered">
|
||||
<img src="{% static 'img/logo_min.svg' %}" alt="">
|
||||
</div>
|
||||
<h1 class="login-heading">Nouveau Mot de Passe</h1>
|
||||
<form class="negative centered login-form" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="input-group" hidden>
|
||||
<label for="userInput">Identifiant</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon-ctn">
|
||||
<i class="icon user"></i>
|
||||
</span>
|
||||
<input type="text" id="userInput" placeholder='Identifiant' value='{{ identifiant }}' name="email">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="password">{{ form.password1.label }}</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon-ctn">
|
||||
<i class="icon key"></i>
|
||||
</span>
|
||||
<input type="password" id="password" placeholder="{{ form.password1.label }}" name="password1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="confirmPassword">{{ form.password2.label }}</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon-ctn">
|
||||
<i class="icon key"></i>
|
||||
</span>
|
||||
<input type="password" id="confirmPassword" placeholder="{{ form.password2.label }}" name="password2">
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#FF0000">{{ message }}</p>
|
||||
{% if form.errors %}
|
||||
{% for field in form %}
|
||||
{% for error in field.errors %}
|
||||
<p style="color:#FF0000">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% for error in form.non_field_errors %}
|
||||
<p style="color:#FF0000">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<div class="form-group-submit negative">
|
||||
<button href="" class="btn primary" type="submit" name="save">Enregistrer</button>
|
||||
<br>
|
||||
<button href="" class="btn" type="submit" name="cancel">Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,37 +0,0 @@
|
||||
{% load static %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
||||
<title>Monteschool</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container negative full-size">
|
||||
<div class="logo-circular centered">
|
||||
<img src="{% static 'img/logo_min.svg' %}" alt="">
|
||||
</div>
|
||||
<h1 class="login-heading"> Réinitialiser Mot de Passe</h1>
|
||||
<form class="negative centered login-form" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="input-group">
|
||||
<label for="username">Identifiant</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon-ctn">
|
||||
<i class="icon user"></i>
|
||||
</span>
|
||||
<input type="text" id="username" placeholder="Identifiant" name="email">
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#FF0000">{{ message }}</p>
|
||||
<div class="form-group-submit negative">
|
||||
<button href="" class="btn primary" type="submit" name="reinit">Réinitialiser</button>
|
||||
<br>
|
||||
<button href="" class="btn" type="submit" name="cancel">Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,64 +0,0 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
||||
<title>Monteschool</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container negative full-size">
|
||||
<div class="logo-circular centered">
|
||||
<img src="{% static 'img/logo_min.svg' %}" alt="">
|
||||
</div>
|
||||
<h1 class="login-heading">S'inscrire</h1>
|
||||
<form class="negative centered login-form" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="input-group">
|
||||
<label for="username">{{ form.email.label }}</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon-ctn">
|
||||
<i class="icon user"></i>
|
||||
</span>
|
||||
<input type="text" id="username" placeholder="Identifiant" name="email">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="password">{{ form.password1.label }}</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon-ctn">
|
||||
<i class="icon key"></i>
|
||||
</span>
|
||||
<input type="password" id="password" placeholder="{{ form.password1.label }}" name="password1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="confirmPassword">{{ form.password2.label }}</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="icon-ctn">
|
||||
<i class="icon key"></i>
|
||||
</span>
|
||||
<input type="password" id="confirmPassword" placeholder="{{ form.password2.label }}" name="password2">
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#FF0000">{{ message }}</p>
|
||||
{% if form.errors %}
|
||||
{% for field in form %}
|
||||
{% for error in field.errors %}
|
||||
<p style="color:#FF0000">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% for error in form.non_field_errors %}
|
||||
<p style="color:#FF0000">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<div class="form-group-submit negative">
|
||||
<button href="" class="btn primary" type="submit" name="validate">Enregistrer</button>
|
||||
<br>
|
||||
<button href="" class="btn" name="cancel">Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -223,14 +223,28 @@ def makeToken(user):
|
||||
"""
|
||||
try:
|
||||
# Récupérer tous les rôles de l'utilisateur actifs
|
||||
roles = ProfileRole.objects.filter(profile=user, is_active=True).values('role_type', 'establishment__id', 'establishment__name', 'establishment__evaluation_frequency', 'establishment__total_capacity')
|
||||
roles_qs = ProfileRole.objects.filter(profile=user, is_active=True).select_related('establishment')
|
||||
roles = []
|
||||
for role in roles_qs:
|
||||
logo_url = ""
|
||||
if role.establishment.logo:
|
||||
# Construit l'URL complète pour le logo
|
||||
logo_url = f"{role.establishment.logo.url}"
|
||||
roles.append({
|
||||
"role_type": role.role_type,
|
||||
"establishment__id": role.establishment.id,
|
||||
"establishment__name": role.establishment.name,
|
||||
"establishment__evaluation_frequency": role.establishment.evaluation_frequency,
|
||||
"establishment__total_capacity": role.establishment.total_capacity,
|
||||
"establishment__logo": logo_url,
|
||||
})
|
||||
|
||||
# Générer le JWT avec la bonne syntaxe datetime
|
||||
access_payload = {
|
||||
'user_id': user.id,
|
||||
'email': user.email,
|
||||
'roleIndexLoginDefault':user.roleIndexLoginDefault,
|
||||
'roles': list(roles),
|
||||
'roleIndexLoginDefault': user.roleIndexLoginDefault,
|
||||
'roles': roles,
|
||||
'type': 'access',
|
||||
'exp': datetime.utcnow() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'],
|
||||
'iat': datetime.utcnow(),
|
||||
@ -361,7 +375,7 @@ class SubscribeView(APIView):
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
retourErreur = error.returnMessage[error.BAD_URL]
|
||||
retourErreur = ''
|
||||
retour = ''
|
||||
newProfilConnection = JSONParser().parse(request)
|
||||
establishment_id = newProfilConnection['establishment_id']
|
||||
@ -437,7 +451,7 @@ class NewPasswordView(APIView):
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
retourErreur = error.returnMessage[error.BAD_URL]
|
||||
retourErreur = ''
|
||||
retour = ''
|
||||
newProfilConnection = JSONParser().parse(request)
|
||||
|
||||
@ -487,7 +501,7 @@ class ResetPasswordView(APIView):
|
||||
}
|
||||
)
|
||||
def post(self, request, code):
|
||||
retourErreur = error.returnMessage[error.BAD_URL]
|
||||
retourErreur = ''
|
||||
retour = ''
|
||||
newProfilConnection = JSONParser().parse(request)
|
||||
|
||||
@ -498,7 +512,7 @@ class ResetPasswordView(APIView):
|
||||
if profil:
|
||||
|
||||
if datetime.strptime(util.convertToStr(util._now(), '%d-%m-%Y %H:%M'), '%d-%m-%Y %H:%M') > datetime.strptime(profil.datePeremption, '%d-%m-%Y %H:%M'):
|
||||
retourErreur = error.returnMessage[error.EXPIRED_URL] % (_uuid)
|
||||
retourErreur = error.returnMessage[error.EXPIRED_URL]
|
||||
elif validationOk:
|
||||
retour = error.returnMessage[error.PASSWORD_CHANGED]
|
||||
|
||||
|
||||
63
Back-End/Common/migrations/0001_initial.py
Normal file
63
Back-End/Common/migrations/0001_initial.py
Normal file
@ -0,0 +1,63 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Cycle',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('number', models.IntegerField(unique=True)),
|
||||
('label', models.CharField(max_length=50)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Domain',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('cycle', models.IntegerField(choices=[(1, 'Cycle 1'), (2, 'Cycle 2'), (3, 'Cycle 3'), (4, 'Cycle 4')])),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentModeType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=50, unique=True)),
|
||||
('label', models.CharField(max_length=255)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentPlanType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=50, unique=True)),
|
||||
('label', models.CharField(max_length=255)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Category',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='Common.domain')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Level',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50)),
|
||||
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='levels', to='Common.cycle')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
Back-End/Common/migrations/__init__.py
Normal file
0
Back-End/Common/migrations/__init__.py
Normal file
@ -1 +0,0 @@
|
||||
# This file is intentionally left blank to make this directory a Python package.
|
||||
@ -1,9 +0,0 @@
|
||||
from django.urls import path, re_path
|
||||
from .views import generate_jwt_token, clone_template, remove_template, download_template
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'generateToken$', generate_jwt_token, name='generate_jwt_token'),
|
||||
re_path(r'cloneTemplate$', clone_template, name='clone_template'),
|
||||
re_path(r'removeTemplate/(?P<id>[0-9]+)$', remove_template, name='remove_template'),
|
||||
re_path(r'downloadTemplate/(?P<slug>[\w-]+)$', download_template, name='download_template')
|
||||
]
|
||||
@ -1,161 +0,0 @@
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
import jwt
|
||||
import datetime
|
||||
import requests
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(['POST'])
|
||||
def generate_jwt_token(request):
|
||||
# Vérifier la clé API
|
||||
api_key = request.headers.get('X-Auth-Token')
|
||||
if not api_key or api_key != settings.DOCUSEAL_JWT["API_KEY"]:
|
||||
return Response({'error': 'Invalid API key'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
# Récupérer les données de la requête
|
||||
user_email = request.data.get('user_email')
|
||||
documents_urls = request.data.get('documents_urls', [])
|
||||
id = request.data.get('id') # Récupérer le id
|
||||
|
||||
# Vérifier les données requises
|
||||
if not user_email:
|
||||
return Response({'error': 'User email is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Utiliser la configuration JWT de DocuSeal depuis les settings
|
||||
jwt_secret = settings.DOCUSEAL_JWT['API_KEY']
|
||||
jwt_algorithm = settings.DOCUSEAL_JWT['ALGORITHM']
|
||||
expiration_delta = settings.DOCUSEAL_JWT['EXPIRATION_DELTA']
|
||||
|
||||
# Définir le payload
|
||||
payload = {
|
||||
'user_email': user_email,
|
||||
'documents_urls': documents_urls,
|
||||
'template_id': id, # Ajouter le id au payload
|
||||
'exp': datetime.datetime.utcnow() + expiration_delta # Temps d'expiration du token
|
||||
}
|
||||
|
||||
# Générer le token JWT
|
||||
token = jwt.encode(payload, jwt_secret, algorithm=jwt_algorithm)
|
||||
|
||||
return Response({'token': token}, status=status.HTTP_200_OK)
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(['POST'])
|
||||
def clone_template(request):
|
||||
# Vérifier la clé API
|
||||
api_key = request.headers.get('X-Auth-Token')
|
||||
if not api_key or api_key != settings.DOCUSEAL_JWT["API_KEY"]:
|
||||
return Response({'error': 'Invalid API key'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
# Récupérer les données de la requête
|
||||
document_id = request.data.get('templateId')
|
||||
email = request.data.get('email')
|
||||
is_required = request.data.get('is_required')
|
||||
|
||||
# Vérifier les données requises
|
||||
if not document_id :
|
||||
return Response({'error': 'template ID is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# URL de l'API de DocuSeal pour cloner le template
|
||||
clone_url = f'https://docuseal.com/api/templates/{document_id}/clone'
|
||||
|
||||
# Faire la requête pour cloner le template
|
||||
try:
|
||||
response = requests.post(clone_url, headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Auth-Token': settings.DOCUSEAL_JWT['API_KEY']
|
||||
})
|
||||
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
return Response({'error': 'Failed to clone template'}, status=response.status_code)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if is_required:
|
||||
# URL de l'API de DocuSeal pour créer une submission
|
||||
submission_url = f'https://docuseal.com/api/submissions'
|
||||
|
||||
# Faire la requête pour cloner le template
|
||||
try:
|
||||
clone_id = data['id']
|
||||
response = requests.post(submission_url, json={'template_id':clone_id, 'send_email': False, 'submitters': [{'email': email}]}, headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Auth-Token': settings.DOCUSEAL_JWT['API_KEY']
|
||||
})
|
||||
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
return Response({'error': 'Failed to create submission'}, status=response.status_code)
|
||||
|
||||
data = response.json()
|
||||
data[0]['id'] = clone_id
|
||||
return Response(data[0], status=status.HTTP_200_OK)
|
||||
|
||||
except requests.RequestException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
else :
|
||||
print(f'NOT REQUIRED -> on ne crée pas de submission')
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
except requests.RequestException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(['DELETE'])
|
||||
def remove_template(request, id):
|
||||
# Vérifier la clé API
|
||||
api_key = request.headers.get('X-Auth-Token')
|
||||
if not api_key or api_key != settings.DOCUSEAL_JWT["API_KEY"]:
|
||||
return Response({'error': 'Invalid API key'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
# URL de l'API de DocuSeal pour cloner le template
|
||||
clone_url = f'https://docuseal.com/api/templates/{id}'
|
||||
|
||||
# Faire la requête pour cloner le template
|
||||
try:
|
||||
response = requests.delete(clone_url, headers={
|
||||
'X-Auth-Token': settings.DOCUSEAL_JWT['API_KEY']
|
||||
})
|
||||
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
return Response({'error': 'Failed to remove template'}, status=response.status_code)
|
||||
|
||||
data = response.json()
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
except requests.RequestException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(['GET'])
|
||||
def download_template(request, slug):
|
||||
# Vérifier la clé API
|
||||
api_key = request.headers.get('X-Auth-Token')
|
||||
if not api_key or api_key != settings.DOCUSEAL_JWT["API_KEY"]:
|
||||
return Response({'error': 'Invalid API key'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
# Vérifier les données requises
|
||||
if not slug :
|
||||
return Response({'error': 'slug is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# URL de l'API de DocuSeal pour cloner le template
|
||||
download_url = f'https://docuseal.com/submitters/{slug}/download'
|
||||
|
||||
# Faire la requête pour cloner le template
|
||||
try:
|
||||
response = requests.get(download_url, headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Auth-Token': settings.DOCUSEAL_JWT['API_KEY']
|
||||
})
|
||||
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
return Response({'error': 'Failed to download template'}, status=response.status_code)
|
||||
|
||||
data = response.json()
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
except requests.RequestException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
31
Back-End/Establishment/migrations/0001_initial.py
Normal file
31
Back-End/Establishment/migrations/0001_initial.py
Normal file
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import Establishment.models
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Establishment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('address', models.CharField(max_length=255)),
|
||||
('total_capacity', models.IntegerField()),
|
||||
('establishment_type', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(choices=[(1, 'Maternelle'), (2, 'Primaire'), (3, 'Secondaire')]), size=None)),
|
||||
('evaluation_frequency', models.IntegerField(choices=[(1, 'Trimestre'), (2, 'Semestre'), (3, 'Année')], default=1)),
|
||||
('licence_code', models.CharField(blank=True, max_length=100)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('logo', models.FileField(blank=True, null=True, upload_to=Establishment.models.registration_logo_upload_to)),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
Back-End/Establishment/migrations/__init__.py
Normal file
0
Back-End/Establishment/migrations/__init__.py
Normal file
@ -2,6 +2,12 @@ from django.db import models
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import os
|
||||
|
||||
def registration_logo_upload_to(instance, filename):
|
||||
ext = os.path.splitext(filename)[1]
|
||||
return f"logos/school_{instance.pk}/logo{ext}"
|
||||
|
||||
class StructureType(models.IntegerChoices):
|
||||
MATERNELLE = 1, _('Maternelle')
|
||||
PRIMAIRE = 2, _('Primaire')
|
||||
@ -13,7 +19,7 @@ class EvaluationFrequency(models.IntegerChoices):
|
||||
YEAR = 3, _("Année")
|
||||
|
||||
class Establishment(models.Model):
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
name = models.CharField(max_length=255)
|
||||
address = models.CharField(max_length=255)
|
||||
total_capacity = models.IntegerField()
|
||||
establishment_type = ArrayField(models.IntegerField(choices=StructureType.choices))
|
||||
@ -21,6 +27,11 @@ class Establishment(models.Model):
|
||||
licence_code = models.CharField(max_length=100, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
logo = models.FileField(
|
||||
upload_to=registration_logo_upload_to,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -1,14 +1,19 @@
|
||||
from django.http.response import JsonResponse
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
||||
from django.utils.decorators import method_decorator
|
||||
from rest_framework.parsers import JSONParser
|
||||
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
from .models import Establishment
|
||||
from .serializers import EstablishmentSerializer
|
||||
from N3wtSchool.bdd import delete_object, getAllObjects
|
||||
from N3wtSchool.bdd import delete_object, getAllObjects, getObject
|
||||
from School.models import EstablishmentCompetency, Competency
|
||||
from django.db.models import Q
|
||||
from Auth.models import Profile, ProfileRole, Directeur
|
||||
from Settings.models import SMTPSettings
|
||||
import N3wtSchool.mailManager as mailer
|
||||
import os
|
||||
from N3wtSchool import settings
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
@ -20,9 +25,8 @@ class EstablishmentListCreateView(APIView):
|
||||
|
||||
def post(self, request):
|
||||
establishment_data = JSONParser().parse(request)
|
||||
establishment_serializer = EstablishmentSerializer(data=establishment_data)
|
||||
if establishment_serializer.is_valid():
|
||||
establishment = establishment_serializer.save()
|
||||
try:
|
||||
establishment, data = create_establishment_with_directeur(establishment_data)
|
||||
# Création des EstablishmentCompetency pour chaque compétence existante
|
||||
competencies = Competency.objects.filter(
|
||||
Q(end_of_cycle=True) | ~Q(level=None)
|
||||
@ -33,12 +37,15 @@ class EstablishmentListCreateView(APIView):
|
||||
competency=competency,
|
||||
defaults={'is_required': True}
|
||||
)
|
||||
return JsonResponse(establishment_serializer.data, safe=False, status=status.HTTP_201_CREATED)
|
||||
return JsonResponse(establishment_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
return JsonResponse(data, safe=False, status=status.HTTP_201_CREATED)
|
||||
except Exception as e:
|
||||
return JsonResponse({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class EstablishmentDetailView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def get(self, request, id=None):
|
||||
try:
|
||||
establishment = Establishment.objects.get(id=id)
|
||||
@ -48,16 +55,78 @@ class EstablishmentDetailView(APIView):
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def put(self, request, id):
|
||||
establishment_data = JSONParser().parse(request)
|
||||
"""
|
||||
Met à jour un établissement existant.
|
||||
Accepte les données en multipart/form-data pour permettre l'upload de fichiers (ex : logo).
|
||||
"""
|
||||
try:
|
||||
establishment = Establishment.objects.get(id=id)
|
||||
except Establishment.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
establishment_serializer = EstablishmentSerializer(establishment, data=establishment_data, partial=True)
|
||||
|
||||
# Utilise request.data pour supporter multipart/form-data (fichiers et champs classiques)
|
||||
establishment_serializer = EstablishmentSerializer(establishment, data=request.data, partial=True)
|
||||
if establishment_serializer.is_valid():
|
||||
establishment_serializer.save()
|
||||
return JsonResponse(establishment_serializer.data, safe=False)
|
||||
return JsonResponse(establishment_serializer.data, safe=False, status=status.HTTP_200_OK)
|
||||
return JsonResponse(establishment_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, id):
|
||||
return delete_object(Establishment, id)
|
||||
return delete_object(Establishment, id)
|
||||
|
||||
def create_establishment_with_directeur(establishment_data):
|
||||
# Extraction des sous-objets
|
||||
# school_name = establishment_data.get("name")
|
||||
directeur_data = establishment_data.pop("directeur", None)
|
||||
smtp_settings_data = establishment_data.pop("smtp_settings", {})
|
||||
|
||||
# Vérification de la présence du directeur
|
||||
if not directeur_data or not directeur_data.get("email"):
|
||||
raise ValueError("Le champ 'directeur.email' est obligatoire.")
|
||||
|
||||
directeur_email = directeur_data.get("email")
|
||||
last_name = directeur_data.get("last_name", "")
|
||||
first_name = directeur_data.get("first_name", "")
|
||||
password = directeur_data.get("password", "Provisoire01!")
|
||||
|
||||
# Création ou récupération du profil utilisateur
|
||||
profile, created = Profile.objects.get_or_create(
|
||||
email=directeur_email,
|
||||
defaults={"username": directeur_email}
|
||||
)
|
||||
if created or not profile.has_usable_password():
|
||||
profile.set_password(password)
|
||||
profile.save()
|
||||
|
||||
# Création de l'établissement
|
||||
establishment_serializer = EstablishmentSerializer(data=establishment_data)
|
||||
establishment_serializer.is_valid(raise_exception=True)
|
||||
# base_dir = os.path.join(settings.MEDIA_ROOT, f"logo/school_{school_name}")
|
||||
# os.makedirs(base_dir, exist_ok=True)
|
||||
establishment = establishment_serializer.save()
|
||||
|
||||
# Création ou récupération du ProfileRole ADMIN pour ce profil et cet établissement
|
||||
profile_role, _ = ProfileRole.objects.get_or_create(
|
||||
profile=profile,
|
||||
establishment=establishment,
|
||||
role_type=ProfileRole.RoleType.PROFIL_ADMIN,
|
||||
defaults={"is_active": False}
|
||||
)
|
||||
|
||||
# Création ou mise à jour du Directeur lié à ce ProfileRole
|
||||
Directeur.objects.update_or_create(
|
||||
profile_role=profile_role,
|
||||
defaults={
|
||||
"last_name": last_name,
|
||||
"first_name": first_name
|
||||
}
|
||||
)
|
||||
|
||||
# Création du SMTPSettings rattaché à l'établissement si des données sont fournies
|
||||
if smtp_settings_data:
|
||||
smtp_settings_data["establishment"] = establishment
|
||||
SMTPSettings.objects.create(**smtp_settings_data)
|
||||
|
||||
# Envoi du mail
|
||||
mailer.sendRegistrationDirector(directeur_email, establishment.pk)
|
||||
return establishment, establishment_serializer.data
|
||||
1
Back-End/GestionEmail/__init__.py
Normal file
1
Back-End/GestionEmail/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
default_app_config = 'GestionEmail.apps.GestionEmailConfig'
|
||||
5
Back-End/GestionEmail/apps.py
Normal file
5
Back-End/GestionEmail/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class GestionEmailConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'GestionEmail'
|
||||
9
Back-End/GestionEmail/urls.py
Normal file
9
Back-End/GestionEmail/urls.py
Normal file
@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
from .views import (
|
||||
SendEmailView, search_recipients
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('send-email/', SendEmailView.as_view(), name='send_email'),
|
||||
path('search-recipients/', search_recipients, name='search_recipients'),
|
||||
]
|
||||
119
Back-End/GestionEmail/views.py
Normal file
119
Back-End/GestionEmail/views.py
Normal file
@ -0,0 +1,119 @@
|
||||
from django.http.response import JsonResponse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django.db.models import Q
|
||||
from Auth.models import Profile, ProfileRole
|
||||
|
||||
import N3wtSchool.mailManager as mailer
|
||||
from N3wtSchool import bdd
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from rest_framework.exceptions import NotFound
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
# Ajouter un logger pour debug
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SendEmailView(APIView):
|
||||
"""
|
||||
API pour envoyer des emails aux parents et professeurs.
|
||||
"""
|
||||
def post(self, request):
|
||||
# Ajouter du debug
|
||||
logger.info(f"Request data received: {request.data}")
|
||||
logger.info(f"Request content type: {request.content_type}")
|
||||
|
||||
data = request.data
|
||||
recipients = data.get('recipients', [])
|
||||
cc = data.get('cc', [])
|
||||
bcc = data.get('bcc', [])
|
||||
subject = data.get('subject', 'Notification')
|
||||
message = data.get('message', '')
|
||||
establishment_id = data.get('establishment_id', '')
|
||||
|
||||
# Debug des données reçues
|
||||
logger.info(f"Recipients: {recipients} (type: {type(recipients)})")
|
||||
logger.info(f"CC: {cc} (type: {type(cc)})")
|
||||
logger.info(f"BCC: {bcc} (type: {type(bcc)})")
|
||||
logger.info(f"Subject: {subject}")
|
||||
logger.info(f"Message length: {len(message) if message else 0}")
|
||||
logger.info(f"Establishment ID: {establishment_id}")
|
||||
|
||||
if not recipients or not message:
|
||||
logger.error("Recipients or message missing")
|
||||
logger.error(f"Recipients empty: {not recipients}, Message empty: {not message}")
|
||||
logger.error(f"Recipients value: '{recipients}', Message value: '{message}'")
|
||||
return Response({'error': 'Les destinataires et le message sont requis.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
# Récupérer la connexion SMTP
|
||||
logger.info("Tentative de récupération de la connexion SMTP...")
|
||||
connection = mailer.getConnection(establishment_id)
|
||||
logger.info(f"Connexion SMTP récupérée: {connection}")
|
||||
|
||||
# Envoyer l'email
|
||||
logger.info("Tentative d'envoi de l'email...")
|
||||
result = mailer.sendMail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
recipients=recipients,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
attachments=[],
|
||||
connection=connection
|
||||
)
|
||||
logger.info(f"Email envoyé avec succès: {result}")
|
||||
return result
|
||||
except NotFound as e:
|
||||
logger.error(f"NotFound error: {str(e)}")
|
||||
return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
logger.error(f"Exception during email sending: {str(e)}")
|
||||
logger.error(f"Exception type: {type(e)}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
def search_recipients(request):
|
||||
"""
|
||||
API pour rechercher des destinataires en fonction d'un terme de recherche et d'un établissement.
|
||||
"""
|
||||
query = request.GET.get('q', '').strip() # Récupérer le terme de recherche depuis les paramètres GET
|
||||
establishment_id = request.GET.get('establishment_id', None) # Récupérer l'ID de l'établissement
|
||||
|
||||
if not query:
|
||||
return JsonResponse([], safe=False) # Retourner une liste vide si aucun terme n'est fourni
|
||||
|
||||
if not establishment_id:
|
||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Rechercher dans les champs pertinents (nom, prénom, email) et filtrer par establishment_id
|
||||
profiles = Profile.objects.filter(
|
||||
Q(first_name__icontains=query) |
|
||||
Q(last_name__icontains=query) |
|
||||
Q(email__icontains=query),
|
||||
roles__establishment_id=establishment_id, # Utiliser 'roles' au lieu de 'profilerole'
|
||||
roles__is_active=True # Filtrer uniquement les ProfileRole actifs
|
||||
).distinct()
|
||||
|
||||
# Construire la réponse avec les rôles associés
|
||||
results = []
|
||||
for profile in profiles:
|
||||
profile_roles = ProfileRole.objects.filter(
|
||||
profile=profile,
|
||||
establishment_id=establishment_id,
|
||||
is_active=True # Inclure uniquement les ProfileRole actifs
|
||||
).values(
|
||||
'id', 'role_type', 'establishment__name', 'is_active'
|
||||
)
|
||||
results.append({
|
||||
'id': profile.id,
|
||||
'first_name': profile.first_name,
|
||||
'last_name': profile.last_name,
|
||||
'email': profile.email,
|
||||
'roles': list(profile_roles) # Inclure tous les rôles actifs associés pour cet établissement
|
||||
})
|
||||
|
||||
return JsonResponse(results, safe=False)
|
||||
@ -1 +1 @@
|
||||
default_app_config = 'GestionMessagerie.apps.GestionMessagerieConfig'
|
||||
default_app_config = 'GestionMessagerie.apps.GestionMessagerieConfig'
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class GestionMessagerieConfig(AppConfig):
|
||||
name = 'GestionMessagerie'
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'GestionMessagerie'
|
||||
|
||||
627
Back-End/GestionMessagerie/consumers.py
Normal file
627
Back-End/GestionMessagerie/consumers.py
Normal file
@ -0,0 +1,627 @@
|
||||
import json
|
||||
import logging
|
||||
from uuid import UUID
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from channels.db import database_sync_to_async
|
||||
from django.utils import timezone
|
||||
from .models import Conversation, ConversationParticipant, Message, UserPresence, MessageRead
|
||||
from .serializers import MessageSerializer, ConversationSerializer
|
||||
from Auth.models import Profile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def serialize_for_websocket(data):
|
||||
"""
|
||||
Convertit récursivement les objets non-sérialisables en JSON en types sérialisables
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return {key: serialize_for_websocket(value) for key, value in data.items()}
|
||||
elif isinstance(data, list):
|
||||
return [serialize_for_websocket(item) for item in data]
|
||||
elif isinstance(data, UUID):
|
||||
return str(data)
|
||||
elif isinstance(data, Decimal):
|
||||
return float(data)
|
||||
elif isinstance(data, datetime):
|
||||
return data.isoformat()
|
||||
else:
|
||||
return data
|
||||
|
||||
class ChatConsumer(AsyncWebsocketConsumer):
|
||||
"""Consumer WebSocket pour la messagerie instantanée"""
|
||||
|
||||
async def connect(self):
|
||||
self.user_id = self.scope['url_route']['kwargs']['user_id']
|
||||
self.user_group_name = f'user_{self.user_id}'
|
||||
|
||||
# Vérifier si l'utilisateur est authentifié
|
||||
user = self.scope.get('user')
|
||||
if not user or user.is_anonymous:
|
||||
logger.warning(f"Tentative de connexion WebSocket non authentifiée pour user_id: {self.user_id}")
|
||||
await self.close()
|
||||
return
|
||||
|
||||
# Vérifier que l'utilisateur connecté correspond à l'user_id de l'URL
|
||||
if str(user.id) != str(self.user_id):
|
||||
logger.warning(f"Tentative d'accès WebSocket avec user_id incorrect: {self.user_id} vs {user.id}")
|
||||
await self.close()
|
||||
return
|
||||
|
||||
self.user = user
|
||||
|
||||
# Rejoindre le groupe utilisateur
|
||||
await self.channel_layer.group_add(
|
||||
self.user_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
# Rejoindre les groupes des conversations de l'utilisateur
|
||||
conversations = await self.get_user_conversations(self.user_id)
|
||||
for conversation in conversations:
|
||||
await self.channel_layer.group_add(
|
||||
f'conversation_{conversation.id}',
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
# Mettre à jour le statut de présence
|
||||
presence = await self.update_user_presence(self.user_id, 'online')
|
||||
|
||||
# Notifier les autres utilisateurs du changement de statut
|
||||
if presence:
|
||||
await self.broadcast_presence_update(self.user_id, 'online')
|
||||
|
||||
# Envoyer les statuts de présence existants des autres utilisateurs connectés
|
||||
await self.send_existing_user_presences()
|
||||
|
||||
await self.accept()
|
||||
|
||||
logger.info(f"User {self.user_id} connected to chat")
|
||||
|
||||
async def send_existing_user_presences(self):
|
||||
"""Envoyer les statuts de présence existants des autres utilisateurs connectés"""
|
||||
try:
|
||||
# Obtenir toutes les conversations de cet utilisateur
|
||||
conversations = await self.get_user_conversations(self.user_id)
|
||||
|
||||
# Créer un set pour éviter les doublons d'utilisateurs
|
||||
other_users = set()
|
||||
|
||||
# Pour chaque conversation, récupérer les participants
|
||||
for conversation in conversations:
|
||||
participants = await self.get_conversation_participants(conversation.id)
|
||||
for participant in participants:
|
||||
if participant.id != self.user_id:
|
||||
other_users.add(participant.id)
|
||||
|
||||
# Envoyer le statut de présence pour chaque utilisateur
|
||||
for user_id in other_users:
|
||||
presence = await self.get_user_presence(user_id)
|
||||
if presence:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'presence_update',
|
||||
'user_id': str(user_id),
|
||||
'status': presence.status
|
||||
}))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending existing user presences: {str(e)}")
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
# Quitter tous les groupes
|
||||
await self.channel_layer.group_discard(
|
||||
self.user_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
if hasattr(self, 'user'):
|
||||
conversations = await self.get_user_conversations(self.user_id)
|
||||
for conversation in conversations:
|
||||
await self.channel_layer.group_discard(
|
||||
f'conversation_{conversation.id}',
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
# Mettre à jour le statut de présence
|
||||
presence = await self.update_user_presence(self.user_id, 'offline')
|
||||
|
||||
# Notifier les autres utilisateurs du changement de statut
|
||||
if presence:
|
||||
await self.broadcast_presence_update(self.user_id, 'offline')
|
||||
|
||||
logger.info(f"User {self.user_id} disconnected from chat")
|
||||
|
||||
async def receive(self, text_data):
|
||||
"""Recevoir et traiter les messages du client"""
|
||||
try:
|
||||
text_data_json = json.loads(text_data)
|
||||
message_type = text_data_json.get('type')
|
||||
|
||||
if message_type == 'send_message':
|
||||
await self.handle_send_message(text_data_json)
|
||||
elif message_type == 'typing_start':
|
||||
await self.handle_typing_start(text_data_json)
|
||||
elif message_type == 'typing_stop':
|
||||
await self.handle_typing_stop(text_data_json)
|
||||
elif message_type == 'mark_as_read':
|
||||
await self.handle_mark_as_read(text_data_json)
|
||||
elif message_type == 'join_conversation':
|
||||
await self.handle_join_conversation(text_data_json)
|
||||
elif message_type == 'leave_conversation':
|
||||
await self.handle_leave_conversation(text_data_json)
|
||||
elif message_type == 'presence_update':
|
||||
await self.handle_presence_update(text_data_json)
|
||||
else:
|
||||
logger.warning(f"Unknown message type: {message_type}")
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'error',
|
||||
'message': f'Unknown message type: {message_type}'
|
||||
}))
|
||||
|
||||
except json.JSONDecodeError:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'error',
|
||||
'message': 'Invalid JSON format'
|
||||
}))
|
||||
except Exception as e:
|
||||
logger.error(f"Error in receive: {str(e)}")
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'error',
|
||||
'message': 'Internal server error'
|
||||
}))
|
||||
|
||||
async def handle_send_message(self, data):
|
||||
"""Gérer l'envoi d'un nouveau message"""
|
||||
conversation_id = data.get('conversation_id')
|
||||
content = data.get('content', '').strip()
|
||||
message_type = data.get('message_type', 'text')
|
||||
attachment = data.get('attachment')
|
||||
|
||||
# Vérifier qu'on a soit du contenu, soit un fichier
|
||||
if not conversation_id or (not content and not attachment):
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'error',
|
||||
'message': 'Conversation ID and content or attachment are required'
|
||||
}))
|
||||
return
|
||||
|
||||
# Vérifier que l'utilisateur peut envoyer dans cette conversation
|
||||
can_send = await self.can_user_send_message(self.user_id, conversation_id)
|
||||
if not can_send:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'error',
|
||||
'message': 'You cannot send messages to this conversation'
|
||||
}))
|
||||
return
|
||||
|
||||
# Créer le message avec ou sans fichier
|
||||
message = await self.create_message(conversation_id, self.user_id, content, message_type, attachment)
|
||||
if not message:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'error',
|
||||
'message': 'Failed to create message'
|
||||
}))
|
||||
return
|
||||
|
||||
# Sérialiser le message
|
||||
message_data = await self.serialize_message(message)
|
||||
|
||||
# Auto-marquer comme lu pour les utilisateurs connectés (présents dans la conversation)
|
||||
await self.auto_mark_read_for_online_users(message, conversation_id)
|
||||
|
||||
# Envoyer le message à tous les participants de la conversation
|
||||
await self.channel_layer.group_send(
|
||||
f'conversation_{conversation_id}',
|
||||
{
|
||||
'type': 'chat_message',
|
||||
'message': message_data
|
||||
}
|
||||
)
|
||||
|
||||
async def handle_typing_start(self, data):
|
||||
"""Gérer le début de frappe"""
|
||||
conversation_id = data.get('conversation_id')
|
||||
if conversation_id:
|
||||
await self.update_typing_status(self.user_id, conversation_id, True)
|
||||
|
||||
# Récupérer le nom de l'utilisateur
|
||||
user_name = await self.get_user_display_name(self.user_id)
|
||||
|
||||
await self.channel_layer.group_send(
|
||||
f'conversation_{conversation_id}',
|
||||
{
|
||||
'type': 'typing_status',
|
||||
'user_id': str(self.user_id),
|
||||
'user_name': user_name,
|
||||
'is_typing': True,
|
||||
'conversation_id': str(conversation_id)
|
||||
}
|
||||
)
|
||||
|
||||
async def handle_typing_stop(self, data):
|
||||
"""Gérer l'arrêt de frappe"""
|
||||
conversation_id = data.get('conversation_id')
|
||||
if conversation_id:
|
||||
await self.update_typing_status(self.user_id, conversation_id, False)
|
||||
|
||||
# Récupérer le nom de l'utilisateur
|
||||
user_name = await self.get_user_display_name(self.user_id)
|
||||
|
||||
await self.channel_layer.group_send(
|
||||
f'conversation_{conversation_id}',
|
||||
{
|
||||
'type': 'typing_status',
|
||||
'user_id': str(self.user_id),
|
||||
'user_name': user_name,
|
||||
'is_typing': False,
|
||||
'conversation_id': str(conversation_id)
|
||||
}
|
||||
)
|
||||
|
||||
async def handle_mark_as_read(self, data):
|
||||
"""Marquer les messages comme lus"""
|
||||
conversation_id = data.get('conversation_id')
|
||||
if conversation_id:
|
||||
await self.mark_conversation_as_read(self.user_id, conversation_id)
|
||||
await self.channel_layer.group_send(
|
||||
f'conversation_{conversation_id}',
|
||||
{
|
||||
'type': 'messages_read',
|
||||
'user_id': str(self.user_id),
|
||||
'conversation_id': str(conversation_id)
|
||||
}
|
||||
)
|
||||
|
||||
async def handle_join_conversation(self, data):
|
||||
"""Rejoindre une conversation"""
|
||||
conversation_id = data.get('conversation_id')
|
||||
if conversation_id:
|
||||
await self.channel_layer.group_add(
|
||||
f'conversation_{conversation_id}',
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
async def handle_leave_conversation(self, data):
|
||||
"""Quitter une conversation"""
|
||||
conversation_id = data.get('conversation_id')
|
||||
if conversation_id:
|
||||
await self.channel_layer.group_discard(
|
||||
f'conversation_{conversation_id}',
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
async def handle_presence_update(self, data):
|
||||
"""Gérer les mises à jour de présence"""
|
||||
status = data.get('status', 'online')
|
||||
if status in ['online', 'offline', 'away']:
|
||||
await self.update_user_presence(self.user_id, status)
|
||||
await self.broadcast_presence_update(self.user_id, status)
|
||||
|
||||
# Méthodes pour recevoir les messages des groupes
|
||||
async def chat_message(self, event):
|
||||
"""Envoyer un message de chat au WebSocket"""
|
||||
message_data = serialize_for_websocket(event['message'])
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'new_message',
|
||||
'message': message_data
|
||||
}))
|
||||
|
||||
async def typing_status(self, event):
|
||||
"""Envoyer le statut de frappe"""
|
||||
# Ne pas envoyer à l'expéditeur
|
||||
if str(event['user_id']) != str(self.user_id):
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'typing_status',
|
||||
'user_id': str(event['user_id']),
|
||||
'user_name': event.get('user_name', ''),
|
||||
'is_typing': event['is_typing'],
|
||||
'conversation_id': str(event['conversation_id'])
|
||||
}))
|
||||
|
||||
async def messages_read(self, event):
|
||||
"""Notifier que des messages ont été lus"""
|
||||
if str(event['user_id']) != str(self.user_id):
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'messages_read',
|
||||
'user_id': str(event['user_id']),
|
||||
'conversation_id': str(event['conversation_id'])
|
||||
}))
|
||||
|
||||
async def user_presence_update(self, event):
|
||||
"""Notifier d'un changement de présence"""
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'presence_update',
|
||||
'user_id': str(event['user_id']),
|
||||
'status': event['status']
|
||||
}))
|
||||
|
||||
async def new_conversation_notification(self, event):
|
||||
"""Notifier d'une nouvelle conversation"""
|
||||
conversation = serialize_for_websocket(event['conversation'])
|
||||
conversation_id = conversation['id']
|
||||
|
||||
# Rejoindre automatiquement le groupe de la nouvelle conversation
|
||||
await self.channel_layer.group_add(
|
||||
f'conversation_{conversation_id}',
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
# Envoyer la notification au client
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'new_conversation',
|
||||
'conversation': conversation
|
||||
}))
|
||||
|
||||
# Diffuser les présences des participants de cette nouvelle conversation
|
||||
try:
|
||||
participants = await self.get_conversation_participants(conversation_id)
|
||||
for participant in participants:
|
||||
# Ne pas diffuser sa propre présence à soi-même
|
||||
if participant.id != self.user_id:
|
||||
presence = await self.get_user_presence(participant.id)
|
||||
if presence:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'presence_update',
|
||||
'user_id': str(participant.id),
|
||||
'status': presence.status
|
||||
}))
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending presence updates for new conversation: {str(e)}")
|
||||
|
||||
async def broadcast_presence_update(self, user_id, status):
|
||||
"""Diffuser un changement de statut de présence à tous les utilisateurs connectés"""
|
||||
try:
|
||||
# Obtenir tous les utilisateurs qui ont des conversations avec cet utilisateur
|
||||
user_conversations = await self.get_user_conversations(user_id)
|
||||
|
||||
# Créer un set pour éviter les doublons d'utilisateurs
|
||||
notified_users = set()
|
||||
|
||||
# Pour chaque conversation, notifier tous les participants
|
||||
for conversation in user_conversations:
|
||||
participants = await self.get_conversation_participants(conversation.id)
|
||||
for participant in participants:
|
||||
if participant.id != user_id and participant.id not in notified_users:
|
||||
notified_users.add(participant.id)
|
||||
# Envoyer la notification au groupe utilisateur
|
||||
await self.channel_layer.group_send(
|
||||
f'user_{participant.id}',
|
||||
{
|
||||
'type': 'user_presence_update',
|
||||
'user_id': user_id,
|
||||
'status': status
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Broadcasted presence update for user {user_id} ({status}) to {len(notified_users)} users")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting presence update: {str(e)}")
|
||||
|
||||
# Méthodes d'accès aux données (database_sync_to_async)
|
||||
@database_sync_to_async
|
||||
def get_user(self, user_id):
|
||||
try:
|
||||
return Profile.objects.get(id=user_id)
|
||||
except Profile.DoesNotExist:
|
||||
return None
|
||||
|
||||
@database_sync_to_async
|
||||
def get_user_display_name(self, user_id):
|
||||
"""Obtenir le nom d'affichage d'un utilisateur"""
|
||||
try:
|
||||
user = Profile.objects.get(id=user_id)
|
||||
if user.first_name and user.last_name:
|
||||
return f"{user.first_name} {user.last_name}"
|
||||
elif user.first_name:
|
||||
return user.first_name
|
||||
elif user.last_name:
|
||||
return user.last_name
|
||||
else:
|
||||
return user.email or f"Utilisateur {user_id}"
|
||||
except Profile.DoesNotExist:
|
||||
return f"Utilisateur {user_id}"
|
||||
|
||||
@database_sync_to_async
|
||||
def get_user_conversations(self, user_id):
|
||||
return list(Conversation.objects.filter(
|
||||
participants__participant_id=user_id,
|
||||
participants__is_active=True,
|
||||
is_active=True
|
||||
).distinct())
|
||||
|
||||
@database_sync_to_async
|
||||
def get_conversation_participants(self, conversation_id):
|
||||
"""Obtenir tous les participants d'une conversation"""
|
||||
return list(Profile.objects.filter(
|
||||
conversation_participants__conversation_id=conversation_id,
|
||||
conversation_participants__is_active=True
|
||||
))
|
||||
|
||||
@database_sync_to_async
|
||||
def get_conversations_data(self, user_id):
|
||||
try:
|
||||
user = Profile.objects.get(id=user_id)
|
||||
conversations = Conversation.objects.filter(
|
||||
participants__participant=user,
|
||||
participants__is_active=True,
|
||||
is_active=True
|
||||
).distinct()
|
||||
|
||||
serializer = ConversationSerializer(conversations, many=True, context={'user': user})
|
||||
return serializer.data
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting conversations data: {str(e)}")
|
||||
return []
|
||||
|
||||
@database_sync_to_async
|
||||
def can_user_send_message(self, user_id, conversation_id):
|
||||
return ConversationParticipant.objects.filter(
|
||||
conversation_id=conversation_id,
|
||||
participant_id=user_id,
|
||||
is_active=True
|
||||
).exists()
|
||||
|
||||
@database_sync_to_async
|
||||
def create_message(self, conversation_id, sender_id, content, message_type, attachment=None):
|
||||
try:
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
sender = Profile.objects.get(id=sender_id)
|
||||
|
||||
message_data = {
|
||||
'conversation': conversation,
|
||||
'sender': sender,
|
||||
'content': content,
|
||||
'message_type': message_type
|
||||
}
|
||||
|
||||
# Ajouter les informations du fichier si présent
|
||||
if attachment:
|
||||
message_data.update({
|
||||
'file_url': attachment.get('fileUrl'),
|
||||
'file_name': attachment.get('fileName'),
|
||||
'file_size': attachment.get('fileSize'),
|
||||
'file_type': attachment.get('fileType'),
|
||||
})
|
||||
# Si c'est un fichier, s'assurer que le type de message est correct
|
||||
if attachment.get('fileType', '').startswith('image/'):
|
||||
message_data['message_type'] = 'image'
|
||||
else:
|
||||
message_data['message_type'] = 'file'
|
||||
|
||||
message = Message.objects.create(**message_data)
|
||||
|
||||
# Mettre à jour l'activité de la conversation
|
||||
conversation.last_activity = message.created_at
|
||||
conversation.save(update_fields=['last_activity'])
|
||||
|
||||
return message
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating message: {str(e)}")
|
||||
return None
|
||||
|
||||
@database_sync_to_async
|
||||
def serialize_message(self, message):
|
||||
serializer = MessageSerializer(message)
|
||||
return serialize_for_websocket(serializer.data)
|
||||
|
||||
@database_sync_to_async
|
||||
def get_user_presence(self, user_id):
|
||||
"""Récupérer la présence d'un utilisateur"""
|
||||
try:
|
||||
return UserPresence.objects.get(user_id=user_id)
|
||||
except UserPresence.DoesNotExist:
|
||||
return None
|
||||
|
||||
@database_sync_to_async
|
||||
def update_user_presence(self, user_id, status):
|
||||
try:
|
||||
user = Profile.objects.get(id=user_id)
|
||||
presence, created = UserPresence.objects.get_or_create(user=user)
|
||||
old_status = presence.status
|
||||
presence.status = status
|
||||
presence.save()
|
||||
|
||||
# Si le statut a changé, notifier les autres utilisateurs
|
||||
if old_status != status or created:
|
||||
logger.info(f"User {user_id} presence changed from {old_status} to {status}")
|
||||
|
||||
return presence
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating user presence: {str(e)}")
|
||||
return None
|
||||
|
||||
@database_sync_to_async
|
||||
def update_typing_status(self, user_id, conversation_id, is_typing):
|
||||
try:
|
||||
user = Profile.objects.get(id=user_id)
|
||||
presence, created = UserPresence.objects.get_or_create(user=user)
|
||||
if is_typing:
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
presence.is_typing_in = conversation
|
||||
else:
|
||||
presence.is_typing_in = None
|
||||
presence.save()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating typing status: {str(e)}")
|
||||
|
||||
@database_sync_to_async
|
||||
def mark_conversation_as_read(self, user_id, conversation_id):
|
||||
"""Marquer tous les messages non lus d'une conversation comme lus"""
|
||||
try:
|
||||
# Mettre à jour le last_read_at du participant
|
||||
participant = ConversationParticipant.objects.get(
|
||||
conversation_id=conversation_id,
|
||||
participant_id=user_id
|
||||
)
|
||||
current_time = timezone.now()
|
||||
participant.last_read_at = current_time
|
||||
participant.save(update_fields=['last_read_at'])
|
||||
|
||||
# Créer des enregistrements MessageRead pour tous les messages non lus
|
||||
# que l'utilisateur n'a pas encore explicitement lus
|
||||
unread_messages = Message.objects.filter(
|
||||
conversation_id=conversation_id,
|
||||
created_at__lte=current_time,
|
||||
is_deleted=False
|
||||
).exclude(
|
||||
sender_id=user_id # Exclure ses propres messages
|
||||
).exclude(
|
||||
read_by__participant_id=user_id # Exclure les messages déjà marqués comme lus
|
||||
)
|
||||
|
||||
# Créer les enregistrements MessageRead en batch
|
||||
message_reads = [
|
||||
MessageRead(message=message, participant_id=user_id, read_at=current_time)
|
||||
for message in unread_messages
|
||||
]
|
||||
|
||||
if message_reads:
|
||||
MessageRead.objects.bulk_create(message_reads, ignore_conflicts=True)
|
||||
logger.info(f"Marked {len(message_reads)} messages as read for user {user_id} in conversation {conversation_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error marking conversation as read: {str(e)}")
|
||||
|
||||
@database_sync_to_async
|
||||
def auto_mark_read_for_online_users(self, message, conversation_id):
|
||||
"""Auto-marquer comme lu pour les utilisateurs en ligne dans la conversation"""
|
||||
try:
|
||||
# Obtenir tous les participants de la conversation (synchrone)
|
||||
participants = ConversationParticipant.objects.filter(
|
||||
conversation_id=conversation_id,
|
||||
is_active=True
|
||||
).exclude(participant_id=message.sender.id)
|
||||
|
||||
# Obtenir l'heure de création du message
|
||||
message_time = message.created_at
|
||||
|
||||
# Préparer les enregistrements MessageRead à créer
|
||||
message_reads = []
|
||||
|
||||
for participant_obj in participants:
|
||||
participant = participant_obj.participant
|
||||
|
||||
# Vérifier si l'utilisateur est en ligne (synchrone)
|
||||
try:
|
||||
presence = UserPresence.objects.filter(user=participant).first()
|
||||
if presence and presence.status == 'online':
|
||||
# Vérifier qu'il n'existe pas déjà un enregistrement MessageRead
|
||||
if not MessageRead.objects.filter(message=message, participant=participant).exists():
|
||||
message_reads.append(MessageRead(
|
||||
message=message,
|
||||
participant=participant,
|
||||
read_at=message_time
|
||||
))
|
||||
except:
|
||||
# En cas d'erreur de présence, ne pas marquer comme lu
|
||||
continue
|
||||
|
||||
# Créer les enregistrements MessageRead en batch
|
||||
if message_reads:
|
||||
MessageRead.objects.bulk_create(message_reads, ignore_conflicts=True)
|
||||
logger.info(f"Auto-marked {len(message_reads)} messages as read for online users in conversation {conversation_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in auto_mark_read_for_online_users: {str(e)}")
|
||||
108
Back-End/GestionMessagerie/middleware.py
Normal file
108
Back-End/GestionMessagerie/middleware.py
Normal file
@ -0,0 +1,108 @@
|
||||
import jwt
|
||||
import logging
|
||||
from urllib.parse import parse_qs
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from channels.middleware import BaseMiddleware
|
||||
from channels.db import database_sync_to_async
|
||||
from Auth.models import Profile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@database_sync_to_async
|
||||
def get_user(user_id):
|
||||
"""Récupérer l'utilisateur de manière asynchrone"""
|
||||
try:
|
||||
return Profile.objects.get(id=user_id)
|
||||
except Profile.DoesNotExist:
|
||||
return AnonymousUser()
|
||||
|
||||
class JWTAuthMiddleware(BaseMiddleware):
|
||||
"""Middleware pour l'authentification JWT dans les WebSockets"""
|
||||
|
||||
def __init__(self, inner):
|
||||
super().__init__(inner)
|
||||
|
||||
def _check_cors_origin(self, scope):
|
||||
"""Vérifier si l'origine est autorisée pour les WebSockets"""
|
||||
origin = None
|
||||
|
||||
# Récupérer l'origine depuis les headers
|
||||
for name, value in scope.get('headers', []):
|
||||
if name == b'origin':
|
||||
origin = value.decode('latin1')
|
||||
break
|
||||
|
||||
if not origin:
|
||||
logger.warning("Aucune origine trouvée dans les headers WebSocket")
|
||||
return False
|
||||
|
||||
# Récupérer les origines autorisées depuis la configuration CORS
|
||||
allowed_origins = getattr(settings, 'CORS_ALLOWED_ORIGINS', [])
|
||||
|
||||
# Si CORS_ORIGIN_ALLOW_ALL est True, autoriser toutes les origines
|
||||
if getattr(settings, 'CORS_ORIGIN_ALLOW_ALL', False):
|
||||
logger.info(f"Origine WebSocket autorisée (CORS_ORIGIN_ALLOW_ALL): {origin}")
|
||||
return True
|
||||
|
||||
# Vérifier si l'origine est dans la liste des origines autorisées
|
||||
if origin in allowed_origins:
|
||||
logger.info(f"Origine WebSocket autorisée: {origin}")
|
||||
return True
|
||||
|
||||
logger.warning(f"Origine WebSocket non autorisée: {origin}. Origines autorisées: {allowed_origins}")
|
||||
return False
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
# Vérifier les CORS pour les WebSockets
|
||||
if not self._check_cors_origin(scope):
|
||||
logger.error("Connexion WebSocket refusée: origine non autorisée")
|
||||
# Fermer la connexion WebSocket avec un code d'erreur
|
||||
await send({
|
||||
'type': 'websocket.close',
|
||||
'code': 1008 # Policy Violation
|
||||
})
|
||||
return
|
||||
|
||||
# Extraire le token de l'URL
|
||||
query_string = parse_qs(scope['query_string'].decode())
|
||||
token = query_string.get('token')
|
||||
|
||||
if token:
|
||||
token = token[0]
|
||||
try:
|
||||
# Décoder le token JWT
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.SIMPLE_JWT['SIGNING_KEY'],
|
||||
algorithms=[settings.SIMPLE_JWT['ALGORITHM']]
|
||||
)
|
||||
|
||||
# Vérifier que c'est un token d'accès
|
||||
if payload.get('type') != 'access':
|
||||
logger.warning(f"Token type invalide: {payload.get('type')}")
|
||||
scope['user'] = AnonymousUser()
|
||||
else:
|
||||
# Récupérer l'utilisateur
|
||||
user_id = payload.get('user_id')
|
||||
user = await get_user(user_id)
|
||||
scope['user'] = user
|
||||
logger.info(f"Utilisateur authentifié via JWT: {user.email if hasattr(user, 'email') else 'Unknown'}")
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Token JWT expiré")
|
||||
scope['user'] = AnonymousUser()
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"Token JWT invalide: {str(e)}")
|
||||
scope['user'] = AnonymousUser()
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'authentification JWT: {str(e)}")
|
||||
scope['user'] = AnonymousUser()
|
||||
else:
|
||||
scope['user'] = AnonymousUser()
|
||||
|
||||
return await super().__call__(scope, receive, send)
|
||||
|
||||
def JWTAuthMiddlewareStack(inner):
|
||||
"""Stack middleware pour l'authentification JWT"""
|
||||
return JWTAuthMiddleware(inner)
|
||||
101
Back-End/GestionMessagerie/migrations/0001_initial.py
Normal file
101
Back-End/GestionMessagerie/migrations/0001_initial.py
Normal file
@ -0,0 +1,101 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Conversation',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('conversation_type', models.CharField(choices=[('private', 'Privée'), ('group', 'Groupe')], default='private', max_length=10)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_activity', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Message',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('content', models.TextField()),
|
||||
('message_type', models.CharField(choices=[('text', 'Texte'), ('file', 'Fichier'), ('image', 'Image'), ('system', 'Système')], default='text', max_length=10)),
|
||||
('file_url', models.URLField(blank=True, null=True)),
|
||||
('file_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('file_size', models.BigIntegerField(blank=True, null=True)),
|
||||
('file_type', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_edited', models.BooleanField(default=False)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='GestionMessagerie.conversation')),
|
||||
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Messagerie',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('objet', models.CharField(blank=True, default='', max_length=200)),
|
||||
('corpus', models.CharField(blank=True, default='', max_length=200)),
|
||||
('date_envoi', models.DateTimeField(auto_now_add=True)),
|
||||
('is_read', models.BooleanField(default=False)),
|
||||
('conversation_id', models.CharField(blank=True, default='', max_length=100)),
|
||||
('destinataire', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='messages_recus', to=settings.AUTH_USER_MODEL)),
|
||||
('emetteur', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='messages_envoyes', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserPresence',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('online', 'En ligne'), ('away', 'Absent'), ('busy', 'Occupé'), ('offline', 'Hors ligne')], default='offline', max_length=10)),
|
||||
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('is_typing_in', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='typing_users', to='GestionMessagerie.conversation')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='presence', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ConversationParticipant',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_read_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='GestionMessagerie.conversation')),
|
||||
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversation_participants', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('conversation', 'participant')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MessageRead',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('read_at', models.DateTimeField(auto_now_add=True)),
|
||||
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_by', to='GestionMessagerie.message')),
|
||||
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_messages', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('message', 'participant')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
Back-End/GestionMessagerie/migrations/__init__.py
Normal file
0
Back-End/GestionMessagerie/migrations/__init__.py
Normal file
@ -1,6 +1,104 @@
|
||||
from django.db import models
|
||||
from Auth.models import Profile
|
||||
from django.utils import timezone
|
||||
import uuid
|
||||
|
||||
class Conversation(models.Model):
|
||||
"""Modèle pour gérer les conversations entre utilisateurs"""
|
||||
CONVERSATION_TYPES = [
|
||||
('private', 'Privée'),
|
||||
('group', 'Groupe'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
name = models.CharField(max_length=255, blank=True, null=True) # Nom pour les groupes
|
||||
conversation_type = models.CharField(max_length=10, choices=CONVERSATION_TYPES, default='private')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
last_activity = models.DateTimeField(default=timezone.now)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self):
|
||||
if self.name:
|
||||
return f'Conversation: {self.name}'
|
||||
return f'Conversation {self.id}'
|
||||
|
||||
def get_participants(self):
|
||||
return Profile.objects.filter(conversation_participants__conversation=self)
|
||||
|
||||
class ConversationParticipant(models.Model):
|
||||
"""Modèle pour gérer les participants d'une conversation"""
|
||||
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='participants')
|
||||
participant = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='conversation_participants')
|
||||
joined_at = models.DateTimeField(auto_now_add=True)
|
||||
last_read_at = models.DateTimeField(default=timezone.now)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('conversation', 'participant')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.participant.email} in {self.conversation.id}'
|
||||
|
||||
class Message(models.Model):
|
||||
"""Modèle pour les messages instantanés"""
|
||||
MESSAGE_TYPES = [
|
||||
('text', 'Texte'),
|
||||
('file', 'Fichier'),
|
||||
('image', 'Image'),
|
||||
('system', 'Système'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages')
|
||||
sender = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='sent_messages')
|
||||
content = models.TextField()
|
||||
message_type = models.CharField(max_length=10, choices=MESSAGE_TYPES, default='text')
|
||||
file_url = models.URLField(blank=True, null=True) # Pour les fichiers/images
|
||||
file_name = models.CharField(max_length=255, blank=True, null=True) # Nom original du fichier
|
||||
file_size = models.BigIntegerField(blank=True, null=True) # Taille en bytes
|
||||
file_type = models.CharField(max_length=100, blank=True, null=True) # MIME type
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
is_edited = models.BooleanField(default=False)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'Message from {self.sender.email} at {self.created_at}'
|
||||
|
||||
class MessageRead(models.Model):
|
||||
"""Modèle pour tracker les messages lus par chaque participant"""
|
||||
message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='read_by')
|
||||
participant = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='read_messages')
|
||||
read_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('message', 'participant')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.participant.email} read {self.message.id}'
|
||||
|
||||
class UserPresence(models.Model):
|
||||
"""Modèle pour gérer la présence des utilisateurs"""
|
||||
PRESENCE_STATUS = [
|
||||
('online', 'En ligne'),
|
||||
('away', 'Absent'),
|
||||
('busy', 'Occupé'),
|
||||
('offline', 'Hors ligne'),
|
||||
]
|
||||
|
||||
user = models.OneToOneField(Profile, on_delete=models.CASCADE, related_name='presence')
|
||||
status = models.CharField(max_length=10, choices=PRESENCE_STATUS, default='offline')
|
||||
last_seen = models.DateTimeField(default=timezone.now)
|
||||
is_typing_in = models.ForeignKey(Conversation, on_delete=models.SET_NULL, null=True, blank=True, related_name='typing_users')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user.email} - {self.status}'
|
||||
|
||||
# Ancien modèle conservé pour compatibilité
|
||||
class Messagerie(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
objet = models.CharField(max_length=200, default="", blank=True)
|
||||
|
||||
7
Back-End/GestionMessagerie/routing.py
Normal file
7
Back-End/GestionMessagerie/routing.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.urls import re_path
|
||||
from . import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r'ws/chat/(?P<user_id>\w+)/$', consumers.ChatConsumer.as_asgi()),
|
||||
re_path(r'ws/chat/conversation/(?P<conversation_id>[\w-]+)/$', consumers.ChatConsumer.as_asgi()),
|
||||
]
|
||||
@ -1,15 +1,266 @@
|
||||
from rest_framework import serializers
|
||||
from Auth.models import Profile
|
||||
from GestionMessagerie.models import Messagerie
|
||||
from GestionMessagerie.models import Messagerie, Conversation, ConversationParticipant, Message, MessageRead, UserPresence
|
||||
from channels.layers import get_channel_layer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
class ProfileSimpleSerializer(serializers.ModelSerializer):
|
||||
"""Sérialiseur simple pour les profils utilisateur"""
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = ['id', 'first_name', 'last_name', 'email']
|
||||
|
||||
class UserPresenceSerializer(serializers.ModelSerializer):
|
||||
"""Sérialiseur pour la présence utilisateur"""
|
||||
user = ProfileSimpleSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = UserPresence
|
||||
fields = ['user', 'status', 'last_seen', 'is_typing_in']
|
||||
|
||||
class MessageReadSerializer(serializers.ModelSerializer):
|
||||
"""Sérialiseur pour les messages lus"""
|
||||
participant = ProfileSimpleSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = MessageRead
|
||||
fields = ['participant', 'read_at']
|
||||
|
||||
class MessageSerializer(serializers.ModelSerializer):
|
||||
"""Sérialiseur pour les messages instantanés"""
|
||||
sender = ProfileSimpleSerializer(read_only=True)
|
||||
read_by = MessageReadSerializer(many=True, read_only=True)
|
||||
attachment = serializers.SerializerMethodField()
|
||||
is_read = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Message
|
||||
fields = ['id', 'conversation', 'sender', 'content', 'message_type', 'file_url',
|
||||
'file_name', 'file_size', 'file_type', 'attachment',
|
||||
'created_at', 'updated_at', 'is_edited', 'is_deleted', 'read_by', 'is_read']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
def get_attachment(self, obj):
|
||||
"""Retourne les informations du fichier attaché sous forme d'objet"""
|
||||
if obj.file_url:
|
||||
return {
|
||||
'fileName': obj.file_name,
|
||||
'fileSize': obj.file_size,
|
||||
'fileType': obj.file_type,
|
||||
'fileUrl': obj.file_url,
|
||||
}
|
||||
return None
|
||||
|
||||
def get_is_read(self, obj):
|
||||
"""Détermine si le message est lu par l'utilisateur actuel"""
|
||||
user = self.context.get('user')
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Si c'est le message de l'utilisateur lui-même, vérifier si quelqu'un d'autre l'a lu
|
||||
if obj.sender == user:
|
||||
# Pour les messages envoyés par l'utilisateur, vérifier si au moins un autre participant l'a explicitement lu
|
||||
# Utiliser le modèle MessageRead pour une vérification précise
|
||||
from .models import MessageRead
|
||||
other_participants = obj.conversation.participants.exclude(participant=user).filter(is_active=True)
|
||||
|
||||
for participant in other_participants:
|
||||
# Vérifier si ce participant a explicitement lu ce message
|
||||
if MessageRead.objects.filter(message=obj, participant=participant.participant).exists():
|
||||
return True
|
||||
|
||||
# Fallback: vérifier last_read_at seulement si l'utilisateur était en ligne récemment
|
||||
# ou si last_read_at est postérieur à created_at (lecture explicite après réception)
|
||||
if (participant.last_read_at and
|
||||
participant.last_read_at > obj.created_at):
|
||||
|
||||
# Vérifier la présence de l'utilisateur pour s'assurer qu'il était en ligne
|
||||
try:
|
||||
from .models import UserPresence
|
||||
user_presence = UserPresence.objects.filter(user=participant.participant).first()
|
||||
|
||||
# Si l'utilisateur était en ligne récemment (dans les 5 minutes suivant le message)
|
||||
# ou si last_read_at est bien après created_at (lecture délibérée)
|
||||
time_diff = participant.last_read_at - obj.created_at
|
||||
if (user_presence and user_presence.last_seen and
|
||||
user_presence.last_seen >= obj.created_at) or time_diff.total_seconds() > 10:
|
||||
return True
|
||||
except:
|
||||
# En cas d'erreur, continuer avec la logique conservative
|
||||
pass
|
||||
|
||||
return False
|
||||
else:
|
||||
# Pour les messages reçus, vérifier si l'utilisateur actuel l'a lu
|
||||
# D'abord vérifier dans MessageRead pour une lecture explicite
|
||||
from .models import MessageRead
|
||||
if MessageRead.objects.filter(message=obj, participant=user).exists():
|
||||
return True
|
||||
|
||||
# Fallback: vérifier last_read_at du participant
|
||||
participant = obj.conversation.participants.filter(
|
||||
participant=user,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if participant and participant.last_read_at:
|
||||
# Seulement considérer comme lu si last_read_at est postérieur à created_at
|
||||
return participant.last_read_at > obj.created_at
|
||||
|
||||
return False
|
||||
|
||||
class ConversationParticipantSerializer(serializers.ModelSerializer):
|
||||
"""Sérialiseur pour les participants d'une conversation"""
|
||||
participant = ProfileSimpleSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ConversationParticipant
|
||||
fields = ['participant', 'joined_at', 'last_read_at', 'is_active']
|
||||
|
||||
class ConversationSerializer(serializers.ModelSerializer):
|
||||
"""Sérialiseur pour les conversations"""
|
||||
participants = ConversationParticipantSerializer(many=True, read_only=True)
|
||||
last_message = serializers.SerializerMethodField()
|
||||
unread_count = serializers.SerializerMethodField()
|
||||
interlocuteur = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Conversation
|
||||
fields = ['id', 'name', 'conversation_type', 'created_at', 'updated_at',
|
||||
'last_activity', 'is_active', 'participants', 'last_message', 'unread_count', 'interlocuteur']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
def get_last_message(self, obj):
|
||||
last_message = obj.messages.filter(is_deleted=False).last()
|
||||
if last_message:
|
||||
return MessageSerializer(last_message).data
|
||||
return None
|
||||
|
||||
def get_unread_count(self, obj):
|
||||
user = self.context.get('user')
|
||||
if not user or not user.is_authenticated:
|
||||
return 0
|
||||
|
||||
participant = obj.participants.filter(participant=user).first()
|
||||
if not participant:
|
||||
return 0
|
||||
|
||||
# Nouvelle logique : compter les messages qui ne sont PAS dans MessageRead
|
||||
# et qui ont été créés après last_read_at (ou tous si last_read_at est None)
|
||||
|
||||
# Base query : messages de la conversation, excluant les propres messages et les supprimés
|
||||
# ET ne comptant que les messages textuels
|
||||
base_query = obj.messages.filter(
|
||||
is_deleted=False,
|
||||
message_type='text' # Ne compter que les messages textuels
|
||||
).exclude(sender=user)
|
||||
|
||||
# Si l'utilisateur n'a pas de last_read_at, tous les messages sont non lus
|
||||
if not participant.last_read_at:
|
||||
unread_from_timestamp = base_query
|
||||
else:
|
||||
# Messages créés après le dernier moment de lecture
|
||||
unread_from_timestamp = base_query.filter(
|
||||
created_at__gt=participant.last_read_at
|
||||
)
|
||||
|
||||
# Soustraire les messages explicitement marqués comme lus dans MessageRead
|
||||
from .models import MessageRead
|
||||
read_message_ids = MessageRead.objects.filter(
|
||||
participant=user,
|
||||
message__conversation=obj
|
||||
).values_list('message_id', flat=True)
|
||||
|
||||
# Compter les messages non lus = messages après last_read_at MOINS ceux explicitement lus
|
||||
unread_count = unread_from_timestamp.exclude(
|
||||
id__in=read_message_ids
|
||||
).count()
|
||||
|
||||
return unread_count
|
||||
|
||||
def get_interlocuteur(self, obj):
|
||||
"""Pour les conversations privées, retourne l'autre participant"""
|
||||
user = self.context.get('user')
|
||||
if not user or not user.is_authenticated or obj.conversation_type != 'private':
|
||||
return None
|
||||
|
||||
# Trouver l'autre participant (pas l'utilisateur actuel)
|
||||
other_participant = obj.participants.filter(is_active=True).exclude(participant=user).first()
|
||||
if other_participant:
|
||||
return ProfileSimpleSerializer(other_participant.participant).data
|
||||
return None
|
||||
|
||||
class ConversationCreateSerializer(serializers.ModelSerializer):
|
||||
"""Sérialiseur pour créer une conversation"""
|
||||
participant_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
write_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Conversation
|
||||
fields = ['name', 'conversation_type', 'participant_ids']
|
||||
|
||||
def create(self, validated_data):
|
||||
participant_ids = validated_data.pop('participant_ids')
|
||||
conversation_type = validated_data.get('conversation_type', 'private')
|
||||
|
||||
# Pour les conversations privées, ne pas utiliser de nom spécifique
|
||||
# Le nom sera géré côté frontend en affichant le nom de l'interlocuteur
|
||||
if conversation_type == 'private':
|
||||
validated_data['name'] = None
|
||||
|
||||
conversation = super().create(validated_data)
|
||||
|
||||
# Ajouter les participants
|
||||
participants = []
|
||||
for participant_id in participant_ids:
|
||||
try:
|
||||
participant = Profile.objects.get(id=participant_id)
|
||||
ConversationParticipant.objects.create(
|
||||
conversation=conversation,
|
||||
participant=participant
|
||||
)
|
||||
participants.append(participant)
|
||||
except Profile.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Notifier les participants via WebSocket de la nouvelle conversation
|
||||
try:
|
||||
from channels.layers import get_channel_layer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer:
|
||||
# Envoyer à chaque participant avec le bon contexte
|
||||
for participant in participants:
|
||||
# Sérialiser la conversation avec le contexte de ce participant
|
||||
conversation_data = ConversationSerializer(conversation, context={'user': participant}).data
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
f'user_{participant.id}',
|
||||
{
|
||||
'type': 'new_conversation_notification',
|
||||
'conversation': conversation_data
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
# Log l'erreur mais ne pas interrompre la création de la conversation
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Erreur lors de la notification WebSocket de nouvelle conversation: {str(e)}")
|
||||
|
||||
return conversation
|
||||
|
||||
# Ancien sérialiseur conservé pour compatibilité
|
||||
class MessageLegacySerializer(serializers.ModelSerializer):
|
||||
destinataire_profil = serializers.SerializerMethodField()
|
||||
emetteur_profil = serializers.SerializerMethodField()
|
||||
class Meta:
|
||||
model = Messagerie
|
||||
fields = '__all__'
|
||||
read_only_fields = ['date_envoi']
|
||||
|
||||
|
||||
def get_destinataire_profil(self, obj):
|
||||
return obj.destinataire.email
|
||||
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
from django.urls import path, re_path
|
||||
from .views import SendEmailView, search_recipients, ConversationListView, ConversationMessagesView, MarkAsReadView
|
||||
from GestionMessagerie.views import MessagerieView, MessageView, MessageSimpleView
|
||||
from django.urls import path
|
||||
from .views import (
|
||||
InstantConversationListView, InstantConversationCreateView, InstantConversationDeleteView,
|
||||
InstantMessageListView, InstantMessageCreateView,
|
||||
InstantMarkAsReadView, FileUploadView,
|
||||
InstantRecipientSearchView
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^messagerie/(?P<profile_id>[0-9]+)$', MessagerieView.as_view(), name="messagerie"),
|
||||
re_path(r'^messages$', MessageView.as_view(), name="messages"),
|
||||
re_path(r'^messages/(?P<id>[0-9]+)$', MessageSimpleView.as_view(), name="messages"),
|
||||
path('send-email/', SendEmailView.as_view(), name='send_email'),
|
||||
path('search-recipients/', search_recipients, name='search_recipients'),
|
||||
# Endpoints pour le chat instantané
|
||||
path('conversations/<int:profile_id>/', ConversationListView.as_view(), name='conversations'),
|
||||
path('conversations/messages/<str:conversation_id>/', ConversationMessagesView.as_view(), name='conversation_messages'),
|
||||
path('conversations/mark-as-read/<str:conversation_id>/', MarkAsReadView.as_view(), name='mark_as_read'),
|
||||
# URLs pour messagerie instantanée
|
||||
path('conversations/', InstantConversationListView.as_view(), name='conversations'),
|
||||
path('create-conversation/', InstantConversationCreateView.as_view(), name='create_conversation'),
|
||||
path('send-message/', InstantMessageCreateView.as_view(), name='send_message'),
|
||||
path('conversations/mark-as-read/', InstantMarkAsReadView.as_view(), name='mark_as_read'),
|
||||
path('search-recipients/', InstantRecipientSearchView.as_view(), name='search_recipients'),
|
||||
path('upload-file/', FileUploadView.as_view(), name='upload_file'),
|
||||
|
||||
# URLs avec paramètres - doivent être après les URLs statiques
|
||||
path('conversations/user/<int:user_id>/', InstantConversationListView.as_view(), name='conversations_by_user'),
|
||||
path('conversations/<uuid:conversation_id>/', InstantConversationDeleteView.as_view(), name='delete_conversation'),
|
||||
path('conversations/<uuid:conversation_id>/messages/', InstantMessageListView.as_view(), name='conversation_messages'),
|
||||
]
|
||||
@ -1,211 +1,455 @@
|
||||
from django.http.response import JsonResponse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.parsers import JSONParser
|
||||
from django.conf import settings
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django.db.models import Q
|
||||
from .models import Messagerie
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from django.db import models
|
||||
from .models import Conversation, ConversationParticipant, Message, UserPresence
|
||||
from Auth.models import Profile, ProfileRole
|
||||
from GestionMessagerie.serializers import MessageSerializer
|
||||
from School.models import Teacher
|
||||
|
||||
from School.serializers import TeacherSerializer
|
||||
|
||||
import N3wtSchool.mailManager as mailer
|
||||
from N3wtSchool import bdd
|
||||
from GestionMessagerie.serializers import (
|
||||
ConversationSerializer, MessageSerializer,
|
||||
ConversationCreateSerializer, UserPresenceSerializer,
|
||||
ProfileSimpleSerializer
|
||||
)
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from rest_framework.exceptions import NotFound
|
||||
from django.utils import timezone
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db.models import Q
|
||||
|
||||
class MessagerieView(APIView):
|
||||
def get(self, request, profile_id):
|
||||
messagesList = bdd.getObjects(_objectName=Messagerie, _columnName='destinataire__id', _value=profile_id)
|
||||
messages_serializer = MessageSerializer(messagesList, many=True)
|
||||
return JsonResponse(messages_serializer.data, safe=False)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MessageView(APIView):
|
||||
def post(self, request):
|
||||
message_data=JSONParser().parse(request)
|
||||
message_serializer = MessageSerializer(data=message_data)
|
||||
# ====================== MESSAGERIE INSTANTANÉE ======================
|
||||
|
||||
if message_serializer.is_valid():
|
||||
message_serializer.save()
|
||||
|
||||
return JsonResponse('Nouveau Message ajouté', safe=False)
|
||||
|
||||
return JsonResponse(message_serializer.errors, safe=False)
|
||||
|
||||
class MessageSimpleView(APIView):
|
||||
def get(self, request, id):
|
||||
message=bdd.getObject(Messagerie, "id", id)
|
||||
message_serializer=MessageSerializer(message)
|
||||
return JsonResponse(message_serializer.data, safe=False)
|
||||
|
||||
class SendEmailView(APIView):
|
||||
class InstantConversationListView(APIView):
|
||||
"""
|
||||
API pour envoyer des emails aux parents et professeurs.
|
||||
API pour lister les conversations instantanées d'un utilisateur
|
||||
"""
|
||||
def post(self, request):
|
||||
data = request.data
|
||||
recipients = data.get('recipients', [])
|
||||
cc = data.get('cc', [])
|
||||
bcc = data.get('bcc', [])
|
||||
subject = data.get('subject', 'Notification')
|
||||
message = data.get('message', '')
|
||||
establishment_id = data.get('establishment_id', '')
|
||||
|
||||
if not recipients or not message:
|
||||
return Response({'error': 'Les destinataires et le message sont requis.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Liste les conversations instantanées d'un utilisateur",
|
||||
responses={200: ConversationSerializer(many=True)}
|
||||
)
|
||||
def get(self, request, user_id=None):
|
||||
try:
|
||||
# Récupérer la connexion SMTP
|
||||
connection = mailer.getConnection(establishment_id)
|
||||
user = Profile.objects.get(id=user_id)
|
||||
|
||||
# Envoyer l'email
|
||||
return mailer.sendMail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
recipients=recipients,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
attachments=[],
|
||||
connection=connection
|
||||
)
|
||||
except NotFound as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND)
|
||||
conversations = Conversation.objects.filter(
|
||||
participants__participant=user,
|
||||
participants__is_active=True,
|
||||
is_active=True
|
||||
).distinct().order_by('-last_activity')
|
||||
|
||||
serializer = ConversationSerializer(conversations, many=True, context={'user': user})
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Profile.DoesNotExist:
|
||||
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class ContactsView(APIView):
|
||||
class InstantConversationCreateView(APIView):
|
||||
"""
|
||||
API pour récupérer les contacts associés à un établissement.
|
||||
"""
|
||||
def get(self, request, establishment_id):
|
||||
try:
|
||||
# Récupérer les enseignants associés à l'établissement
|
||||
teachers = Teacher.objects.filter(profile_role__establishment_id=establishment_id)
|
||||
teachers_serializer = TeacherSerializer(teachers, many=True)
|
||||
|
||||
# Ajouter un contact pour l'administration
|
||||
admin_contact = {
|
||||
"id": "admin",
|
||||
"name": "Administration",
|
||||
"email": "admin@etablissement.com",
|
||||
"profilePic": "https://www.gravatar.com/avatar/admin"
|
||||
}
|
||||
|
||||
contacts = [admin_contact] + teachers_serializer.data
|
||||
return Response(contacts, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
def search_recipients(request):
|
||||
"""
|
||||
API pour rechercher des destinataires en fonction d'un terme de recherche et d'un établissement.
|
||||
"""
|
||||
query = request.GET.get('q', '').strip() # Récupérer le terme de recherche depuis les paramètres GET
|
||||
establishment_id = request.GET.get('establishment_id', None) # Récupérer l'ID de l'établissement
|
||||
|
||||
if not query:
|
||||
return JsonResponse([], safe=False) # Retourner une liste vide si aucun terme n'est fourni
|
||||
|
||||
if not establishment_id:
|
||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Rechercher dans les champs pertinents (nom, prénom, email) et filtrer par establishment_id
|
||||
profiles = Profile.objects.filter(
|
||||
Q(first_name__icontains=query) |
|
||||
Q(last_name__icontains=query) |
|
||||
Q(email__icontains=query),
|
||||
roles__establishment_id=establishment_id, # Utiliser 'roles' au lieu de 'profilerole'
|
||||
roles__is_active=True # Filtrer uniquement les ProfileRole actifs
|
||||
).distinct()
|
||||
|
||||
# Construire la réponse avec les rôles associés
|
||||
results = []
|
||||
for profile in profiles:
|
||||
profile_roles = ProfileRole.objects.filter(
|
||||
profile=profile,
|
||||
establishment_id=establishment_id,
|
||||
is_active=True # Inclure uniquement les ProfileRole actifs
|
||||
).values(
|
||||
'id', 'role_type', 'establishment__name', 'is_active'
|
||||
)
|
||||
results.append({
|
||||
'id': profile.id,
|
||||
'first_name': profile.first_name,
|
||||
'last_name': profile.last_name,
|
||||
'email': profile.email,
|
||||
'roles': list(profile_roles) # Inclure tous les rôles actifs associés pour cet établissement
|
||||
})
|
||||
|
||||
return JsonResponse(results, safe=False)
|
||||
|
||||
class ConversationListView(APIView):
|
||||
"""
|
||||
Liste les conversations d'un utilisateur (parent ou enseignant).
|
||||
Retourne la liste des interlocuteurs et le dernier message échangé.
|
||||
API pour créer une nouvelle conversation instantanée
|
||||
"""
|
||||
@swagger_auto_schema(
|
||||
operation_description="Liste les conversations d'un utilisateur (parent ou enseignant).",
|
||||
responses={200: openapi.Response('Liste des conversations')}
|
||||
operation_description="Crée une nouvelle conversation instantanée",
|
||||
request_body=ConversationCreateSerializer,
|
||||
responses={201: ConversationSerializer}
|
||||
)
|
||||
def get(self, request, profile_id):
|
||||
# Récupérer toutes les conversations où l'utilisateur est émetteur ou destinataire
|
||||
messages = Messagerie.objects.filter(Q(emetteur_id=profile_id) | Q(destinataire_id=profile_id))
|
||||
# Grouper par conversation_id
|
||||
conversations = {}
|
||||
for msg in messages.order_by('-date_envoi'):
|
||||
conv_id = msg.conversation_id or f"{min(msg.emetteur_id, msg.destinataire_id)}_{max(msg.emetteur_id, msg.destinataire_id)}"
|
||||
if conv_id not in conversations:
|
||||
conversations[conv_id] = msg
|
||||
# Préparer la réponse
|
||||
data = []
|
||||
for conv_id, last_msg in conversations.items():
|
||||
interlocuteur = last_msg.emetteur if last_msg.destinataire_id == int(profile_id) else last_msg.destinataire
|
||||
data.append({
|
||||
'conversation_id': conv_id,
|
||||
'last_message': MessageSerializer(last_msg).data,
|
||||
'interlocuteur': {
|
||||
'id': interlocuteur.id,
|
||||
'first_name': interlocuteur.first_name,
|
||||
'last_name': interlocuteur.last_name,
|
||||
'email': interlocuteur.email,
|
||||
}
|
||||
})
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
def post(self, request):
|
||||
serializer = ConversationCreateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
conversation = serializer.save()
|
||||
response_serializer = ConversationSerializer(conversation, context={'user': request.user})
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class ConversationMessagesView(APIView):
|
||||
class InstantMessageListView(APIView):
|
||||
"""
|
||||
Récupère tous les messages d'une conversation donnée.
|
||||
API pour lister les messages d'une conversation
|
||||
"""
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les messages d'une conversation donnée.",
|
||||
responses={200: openapi.Response('Liste des messages')}
|
||||
operation_description="Liste les messages d'une conversation",
|
||||
responses={200: MessageSerializer(many=True)}
|
||||
)
|
||||
def get(self, request, conversation_id):
|
||||
messages = Messagerie.objects.filter(conversation_id=conversation_id).order_by('date_envoi')
|
||||
serializer = MessageSerializer(messages, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
try:
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
messages = conversation.messages.filter(is_deleted=False).order_by('created_at')
|
||||
|
||||
class MarkAsReadView(APIView):
|
||||
# Récupérer l'utilisateur actuel depuis les paramètres de requête
|
||||
user_id = request.GET.get('user_id')
|
||||
user = None
|
||||
if user_id:
|
||||
try:
|
||||
user = Profile.objects.get(id=user_id)
|
||||
except Profile.DoesNotExist:
|
||||
pass
|
||||
|
||||
serializer = MessageSerializer(messages, many=True, context={'user': user})
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantMessageCreateView(APIView):
|
||||
"""
|
||||
Marque tous les messages reçus dans une conversation comme lus pour l'utilisateur connecté.
|
||||
API pour envoyer un nouveau message instantané
|
||||
"""
|
||||
@swagger_auto_schema(
|
||||
operation_description="Marque tous les messages reçus dans une conversation comme lus pour l'utilisateur connecté.",
|
||||
operation_description="Envoie un nouveau message instantané",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'profile_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID du profil utilisateur')
|
||||
'conversation_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID de la conversation'),
|
||||
'sender_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID de l\'expéditeur'),
|
||||
'content': openapi.Schema(type=openapi.TYPE_STRING, description='Contenu du message'),
|
||||
'message_type': openapi.Schema(type=openapi.TYPE_STRING, description='Type de message', default='text')
|
||||
},
|
||||
required=['profile_id']
|
||||
required=['conversation_id', 'sender_id', 'content']
|
||||
),
|
||||
responses={200: openapi.Response('Statut OK')}
|
||||
responses={201: MessageSerializer}
|
||||
)
|
||||
def post(self, request):
|
||||
try:
|
||||
conversation_id = request.data.get('conversation_id')
|
||||
sender_id = request.data.get('sender_id')
|
||||
content = request.data.get('content', '').strip()
|
||||
message_type = request.data.get('message_type', 'text')
|
||||
|
||||
if not all([conversation_id, sender_id, content]):
|
||||
return Response(
|
||||
{'error': 'conversation_id, sender_id, and content are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Vérifier que la conversation existe
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
|
||||
# Vérifier que l'expéditeur existe et peut envoyer dans cette conversation
|
||||
sender = Profile.objects.get(id=sender_id)
|
||||
participant = ConversationParticipant.objects.filter(
|
||||
conversation=conversation,
|
||||
participant=sender,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if not participant:
|
||||
return Response(
|
||||
{'error': 'You are not a participant in this conversation'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Récupérer les données de fichier si disponibles
|
||||
file_url = request.data.get('file_url')
|
||||
file_name = request.data.get('file_name')
|
||||
file_type = request.data.get('file_type')
|
||||
file_size = request.data.get('file_size')
|
||||
|
||||
# Créer le message
|
||||
message = Message.objects.create(
|
||||
conversation=conversation,
|
||||
sender=sender,
|
||||
content=content,
|
||||
message_type=message_type,
|
||||
file_url=file_url,
|
||||
file_name=file_name,
|
||||
file_type=file_type,
|
||||
file_size=file_size
|
||||
)
|
||||
|
||||
# Mettre à jour l'activité de la conversation
|
||||
conversation.last_activity = message.created_at
|
||||
conversation.save(update_fields=['last_activity'])
|
||||
|
||||
serializer = MessageSerializer(message)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Profile.DoesNotExist:
|
||||
return Response({'error': 'Sender not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantMarkAsReadView(APIView):
|
||||
"""
|
||||
API pour marquer une conversation comme lue
|
||||
"""
|
||||
@swagger_auto_schema(
|
||||
operation_description="Marque une conversation comme lue pour un utilisateur",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'user_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID de l\'utilisateur')
|
||||
},
|
||||
required=['user_id']
|
||||
),
|
||||
responses={200: openapi.Response('Success')}
|
||||
)
|
||||
def post(self, request, conversation_id):
|
||||
profile_id = request.data.get('profile_id')
|
||||
Messagerie.objects.filter(conversation_id=conversation_id, destinataire_id=profile_id, is_read=False).update(is_read=True)
|
||||
return Response({'status': 'ok'}, status=status.HTTP_200_OK)
|
||||
try:
|
||||
user_id = request.data.get('user_id')
|
||||
if not user_id:
|
||||
return Response({'error': 'user_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
participant = ConversationParticipant.objects.get(
|
||||
conversation_id=conversation_id,
|
||||
participant_id=user_id,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
participant.last_read_at = timezone.now()
|
||||
participant.save(update_fields=['last_read_at'])
|
||||
|
||||
return Response({'status': 'success'}, status=status.HTTP_200_OK)
|
||||
|
||||
except ConversationParticipant.DoesNotExist:
|
||||
return Response({'error': 'Participant not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class UserPresenceView(APIView):
|
||||
"""
|
||||
API pour gérer la présence des utilisateurs
|
||||
"""
|
||||
@swagger_auto_schema(
|
||||
operation_description="Met à jour le statut de présence d'un utilisateur",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'status': openapi.Schema(type=openapi.TYPE_STRING, description='Statut de présence')
|
||||
},
|
||||
required=['status']
|
||||
),
|
||||
responses={200: UserPresenceSerializer}
|
||||
)
|
||||
def post(self, request, user_id):
|
||||
try:
|
||||
user = Profile.objects.get(id=user_id)
|
||||
status_value = request.data.get('status')
|
||||
|
||||
if status_value not in ['online', 'away', 'busy', 'offline']:
|
||||
return Response({'error': 'Invalid status'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
presence, created = UserPresence.objects.get_or_create(user=user)
|
||||
presence.status = status_value
|
||||
presence.last_seen = timezone.now()
|
||||
presence.save()
|
||||
|
||||
serializer = UserPresenceSerializer(presence)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Profile.DoesNotExist:
|
||||
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère le statut de présence d'un utilisateur",
|
||||
responses={200: UserPresenceSerializer}
|
||||
)
|
||||
def get(self, request, user_id):
|
||||
try:
|
||||
user = Profile.objects.get(id=user_id)
|
||||
presence, created = UserPresence.objects.get_or_create(user=user)
|
||||
|
||||
if created:
|
||||
presence.status = 'offline'
|
||||
presence.save()
|
||||
|
||||
serializer = UserPresenceSerializer(presence)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Profile.DoesNotExist:
|
||||
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class FileUploadView(APIView):
|
||||
"""
|
||||
API pour l'upload de fichiers dans la messagerie instantanée
|
||||
"""
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Upload un fichier pour la messagerie",
|
||||
manual_parameters=[
|
||||
openapi.Parameter('file', openapi.IN_FORM, description="Fichier à uploader", type=openapi.TYPE_FILE, required=True),
|
||||
openapi.Parameter('conversation_id', openapi.IN_FORM, description="ID de la conversation", type=openapi.TYPE_INTEGER, required=True),
|
||||
openapi.Parameter('sender_id', openapi.IN_FORM, description="ID de l'expéditeur", type=openapi.TYPE_INTEGER, required=True),
|
||||
],
|
||||
responses={
|
||||
200: openapi.Response('Success', openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'fileUrl': openapi.Schema(type=openapi.TYPE_STRING),
|
||||
'fileName': openapi.Schema(type=openapi.TYPE_STRING),
|
||||
'fileSize': openapi.Schema(type=openapi.TYPE_INTEGER),
|
||||
'fileType': openapi.Schema(type=openapi.TYPE_STRING),
|
||||
}
|
||||
)),
|
||||
400: 'Bad Request',
|
||||
413: 'File too large',
|
||||
415: 'Unsupported file type'
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
try:
|
||||
file = request.FILES.get('file')
|
||||
conversation_id = request.data.get('conversation_id')
|
||||
sender_id = request.data.get('sender_id')
|
||||
|
||||
if not file:
|
||||
return Response({'error': 'Aucun fichier fourni'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not conversation_id or not sender_id:
|
||||
return Response({'error': 'conversation_id et sender_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Vérifier que la conversation existe et que l'utilisateur y participe
|
||||
try:
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
sender = Profile.objects.get(id=sender_id)
|
||||
|
||||
# Vérifier que l'expéditeur participe à la conversation
|
||||
if not ConversationParticipant.objects.filter(
|
||||
conversation=conversation,
|
||||
participant=sender,
|
||||
is_active=True
|
||||
).exists():
|
||||
return Response({'error': 'Accès non autorisé à cette conversation'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
except (Conversation.DoesNotExist, Profile.DoesNotExist):
|
||||
return Response({'error': 'Conversation ou utilisateur introuvable'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Valider le type de fichier
|
||||
allowed_types = [
|
||||
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain'
|
||||
]
|
||||
|
||||
if file.content_type not in allowed_types:
|
||||
return Response({'error': 'Type de fichier non autorisé'}, status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE)
|
||||
|
||||
# Valider la taille du fichier (10MB max)
|
||||
max_size = 10 * 1024 * 1024 # 10MB
|
||||
if file.size > max_size:
|
||||
return Response({'error': 'Fichier trop volumineux (max 10MB)'}, status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
|
||||
|
||||
# Générer un nom de fichier unique
|
||||
file_extension = os.path.splitext(file.name)[1]
|
||||
unique_filename = f"{uuid.uuid4()}{file_extension}"
|
||||
|
||||
# Chemin de stockage : messagerie/conversation_id/
|
||||
storage_path = f"messagerie/{conversation_id}/{unique_filename}"
|
||||
|
||||
# Sauvegarder le fichier
|
||||
file_path = default_storage.save(storage_path, ContentFile(file.read()))
|
||||
|
||||
# Générer l'URL du fichier
|
||||
file_url = default_storage.url(file_path)
|
||||
if not file_url.startswith('http'):
|
||||
# Construire l'URL complète si nécessaire
|
||||
file_url = request.build_absolute_uri(file_url)
|
||||
|
||||
return Response({
|
||||
'fileUrl': file_url,
|
||||
'fileName': file.name,
|
||||
'fileSize': file.size,
|
||||
'fileType': file.content_type,
|
||||
'filePath': file_path
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': f'Erreur lors de l\'upload: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantRecipientSearchView(APIView):
|
||||
"""
|
||||
API pour rechercher des destinataires pour la messagerie instantanée
|
||||
"""
|
||||
@swagger_auto_schema(
|
||||
operation_description="Recherche des destinataires pour la messagerie instantanée",
|
||||
manual_parameters=[
|
||||
openapi.Parameter('establishment_id', openapi.IN_QUERY, description="ID de l'établissement", type=openapi.TYPE_INTEGER, required=True),
|
||||
openapi.Parameter('q', openapi.IN_QUERY, description="Terme de recherche", type=openapi.TYPE_STRING, required=True)
|
||||
],
|
||||
responses={200: ProfileSimpleSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
try:
|
||||
establishment_id = request.query_params.get('establishment_id')
|
||||
search_query = request.query_params.get('q', '').strip()
|
||||
|
||||
if not establishment_id:
|
||||
return Response({'error': 'establishment_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Récupérer les IDs des profils actifs dans l'établissement
|
||||
profile_roles = ProfileRole.objects.filter(
|
||||
establishment_id=establishment_id,
|
||||
is_active=True
|
||||
).values_list('profile_id', flat=True)
|
||||
|
||||
# Rechercher les profils correspondants
|
||||
users = Profile.objects.filter(id__in=profile_roles)
|
||||
|
||||
# Appliquer le filtre de recherche si un terme est fourni
|
||||
if search_query:
|
||||
users = users.filter(
|
||||
Q(first_name__icontains=search_query) |
|
||||
Q(last_name__icontains=search_query) |
|
||||
Q(email__icontains=search_query)
|
||||
)
|
||||
|
||||
# Exclure l'utilisateur actuel des résultats
|
||||
if request.user.is_authenticated:
|
||||
users = users.exclude(id=request.user.id)
|
||||
|
||||
serializer = ProfileSimpleSerializer(users[:10], many=True) # Limiter à 10 résultats
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantConversationDeleteView(APIView):
|
||||
"""
|
||||
API pour supprimer (désactiver) une conversation instantanée
|
||||
"""
|
||||
@swagger_auto_schema(
|
||||
operation_description="Supprime une conversation instantanée (désactivation soft)",
|
||||
responses={200: openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
|
||||
'message': openapi.Schema(type=openapi.TYPE_STRING)
|
||||
}
|
||||
)}
|
||||
)
|
||||
def delete(self, request, conversation_id):
|
||||
try:
|
||||
# Récupérer la conversation par son ID UUID
|
||||
conversation = Conversation.objects.filter(id=conversation_id).first()
|
||||
|
||||
if not conversation:
|
||||
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Suppression simple : désactiver la conversation
|
||||
conversation.is_active = False
|
||||
conversation.save()
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Conversation deleted successfully'
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting conversation: {str(e)}")
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
28
Back-End/GestionNotification/migrations/0001_initial.py
Normal file
28
Back-End/GestionNotification/migrations/0001_initial.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('message', models.CharField(max_length=255)),
|
||||
('is_read', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('typeNotification', models.IntegerField(choices=[(0, 'Aucune notification'), (1, 'Un message a été reçu'), (2, "Le dossier d'inscription a été mis à jour")], default=0)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
Back-End/GestionNotification/migrations/__init__.py
Normal file
0
Back-End/GestionNotification/migrations/__init__.py
Normal file
@ -1,8 +0,0 @@
|
||||
{
|
||||
"hostSMTP": "",
|
||||
"portSMTP": 25,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"useSSL": false,
|
||||
"useTLS": false
|
||||
}
|
||||
@ -8,9 +8,40 @@ https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from channels.security.websocket import AllowedHostsOriginValidator
|
||||
from django.urls import re_path
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'N3wtSchool.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
# Initialize Django ASGI application early to ensure the AppRegistry
|
||||
# is populated before importing code that may import ORM models.
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
# Import consumers after Django is initialized
|
||||
from GestionMessagerie.consumers import ChatConsumer
|
||||
from GestionMessagerie.middleware import JWTAuthMiddlewareStack
|
||||
|
||||
# WebSocket URL patterns
|
||||
websocket_urlpatterns = [
|
||||
re_path(r'ws/chat/(?P<user_id>\w+)/$', ChatConsumer.as_asgi()),
|
||||
]
|
||||
|
||||
# Créer l'application ASGI avec gestion des fichiers statiques
|
||||
if settings.DEBUG:
|
||||
# En mode DEBUG, utiliser ASGIStaticFilesHandler pour servir les fichiers statiques
|
||||
http_application = ASGIStaticFilesHandler(django_asgi_app)
|
||||
else:
|
||||
http_application = django_asgi_app
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": http_application,
|
||||
"websocket": AllowedHostsOriginValidator(
|
||||
JWTAuthMiddlewareStack(
|
||||
URLRouter(websocket_urlpatterns)
|
||||
)
|
||||
),
|
||||
})
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from typing import Final
|
||||
from N3wtSchool import settings
|
||||
|
||||
WRONG_ID: Final = 1
|
||||
INCOMPLETE: Final = 2
|
||||
@ -8,11 +9,14 @@ DIFFERENT_PASWWORD: Final = 5
|
||||
PROFIL_NOT_EXISTS: Final = 6
|
||||
MESSAGE_REINIT_PASSWORD: Final = 7
|
||||
EXPIRED_URL: Final = 8
|
||||
PASSWORD_CHANGED: Final = 8
|
||||
WRONG_MAIL_FORMAT: Final = 9
|
||||
PROFIL_INACTIVE: Final = 10
|
||||
MESSAGE_ACTIVATION_PROFILE: Final = 11
|
||||
PROFIL_ACTIVE: Final = 12
|
||||
PASSWORD_CHANGED: Final = 9
|
||||
WRONG_MAIL_FORMAT: Final = 10
|
||||
PROFIL_INACTIVE: Final = 11
|
||||
MESSAGE_ACTIVATION_PROFILE: Final = 12
|
||||
PROFIL_ACTIVE: Final = 13
|
||||
|
||||
def get_expired_url_message():
|
||||
return f"L'URL a expiré. Effectuer à nouveau la demande de réinitialisation de mot de passe : {settings.BASE_URL}/password/new"
|
||||
|
||||
returnMessage = {
|
||||
WRONG_ID:'Identifiants invalides',
|
||||
@ -22,7 +26,7 @@ returnMessage = {
|
||||
DIFFERENT_PASWWORD: 'Les mots de passe ne correspondent pas',
|
||||
PROFIL_NOT_EXISTS: 'Aucun profil associé à cet utilisateur',
|
||||
MESSAGE_REINIT_PASSWORD: 'Un mail a été envoyé à l\'adresse \'%s\'',
|
||||
EXPIRED_URL:'L\'URL a expiré. Effectuer à nouveau la demande de réinitialisation de mot de passe : http://localhost:3000/password/reset?uuid=%s',
|
||||
EXPIRED_URL: get_expired_url_message(),
|
||||
PASSWORD_CHANGED: 'Le mot de passe a été réinitialisé',
|
||||
WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée',
|
||||
PROFIL_INACTIVE: 'Le profil n\'est pas actif',
|
||||
|
||||
@ -6,40 +6,65 @@ import re
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from Settings.models import MailSettings
|
||||
from Settings.models import SMTPSettings
|
||||
from Establishment.models import Establishment # Importer le modèle Establishment
|
||||
import logging
|
||||
|
||||
# Ajouter un logger pour debug
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def getConnection(id_establishement):
|
||||
try:
|
||||
# Récupérer l'instance de l'établissement
|
||||
establishment = Establishment.objects.get(id=id_establishement)
|
||||
logger.info(f"Establishment trouvé: {establishment.name} (ID: {id_establishement})")
|
||||
|
||||
# Récupérer les paramètres SMTP associés à l'établissement
|
||||
smtp_settings = MailSettings.objects.get(establishment=establishment)
|
||||
|
||||
# Créer une connexion SMTP avec les paramètres récupérés
|
||||
connection = get_connection(
|
||||
host=smtp_settings.smtp_server,
|
||||
port=smtp_settings.smtp_port,
|
||||
username=smtp_settings.mail_user,
|
||||
password=smtp_settings.mail_password,
|
||||
use_tls=smtp_settings.use_tls,
|
||||
use_ssl=smtp_settings.use_ssl
|
||||
)
|
||||
return connection
|
||||
try:
|
||||
# Récupérer les paramètres SMTP associés à l'établissement
|
||||
smtp_settings = SMTPSettings.objects.get(establishment=establishment)
|
||||
logger.info(f"Paramètres SMTP trouvés pour {establishment.name}: {smtp_settings.smtp_server}:{smtp_settings.smtp_port}")
|
||||
|
||||
# Créer une connexion SMTP avec les paramètres récupérés
|
||||
connection = get_connection(
|
||||
host=smtp_settings.smtp_server,
|
||||
port=smtp_settings.smtp_port,
|
||||
username=smtp_settings.smtp_user,
|
||||
password=smtp_settings.smtp_password,
|
||||
use_tls=smtp_settings.use_tls,
|
||||
use_ssl=smtp_settings.use_ssl
|
||||
)
|
||||
return connection
|
||||
except SMTPSettings.DoesNotExist:
|
||||
logger.warning(f"Aucun paramètre SMTP spécifique trouvé pour l'établissement {establishment.name} (ID: {id_establishement})")
|
||||
# Aucun paramètre SMTP spécifique, retournera None
|
||||
return None
|
||||
except Establishment.DoesNotExist:
|
||||
logger.error(f"Aucun établissement trouvé avec l'ID {id_establishement}")
|
||||
raise NotFound(f"Aucun établissement trouvé avec l'ID {id_establishement}")
|
||||
except MailSettings.DoesNotExist:
|
||||
raise NotFound(f"Aucun paramètre SMTP trouvé pour l'établissement {id_establishement}")
|
||||
|
||||
|
||||
def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connection=None):
|
||||
try:
|
||||
# S'assurer que recipients, cc, bcc sont des listes
|
||||
if isinstance(recipients, str):
|
||||
recipients = [recipients]
|
||||
if isinstance(cc, str):
|
||||
cc = [cc]
|
||||
if isinstance(bcc, str):
|
||||
bcc = [bcc]
|
||||
|
||||
# Récupération robuste du username
|
||||
username = getattr(connection, 'username', None)
|
||||
|
||||
plain_message = strip_tags(message)
|
||||
from_email = settings.EMAIL_HOST_USER
|
||||
if connection is not None:
|
||||
from_email = connection.username
|
||||
from_email = username
|
||||
logger.info(f"Utilisation de la connexion SMTP spécifique: {username}")
|
||||
else:
|
||||
from_email = settings.EMAIL_HOST_USER
|
||||
logger.info(f"Utilisation de la configuration SMTP par défaut: {from_email}")
|
||||
|
||||
logger.info(f"From email: {from_email}")
|
||||
logger.info(f"Configuration par défaut - Host: {settings.EMAIL_HOST}, Port: {settings.EMAIL_PORT}, Use TLS: {settings.EMAIL_USE_TLS}")
|
||||
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
@ -52,15 +77,20 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
|
||||
)
|
||||
email.attach_alternative(message, "text/html")
|
||||
|
||||
# Ajout des pièces jointes
|
||||
for attachment in attachments:
|
||||
# attachment doit être un tuple (filename, content, mimetype)
|
||||
# ex: ("document.pdf", fichier.read(), "application/pdf")
|
||||
email.attach(*attachment)
|
||||
|
||||
logger.info("Tentative d'envoi de l'email...")
|
||||
email.send(fail_silently=False)
|
||||
logger.info("Email envoyé avec succès !")
|
||||
return Response({'message': 'Email envoyé avec succès.'}, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'envoi de l'email: {str(e)}")
|
||||
logger.error(f"Settings : {connection}")
|
||||
logger.error(f"Settings : {connection}")
|
||||
logger.error(f"Type d'erreur: {type(e)}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
def envoieReinitMotDePasse(recipients, code):
|
||||
@ -73,7 +103,28 @@ def envoieReinitMotDePasse(recipients, code):
|
||||
}
|
||||
subject = EMAIL_REINIT_SUBJECT
|
||||
html_message = render_to_string('emails/resetPassword.html', context)
|
||||
sendMail(subject, html_message, recipients)
|
||||
sendMail(subject=subject, message=html_message, recipients=recipients)
|
||||
|
||||
except Exception as e:
|
||||
errorMessage = str(e)
|
||||
|
||||
return errorMessage
|
||||
|
||||
def sendRegistrationDirector(recipients, establishment_id):
|
||||
errorMessage = ''
|
||||
try:
|
||||
# Préparation du contexte pour le template
|
||||
EMAIL_INSCRIPTION_SUBJECT = '[N3WT-SCHOOL] Bienvenue dans la communauté !'
|
||||
context = {
|
||||
'BASE_URL': settings.BASE_URL,
|
||||
'URL_DJANGO': settings.URL_DJANGO,
|
||||
'email': recipients,
|
||||
'establishment': establishment_id
|
||||
}
|
||||
subject = EMAIL_INSCRIPTION_SUBJECT
|
||||
html_message = render_to_string('emails/subscribeDirector.html', context)
|
||||
sendMail(subject=subject, message=html_message, recipients=recipients)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
errorMessage = str(e)
|
||||
@ -91,10 +142,11 @@ def sendRegisterForm(recipients, establishment_id):
|
||||
'email': recipients,
|
||||
'establishment': establishment_id
|
||||
}
|
||||
|
||||
# Récupérer la connexion SMTP
|
||||
connection = getConnection(establishment_id)
|
||||
subject = EMAIL_INSCRIPTION_SUBJECT
|
||||
html_message = render_to_string('emails/inscription.html', context)
|
||||
sendMail(subject, html_message, recipients)
|
||||
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
@ -113,9 +165,11 @@ def sendMandatSEPA(recipients, establishment_id):
|
||||
'establishment': establishment_id
|
||||
}
|
||||
|
||||
# Récupérer la connexion SMTP
|
||||
connection = getConnection(establishment_id)
|
||||
subject = EMAIL_INSCRIPTION_SUBJECT
|
||||
html_message = render_to_string('emails/sepa.html', context)
|
||||
sendMail(subject, html_message, recipients)
|
||||
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
|
||||
|
||||
except Exception as e:
|
||||
errorMessage = str(e)
|
||||
@ -153,25 +207,4 @@ def isValid(message, fiche_inscription):
|
||||
responsable = eleve.getMainGuardian()
|
||||
mailReponsableAVerifier = responsable.mail
|
||||
|
||||
return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id)
|
||||
|
||||
def test_mailbox_uri(uri):
|
||||
"""
|
||||
Teste la validité d'une URI IMAP en tentant une connexion réelle.
|
||||
Retourne True si la connexion réussit, False sinon.
|
||||
"""
|
||||
from django_mailbox.models import Mailbox
|
||||
try:
|
||||
mailbox = Mailbox(uri=uri)
|
||||
conn = mailbox.get_connection()
|
||||
# Essaye de récupérer un message (ou juste la connexion)
|
||||
# Pour IMAP, get_message() va ouvrir et fermer la connexion
|
||||
try:
|
||||
next(conn.get_message())
|
||||
except StopIteration:
|
||||
# Aucun message, mais connexion OK
|
||||
pass
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Erreur de connexion IMAP : {e}")
|
||||
return False
|
||||
return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id)
|
||||
@ -1,8 +1,11 @@
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class ContentSecurityPolicyMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
response['Content-Security-Policy'] = "frame-ancestors 'self' http://localhost:3000"
|
||||
response['Content-Security-Policy'] = f"frame-ancestors 'self' {settings.BASE_URL}"
|
||||
return response
|
||||
|
||||
@ -24,18 +24,16 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
MEDIA_URL = '/data/'
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'data')
|
||||
|
||||
BASE_URL = os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000')
|
||||
BASE_URL = os.getenv('BASE_URL', 'http://localhost:3000')
|
||||
|
||||
|
||||
LOGIN_REDIRECT_URL = '/Subscriptions/registerForms'
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'django-insecure-afjm6kvigncxzx6jjjf(qb0n(*qvi#je79r=gqflcn007d_ve9'
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
DEBUG = os.getenv('DJANGO_DEBUG', True)
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
@ -46,6 +44,7 @@ INSTALLED_APPS = [
|
||||
'Subscriptions.apps.GestioninscriptionsConfig',
|
||||
'Auth.apps.GestionloginConfig',
|
||||
'GestionMessagerie.apps.GestionMessagerieConfig',
|
||||
'GestionEmail.apps.GestionEmailConfig',
|
||||
'GestionNotification.apps.GestionNotificationConfig',
|
||||
'School.apps.SchoolConfig',
|
||||
'Planning.apps.PlanningConfig',
|
||||
@ -63,14 +62,14 @@ INSTALLED_APPS = [
|
||||
'N3wtSchool',
|
||||
'drf_yasg',
|
||||
'rest_framework_simplejwt',
|
||||
'django_mailbox',
|
||||
'channels',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware', # Déplacez ici, avant CorsMiddleware
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
@ -162,21 +161,16 @@ LOGGING = {
|
||||
"level": os.getenv("GESTION_MESSAGERIE_LOG_LEVEL", "INFO"),
|
||||
"propagate": False,
|
||||
},
|
||||
"GestionEmail": {
|
||||
"handlers": ["console"],
|
||||
"level": os.getenv("GESTION_EMAIL_LOG_LEVEL", "INFO"),
|
||||
"propagate": False,
|
||||
},
|
||||
"School": {
|
||||
"handlers": ["console"],
|
||||
"level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"),
|
||||
"propagate": False,
|
||||
},
|
||||
"django_mailbox": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
"django_mailbox.models": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -215,8 +209,6 @@ USE_I18N = True
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
||||
|
||||
DEBUG = True
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
@ -236,62 +228,70 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
########################################################################
|
||||
|
||||
|
||||
|
||||
DJANGO_SUPERUSER_PASSWORD='admin'
|
||||
DJANGO_SUPERUSER_USERNAME='admin'
|
||||
DJANGO_SUPERUSER_EMAIL='admin@n3wtschool.com'
|
||||
# Configuration de l'email de l'application
|
||||
smtp_config_file = 'N3wtSchool/Configuration/application.json'
|
||||
|
||||
if os.path.exists(smtp_config_file):
|
||||
try:
|
||||
with open(smtp_config_file, 'r') as f:
|
||||
smtpSettings = json.load(f)
|
||||
EMAIL_HOST = smtpSettings.get('hostSMTP', '')
|
||||
EMAIL_PORT = smtpSettings.get('portSMTP', 587)
|
||||
EMAIL_HOST_USER = smtpSettings.get('username', '')
|
||||
EMAIL_HOST_PASSWORD = smtpSettings.get('password', '')
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
EMAIL_USE_TLS = smtpSettings.get('useTLS', True)
|
||||
EMAIL_USE_SSL = smtpSettings.get('useSSL', False)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la lecture du fichier de configuration SMTP : {e}")
|
||||
else:
|
||||
logger.error(f"Fichier de configuration SMTP introuvable : {smtp_config_file}")
|
||||
EMAIL_HOST = os.getenv('EMAIL_HOST', 'smtp.example.com')
|
||||
EMAIL_PORT = os.getenv('EMAIL_PORT', 587)
|
||||
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
|
||||
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'true').lower() == 'true'
|
||||
EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'false').lower() == 'true'
|
||||
|
||||
DOCUMENT_DIR = 'documents'
|
||||
|
||||
CORS_ORIGIN_ALLOW_ALL = True
|
||||
CORS_ALLOW_ALL_HEADERS = True
|
||||
# Configuration CORS temporaire pour debug
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
# Configuration CORS spécifique pour la production
|
||||
CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000,http://localhost:8080,http://127.0.0.1:3000,http://127.0.0.1:8080').split(',')
|
||||
|
||||
|
||||
CORS_ALLOW_HEADERS = [
|
||||
'content-type',
|
||||
'accept',
|
||||
'accept-encoding',
|
||||
'authorization',
|
||||
'content-type',
|
||||
'dnt',
|
||||
'origin',
|
||||
'user-agent',
|
||||
'x-csrftoken',
|
||||
'x-requested-with',
|
||||
'X-Auth-Token',
|
||||
'x-csrftoken'
|
||||
]
|
||||
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000')
|
||||
# Méthodes HTTP autorisées
|
||||
CORS_ALLOWED_METHODS = [
|
||||
'DELETE',
|
||||
'GET',
|
||||
'OPTIONS',
|
||||
'PATCH',
|
||||
'POST',
|
||||
'PUT',
|
||||
]
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:3000,http://localhost:8080').split(',')
|
||||
|
||||
CSRF_COOKIE_HTTPONLY = False
|
||||
CSRF_COOKIE_SECURE = False
|
||||
CSRF_COOKIE_SECURE = os.getenv('CSRF_COOKIE_SECURE', 'false').lower() == 'true'
|
||||
CSRF_COOKIE_NAME = 'csrftoken'
|
||||
CSRF_COOKIE_DOMAIN = os.getenv('CSRF_COOKIE_DOMAIN', '')
|
||||
|
||||
USE_TZ = True
|
||||
TZ_APPLI = 'Europe/Paris'
|
||||
|
||||
DB_NAME = os.getenv('DB_NAME', 'school')
|
||||
DB_USER = os.getenv('DB_USER', 'postgres')
|
||||
DB_PASSWORD = os.getenv('DB_PASSWORD', 'postgres')
|
||||
DB_HOST = os.getenv('DB_HOST', 'database')
|
||||
DB_PORT = os.getenv('DB_PORT', '5432')
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
"NAME": "school",
|
||||
"USER": "postgres",
|
||||
"PASSWORD": "postgres",
|
||||
"HOST": "database",
|
||||
"PORT": "5432",
|
||||
"NAME": DB_NAME,
|
||||
"USER": DB_USER,
|
||||
"PASSWORD": DB_PASSWORD,
|
||||
"HOST": DB_HOST,
|
||||
"PORT": DB_PORT,
|
||||
}
|
||||
}
|
||||
|
||||
@ -314,6 +314,7 @@ REST_FRAMEWORK = {
|
||||
'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE,
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
),
|
||||
}
|
||||
|
||||
@ -325,14 +326,14 @@ CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_TIMEZONE = 'Europe/Paris'
|
||||
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
|
||||
|
||||
URL_DJANGO = 'http://localhost:8080/'
|
||||
URL_DJANGO = os.getenv('URL_DJANGO', 'http://localhost:8080/')
|
||||
|
||||
REDIS_HOST = 'redis'
|
||||
REDIS_PORT = 6379
|
||||
REDIS_DB = 0
|
||||
REDIS_PASSWORD = None
|
||||
|
||||
SECRET_KEY = 'QWQ8bYlCz1NpQ9G0vR5kxMnvWszfH2y3'
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'QWQ8bYlCz1NpQ9G0vR5kxMnvWszfH2y3')
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||
@ -348,11 +349,14 @@ SIMPLE_JWT = {
|
||||
'TOKEN_TYPE_CLAIM': 'token_type',
|
||||
}
|
||||
|
||||
# Configuration for DocuSeal JWT
|
||||
DOCUSEAL_API_KEY="LRvUTQCbMSSpManYKshdQk9Do6rBQgjHyPrbGfxU3Jg"
|
||||
DOCUSEAL_JWT = {
|
||||
'ALGORITHM': 'HS256',
|
||||
'SIGNING_KEY': SECRET_KEY,
|
||||
'EXPIRATION_DELTA': timedelta(hours=1),
|
||||
'API_KEY': DOCUSEAL_API_KEY
|
||||
# Django Channels Configuration
|
||||
ASGI_APPLICATION = 'N3wtSchool.asgi.application'
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
'default': {
|
||||
'BACKEND': 'channels_redis.core.RedisChannelLayer',
|
||||
'CONFIG': {
|
||||
"hosts": [('redis', 6379)],
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -14,9 +14,8 @@ def setup_periodic_tasks(sender, **kwargs):
|
||||
|
||||
# Déclarer la tâche périodique
|
||||
PeriodicTask.objects.get_or_create(
|
||||
interval=schedule,
|
||||
name='getMail',
|
||||
task='N3wtSchool.tasks.run_getmail',
|
||||
kwargs=json.dumps({}),
|
||||
defaults={"enabled": False}
|
||||
interval=schedule, # Utiliser l'intervalle défini ci-dessus
|
||||
name='Tâche périodique toutes les 5 secondes',
|
||||
task='Subscriptions.tasks.check_for_signature_deadlines', # Remplacer par le nom de ta tâche
|
||||
kwargs=json.dumps({}) # Si nécessaire, ajoute
|
||||
)
|
||||
@ -1,33 +0,0 @@
|
||||
from celery import shared_task
|
||||
from django_mailbox.models import Message, Mailbox
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("django_mailbox")
|
||||
|
||||
@shared_task
|
||||
def run_getmail():
|
||||
"""
|
||||
Tâche périodique pour lancer l'import IMAP sur toutes les mailboxes actives
|
||||
et traiter les nouveaux messages interceptés selon des critères.
|
||||
"""
|
||||
logger.info("Début import IMAP")
|
||||
|
||||
for mailbox in Mailbox.objects.filter(active=True):
|
||||
messages = mailbox.get_new_mail()
|
||||
try:
|
||||
for message in messages:
|
||||
logger.info(f"[IMAP] Tentative d'import : {message.subject} de {message.from_address}")
|
||||
# Filtrage sur le sujet et l'émetteur
|
||||
if (
|
||||
message.subject == "n3wt"
|
||||
and message.from_address == "anthony.casini.30@gmail.com"
|
||||
):
|
||||
logger.info(f"[IMAP] Message importé : {message.subject} de {message.from_address}")
|
||||
else:
|
||||
# Optionnel : supprimer le message importé qui ne correspond pas
|
||||
message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'import des messages pour la mailbox {mailbox}: {e}", exc_info=True)
|
||||
|
||||
logger.info("Fin import IMAP")
|
||||
@ -43,9 +43,9 @@ urlpatterns = [
|
||||
path("Subscriptions/", include(("Subscriptions.urls", 'Subscriptions'), namespace='Subscriptions')),
|
||||
path("Auth/", include(("Auth.urls", 'Auth'), namespace='Auth')),
|
||||
path("GestionMessagerie/", include(("GestionMessagerie.urls", 'GestionMessagerie'), namespace='GestionMessagerie')),
|
||||
path("GestionEmail/", include(("GestionEmail.urls", 'GestionEmail'), namespace='GestionEmail')),
|
||||
path("GestionNotification/", include(("GestionNotification.urls", 'GestionNotification'), namespace='GestionNotification')),
|
||||
path("School/", include(("School.urls", 'School'), namespace='School')),
|
||||
path("DocuSeal/", include(("DocuSeal.urls", 'DocuSeal'), namespace='DocuSeal')),
|
||||
path("Planning/", include(("Planning.urls", 'Planning'), namespace='Planning')),
|
||||
path("Establishment/", include(("Establishment.urls", 'Establishment'), namespace='Establishment')),
|
||||
path("Settings/", include(("Settings.urls", 'Settings'), namespace='Settings')),
|
||||
|
||||
44
Back-End/Planning/migrations/0001_initial.py
Normal file
44
Back-End/Planning/migrations/0001_initial.py
Normal file
@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('Establishment', '0001_initial'),
|
||||
('School', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Planning',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True, default='', null=True)),
|
||||
('color', models.CharField(default='#000000', max_length=255)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Establishment.establishment')),
|
||||
('school_class', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='planning', to='School.schoolclass')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Events',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True, default='', null=True)),
|
||||
('start', models.DateTimeField()),
|
||||
('end', models.DateTimeField()),
|
||||
('recursionType', models.IntegerField(choices=[(0, 'Aucune'), (1, 'Quotidienne'), (2, 'Hebdomadaire'), (3, 'Mensuel'), (4, 'Personnalisé')], default=0)),
|
||||
('recursionEnd', models.DateTimeField(blank=True, default=None, null=True)),
|
||||
('color', models.CharField(max_length=255)),
|
||||
('location', models.CharField(blank=True, default='', max_length=255, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('planning', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Planning.planning')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
Back-End/Planning/migrations/__init__.py
Normal file
0
Back-End/Planning/migrations/__init__.py
Normal file
@ -147,7 +147,7 @@ class EventsWithIdView(APIView):
|
||||
return JsonResponse({'error': 'Event not found'}, status=404)
|
||||
|
||||
event.delete()
|
||||
return JsonResponse({'message': 'Event deleted'}, status=204)
|
||||
return JsonResponse({'message': 'Event deleted'}, status=200)
|
||||
|
||||
class UpcomingEventsView(APIView):
|
||||
def get(self, request):
|
||||
|
||||
145
Back-End/School/migrations/0001_initial.py
Normal file
145
Back-End/School/migrations/0001_initial.py
Normal file
@ -0,0 +1,145 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('Auth', '0001_initial'),
|
||||
('Common', '0001_initial'),
|
||||
('Establishment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Competency',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.TextField()),
|
||||
('end_of_cycle', models.BooleanField(blank=True, default=False, null=True)),
|
||||
('level', models.CharField(blank=True, max_length=50, null=True)),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competencies', to='Common.category')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Discount',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('discount_type', models.IntegerField(choices=[(0, 'Currency'), (1, 'Percent')], default=0)),
|
||||
('type', models.IntegerField(choices=[(0, 'Registration Fee'), (1, 'Tuition Fee')], default=0)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='discounts', to='Establishment.establishment')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EstablishmentCompetency',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('custom_name', models.TextField(blank=True, help_text='Nom de la compétence custom', null=True)),
|
||||
('is_required', models.BooleanField(default=True)),
|
||||
('competency', models.ForeignKey(blank=True, help_text='Compétence de référence (optionnelle si custom)', null=True, on_delete=django.db.models.deletion.CASCADE, to='School.competency')),
|
||||
('custom_category', models.ForeignKey(blank=True, help_text='Catégorie de la compétence custom', null=True, on_delete=django.db.models.deletion.CASCADE, to='Common.category')),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Establishment.establishment')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('establishment', 'competency', 'custom_name', 'custom_category')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='competency',
|
||||
name='establishments',
|
||||
field=models.ManyToManyField(blank=True, related_name='competencies', through='School.EstablishmentCompetency', to='Establishment.establishment'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Fee',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('base_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('type', models.IntegerField(choices=[(0, 'Registration Fee'), (1, 'Tuition Fee')], default=0)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fees', to='Establishment.establishment')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentMode',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('type', models.IntegerField(choices=[(0, 'Registration Fee'), (1, 'Tuition Fee')], default=0)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_modes', to='Establishment.establishment')),
|
||||
('mode', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_modes', to='Common.paymentmodetype')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentPlan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('due_dates', django.contrib.postgres.fields.ArrayField(base_field=models.DateField(), blank=True, null=True, size=None)),
|
||||
('type', models.IntegerField(choices=[(0, 'Registration Fee'), (1, 'Tuition Fee')], default=0)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_plans', to='Establishment.establishment')),
|
||||
('plan_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_plans', to='Common.paymentplantype')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SchoolClass',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('atmosphere_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('age_range', models.JSONField(blank=True, null=True)),
|
||||
('number_of_students', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('teaching_language', models.CharField(blank=True, max_length=255)),
|
||||
('school_year', models.CharField(blank=True, max_length=9)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('type', models.IntegerField(choices=[(1, 'Annuel'), (2, 'Semestriel'), (3, 'Trimestriel')], default=1)),
|
||||
('time_range', models.JSONField(default=list)),
|
||||
('opening_days', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, size=None)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='school_classes', to='Establishment.establishment')),
|
||||
('levels', models.ManyToManyField(blank=True, related_name='school_classes', to='Common.level')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Planning',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('level', models.IntegerField(blank=True, choices=[(1, 'Très Petite Section (TPS)'), (2, 'Petite Section (PS)'), (3, 'Moyenne Section (MS)'), (4, 'Grande Section (GS)'), (5, 'Cours Préparatoire (CP)'), (6, 'Cours Élémentaire 1 (CE1)'), (7, 'Cours Élémentaire 2 (CE2)'), (8, 'Cours Moyen 1 (CM1)'), (9, 'Cours Moyen 2 (CM2)')], null=True)),
|
||||
('schedule', models.JSONField(default=dict)),
|
||||
('school_class', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='plannings', to='School.schoolclass')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Speciality',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('color_code', models.CharField(default='#FFFFFF', max_length=7)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='specialities', to='Establishment.establishment')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Teacher',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('last_name', models.CharField(max_length=100)),
|
||||
('first_name', models.CharField(max_length=100)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('profile_role', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='teacher_profile', to='Auth.profilerole')),
|
||||
('specialities', models.ManyToManyField(blank=True, to='School.speciality')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='schoolclass',
|
||||
name='teachers',
|
||||
field=models.ManyToManyField(blank=True, to='School.teacher'),
|
||||
),
|
||||
]
|
||||
0
Back-End/School/migrations/__init__.py
Normal file
0
Back-End/School/migrations/__init__.py
Normal file
@ -33,6 +33,10 @@ from N3wtSchool.bdd import delete_object, getAllObjects, getObject
|
||||
from django.db.models import Q
|
||||
from collections import defaultdict
|
||||
from Subscriptions.models import Student, StudentCompetency
|
||||
from Subscriptions.util import getCurrentSchoolYear
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
@ -581,7 +585,6 @@ class EstablishmentCompetencyListCreateView(APIView):
|
||||
|
||||
try:
|
||||
category = Category.objects.get(id=category_id)
|
||||
# Vérifier si une compétence custom du même nom existe déjà pour cet établissement et cette catégorie
|
||||
ec_exists = EstablishmentCompetency.objects.filter(
|
||||
establishment_id=establishment_id,
|
||||
competency__isnull=True,
|
||||
@ -598,13 +601,32 @@ class EstablishmentCompetencyListCreateView(APIView):
|
||||
custom_category=category,
|
||||
is_required=False
|
||||
)
|
||||
# Associer à tous les élèves de l'établissement
|
||||
|
||||
# Récupérer l'établissement et sa fréquence d'évaluation
|
||||
establishment = ec.establishment
|
||||
evaluation_frequency = establishment.evaluation_frequency # 1=Trimestre, 2=Semestre, 3=Année
|
||||
|
||||
# Déterminer l'année scolaire courante
|
||||
school_year = getCurrentSchoolYear()
|
||||
|
||||
# Générer les périodes selon la fréquence
|
||||
periods = []
|
||||
if evaluation_frequency == 1: # Trimestre
|
||||
periods = [f"T{i+1}_{school_year}" for i in range(3)]
|
||||
elif evaluation_frequency == 2: # Semestre
|
||||
periods = [f"S{i+1}_{school_year}" for i in range(2)]
|
||||
elif evaluation_frequency == 3: # Année
|
||||
periods = [f"A_{school_year}"]
|
||||
|
||||
# Associer à tous les élèves de l'établissement pour chaque période
|
||||
students = Student.objects.filter(associated_class__establishment_id=establishment_id)
|
||||
for student in students:
|
||||
StudentCompetency.objects.get_or_create(
|
||||
student=student,
|
||||
establishment_competency=ec
|
||||
)
|
||||
for period in periods:
|
||||
StudentCompetency.objects.get_or_create(
|
||||
student=student,
|
||||
establishment_competency=ec,
|
||||
period=period
|
||||
)
|
||||
|
||||
created.append({
|
||||
"competence_id": ec.id,
|
||||
|
||||
29
Back-End/Settings/migrations/0001_initial.py
Normal file
29
Back-End/Settings/migrations/0001_initial.py
Normal file
@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('Establishment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SMTPSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('smtp_server', models.CharField(max_length=255)),
|
||||
('smtp_port', models.PositiveIntegerField()),
|
||||
('smtp_user', models.CharField(max_length=255)),
|
||||
('smtp_password', models.CharField(max_length=255)),
|
||||
('use_tls', models.BooleanField(default=True)),
|
||||
('use_ssl', models.BooleanField(default=False)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Establishment.establishment')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
Back-End/Settings/migrations/__init__.py
Normal file
0
Back-End/Settings/migrations/__init__.py
Normal file
@ -4,22 +4,14 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.conf import settings
|
||||
from Establishment.models import Establishment
|
||||
|
||||
class MailSettings(models.Model):
|
||||
class SMTPSettings(models.Model):
|
||||
establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE)
|
||||
|
||||
# Paramètres communs (si tu veux un seul user/pass pour SMTP et IMAP)
|
||||
mail_user = models.CharField(max_length=255)
|
||||
mail_password = models.CharField(max_length=255)
|
||||
|
||||
# SMTP
|
||||
smtp_server = models.CharField(max_length=255)
|
||||
smtp_port = models.PositiveIntegerField()
|
||||
smtp_user = models.CharField(max_length=255)
|
||||
smtp_password = models.CharField(max_length=255)
|
||||
use_tls = models.BooleanField(default=True)
|
||||
use_ssl = models.BooleanField(default=False)
|
||||
|
||||
# IMAP
|
||||
imap_server = models.CharField(max_length=255)
|
||||
imap_port = models.PositiveIntegerField(default=993)
|
||||
|
||||
def __str__(self):
|
||||
return f"MailSettings ({self.establishment} - {self.mail_user})"
|
||||
return f"SMTP Settings ({self.smtp_server}:{self.smtp_port})"
|
||||
@ -1,7 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
from .models import MailSettings
|
||||
from .models import SMTPSettings
|
||||
|
||||
class MailSettingsSerializer(serializers.ModelSerializer):
|
||||
class SMTPSettingsSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = MailSettings
|
||||
model = SMTPSettings
|
||||
fields = '__all__'
|
||||
@ -1,7 +1,6 @@
|
||||
from django.urls import path
|
||||
from .views import MailSettingsView, SyncImapView
|
||||
from .views import SMTPSettingsView
|
||||
|
||||
urlpatterns = [
|
||||
path('mail-settings/', MailSettingsView.as_view(), name='smtp_settings'),
|
||||
path('sync-imap/', SyncImapView.as_view(), name='sync-imap'),
|
||||
path('smtp-settings/', SMTPSettingsView.as_view(), name='smtp_settings'),
|
||||
]
|
||||
@ -1,16 +1,12 @@
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from .models import MailSettings
|
||||
from .serializers import MailSettingsSerializer
|
||||
from .models import SMTPSettings
|
||||
from .serializers import SMTPSettingsSerializer
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django_mailbox.models import Mailbox
|
||||
from Auth.models import Profile
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
import urllib.parse
|
||||
|
||||
class MailSettingsView(APIView):
|
||||
class SMTPSettingsView(APIView):
|
||||
"""
|
||||
API pour gérer les paramètres SMTP.
|
||||
"""
|
||||
@ -27,7 +23,7 @@ class MailSettingsView(APIView):
|
||||
)
|
||||
],
|
||||
responses={
|
||||
200: MailSettingsSerializer(many=True),
|
||||
200: SMTPSettingsSerializer(many=True),
|
||||
404: openapi.Response(description="Aucun paramètre SMTP trouvé."),
|
||||
500: openapi.Response(description="Erreur interne du serveur."),
|
||||
},
|
||||
@ -38,48 +34,32 @@ class MailSettingsView(APIView):
|
||||
try:
|
||||
if establishment_id:
|
||||
# Récupérer les paramètres SMTP pour un établissement spécifique
|
||||
mail_settings = MailSettings.objects.filter(establishment_id=establishment_id).first()
|
||||
if not mail_settings:
|
||||
smtp_settings = SMTPSettings.objects.filter(establishment_id=establishment_id).first()
|
||||
if not smtp_settings:
|
||||
return Response(
|
||||
{'error': f"Aucun paramètre SMTP trouvé pour l'établissement {establishment_id}."},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
serializer = MailSettingsSerializer(mail_settings)
|
||||
serializer = SMTPSettingsSerializer(smtp_settings)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
# Récupérer tous les paramètres SMTP
|
||||
mail_settings = MailSettings.objects.all()
|
||||
if not mail_settings.exists():
|
||||
smtp_settings = SMTPSettings.objects.all()
|
||||
if not smtp_settings.exists():
|
||||
return Response(
|
||||
{'error': "Aucun paramètre SMTP trouvé."},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
serializer = MailSettingsSerializer(mail_settings, many=True)
|
||||
# ...dans une vue ou un serializer...
|
||||
from N3wtSchool.mailManager import test_mailbox_uri
|
||||
import urllib.parse
|
||||
|
||||
imap_user = "anthony.audrey.34@gmail.com"
|
||||
imap_password = "cztn wyme odjt lbjt"
|
||||
imap_server = "imap.gmail.com"
|
||||
imap_port = 993
|
||||
|
||||
encoded_user = urllib.parse.quote(imap_user)
|
||||
encoded_password = urllib.parse.quote(imap_password)
|
||||
uri = f"imap+ssl://{encoded_user}:{encoded_password}@{imap_server}:{imap_port}"
|
||||
if not test_mailbox_uri(uri):
|
||||
print(f'uri : {uri}')
|
||||
return Response({'error': "Connexion IMAP impossible. Vérifiez les paramètres."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# Ensuite, tu peux créer la Mailbox si la connexion est OK
|
||||
serializer = SMTPSettingsSerializer(smtp_settings, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Créer ou mettre à jour les paramètres SMTP pour un établissement spécifique",
|
||||
request_body=MailSettingsSerializer,
|
||||
request_body=SMTPSettingsSerializer,
|
||||
responses={
|
||||
200: MailSettingsSerializer(),
|
||||
200: SMTPSettingsSerializer(),
|
||||
400: openapi.Response(description="Données invalides."),
|
||||
500: openapi.Response(description="Erreur interne du serveur."),
|
||||
},
|
||||
@ -87,58 +67,15 @@ class MailSettingsView(APIView):
|
||||
def post(self, request):
|
||||
data = request.data
|
||||
try:
|
||||
mail_settings = MailSettings.objects.first()
|
||||
if mail_settings:
|
||||
serializer = MailSettingsSerializer(mail_settings, data=data)
|
||||
smtp_settings = SMTPSettings.objects.first()
|
||||
if smtp_settings:
|
||||
serializer = SMTPSettingsSerializer(smtp_settings, data=data)
|
||||
else:
|
||||
serializer = MailSettingsSerializer(data=data)
|
||||
serializer = SMTPSettingsSerializer(data=data)
|
||||
|
||||
if serializer.is_valid():
|
||||
mail_settings_instance = serializer.save()
|
||||
|
||||
# Création de la mailbox pour le profil si profil_id fourni
|
||||
profile_id = data.get('profile_id')
|
||||
if profile_id:
|
||||
try:
|
||||
profile = Profile.objects.get(id=profile_id)
|
||||
email = mail_settings_instance.mail_user
|
||||
imap_server = mail_settings_instance.imap_server
|
||||
imap_port = mail_settings_instance.imap_port
|
||||
imap_user = mail_settings_instance.mail_user
|
||||
imap_password = mail_settings_instance.mail_password
|
||||
|
||||
# Encodage du username et du mot de passe pour l'URI IMAP
|
||||
encoded_user = urllib.parse.quote(imap_user)
|
||||
encoded_password = urllib.parse.quote(imap_password)
|
||||
uri = f"imap+ssl://{encoded_user}:{encoded_password}@{imap_server}:{imap_port}"
|
||||
|
||||
mailbox, created = Mailbox.objects.get_or_create(
|
||||
name=email,
|
||||
defaults={
|
||||
"uri": uri,
|
||||
"from_email": email,
|
||||
"active": True,
|
||||
}
|
||||
)
|
||||
# Associer la mailbox au profil si champ prévu
|
||||
if hasattr(profile, "mailbox"):
|
||||
profile.mailbox = mailbox
|
||||
profile.save()
|
||||
except Profile.DoesNotExist:
|
||||
pass # Profil non trouvé, on ignore
|
||||
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class SyncImapView(APIView):
|
||||
def post(self, request):
|
||||
sync_imap = request.data.get("sync_imap", False)
|
||||
try:
|
||||
task = PeriodicTask.objects.get(name='getMail')
|
||||
task.enabled = bool(sync_imap)
|
||||
task.save()
|
||||
return Response({"success": True, "enabled": task.enabled})
|
||||
except PeriodicTask.DoesNotExist:
|
||||
return Response({"error": "Tâche non trouvée."}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
@ -1,18 +0,0 @@
|
||||
{
|
||||
"activationMailRelance": "Oui",
|
||||
"delaiRelance": "30",
|
||||
"ambiances": [
|
||||
"2-3 ans",
|
||||
"3-6 ans",
|
||||
"6-12 ans"
|
||||
],
|
||||
"genres": [
|
||||
"Fille",
|
||||
"Garçon"
|
||||
],
|
||||
"modesPaiement": [
|
||||
"Chèque",
|
||||
"Virement",
|
||||
"Prélèvement SEPA"
|
||||
]
|
||||
}
|
||||
0
Back-End/Subscriptions/management/__init__.py
Normal file
0
Back-End/Subscriptions/management/__init__.py
Normal file
43
Back-End/Subscriptions/management/commands/test_email.py
Normal file
43
Back-End/Subscriptions/management/commands/test_email.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""
|
||||
Management command pour tester la configuration email Django
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from N3wtSchool.mailManager import getConnection, sendMail
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Test de la configuration email'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--establishment-id', type=int, help='ID de l\'établissement pour test')
|
||||
parser.add_argument('--email', type=str, default='test@example.com', help='Email de destination')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write("=== Test de configuration email ===")
|
||||
|
||||
# Affichage de la configuration
|
||||
self.stdout.write(f"EMAIL_HOST: {settings.EMAIL_HOST}")
|
||||
self.stdout.write(f"EMAIL_PORT: {settings.EMAIL_PORT}")
|
||||
self.stdout.write(f"EMAIL_HOST_USER: {settings.EMAIL_HOST_USER}")
|
||||
self.stdout.write(f"EMAIL_HOST_PASSWORD: {settings.EMAIL_HOST_PASSWORD}")
|
||||
self.stdout.write(f"EMAIL_USE_TLS: {settings.EMAIL_USE_TLS}")
|
||||
self.stdout.write(f"EMAIL_USE_SSL: {settings.EMAIL_USE_SSL}")
|
||||
self.stdout.write(f"EMAIL_BACKEND: {settings.EMAIL_BACKEND}")
|
||||
|
||||
# Test 1: Configuration par défaut Django
|
||||
self.stdout.write("\n--- Test : Configuration EMAIL par défaut ---")
|
||||
try:
|
||||
result = send_mail(
|
||||
'Test Django Email',
|
||||
'Ceci est un test de la configuration email par défaut.',
|
||||
settings.EMAIL_HOST_USER,
|
||||
[options['email']],
|
||||
fail_silently=False,
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"✅ Email envoyé avec succès (résultat: {result})"))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"❌ Erreur: {e}"))
|
||||
215
Back-End/Subscriptions/migrations/0001_initial.py
Normal file
215
Back-End/Subscriptions/migrations/0001_initial.py
Normal file
@ -0,0 +1,215 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import Subscriptions.models
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('Auth', '__first__'),
|
||||
('Common', '0001_initial'),
|
||||
('Establishment', '0001_initial'),
|
||||
('School', '__first__'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Language',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('label', models.CharField(default='', max_length=200)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Student',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('photo', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_photo_upload_to)),
|
||||
('last_name', models.CharField(default='', max_length=200)),
|
||||
('first_name', models.CharField(default='', max_length=200)),
|
||||
('gender', models.IntegerField(blank=True, choices=[(0, 'Sélection du genre'), (1, 'Garçon'), (2, 'Fille')], default=0)),
|
||||
('nationality', models.CharField(blank=True, default='', max_length=200)),
|
||||
('address', models.CharField(blank=True, default='', max_length=200)),
|
||||
('birth_date', models.DateField(blank=True, null=True)),
|
||||
('birth_place', models.CharField(blank=True, default='', max_length=200)),
|
||||
('birth_postal_code', models.IntegerField(blank=True, default=0)),
|
||||
('attending_physician', models.CharField(blank=True, default='', max_length=200)),
|
||||
('associated_class', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='School.schoolclass')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RegistrationSchoolFileTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.CharField(default='', max_length=255)),
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_school_file_upload_to)),
|
||||
('formTemplateData', models.JSONField(blank=True, default=list, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Sibling',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('last_name', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('first_name', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('birth_date', models.DateField(blank=True, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Guardian',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('last_name', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('first_name', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('birth_date', models.DateField(blank=True, null=True)),
|
||||
('address', models.CharField(blank=True, default='', max_length=200)),
|
||||
('phone', models.CharField(blank=True, default='', max_length=200)),
|
||||
('profession', models.CharField(blank=True, default='', max_length=200)),
|
||||
('profile_role', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='guardian_profile', to='Auth.profilerole')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RegistrationFileGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('establishment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='file_group', to='Establishment.establishment')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RegistrationForm',
|
||||
fields=[
|
||||
('student', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='Subscriptions.student')),
|
||||
('status', models.IntegerField(choices=[(0, "Pas de dossier d'inscription"), (1, "Dossier d'inscription initialisé"), (2, "Dossier d'inscription envoyé"), (3, "Dossier d'inscription en cours de validation"), (4, "Dossier d'inscription à relancer"), (5, "Dossier d'inscription validé"), (6, "Dossier d'inscription archivé"), (7, 'Mandat SEPA envoyé'), (8, 'Mandat SEPA à envoyer')], default=0)),
|
||||
('last_update', models.DateTimeField(auto_now=True)),
|
||||
('school_year', models.CharField(blank=True, default='', max_length=9)),
|
||||
('notes', models.CharField(blank=True, max_length=200)),
|
||||
('registration_link_code', models.CharField(blank=True, default='', max_length=200)),
|
||||
('registration_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)),
|
||||
('sepa_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)),
|
||||
('fusion_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)),
|
||||
('associated_rf', models.CharField(blank=True, default='', max_length=200)),
|
||||
('discounts', models.ManyToManyField(blank=True, related_name='register_forms', to='School.discount')),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='register_forms', to='Establishment.establishment')),
|
||||
('fees', models.ManyToManyField(blank=True, related_name='register_forms', to='School.fee')),
|
||||
('fileGroup', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='register_forms', to='Subscriptions.registrationfilegroup')),
|
||||
('registration_payment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='registration_payment_modes_forms', to='School.paymentmode')),
|
||||
('registration_payment_plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='registration_payment_plans_forms', to='School.paymentplan')),
|
||||
('tuition_payment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tuition_payment_modes_forms', to='School.paymentmode')),
|
||||
('tuition_payment_plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tuition_payment_plans_forms', to='School.paymentplan')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='guardians',
|
||||
field=models.ManyToManyField(blank=True, to='Subscriptions.guardian'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='level',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='Common.level'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='profiles',
|
||||
field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='spoken_languages',
|
||||
field=models.ManyToManyField(blank=True, to='Subscriptions.language'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BilanCompetence',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_bilan_form_upload_to)),
|
||||
('period', models.CharField(help_text='Période ex: T1-2024_2025, S1-2024_2025, A-2024_2025', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bilans', to='Subscriptions.student')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AbsenceManagement',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('day', models.DateField(blank=True, null=True)),
|
||||
('moment', models.IntegerField(choices=[(1, 'Morning'), (2, 'Afternoon'), (3, 'Total')], default=3)),
|
||||
('reason', models.IntegerField(choices=[(1, 'Justified Absence'), (2, 'Unjustified Absence'), (3, 'Justified Late'), (4, 'Unjustified Late')], default=2)),
|
||||
('commentaire', models.TextField(blank=True, null=True)),
|
||||
('establishment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='absences', to='Establishment.establishment')),
|
||||
('student', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='absences', to='Subscriptions.student')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RegistrationParentFileMaster',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('description', models.CharField(blank=True, max_length=500, null=True)),
|
||||
('is_required', models.BooleanField(default=False)),
|
||||
('groups', models.ManyToManyField(blank=True, related_name='parent_file_masters', to='Subscriptions.registrationfilegroup')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RegistrationSchoolFileMaster',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('is_required', models.BooleanField(default=False)),
|
||||
('formMasterData', models.JSONField(blank=True, default=list, null=True)),
|
||||
('groups', models.ManyToManyField(blank=True, related_name='school_file_masters', to='Subscriptions.registrationfilegroup')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='registration_files',
|
||||
field=models.ManyToManyField(blank=True, related_name='students', to='Subscriptions.registrationschoolfiletemplate'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='registrationschoolfiletemplate',
|
||||
name='master',
|
||||
field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='school_file_templates', to='Subscriptions.registrationschoolfilemaster'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='siblings',
|
||||
field=models.ManyToManyField(blank=True, to='Subscriptions.sibling'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='registrationschoolfiletemplate',
|
||||
name='registration_form',
|
||||
field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='school_file_templates', to='Subscriptions.registrationform'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RegistrationParentFileTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_parent_file_upload_to)),
|
||||
('master', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_file_templates', to='Subscriptions.registrationparentfilemaster')),
|
||||
('registration_form', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_file_templates', to='Subscriptions.registrationform')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StudentCompetency',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('score', models.IntegerField(blank=True, null=True)),
|
||||
('comment', models.TextField(blank=True, null=True)),
|
||||
('period', models.CharField(blank=True, default='', help_text="Période d'évaluation ex: T1-2024_2025, S1-2024_2025, A-2024_2025", max_length=20)),
|
||||
('establishment_competency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_scores', to='School.establishmentcompetency')),
|
||||
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competency_scores', to='Subscriptions.student')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('student', 'establishment_competency', 'period')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
Back-End/Subscriptions/migrations/__init__.py
Normal file
0
Back-End/Subscriptions/migrations/__init__.py
Normal file
@ -2,11 +2,12 @@ from django.db import models
|
||||
from django.utils.timezone import now
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger("SubscriptionModels")
|
||||
|
||||
class Language(models.Model):
|
||||
"""
|
||||
Représente une langue parlée par l’élève.
|
||||
@ -274,6 +275,16 @@ class RegistrationForm(models.Model):
|
||||
return "RF_" + self.student.last_name + "_" + self.student.first_name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Préparer le flag de création / changement de fileGroup
|
||||
was_new = self.pk is None
|
||||
old_fileGroup = None
|
||||
if not was_new:
|
||||
try:
|
||||
old_instance = RegistrationForm.objects.get(pk=self.pk)
|
||||
old_fileGroup = old_instance.fileGroup
|
||||
except RegistrationForm.DoesNotExist:
|
||||
old_fileGroup = None
|
||||
|
||||
# Vérifier si un fichier existant doit être remplacé
|
||||
if self.pk: # Si l'objet existe déjà dans la base de données
|
||||
try:
|
||||
@ -287,25 +298,186 @@ class RegistrationForm(models.Model):
|
||||
# Appeler la méthode save originale
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Après save : si nouveau ou changement de fileGroup -> créer les templates
|
||||
fileGroup_changed = (self.fileGroup is not None) and (old_fileGroup is None or (old_fileGroup and old_fileGroup.id != self.fileGroup.id))
|
||||
if was_new or fileGroup_changed:
|
||||
try:
|
||||
import Subscriptions.util as util
|
||||
created = util.create_templates_for_registration_form(self)
|
||||
if created:
|
||||
logger.info("Created %d templates for RegistrationForm %s", len(created), self.pk)
|
||||
except Exception as e:
|
||||
logger.exception("Error creating templates for RegistrationForm %s: %s", self.pk, e)
|
||||
|
||||
#############################################################
|
||||
####################### MASTER FILES ########################
|
||||
#############################################################
|
||||
|
||||
####### DocuSeal masters (documents école, à signer ou pas) #######
|
||||
####### Formulaires masters (documents école, à signer ou pas) #######
|
||||
def registration_school_file_master_upload_to(instance, filename):
|
||||
# Stocke les fichiers masters dans un dossier dédié
|
||||
# Utilise l'ID si le nom n'est pas encore disponible
|
||||
est_name = None
|
||||
if instance.establishment and instance.establishment.name:
|
||||
est_name = instance.establishment.name
|
||||
else:
|
||||
# fallback si pas d'établissement (devrait être rare)
|
||||
est_name = "unknown_establishment"
|
||||
return f"{est_name}/Formulaires/{filename}"
|
||||
|
||||
class RegistrationSchoolFileMaster(models.Model):
|
||||
groups = models.ManyToManyField(RegistrationFileGroup, related_name='school_file_masters', blank=True)
|
||||
id = models.IntegerField(primary_key=True)
|
||||
name = models.CharField(max_length=255, default="")
|
||||
is_required = models.BooleanField(default=False)
|
||||
formMasterData = models.JSONField(default=list, blank=True, null=True)
|
||||
file = models.FileField(
|
||||
upload_to=registration_school_file_master_upload_to,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Fichier du formulaire existant (PDF, DOC, etc.)"
|
||||
)
|
||||
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='school_file_masters', null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.group.name} - {self.id}'
|
||||
return f'{self.name} - {self.id}'
|
||||
|
||||
@property
|
||||
def file_url(self):
|
||||
if self.file and hasattr(self.file, 'url'):
|
||||
return self.file.url
|
||||
return None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
affected_rf_ids = set()
|
||||
is_new = self.pk is None
|
||||
|
||||
# Log création ou modification du master
|
||||
if is_new:
|
||||
logger.info(f"[FormPerso] Création master '{self.name}' pour établissement '{self.establishment}'")
|
||||
else:
|
||||
logger.info(f"[FormPerso] Modification master '{self.name}' (id={self.pk}) pour établissement '{self.establishment}'")
|
||||
|
||||
# --- Suppression de l'ancien fichier master si le nom change (form existant ou dynamique) ---
|
||||
if self.pk:
|
||||
try:
|
||||
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
|
||||
if old.file and old.file.name:
|
||||
old_filename = os.path.basename(old.file.name)
|
||||
# Nouveau nom selon le type (dynamique ou existant)
|
||||
if (
|
||||
self.formMasterData
|
||||
and isinstance(self.formMasterData, dict)
|
||||
and self.formMasterData.get("fields")
|
||||
):
|
||||
new_filename = f"{self.name}.pdf"
|
||||
else:
|
||||
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
|
||||
extension = os.path.splitext(old_filename)[1]
|
||||
new_filename = f"{self.name}{extension}" if extension else self.name
|
||||
if new_filename and old_filename != new_filename:
|
||||
old_file_path = old.file.path
|
||||
if os.path.exists(old_file_path):
|
||||
try:
|
||||
os.remove(old_file_path)
|
||||
logger.info(f"[FormPerso] Suppression de l'ancien fichier master: {old_file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"[FormPerso] Erreur suppression ancien fichier master: {e}")
|
||||
# Correction du nom du fichier pour éviter le suffixe random
|
||||
if (
|
||||
not self.formMasterData
|
||||
or not (isinstance(self.formMasterData, dict) and self.formMasterData.get("fields"))
|
||||
):
|
||||
# Si le fichier existe et le nom ne correspond pas, renommer le fichier physique et mettre à jour le FileField
|
||||
if self.file and self.file.name:
|
||||
current_filename = os.path.basename(self.file.name)
|
||||
current_path = self.file.path
|
||||
expected_filename = new_filename
|
||||
expected_path = os.path.join(os.path.dirname(current_path), expected_filename)
|
||||
if current_filename != expected_filename:
|
||||
try:
|
||||
if os.path.exists(current_path):
|
||||
os.rename(current_path, expected_path)
|
||||
self.file.name = os.path.join(os.path.dirname(self.file.name), expected_filename).replace("\\", "/")
|
||||
logger.info(f"[FormPerso] Renommage du fichier master: {current_path} -> {expected_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"[FormPerso] Erreur lors du renommage du fichier master: {e}")
|
||||
except RegistrationSchoolFileMaster.DoesNotExist:
|
||||
pass
|
||||
|
||||
# --- Traitement PDF dynamique AVANT le super().save() ---
|
||||
if (
|
||||
self.formMasterData
|
||||
and isinstance(self.formMasterData, dict)
|
||||
and self.formMasterData.get("fields")
|
||||
):
|
||||
from Subscriptions.util import generate_form_json_pdf
|
||||
pdf_filename = f"{self.name}.pdf"
|
||||
pdf_file = generate_form_json_pdf(self, self.formMasterData)
|
||||
self.file.save(pdf_filename, pdf_file, save=False)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Synchronisation des templates pour tous les dossiers d'inscription concernés (création ou modification)
|
||||
try:
|
||||
# Import local pour éviter le circular import
|
||||
from Subscriptions.util import create_templates_for_registration_form
|
||||
from Subscriptions.models import RegistrationForm, RegistrationSchoolFileTemplate
|
||||
# Détermination des RF concernés
|
||||
if is_new:
|
||||
new_groups = set(self.groups.values_list('id', flat=True))
|
||||
affected_rf_ids.update(
|
||||
RegistrationForm.objects.filter(fileGroup__in=list(new_groups)).values_list('pk', flat=True)
|
||||
)
|
||||
else:
|
||||
try:
|
||||
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
|
||||
old_groups = set(old.groups.values_list('id', flat=True))
|
||||
new_groups = set(self.groups.values_list('id', flat=True))
|
||||
affected_rf_ids.update(
|
||||
RegistrationForm.objects.filter(fileGroup__in=list(old_groups | new_groups)).values_list('pk', flat=True)
|
||||
)
|
||||
form_data_changed = (
|
||||
old.formMasterData != self.formMasterData
|
||||
and self.formMasterData
|
||||
and isinstance(self.formMasterData, dict)
|
||||
and self.formMasterData.get("fields")
|
||||
)
|
||||
name_changed = old.name != self.name
|
||||
if form_data_changed or name_changed:
|
||||
logger.info(f"[FormPerso] Modification du contenu du master '{self.name}' (id={self.pk})")
|
||||
except RegistrationSchoolFileMaster.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Pour chaque RF concerné, régénérer les templates
|
||||
for rf_id in affected_rf_ids:
|
||||
try:
|
||||
rf = RegistrationForm.objects.get(pk=rf_id)
|
||||
logger.info(f"[FormPerso] Synchronisation template pour élève '{rf.student.last_name}_{rf.student.first_name}' (RF id={rf.pk}) suite à modification/ajout du master '{self.name}'")
|
||||
create_templates_for_registration_form(rf)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la synchronisation des templates pour RF {rf_id} après modification du master {self.pk}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur globale lors de la synchronisation des templates après modification du master {self.pk}: {e}")
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
logger.info(f"[FormPerso] Suppression master '{self.name}' (id={self.pk}) et tous ses templates")
|
||||
# Import local pour éviter le circular import
|
||||
from Subscriptions.models import RegistrationSchoolFileTemplate
|
||||
templates = RegistrationSchoolFileTemplate.objects.filter(master=self)
|
||||
for tmpl in templates:
|
||||
logger.info(f"[FormPerso] Suppression template '{tmpl.name}' pour élève '{tmpl.registration_form.student.last_name}_{tmpl.registration_form.student.first_name}' (RF id={tmpl.registration_form.pk})")
|
||||
if self.file and hasattr(self.file, 'path') and os.path.exists(self.file.path):
|
||||
try:
|
||||
self.file.delete(save=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la suppression du fichier master: {e}")
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
####### Parent files masters (documents à fournir par les parents) #######
|
||||
class RegistrationParentFileMaster(models.Model):
|
||||
groups = models.ManyToManyField(RegistrationFileGroup, related_name='parent_file_masters', blank=True)
|
||||
name = models.CharField(max_length=255, default="")
|
||||
description = models.CharField(blank=True, null=True)
|
||||
description = models.CharField(max_length=500, blank=True, null=True)
|
||||
is_required = models.BooleanField(default=False)
|
||||
|
||||
############################################################
|
||||
@ -313,19 +485,19 @@ class RegistrationParentFileMaster(models.Model):
|
||||
############################################################
|
||||
|
||||
def registration_school_file_upload_to(instance, filename):
|
||||
return f"registration_files/dossier_rf_{instance.registration_form.pk}/school/{filename}"
|
||||
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/{filename}"
|
||||
|
||||
def registration_parent_file_upload_to(instance, filename):
|
||||
return f"registration_files/dossier_rf_{instance.registration_form.pk}/parent/{filename}"
|
||||
return f"registration_files/{instance.registration_form.establishment.name}/dossier_rf_{instance.registration_form.pk}/parent/{filename}"
|
||||
|
||||
####### DocuSeal templates (par dossier d'inscription) #######
|
||||
####### Formulaires templates (par dossier d'inscription) #######
|
||||
class RegistrationSchoolFileTemplate(models.Model):
|
||||
master = models.ForeignKey(RegistrationSchoolFileMaster, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
||||
id = models.IntegerField(primary_key=True)
|
||||
slug = models.CharField(max_length=255, default="")
|
||||
name = models.CharField(max_length=255, default="")
|
||||
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
||||
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
|
||||
formTemplateData = models.JSONField(default=list, blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -355,7 +527,7 @@ class StudentCompetency(models.Model):
|
||||
|
||||
class Meta:
|
||||
unique_together = ('student', 'establishment_competency', 'period')
|
||||
|
||||
|
||||
indexes = [
|
||||
models.Index(fields=['student', 'establishment_competency', 'period']),
|
||||
]
|
||||
@ -394,7 +566,8 @@ class RegistrationParentFileTemplate(models.Model):
|
||||
registration_files = RegistrationParentFileTemplate.objects.filter(registration_form=register_form_id)
|
||||
filenames = []
|
||||
for reg_file in registration_files:
|
||||
filenames.append(reg_file.file.path)
|
||||
if reg_file.file and hasattr(reg_file.file, 'path'):
|
||||
filenames.append(reg_file.file.path)
|
||||
return filenames
|
||||
|
||||
class AbsenceMoment(models.IntegerChoices):
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
from rest_framework import serializers
|
||||
from .models import (
|
||||
RegistrationFileGroup,
|
||||
RegistrationForm,
|
||||
Student,
|
||||
Guardian,
|
||||
Sibling,
|
||||
RegistrationFileGroup,
|
||||
RegistrationForm,
|
||||
Student,
|
||||
Guardian,
|
||||
Sibling,
|
||||
Language,
|
||||
RegistrationSchoolFileMaster,
|
||||
RegistrationSchoolFileTemplate,
|
||||
RegistrationParentFileMaster,
|
||||
RegistrationParentFileTemplate,
|
||||
RegistrationSchoolFileMaster,
|
||||
RegistrationSchoolFileTemplate,
|
||||
RegistrationParentFileMaster,
|
||||
RegistrationParentFileTemplate,
|
||||
AbsenceManagement,
|
||||
BilanCompetence
|
||||
)
|
||||
@ -95,7 +95,7 @@ class RegistrationFormSimpleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = RegistrationForm
|
||||
fields = ['student_id', 'last_name', 'first_name', 'guardians']
|
||||
|
||||
|
||||
def get_last_name(self, obj):
|
||||
return obj.student.last_name
|
||||
|
||||
@ -164,12 +164,20 @@ class StudentSerializer(serializers.ModelSerializer):
|
||||
|
||||
if guardian_id:
|
||||
# Si un ID est fourni, récupérer ou mettre à jour le Guardian existant
|
||||
guardian_instance, created = Guardian.objects.update_or_create(
|
||||
id=guardian_id,
|
||||
defaults=guardian_data
|
||||
)
|
||||
guardians_ids.append(guardian_instance.id)
|
||||
continue
|
||||
try:
|
||||
guardian_instance = Guardian.objects.get(id=guardian_id)
|
||||
# Mettre à jour explicitement tous les champs y compris birth_date, profession, address
|
||||
for field, value in guardian_data.items():
|
||||
if field != 'id': # Ne pas mettre à jour l'ID
|
||||
setattr(guardian_instance, field, value)
|
||||
guardian_instance.save()
|
||||
guardians_ids.append(guardian_instance.id)
|
||||
continue
|
||||
except Guardian.DoesNotExist:
|
||||
# Si le guardian n'existe pas, créer un nouveau
|
||||
guardian_instance = Guardian.objects.create(**guardian_data)
|
||||
guardians_ids.append(guardian_instance.id)
|
||||
continue
|
||||
|
||||
if profile_role_data:
|
||||
# Vérifiez si 'profile_data' est fourni pour créer un nouveau profil
|
||||
@ -381,16 +389,20 @@ class RegistrationFormSerializer(serializers.ModelSerializer):
|
||||
|
||||
class StudentByParentSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
associated_class_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Student
|
||||
fields = ['id', 'last_name', 'first_name', 'level', 'photo']
|
||||
fields = ['id', 'last_name', 'first_name', 'level', 'photo', 'associated_class_name']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(StudentByParentSerializer, self).__init__(*args, **kwargs)
|
||||
for field in self.fields:
|
||||
self.fields[field].required = False
|
||||
|
||||
def get_associated_class_name(self, obj):
|
||||
return obj.associated_class.atmosphere_name if obj.associated_class else None
|
||||
|
||||
class RegistrationFormByParentSerializer(serializers.ModelSerializer):
|
||||
student = StudentByParentSerializer(many=False, required=True)
|
||||
|
||||
|
||||
@ -5,6 +5,8 @@ from Subscriptions.automate import Automate_RF_Register, updateStateMachine
|
||||
from .models import RegistrationForm
|
||||
from GestionMessagerie.models import Messagerie
|
||||
from N3wtSchool import settings, bdd
|
||||
from N3wtSchool.mailManager import sendMail, getConnection
|
||||
from django.template.loader import render_to_string
|
||||
import requests
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -26,17 +28,82 @@ def send_notification(dossier):
|
||||
# Changer l'état de l'automate
|
||||
updateStateMachine(dossier, 'EVENT_FOLLOW_UP')
|
||||
|
||||
url = settings.URL_DJANGO + 'GestionMessagerie/message'
|
||||
# Envoyer un email de relance aux responsables
|
||||
try:
|
||||
# Récupérer l'établissement du dossier
|
||||
establishment_id = dossier.establishment.id
|
||||
|
||||
destinataires = dossier.eleve.profiles.all()
|
||||
for destinataire in destinataires:
|
||||
message = {
|
||||
"objet": "[RELANCE]",
|
||||
"destinataire" : destinataire.id,
|
||||
"corpus": "RELANCE pour le dossier d'inscription"
|
||||
# Obtenir la connexion SMTP pour cet établissement
|
||||
connection = getConnection(establishment_id)
|
||||
|
||||
# Préparer le contenu de l'email
|
||||
subject = f"[RELANCE] Dossier d'inscription en attente - {dossier.eleve.first_name} {dossier.eleve.last_name}"
|
||||
|
||||
context = {
|
||||
'student_name': f"{dossier.eleve.first_name} {dossier.eleve.last_name}",
|
||||
'deadline_date': (timezone.now() - timezone.timedelta(days=settings.EXPIRATION_DI_NB_DAYS)).strftime('%d/%m/%Y'),
|
||||
'establishment_name': dossier.establishment.name,
|
||||
'base_url': settings.BASE_URL
|
||||
}
|
||||
|
||||
response = requests.post(url, json=message)
|
||||
# Utiliser un template HTML pour l'email (si disponible)
|
||||
try:
|
||||
html_message = render_to_string('emails/relance_signature.html', context)
|
||||
except:
|
||||
# Si pas de template, message simple
|
||||
html_message = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Relance - Dossier d'inscription en attente</h2>
|
||||
<p>Bonjour,</p>
|
||||
<p>Le dossier d'inscription de <strong>{context['student_name']}</strong> est en attente de signature depuis plus de {settings.EXPIRATION_DI_NB_DAYS} jours.</p>
|
||||
<p>Merci de vous connecter à votre espace pour finaliser l'inscription.</p>
|
||||
<p>Cordialement,<br>L'équipe {context['establishment_name']}</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# Récupérer les emails des responsables
|
||||
destinataires = []
|
||||
profiles = dossier.eleve.profiles.all()
|
||||
for profile in profiles:
|
||||
if profile.email:
|
||||
destinataires.append(profile.email)
|
||||
|
||||
if destinataires:
|
||||
# Envoyer l'email
|
||||
result = sendMail(
|
||||
subject=subject,
|
||||
message=html_message,
|
||||
recipients=destinataires,
|
||||
connection=connection
|
||||
)
|
||||
logger.info(f"Email de relance envoyé pour le dossier {dossier.id} à {destinataires}")
|
||||
else:
|
||||
logger.warning(f"Aucun email trouvé pour les responsables du dossier {dossier.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'envoi de l'email de relance pour le dossier {dossier.id}: {str(e)}")
|
||||
|
||||
# En cas d'erreur email, utiliser la messagerie interne comme fallback
|
||||
try:
|
||||
url = settings.URL_DJANGO + 'GestionMessagerie/send-message/'
|
||||
|
||||
# Créer ou récupérer une conversation avec chaque responsable
|
||||
destinataires = dossier.eleve.profiles.all()
|
||||
for destinataire in destinataires:
|
||||
message_data = {
|
||||
"conversation_id": None, # Sera géré par l'API
|
||||
"sender_id": 1, # ID du système ou admin
|
||||
"content": f"RELANCE pour le dossier d'inscription de {dossier.eleve.first_name} {dossier.eleve.last_name}"
|
||||
}
|
||||
|
||||
response = requests.post(url, json=message_data)
|
||||
if response.status_code != 201:
|
||||
logger.error(f"Erreur lors de l'envoi du message interne: {response.text}")
|
||||
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Erreur lors de l'envoi du message interne de fallback: {str(inner_e)}")
|
||||
|
||||
# subject = f"Dossier d'inscription non signé - {dossier.objet}"
|
||||
# message = f"Le dossier d'inscription avec l'objet '{dossier.objet}' n'a pas été signé depuis {dossier.created_at}."
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
|
||||
<h1>Confirmation d'inscription</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
121
Back-End/Subscriptions/templates/emails/relance_signature.html
Normal file
121
Back-End/Subscriptions/templates/emails/relance_signature.html
Normal file
@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Relance - Dossier d'inscription</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
border-bottom: 3px solid #007bff;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #007bff;
|
||||
margin: 0;
|
||||
}
|
||||
.alert {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.alert-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.content {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.student-info {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #007bff;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 12px 25px;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>{{ establishment_name }}</h1>
|
||||
<p>Relance - Dossier d'inscription</p>
|
||||
</div>
|
||||
|
||||
<div class="alert">
|
||||
<span class="alert-icon">⚠️</span>
|
||||
<strong>Attention :</strong> Votre dossier d'inscription nécessite votre attention
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Bonjour,</p>
|
||||
|
||||
<div class="student-info">
|
||||
<h3>Dossier d'inscription de : <strong>{{ student_name }}</strong></h3>
|
||||
<p>En attente depuis le : <strong>{{ deadline_date }}</strong></p>
|
||||
</div>
|
||||
|
||||
<p>Nous vous informons que le dossier d'inscription mentionné ci-dessus est en attente de finalisation depuis plus de {{ deadline_date }}.</p>
|
||||
|
||||
<p><strong>Action requise :</strong></p>
|
||||
<ul>
|
||||
<li>Connectez-vous à votre espace personnel</li>
|
||||
<li>Vérifiez les documents manquants</li>
|
||||
<li>Complétez et signez les formulaires en attente</li>
|
||||
</ul>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="{{ base_url }}" class="cta-button">Accéder à mon espace</a>
|
||||
</div>
|
||||
|
||||
<p>Si vous rencontrez des difficultés ou avez des questions concernant ce dossier, n'hésitez pas à nous contacter.</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Cordialement,<br>
|
||||
L'équipe {{ establishment_name }}</p>
|
||||
|
||||
<hr style="margin: 20px 0;">
|
||||
|
||||
<p style="font-size: 12px;">
|
||||
Cet email a été envoyé automatiquement. Si vous pensez avoir reçu ce message par erreur,
|
||||
veuillez contacter l'établissement directement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Confirmation de souscription</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background-color: #f4f4f4;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.logo {
|
||||
width: 120px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
.footer {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{{URL_DJANGO}}/static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
|
||||
<h1>Confirmation de souscription</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Bonjour,</p>
|
||||
<p>Nous sommes ravis de vous compter parmi les utilisateurs de N3wt School et vous remercions pour votre confiance</p>
|
||||
<p>Vous trouverez ci-joint le lien vers la page d'authentification : <a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
|
||||
<p>S'il s'agit de votre première connexion, veuillez procéder à l'activation de votre compte à cette url : <a href="{{BASE_URL}}/users/subscribe?establishment_id={{establishment}}">{{BASE_URL}}/users/subscribe</a></p>
|
||||
<p>votre identifiant est : {{ email }}</p>
|
||||
<p>Notre équipe est à votre disposition pour vous aider à tirer pleinement parti des fonctionnalités offertes par Newt School.</p>
|
||||
<p>N'hésitez pas à nous contacter pour toute question ou besoin d'assistance.</p>
|
||||
<p>Cordialement,</p>
|
||||
<p>L'équipe N3wt School</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,233 +1,228 @@
|
||||
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Fiche élève de {{ student.last_name }} {{ student.first_name }}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
}
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
font-size: 12pt;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #333;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 3px solid #4CAF50;
|
||||
padding-bottom: 15px;
|
||||
position: relative;
|
||||
}
|
||||
.title {
|
||||
font-size: 20pt;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
margin: 0;
|
||||
}
|
||||
.photo {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border: 2px solid #4CAF50;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background-color: #fefefe;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 18pt;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 10px;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
td {
|
||||
padding: 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.label-cell {
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
width: 35%;
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.value-cell {
|
||||
color: #333;
|
||||
width: 65%;
|
||||
text-align: left;
|
||||
}
|
||||
.phone {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.signature {
|
||||
margin-top: 30px;
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
color: #555;
|
||||
}
|
||||
.signature-text {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% load myTemplateTag %}
|
||||
<div class="container">
|
||||
<!-- Header Section -->
|
||||
<div class="header">
|
||||
<h1 class="title">Fiche élève de {{ student.last_name }} {{ student.first_name }}</h1>
|
||||
{% if student.photo %}
|
||||
<img
|
||||
src="{{ student.get_photo_url }}"
|
||||
alt="Photo de l'élève"
|
||||
class="photo"
|
||||
/>
|
||||
{% else %}
|
||||
<img
|
||||
src="/static/img/default-photo.jpg"
|
||||
alt="Photo par défaut"
|
||||
class="photo"
|
||||
/>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Student Section -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">ÉLÈVE</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td class="label-cell">Nom :</td>
|
||||
<td class="value-cell">{{ student.last_name }}</td>
|
||||
<td class="label-cell">Prénom :</td>
|
||||
<td class="value-cell">{{ student.first_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Adresse :</td>
|
||||
<td colspan="3" class="value-cell">{{ student.address }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Genre :</td>
|
||||
<td class="value-cell">{{ student|getStudentGender }}</td>
|
||||
<td class="label-cell">Né(e) le :</td>
|
||||
<td class="value-cell">{{ student.birth_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">À :</td>
|
||||
<td colspan="3" class="value-cell">{{ student.birth_place }} ({{ student.birth_postal_code }})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Nationalité :</td>
|
||||
<td class="value-cell">{{ student.nationality }}</td>
|
||||
<td class="label-cell">Niveau :</td>
|
||||
<td class="value-cell">{{ student|getStudentLevel }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Guardians Section -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">RESPONSABLES</h2>
|
||||
{% for guardian in student.getGuardians %}
|
||||
<div class="subsection">
|
||||
<h3 class="subsection-title">Responsable {{ forloop.counter }}</h3>
|
||||
<table>
|
||||
<tr>
|
||||
<td class="label-cell">Nom :</td>
|
||||
<td class="value-cell">{{ guardian.last_name }}</td>
|
||||
<td class="label-cell">Prénom :</td>
|
||||
<td class="value-cell">{{ guardian.first_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Adresse :</td>
|
||||
<td colspan="3" class="value-cell">{{ guardian.address }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Né(e) le :</td>
|
||||
<td class="value-cell">{{ guardian.birth_date }}</td>
|
||||
<td class="label-cell">Email :</td>
|
||||
<td class="value-cell">{{ guardian.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Téléphone :</td>
|
||||
<td class="value-cell phone">{{ guardian.phone|phone_format }}</td>
|
||||
<td class="label-cell">Profession :</td>
|
||||
<td class="value-cell">{{ guardian.profession }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Siblings Section -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">FRATRIE</h2>
|
||||
{% for sibling in student.getSiblings %}
|
||||
<div class="subsection">
|
||||
<h3 class="subsection-title">Frère/Soeur {{ forloop.counter }}</h3>
|
||||
<table>
|
||||
<tr>
|
||||
<td class="label-cell">Nom :</td>
|
||||
<td class="value-cell">{{ sibling.last_name }}</td>
|
||||
<td class="label-cell">Prénom :</td>
|
||||
<td class="value-cell">{{ sibling.first_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Né(e) le :</td>
|
||||
<td colspan="3" class="value-cell">{{ sibling.birth_date }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Payment Section -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">MODALITÉS DE PAIEMENT</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td class="label-cell">Frais d'inscription :</td>
|
||||
<td class="value-cell">{{ student|getRegistrationPaymentMethod }} en {{ student|getRegistrationPaymentPlan }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Frais de scolarité :</td>
|
||||
<td class="value-cell">{{ student|getTuitionPaymentMethod }} en {{ student|getTuitionPaymentPlan }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Signature Section -->
|
||||
<div class="signature">
|
||||
Fait le <span class="signature-text">{{ signatureDate }}</span> à <span class="signature-text">{{ signatureTime }}</span>
|
||||
</div>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Fiche élève de {{ student.last_name }} {{ student.first_name }}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
}
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
font-size: 12pt;
|
||||
color: #222;
|
||||
background: #fff;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
padding-bottom: 12px;
|
||||
position: relative;
|
||||
}
|
||||
.title {
|
||||
font-size: 22pt;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
margin: 0;
|
||||
}
|
||||
.photo {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #4CAF50;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 32px; /* Espacement augmenté entre les sections */
|
||||
}
|
||||
.section-title {
|
||||
font-size: 15pt;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 18px; /* Espacement sous le titre de section */
|
||||
border-bottom: 1px solid #4CAF50;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #bbb;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background: #f3f3f3;
|
||||
font-weight: bold;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background: #fafafa;
|
||||
}
|
||||
.label-cell {
|
||||
font-weight: bold;
|
||||
width: 30%;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
.value-cell {
|
||||
width: 70%;
|
||||
}
|
||||
.signature {
|
||||
margin-top: 30px;
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
color: #555;
|
||||
}
|
||||
.signature-text {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.subsection-title {
|
||||
font-size: 12pt;
|
||||
color: #333;
|
||||
margin: 8px 0 4px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% load myTemplateTag %}
|
||||
<div class="container">
|
||||
<!-- Header Section -->
|
||||
<div class="header">
|
||||
<h1 class="title">Fiche élève de {{ student.last_name }} {{ student.first_name }}</h1>
|
||||
{% if student.photo %}
|
||||
<img src="{{ student.get_photo_url }}" alt="Photo de l'élève" class="photo" />
|
||||
{% else %}
|
||||
<img src="/static/img/default-photo.jpg" alt="Photo par défaut" class="photo" />
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<!-- Élève -->
|
||||
<div class="section">
|
||||
<div class="section-title">ÉLÈVE</div>
|
||||
<table>
|
||||
<tr>
|
||||
<td class="label-cell">Nom</td>
|
||||
<td class="value-cell">{{ student.last_name }}</td>
|
||||
<td class="label-cell">Prénom</td>
|
||||
<td class="value-cell">{{ student.first_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Adresse</td>
|
||||
<td class="value-cell" colspan="3">{{ student.address }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Genre</td>
|
||||
<td class="value-cell">{{ student|getStudentGender }}</td>
|
||||
<td class="label-cell">Né(e) le</td>
|
||||
<td class="value-cell">{{ student.birth_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">À</td>
|
||||
<td class="value-cell">{{ student.birth_place }} ({{ student.birth_postal_code }})</td>
|
||||
<td class="label-cell">Nationalité</td>
|
||||
<td class="value-cell">{{ student.nationality }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Niveau</td>
|
||||
<td class="value-cell">{{ student|getStudentLevel }}</td>
|
||||
<td class="label-cell"></td>
|
||||
<td class="value-cell"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Responsables -->
|
||||
<div class="section">
|
||||
<div class="section-title">RESPONSABLES</div>
|
||||
{% for guardian in student.getGuardians %}
|
||||
<div>
|
||||
<div class="subsection-title">Responsable {{ forloop.counter }}</div>
|
||||
<table>
|
||||
<tr>
|
||||
<td class="label-cell">Nom</td>
|
||||
<td class="value-cell">{{ guardian.last_name }}</td>
|
||||
<td class="label-cell">Prénom</td>
|
||||
<td class="value-cell">{{ guardian.first_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Adresse</td>
|
||||
<td class="value-cell" colspan="3">{{ guardian.address }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Email</td>
|
||||
<td class="value-cell" colspan="3">{{ guardian.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Né(e) le</td>
|
||||
<td class="value-cell">{{ guardian.birth_date }}</td>
|
||||
<td class="label-cell">Téléphone</td>
|
||||
<td class="value-cell">{{ guardian.phone|phone_format }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Profession</td>
|
||||
<td class="value-cell" colspan="3">{{ guardian.profession }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Fratrie -->
|
||||
<div class="section">
|
||||
<div class="section-title">FRATRIE</div>
|
||||
{% for sibling in student.getSiblings %}
|
||||
<div>
|
||||
<div class="subsection-title">Frère/Soeur {{ forloop.counter }}</div>
|
||||
<table>
|
||||
<tr>
|
||||
<td class="label-cell">Nom</td>
|
||||
<td class="value-cell">{{ sibling.last_name }}</td>
|
||||
<td class="label-cell">Prénom</td>
|
||||
<td class="value-cell">{{ sibling.first_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Né(e) le</td>
|
||||
<td class="value-cell" colspan="3">{{ sibling.birth_date }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Paiement -->
|
||||
<div class="section">
|
||||
<div class="section-title">MODALITÉS DE PAIEMENT</div>
|
||||
<table>
|
||||
<tr>
|
||||
<td class="label-cell">Frais d'inscription</td>
|
||||
<td class="value-cell">{{ student|getRegistrationPaymentMethod }} en {{ student|getRegistrationPaymentPlan }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Frais de scolarité</td>
|
||||
<td class="value-cell">{{ student|getTuitionPaymentMethod }} en {{ student|getTuitionPaymentPlan }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Signature -->
|
||||
<div class="signature">
|
||||
Fait le <span class="signature-text">{{ signatureDate }}</span> à <span class="signature-text">{{ signatureTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -24,7 +24,10 @@ from .views import (
|
||||
)
|
||||
|
||||
from .views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group
|
||||
from .views import registration_file_views, get_school_file_templates_by_rf, get_parent_file_templates_by_rf
|
||||
from .views import (
|
||||
get_school_file_templates_by_rf,
|
||||
get_parent_file_templates_by_rf
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^registerForms/(?P<id>[0-9]+)/archive$', archive, name="archive"),
|
||||
|
||||
@ -8,6 +8,9 @@ from N3wtSchool import renderers
|
||||
from N3wtSchool import bdd
|
||||
|
||||
from io import BytesIO
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files import File
|
||||
from pathlib import Path
|
||||
import os
|
||||
@ -21,8 +24,250 @@ from PyPDF2 import PdfMerger
|
||||
import shutil
|
||||
import logging
|
||||
|
||||
import json
|
||||
from django.http import QueryDict
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def build_payload_from_request(request):
|
||||
"""
|
||||
Normalise la request en payload prêt à être donné au serializer.
|
||||
- supporte multipart/form-data où le front envoie 'data' (JSON string) ou un fichier JSON + fichiers
|
||||
- supporte application/json ou form-data simple
|
||||
Retour: (payload_dict, None) ou (None, Response erreur)
|
||||
"""
|
||||
# Si c'est du JSON pur (Content-Type: application/json)
|
||||
if hasattr(request, 'content_type') and 'application/json' in request.content_type:
|
||||
try:
|
||||
# request.data contient déjà le JSON parsé par Django REST
|
||||
payload = dict(request.data) if hasattr(request.data, 'items') else request.data
|
||||
logger.info(f"JSON payload extracted: {payload}")
|
||||
return payload, None
|
||||
except Exception as e:
|
||||
logger.error(f'Error processing JSON: {e}')
|
||||
return None, Response({'error': "Invalid JSON", 'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Cas multipart/form-data avec champ 'data'
|
||||
data_field = request.data.get('data') if hasattr(request.data, 'get') else None
|
||||
if data_field:
|
||||
try:
|
||||
# Si 'data' est un fichier (InMemoryUploadedFile ou fichier similaire), lire et décoder
|
||||
if hasattr(data_field, 'read'):
|
||||
raw = data_field.read()
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
text = raw.decode('utf-8')
|
||||
else:
|
||||
text = raw
|
||||
payload = json.loads(text)
|
||||
# Si 'data' est bytes déjà
|
||||
elif isinstance(data_field, (bytes, bytearray)):
|
||||
payload = json.loads(data_field.decode('utf-8'))
|
||||
# Si 'data' est une string JSON
|
||||
elif isinstance(data_field, str):
|
||||
payload = json.loads(data_field)
|
||||
else:
|
||||
# type inattendu
|
||||
raise ValueError(f"Unsupported 'data' type: {type(data_field)}")
|
||||
except (json.JSONDecodeError, ValueError, UnicodeDecodeError) as e:
|
||||
logger.error(f'Invalid JSON in "data": {e}')
|
||||
return None, Response({'error': "Invalid JSON in 'data'", 'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
payload = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data)
|
||||
if isinstance(payload, QueryDict):
|
||||
payload = payload.dict()
|
||||
|
||||
# Attacher les fichiers présents (ex: photo, files.*, etc.), sauf 'data' (déjà traité)
|
||||
for f_key, f_val in request.FILES.items():
|
||||
if f_key == 'data':
|
||||
# remettre le pointeur au début si besoin (déjà lu) — non indispensable ici mais sûr
|
||||
try:
|
||||
f_val.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
# ne pas mettre le fichier 'data' dans le payload (c'est le JSON)
|
||||
continue
|
||||
payload[f_key] = f_val
|
||||
|
||||
return payload, None
|
||||
|
||||
def create_templates_for_registration_form(register_form):
|
||||
"""
|
||||
Idempotent:
|
||||
- supprime les templates existants qui ne correspondent pas
|
||||
aux masters du fileGroup courant du register_form (et supprime leurs fichiers).
|
||||
- crée les templates manquants pour les masters du fileGroup courant.
|
||||
Retourne la liste des templates créés.
|
||||
"""
|
||||
from Subscriptions.models import (
|
||||
RegistrationSchoolFileMaster,
|
||||
RegistrationSchoolFileTemplate,
|
||||
RegistrationParentFileMaster,
|
||||
RegistrationParentFileTemplate,
|
||||
registration_school_file_upload_to,
|
||||
)
|
||||
|
||||
created = []
|
||||
logger.info("util.create_templates_for_registration_form - create_templates_for_registration_form")
|
||||
|
||||
# Récupérer les masters du fileGroup courant
|
||||
current_group = getattr(register_form, "fileGroup", None)
|
||||
if not current_group:
|
||||
# Si plus de fileGroup, supprimer tous les templates existants pour ce RF
|
||||
school_existing = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form)
|
||||
for t in school_existing:
|
||||
try:
|
||||
if getattr(t, "file", None):
|
||||
logger.info("Deleted school template %s for RF %s", t.pk, register_form.pk)
|
||||
t.file.delete(save=False)
|
||||
except Exception:
|
||||
logger.exception("Erreur suppression fichier school template %s", getattr(t, "pk", None))
|
||||
t.delete()
|
||||
parent_existing = RegistrationParentFileTemplate.objects.filter(registration_form=register_form)
|
||||
for t in parent_existing:
|
||||
try:
|
||||
if getattr(t, "file", None):
|
||||
t.file.delete(save=False)
|
||||
except Exception:
|
||||
logger.exception("Erreur suppression fichier parent template %s", getattr(t, "pk", None))
|
||||
t.delete()
|
||||
return created
|
||||
|
||||
school_masters = RegistrationSchoolFileMaster.objects.filter(groups=current_group).distinct()
|
||||
parent_masters = RegistrationParentFileMaster.objects.filter(groups=current_group).distinct()
|
||||
|
||||
school_master_ids = {m.pk for m in school_masters}
|
||||
parent_master_ids = {m.pk for m in parent_masters}
|
||||
|
||||
# Supprimer les school templates obsolètes
|
||||
for tmpl in RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form):
|
||||
if not tmpl.master_id or tmpl.master_id not in school_master_ids:
|
||||
try:
|
||||
if getattr(tmpl, "file", None):
|
||||
tmpl.file.delete(save=False)
|
||||
except Exception:
|
||||
logger.exception("Erreur suppression fichier school template obsolète %s", getattr(tmpl, "pk", None))
|
||||
tmpl.delete()
|
||||
logger.info("Deleted obsolete school template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk)
|
||||
|
||||
# Supprimer les parent templates obsolètes
|
||||
for tmpl in RegistrationParentFileTemplate.objects.filter(registration_form=register_form):
|
||||
if not tmpl.master_id or tmpl.master_id not in parent_master_ids:
|
||||
try:
|
||||
if getattr(tmpl, "file", None):
|
||||
tmpl.file.delete(save=False)
|
||||
except Exception:
|
||||
logger.exception("Erreur suppression fichier parent template obsolète %s", getattr(tmpl, "pk", None))
|
||||
tmpl.delete()
|
||||
logger.info("Deleted obsolete parent template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk)
|
||||
|
||||
# Créer les school templates manquants ou mettre à jour les existants si le master a changé
|
||||
for m in school_masters:
|
||||
tmpl_qs = RegistrationSchoolFileTemplate.objects.filter(master=m, registration_form=register_form)
|
||||
tmpl = tmpl_qs.first() if tmpl_qs.exists() else None
|
||||
|
||||
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
|
||||
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
|
||||
|
||||
file_name = None
|
||||
if m.file and hasattr(m.file, 'name') and m.file.name:
|
||||
file_name = os.path.basename(m.file.name)
|
||||
elif m.file:
|
||||
file_name = str(m.file)
|
||||
else:
|
||||
try:
|
||||
pdf_file = generate_form_json_pdf(register_form, m.formMasterData)
|
||||
file_name = os.path.basename(pdf_file.name)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la génération du PDF pour le template: {e}")
|
||||
file_name = None
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
upload_rel_path = registration_school_file_upload_to(
|
||||
type("Tmp", (), {
|
||||
"registration_form": register_form,
|
||||
"establishment": getattr(register_form, "establishment", None),
|
||||
"student": getattr(register_form, "student", None)
|
||||
})(),
|
||||
file_name
|
||||
)
|
||||
abs_path = os.path.join(settings.MEDIA_ROOT, upload_rel_path)
|
||||
master_file_path = m.file.path if m.file and hasattr(m.file, 'path') else None
|
||||
|
||||
if tmpl:
|
||||
template_file_name = os.path.basename(tmpl.file.name) if tmpl.file and tmpl.file.name else None
|
||||
master_file_changed = template_file_name != file_name
|
||||
# --- GESTION FORM EXISTANT : suppression ancien template si nom ou contenu master changé ---
|
||||
if master_file_changed or (
|
||||
master_file_path and os.path.exists(master_file_path) and
|
||||
(not tmpl.file or not os.path.exists(abs_path) or os.path.getmtime(master_file_path) > os.path.getmtime(abs_path))
|
||||
):
|
||||
# Supprimer l'ancien fichier du template (même si le nom change)
|
||||
if tmpl.file and tmpl.file.name:
|
||||
old_template_path = os.path.join(settings.MEDIA_ROOT, tmpl.file.name)
|
||||
if os.path.exists(old_template_path):
|
||||
try:
|
||||
os.remove(old_template_path)
|
||||
logger.info(f"util.create_templates_for_registration_form - Suppression ancien fichier template: {old_template_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur suppression ancien fichier template: {e}")
|
||||
# Copier le nouveau fichier du master (form existant)
|
||||
if master_file_path and os.path.exists(master_file_path):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
import shutil
|
||||
shutil.copy2(master_file_path, abs_path)
|
||||
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
|
||||
tmpl.file.name = upload_rel_path
|
||||
tmpl.name = m.name or ""
|
||||
tmpl.slug = slug
|
||||
tmpl.formTemplateData = m.formMasterData or []
|
||||
tmpl.save()
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la copie du fichier master pour mise à jour du template: {e}")
|
||||
created.append(tmpl)
|
||||
logger.info("util.create_templates_for_registration_form - Mise à jour school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
||||
continue
|
||||
|
||||
# Sinon, création du template comme avant
|
||||
tmpl = RegistrationSchoolFileTemplate(
|
||||
master=m,
|
||||
registration_form=register_form,
|
||||
name=m.name or "",
|
||||
formTemplateData=m.formMasterData or [],
|
||||
slug=slug,
|
||||
)
|
||||
if file_name:
|
||||
# Copier le fichier du master si besoin (form existant)
|
||||
if master_file_path and not os.path.exists(abs_path):
|
||||
try:
|
||||
import shutil
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
shutil.copy2(master_file_path, abs_path)
|
||||
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la copie du fichier master pour le template: {e}")
|
||||
tmpl.file.name = upload_rel_path
|
||||
tmpl.save()
|
||||
created.append(tmpl)
|
||||
logger.info("util.create_templates_for_registration_form - Created school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
||||
|
||||
# Créer les parent templates manquants
|
||||
for m in parent_masters:
|
||||
exists = RegistrationParentFileTemplate.objects.filter(master=m, registration_form=register_form).exists()
|
||||
if exists:
|
||||
continue
|
||||
tmpl = RegistrationParentFileTemplate.objects.create(
|
||||
master=m,
|
||||
registration_form=register_form,
|
||||
file=None,
|
||||
)
|
||||
created.append(tmpl)
|
||||
logger.info("Created parent template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
||||
|
||||
return created
|
||||
|
||||
def recupereListeFichesInscription():
|
||||
"""
|
||||
Retourne la liste complète des fiches d’inscription.
|
||||
@ -212,4 +457,52 @@ def getHistoricalYears(count=5):
|
||||
historical_start_year = start_year - i
|
||||
historical_years.append(f"{historical_start_year}-{historical_start_year + 1}")
|
||||
|
||||
return historical_years
|
||||
return historical_years
|
||||
|
||||
def generate_form_json_pdf(register_form, form_json):
|
||||
"""
|
||||
Génère un PDF du rendu du formulaire dynamique à partir du JSON (formConfig)
|
||||
et l'associe au RegistrationSchoolFileTemplate.
|
||||
Le PDF contient le titre, les labels et types de champs.
|
||||
Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier.
|
||||
"""
|
||||
|
||||
# Récupérer le nom du formulaire
|
||||
form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
|
||||
filename = f"{form_name}.pdf"
|
||||
|
||||
# Générer le PDF
|
||||
buffer = BytesIO()
|
||||
c = canvas.Canvas(buffer, pagesize=A4)
|
||||
y = 800
|
||||
|
||||
# Titre
|
||||
c.setFont("Helvetica-Bold", 20)
|
||||
c.drawString(100, y, form_json.get("title", "Formulaire"))
|
||||
y -= 40
|
||||
|
||||
# Champs
|
||||
c.setFont("Helvetica", 12)
|
||||
fields = form_json.get("fields", [])
|
||||
for field in fields:
|
||||
label = field.get("label", field.get("id", ""))
|
||||
ftype = field.get("type", "")
|
||||
c.drawString(100, y, f"{label} [{ftype}]")
|
||||
y -= 25
|
||||
if y < 100:
|
||||
c.showPage()
|
||||
y = 800
|
||||
|
||||
c.save()
|
||||
buffer.seek(0)
|
||||
pdf_content = buffer.read()
|
||||
|
||||
# Si un fichier existe déjà sur le template, le supprimer (optionnel, à adapter selon usage)
|
||||
if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name:
|
||||
existing_file_path = os.path.join(settings.MEDIA_ROOT, register_form.registration_file.name.lstrip('/'))
|
||||
if os.path.exists(existing_file_path):
|
||||
os.remove(existing_file_path)
|
||||
register_form.registration_file.delete(save=False)
|
||||
|
||||
# Retourner le ContentFile avec uniquement le nom du fichier
|
||||
return ContentFile(pdf_content, name=os.path.basename(filename))
|
||||
|
||||
@ -1,11 +1,25 @@
|
||||
from .register_form_views import RegisterFormView, RegisterFormWithIdView, send, resend, archive, get_school_file_templates_by_rf, get_parent_file_templates_by_rf
|
||||
from .registration_file_views import (
|
||||
from .register_form_views import (
|
||||
RegisterFormView,
|
||||
RegisterFormWithIdView,
|
||||
send,
|
||||
resend,
|
||||
archive,
|
||||
get_school_file_templates_by_rf,
|
||||
get_parent_file_templates_by_rf
|
||||
)
|
||||
from .registration_school_file_masters_views import (
|
||||
RegistrationSchoolFileMasterView,
|
||||
RegistrationSchoolFileMasterSimpleView,
|
||||
RegistrationSchoolFileMasterSimpleView,
|
||||
)
|
||||
from .registration_school_file_templates_views import (
|
||||
RegistrationSchoolFileTemplateView,
|
||||
RegistrationSchoolFileTemplateSimpleView,
|
||||
RegistrationSchoolFileTemplateSimpleView
|
||||
)
|
||||
from .registration_parent_file_masters_views import (
|
||||
RegistrationParentFileMasterView,
|
||||
RegistrationParentFileMasterSimpleView,
|
||||
RegistrationParentFileMasterSimpleView
|
||||
)
|
||||
from .registration_parent_file_templates_views import (
|
||||
RegistrationParentFileTemplateSimpleView,
|
||||
RegistrationParentFileTemplateView
|
||||
)
|
||||
@ -33,7 +47,7 @@ __all__ = [
|
||||
'RegistrationFileGroupSimpleView',
|
||||
'get_registration_files_by_group',
|
||||
'get_school_file_templates_by_rf',
|
||||
'get_parent_file_templates_by_rf'
|
||||
'get_parent_file_templates_by_rf',
|
||||
'StudentView',
|
||||
'StudentListView',
|
||||
'ChildrenListView',
|
||||
|
||||
@ -17,10 +17,10 @@ import Subscriptions.util as util
|
||||
from Subscriptions.serializers import RegistrationFormSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileTemplateSerializer
|
||||
from Subscriptions.pagination import CustomSubscriptionPagination
|
||||
from Subscriptions.models import (
|
||||
Guardian,
|
||||
RegistrationForm,
|
||||
RegistrationSchoolFileTemplate,
|
||||
RegistrationFileGroup,
|
||||
Guardian,
|
||||
RegistrationForm,
|
||||
RegistrationSchoolFileTemplate,
|
||||
RegistrationFileGroup,
|
||||
RegistrationParentFileTemplate,
|
||||
StudentCompetency
|
||||
)
|
||||
@ -88,6 +88,7 @@ class RegisterFormView(APIView):
|
||||
filter = request.GET.get('filter', '').strip()
|
||||
page_size = request.GET.get('page_size', None)
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
search = request.GET.get('search', '').strip() # <-- Ajout du paramètre search
|
||||
|
||||
# Gestion du page_size
|
||||
if page_size is not None:
|
||||
@ -113,7 +114,14 @@ class RegisterFormView(APIView):
|
||||
registerForms_List = None
|
||||
|
||||
if registerForms_List:
|
||||
registerForms_List = registerForms_List.filter(establishment=establishment_id).order_by('-last_update')
|
||||
registerForms_List = registerForms_List.filter(establishment=establishment_id)
|
||||
# Ajout du filtre search sur le nom et prénom de l'élève
|
||||
if search:
|
||||
registerForms_List = registerForms_List.filter(
|
||||
Q(student__first_name__icontains=search) |
|
||||
Q(student__last_name__icontains=search)
|
||||
)
|
||||
registerForms_List = registerForms_List.order_by('-last_update')
|
||||
|
||||
if not registerForms_List:
|
||||
return JsonResponse({'error': 'aucune donnée trouvée', 'count': 0}, safe=False)
|
||||
@ -423,6 +431,262 @@ class RegisterFormWithIdView(APIView):
|
||||
# Retourner les données mises à jour
|
||||
return JsonResponse(studentForm_serializer.data, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'student_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données étudiant'),
|
||||
'guardians_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données responsables'),
|
||||
'siblings_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données fratrie'),
|
||||
'payment_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données de paiement'),
|
||||
'current_page': openapi.Schema(type=openapi.TYPE_INTEGER, description='Page actuelle du formulaire'),
|
||||
'auto_save': openapi.Schema(type=openapi.TYPE_BOOLEAN, description='Indicateur auto-save'),
|
||||
}
|
||||
),
|
||||
responses={200: RegistrationFormSerializer()},
|
||||
operation_description="Auto-sauvegarde partielle d'un dossier d'inscription.",
|
||||
operation_summary="Auto-sauvegarder un dossier d'inscription"
|
||||
)
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
def patch(self, request, id):
|
||||
"""
|
||||
Auto-sauvegarde partielle d'un dossier d'inscription.
|
||||
Cette méthode est optimisée pour les sauvegardes automatiques périodiques.
|
||||
"""
|
||||
try:
|
||||
# Récupérer le dossier d'inscription
|
||||
registerForm = bdd.getObject(_objectName=RegistrationForm, _columnName='student__id', _value=id)
|
||||
if not registerForm:
|
||||
return JsonResponse({"error": "Dossier d'inscription introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Préparer les données à mettre à jour
|
||||
update_data = {}
|
||||
|
||||
# Traiter les données étudiant si présentes
|
||||
if 'student_data' in request.data:
|
||||
try:
|
||||
student_data = json.loads(request.data['student_data'])
|
||||
|
||||
# Extraire les données de paiement des données étudiant
|
||||
payment_fields = ['registration_payment', 'tuition_payment', 'registration_payment_plan', 'tuition_payment_plan']
|
||||
payment_data = {}
|
||||
|
||||
for field in payment_fields:
|
||||
if field in student_data:
|
||||
payment_data[field] = student_data.pop(field)
|
||||
|
||||
# Si nous avons des données de paiement, les traiter
|
||||
if payment_data:
|
||||
logger.debug(f"Auto-save: extracted payment_data from student_data = {payment_data}")
|
||||
|
||||
# Traiter les données de paiement
|
||||
payment_updates = {}
|
||||
|
||||
# Gestion du mode de paiement d'inscription
|
||||
if 'registration_payment' in payment_data and payment_data['registration_payment']:
|
||||
try:
|
||||
from School.models import PaymentMode
|
||||
payment_mode = PaymentMode.objects.get(id=payment_data['registration_payment'])
|
||||
registerForm.registration_payment = payment_mode
|
||||
payment_updates['registration_payment'] = payment_mode.id
|
||||
except PaymentMode.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentMode with id {payment_data['registration_payment']} not found")
|
||||
|
||||
# Gestion du mode de paiement de scolarité
|
||||
if 'tuition_payment' in payment_data and payment_data['tuition_payment']:
|
||||
try:
|
||||
from School.models import PaymentMode
|
||||
payment_mode = PaymentMode.objects.get(id=payment_data['tuition_payment'])
|
||||
registerForm.tuition_payment = payment_mode
|
||||
payment_updates['tuition_payment'] = payment_mode.id
|
||||
except PaymentMode.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentMode with id {payment_data['tuition_payment']} not found")
|
||||
|
||||
# Gestion du plan de paiement d'inscription
|
||||
if 'registration_payment_plan' in payment_data and payment_data['registration_payment_plan']:
|
||||
try:
|
||||
from School.models import PaymentPlan
|
||||
payment_plan = PaymentPlan.objects.get(id=payment_data['registration_payment_plan'])
|
||||
registerForm.registration_payment_plan = payment_plan
|
||||
payment_updates['registration_payment_plan'] = payment_plan.id
|
||||
except PaymentPlan.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['registration_payment_plan']} not found")
|
||||
|
||||
# Gestion du plan de paiement de scolarité
|
||||
if 'tuition_payment_plan' in payment_data and payment_data['tuition_payment_plan']:
|
||||
try:
|
||||
from School.models import PaymentPlan
|
||||
payment_plan = PaymentPlan.objects.get(id=payment_data['tuition_payment_plan'])
|
||||
registerForm.tuition_payment_plan = payment_plan
|
||||
payment_updates['tuition_payment_plan'] = payment_plan.id
|
||||
except PaymentPlan.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['tuition_payment_plan']} not found")
|
||||
|
||||
# Sauvegarder les modifications de paiement
|
||||
if payment_updates:
|
||||
registerForm.save()
|
||||
logger.debug(f"Auto-save: Payment data updated - {payment_updates}")
|
||||
|
||||
update_data['student'] = student_data
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Auto-save: Invalid JSON in student_data")
|
||||
|
||||
# Traiter les données des responsables si présentes
|
||||
if 'guardians_data' in request.data:
|
||||
try:
|
||||
guardians_data = json.loads(request.data['guardians_data'])
|
||||
logger.debug(f"Auto-save: guardians_data = {guardians_data}")
|
||||
|
||||
# Enregistrer directement chaque guardian avec le modèle
|
||||
for i, guardian_data in enumerate(guardians_data):
|
||||
guardian_id = guardian_data.get('id')
|
||||
if guardian_id:
|
||||
try:
|
||||
# Récupérer le guardian existant et mettre à jour ses champs
|
||||
guardian = Guardian.objects.get(id=guardian_id)
|
||||
|
||||
# Mettre à jour les champs si ils sont présents
|
||||
if 'birth_date' in guardian_data and guardian_data['birth_date']:
|
||||
guardian.birth_date = guardian_data['birth_date']
|
||||
if 'profession' in guardian_data:
|
||||
guardian.profession = guardian_data['profession']
|
||||
if 'address' in guardian_data:
|
||||
guardian.address = guardian_data['address']
|
||||
if 'phone' in guardian_data:
|
||||
guardian.phone = guardian_data['phone']
|
||||
if 'first_name' in guardian_data:
|
||||
guardian.first_name = guardian_data['first_name']
|
||||
if 'last_name' in guardian_data:
|
||||
guardian.last_name = guardian_data['last_name']
|
||||
|
||||
guardian.save()
|
||||
logger.debug(f"Guardian {i}: Updated birth_date={guardian.birth_date}, profession={guardian.profession}, address={guardian.address}")
|
||||
|
||||
except Guardian.DoesNotExist:
|
||||
logger.warning(f"Auto-save: Guardian with id {guardian_id} not found")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Auto-save: Invalid JSON in guardians_data")
|
||||
|
||||
# Traiter les données de la fratrie si présentes
|
||||
if 'siblings_data' in request.data:
|
||||
try:
|
||||
siblings_data = json.loads(request.data['siblings_data'])
|
||||
logger.debug(f"Auto-save: siblings_data = {siblings_data}")
|
||||
|
||||
# Enregistrer directement chaque sibling avec le modèle
|
||||
for i, sibling_data in enumerate(siblings_data):
|
||||
sibling_id = sibling_data.get('id')
|
||||
if sibling_id:
|
||||
try:
|
||||
# Récupérer le sibling existant et mettre à jour ses champs
|
||||
from Subscriptions.models import Sibling
|
||||
sibling = Sibling.objects.get(id=sibling_id)
|
||||
|
||||
# Mettre à jour les champs si ils sont présents
|
||||
if 'first_name' in sibling_data:
|
||||
sibling.first_name = sibling_data['first_name']
|
||||
if 'last_name' in sibling_data:
|
||||
sibling.last_name = sibling_data['last_name']
|
||||
if 'birth_date' in sibling_data and sibling_data['birth_date']:
|
||||
sibling.birth_date = sibling_data['birth_date']
|
||||
|
||||
sibling.save()
|
||||
logger.debug(f"Sibling {i}: Updated first_name={sibling.first_name}, last_name={sibling.last_name}, birth_date={sibling.birth_date}")
|
||||
|
||||
except Sibling.DoesNotExist:
|
||||
logger.warning(f"Auto-save: Sibling with id {sibling_id} not found")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Auto-save: Invalid JSON in siblings_data")
|
||||
|
||||
# Traiter les données de paiement si présentes
|
||||
if 'payment_data' in request.data:
|
||||
try:
|
||||
payment_data = json.loads(request.data['payment_data'])
|
||||
logger.debug(f"Auto-save: payment_data = {payment_data}")
|
||||
|
||||
# Mettre à jour directement les champs de paiement du formulaire
|
||||
payment_updates = {}
|
||||
|
||||
# Gestion du mode de paiement d'inscription
|
||||
if 'registration_payment' in payment_data and payment_data['registration_payment']:
|
||||
try:
|
||||
from School.models import PaymentMode
|
||||
payment_mode = PaymentMode.objects.get(id=payment_data['registration_payment'])
|
||||
registerForm.registration_payment = payment_mode
|
||||
payment_updates['registration_payment'] = payment_mode.id
|
||||
except PaymentMode.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentMode with id {payment_data['registration_payment']} not found")
|
||||
|
||||
# Gestion du mode de paiement de scolarité
|
||||
if 'tuition_payment' in payment_data and payment_data['tuition_payment']:
|
||||
try:
|
||||
from School.models import PaymentMode
|
||||
payment_mode = PaymentMode.objects.get(id=payment_data['tuition_payment'])
|
||||
registerForm.tuition_payment = payment_mode
|
||||
payment_updates['tuition_payment'] = payment_mode.id
|
||||
except PaymentMode.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentMode with id {payment_data['tuition_payment']} not found")
|
||||
|
||||
# Gestion du plan de paiement d'inscription
|
||||
if 'registration_payment_plan' in payment_data and payment_data['registration_payment_plan']:
|
||||
try:
|
||||
from School.models import PaymentPlan
|
||||
payment_plan = PaymentPlan.objects.get(id=payment_data['registration_payment_plan'])
|
||||
registerForm.registration_payment_plan = payment_plan
|
||||
payment_updates['registration_payment_plan'] = payment_plan.id
|
||||
except PaymentPlan.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['registration_payment_plan']} not found")
|
||||
|
||||
# Gestion du plan de paiement de scolarité
|
||||
if 'tuition_payment_plan' in payment_data and payment_data['tuition_payment_plan']:
|
||||
try:
|
||||
from School.models import PaymentPlan
|
||||
payment_plan = PaymentPlan.objects.get(id=payment_data['tuition_payment_plan'])
|
||||
registerForm.tuition_payment_plan = payment_plan
|
||||
payment_updates['tuition_payment_plan'] = payment_plan.id
|
||||
except PaymentPlan.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['tuition_payment_plan']} not found")
|
||||
|
||||
# Sauvegarder les modifications de paiement
|
||||
if payment_updates:
|
||||
registerForm.save()
|
||||
logger.debug(f"Auto-save: Payment data updated - {payment_updates}")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Auto-save: Invalid JSON in payment_data")
|
||||
|
||||
# Mettre à jour la page actuelle si présente
|
||||
if 'current_page' in request.data:
|
||||
try:
|
||||
current_page = int(request.data['current_page'])
|
||||
# Vous pouvez sauvegarder cette info dans un champ du modèle si nécessaire
|
||||
logger.debug(f"Auto-save: current_page = {current_page}")
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Auto-save: Invalid current_page value")
|
||||
|
||||
# Effectuer la mise à jour partielle seulement si nous avons des données
|
||||
if update_data:
|
||||
serializer = RegistrationFormSerializer(registerForm, data=update_data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
logger.debug(f"Auto-save successful for student {id}")
|
||||
return JsonResponse({"status": "auto_save_success", "timestamp": util._now().isoformat()}, safe=False)
|
||||
else:
|
||||
logger.warning(f"Auto-save validation errors: {serializer.errors}")
|
||||
# Pour l'auto-save, on retourne un succès même en cas d'erreur de validation
|
||||
return JsonResponse({"status": "auto_save_partial", "errors": serializer.errors}, safe=False)
|
||||
else:
|
||||
# Pas de données à sauvegarder, mais on retourne un succès
|
||||
return JsonResponse({"status": "auto_save_no_data"}, safe=False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-save error for student {id}: {str(e)}")
|
||||
# Pour l'auto-save, on ne retourne pas d'erreur HTTP pour éviter d'interrompre l'UX
|
||||
return JsonResponse({"status": "auto_save_failed", "error": str(e)}, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
responses={204: 'No Content'},
|
||||
operation_description="Supprime un dossier d'inscription donné.",
|
||||
|
||||
@ -0,0 +1,272 @@
|
||||
from django.http.response import JsonResponse
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
|
||||
from Subscriptions.serializers import RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
|
||||
from Subscriptions.models import (
|
||||
RegistrationForm,
|
||||
RegistrationParentFileMaster,
|
||||
RegistrationParentFileTemplate
|
||||
)
|
||||
from N3wtSchool import bdd
|
||||
import logging
|
||||
import Subscriptions.util as util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RegistrationParentFileMasterView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les fichiers parents pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationParentFileMasterSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les fichiers parents liés à l'établissement
|
||||
templates = RegistrationParentFileMaster.objects.filter(
|
||||
groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationParentFileMasterSerializer(templates, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Crée un nouveau fichier parent",
|
||||
request_body=RegistrationParentFileMasterSerializer,
|
||||
responses={
|
||||
201: RegistrationParentFileMasterSerializer,
|
||||
400: "Données invalides"
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
logger.info(f"raw request.data: {request.data}")
|
||||
|
||||
payload, resp = util.build_payload_from_request(request)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
logger.info(f"payload for serializer: {payload}")
|
||||
serializer = RegistrationParentFileMasterSerializer(data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
obj = serializer.save()
|
||||
|
||||
# Propager la création des templates côté serveur pour les RegistrationForm
|
||||
try:
|
||||
groups_qs = obj.groups.all()
|
||||
if groups_qs.exists():
|
||||
rfs = RegistrationForm.objects.filter(fileGroup__in=groups_qs).distinct()
|
||||
for rf in rfs:
|
||||
try:
|
||||
util.create_templates_for_registration_form(rf)
|
||||
except Exception as e:
|
||||
logger.exception("Error creating templates for RF %s from parent master %s: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||
except Exception:
|
||||
logger.exception("Error while propagating templates after parent master creation %s", getattr(obj, 'pk', None))
|
||||
|
||||
return Response(RegistrationParentFileMasterSerializer(obj).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
logger.error(f"serializer errors: {serializer.errors}")
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationParentFileMasterSimpleView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un fichier parent spécifique",
|
||||
responses={
|
||||
200: RegistrationParentFileMasterSerializer,
|
||||
404: "Fichier parent non trouvé"
|
||||
}
|
||||
)
|
||||
def get(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({"errorMessage":'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = RegistrationParentFileMasterSerializer(template)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Met à jour un fichier parent existant",
|
||||
request_body=RegistrationParentFileMasterSerializer,
|
||||
responses={
|
||||
200: RegistrationParentFileMasterSerializer,
|
||||
400: "Données invalides",
|
||||
404: "Fichier parent non trouvé"
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
master = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
||||
if master is None:
|
||||
return JsonResponse({'erreur': "Le master de fichier parent n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# snapshot des groups avant update
|
||||
old_group_ids = set(master.groups.values_list('id', flat=True))
|
||||
|
||||
# Normaliser payload (supporte form-data avec champ 'data' JSON ou fichier JSON)
|
||||
payload, resp = util.build_payload_from_request(request)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
logger.info(f"payload for update serializer: {payload}")
|
||||
serializer = RegistrationParentFileMasterSerializer(master, data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
obj = serializer.save()
|
||||
|
||||
# groups après update
|
||||
new_group_ids = set(obj.groups.values_list('id', flat=True))
|
||||
|
||||
removed_group_ids = old_group_ids - new_group_ids
|
||||
added_group_ids = new_group_ids - old_group_ids
|
||||
|
||||
# Pour chaque RF appartenant aux groupes retirés -> nettoyer les templates (idempotent)
|
||||
if removed_group_ids:
|
||||
try:
|
||||
rfs_removed = RegistrationForm.objects.filter(fileGroup__in=list(removed_group_ids)).distinct()
|
||||
for rf in rfs_removed:
|
||||
try:
|
||||
util.create_templates_for_registration_form(rf)
|
||||
except Exception as e:
|
||||
logger.exception("Error cleaning templates for RF %s after parent master %s group removal: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||
except Exception:
|
||||
logger.exception("Error while processing RFs for removed groups after parent master update %s", getattr(obj, 'pk', None))
|
||||
|
||||
# Pour chaque RF appartenant aux groupes ajoutés -> créer les templates manquants
|
||||
if added_group_ids:
|
||||
try:
|
||||
rfs_added = RegistrationForm.objects.filter(fileGroup__in=list(added_group_ids)).distinct()
|
||||
for rf in rfs_added:
|
||||
try:
|
||||
util.create_templates_for_registration_form(rf)
|
||||
except Exception as e:
|
||||
logger.exception("Error creating templates for RF %s after parent master %s group addition: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||
except Exception:
|
||||
logger.exception("Error while processing RFs for added groups after parent master update %s", getattr(obj, 'pk', None))
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
logger.error(f"serializer errors on put: {serializer.errors}")
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Supprime un fichier parent",
|
||||
responses={
|
||||
204: "Suppression réussie",
|
||||
404: "Fichier parent non trouvé"
|
||||
}
|
||||
)
|
||||
def delete(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
||||
if template is not None:
|
||||
template.delete()
|
||||
return JsonResponse({'message': 'La suppression du fichier parent a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
class RegistrationParentFileTemplateView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les templates parents pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
|
||||
templates = RegistrationParentFileTemplate.objects.filter(
|
||||
master__groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Crée un nouveau template d'inscription",
|
||||
request_body=RegistrationParentFileTemplateSerializer,
|
||||
responses={
|
||||
201: RegistrationParentFileTemplateSerializer,
|
||||
400: "Données invalides"
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationParentFileTemplateSimpleView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un template d'inscription spécifique",
|
||||
responses={
|
||||
200: RegistrationParentFileTemplateSerializer,
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def get(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = RegistrationParentFileTemplateSerializer(template)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Met à jour un template d'inscription existant",
|
||||
request_body=RegistrationParentFileTemplateSerializer,
|
||||
responses={
|
||||
200: RegistrationParentFileTemplateSerializer,
|
||||
400: "Données invalides",
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Supprime un template d'inscription",
|
||||
responses={
|
||||
204: "Suppression réussie",
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def delete(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is not None:
|
||||
# Suppression du fichier PDF associé avant suppression de l'objet
|
||||
if template.file and template.file.name:
|
||||
template.file.delete(save=False)
|
||||
template.delete()
|
||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
@ -0,0 +1,111 @@
|
||||
from django.http.response import JsonResponse
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
|
||||
from Subscriptions.serializers import RegistrationParentFileTemplateSerializer
|
||||
from Subscriptions.models import (
|
||||
RegistrationParentFileTemplate
|
||||
)
|
||||
from N3wtSchool import bdd
|
||||
import logging
|
||||
import Subscriptions.util as util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RegistrationParentFileTemplateView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les templates parents pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
|
||||
templates = RegistrationParentFileTemplate.objects.filter(
|
||||
master__groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Crée un nouveau template d'inscription",
|
||||
request_body=RegistrationParentFileTemplateSerializer,
|
||||
responses={
|
||||
201: RegistrationParentFileTemplateSerializer,
|
||||
400: "Données invalides"
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationParentFileTemplateSimpleView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un template d'inscription spécifique",
|
||||
responses={
|
||||
200: RegistrationParentFileTemplateSerializer,
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def get(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = RegistrationParentFileTemplateSerializer(template)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Met à jour un template d'inscription existant",
|
||||
request_body=RegistrationParentFileTemplateSerializer,
|
||||
responses={
|
||||
200: RegistrationParentFileTemplateSerializer,
|
||||
400: "Données invalides",
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Supprime un template d'inscription",
|
||||
responses={
|
||||
204: "Suppression réussie",
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def delete(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is not None:
|
||||
# Suppression du fichier PDF associé
|
||||
if template.file and template.file.name:
|
||||
template.file.delete(save=False)
|
||||
template.delete()
|
||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
@ -0,0 +1,190 @@
|
||||
from django.http.response import JsonResponse
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
|
||||
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer
|
||||
from Subscriptions.models import (
|
||||
RegistrationForm,
|
||||
RegistrationSchoolFileMaster,
|
||||
RegistrationSchoolFileTemplate
|
||||
)
|
||||
from N3wtSchool import bdd
|
||||
import logging
|
||||
import Subscriptions.util as util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RegistrationSchoolFileMasterView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les masters de templates d'inscription pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationSchoolFileMasterSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les masters liés à l'établissement via groups.establishment
|
||||
masters = RegistrationSchoolFileMaster.objects.filter(
|
||||
groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationSchoolFileMasterSerializer(masters, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Crée un nouveau master de template d'inscription",
|
||||
request_body=RegistrationSchoolFileMasterSerializer,
|
||||
responses={
|
||||
201: RegistrationSchoolFileMasterSerializer,
|
||||
400: "Données invalides"
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
logger.info(f"raw request.data: {request.data}")
|
||||
|
||||
payload, resp = util.build_payload_from_request(request)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
|
||||
logger.info(f"payload for serializer: {payload}")
|
||||
serializer = RegistrationSchoolFileMasterSerializer(data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
obj = serializer.save()
|
||||
|
||||
# Propager la création des templates côté serveur pour les RegistrationForm
|
||||
try:
|
||||
groups_qs = obj.groups.all()
|
||||
if groups_qs.exists():
|
||||
# Tous les RegistrationForm dont fileGroup est dans les groups du master
|
||||
rfs = RegistrationForm.objects.filter(fileGroup__in=groups_qs).distinct()
|
||||
for rf in rfs:
|
||||
try:
|
||||
util.create_templates_for_registration_form(rf)
|
||||
except Exception as e:
|
||||
logger.exception("Error creating templates for RF %s from master %s: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||
except Exception:
|
||||
logger.exception("Error while propagating templates after master creation %s", getattr(obj, 'pk', None))
|
||||
|
||||
|
||||
return Response(RegistrationSchoolFileMasterSerializer(obj).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
logger.error(f"serializer errors: {serializer.errors}")
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationSchoolFileMasterSimpleView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un master de template d'inscription spécifique",
|
||||
responses={
|
||||
200: RegistrationSchoolFileMasterSerializer,
|
||||
404: "Master non trouvé"
|
||||
}
|
||||
)
|
||||
def get(self, request, id):
|
||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||
if master is None:
|
||||
return JsonResponse({"errorMessage":'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = RegistrationSchoolFileMasterSerializer(master)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Met à jour un master de template d'inscription existant",
|
||||
request_body=RegistrationSchoolFileMasterSerializer,
|
||||
responses={
|
||||
200: RegistrationSchoolFileMasterSerializer,
|
||||
400: "Données invalides",
|
||||
404: "Master non trouvé"
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||
if master is None:
|
||||
return JsonResponse({'erreur': "Le master de template n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# snapshot des groups avant update
|
||||
old_group_ids = set(master.groups.values_list('id', flat=True))
|
||||
|
||||
# Normaliser payload (supporte form-data avec champ 'data' JSON ou fichier JSON)
|
||||
payload, resp = util.build_payload_from_request(request)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
|
||||
logger.info(f"payload for update serializer: {payload}")
|
||||
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
obj = serializer.save()
|
||||
|
||||
# groups après update
|
||||
new_group_ids = set(obj.groups.values_list('id', flat=True))
|
||||
|
||||
removed_group_ids = old_group_ids - new_group_ids
|
||||
added_group_ids = new_group_ids - old_group_ids
|
||||
|
||||
# Pour chaque RF appartenant aux groupes retirés -> nettoyer les templates (idempotent)
|
||||
if removed_group_ids:
|
||||
logger.info("REMOVE IDs")
|
||||
try:
|
||||
rfs_removed = RegistrationForm.objects.filter(fileGroup__in=list(removed_group_ids)).distinct()
|
||||
for rf in rfs_removed:
|
||||
try:
|
||||
util.create_templates_for_registration_form(rf) # supprimera les templates obsolètes
|
||||
except Exception as e:
|
||||
logger.exception("Error cleaning templates for RF %s after master %s group removal: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||
except Exception:
|
||||
logger.exception("Error while processing RFs for removed groups after master update %s", getattr(obj, 'pk', None))
|
||||
|
||||
# Pour chaque RF appartenant aux groupes ajoutés -> créer les templates manquants
|
||||
if added_group_ids:
|
||||
logger.info("ADD IDs")
|
||||
try:
|
||||
rfs_added = RegistrationForm.objects.filter(fileGroup__in=list(added_group_ids)).distinct()
|
||||
for rf in rfs_added:
|
||||
try:
|
||||
util.create_templates_for_registration_form(rf) # créera les templates manquants
|
||||
except Exception as e:
|
||||
logger.exception("Error creating templates for RF %s after master %s group addition: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||
except Exception:
|
||||
logger.exception("Error while processing RFs for added groups after master update %s", getattr(obj, 'pk', None))
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
logger.error(f"serializer errors on put: {serializer.errors}")
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Supprime un master de template d'inscription",
|
||||
responses={
|
||||
204: "Suppression réussie",
|
||||
404: "Master non trouvé"
|
||||
}
|
||||
)
|
||||
def delete(self, request, id):
|
||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||
if master is not None:
|
||||
# Supprimer tous les templates liés et leurs fichiers PDF
|
||||
templates = RegistrationSchoolFileTemplate.objects.filter(master=master)
|
||||
for template in templates:
|
||||
if template.file and template.file.name:
|
||||
template.file.delete(save=False)
|
||||
template.delete()
|
||||
master.delete()
|
||||
return JsonResponse({'message': 'La suppression du master de template et des fichiers associés a été effectuée avec succès'}, safe=False, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
@ -5,18 +5,40 @@ from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
import os
|
||||
|
||||
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
|
||||
from Subscriptions.models import RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, RegistrationParentFileMaster, RegistrationParentFileTemplate
|
||||
from N3wtSchool import bdd
|
||||
import logging
|
||||
import Subscriptions.util as util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RegistrationSchoolFileMasterView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les masters de templates d'inscription",
|
||||
operation_description="Récupère tous les masters de templates d'inscription pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationSchoolFileMasterSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
masters = RegistrationSchoolFileMaster.objects.all()
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les masters liés à l'établissement via groups.establishment
|
||||
masters = RegistrationSchoolFileMaster.objects.filter(
|
||||
groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationSchoolFileMasterSerializer(masters, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@ -29,10 +51,19 @@ class RegistrationSchoolFileMasterView(APIView):
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = RegistrationSchoolFileMasterSerializer(data=request.data)
|
||||
logger.info(f"raw request.data: {request.data}")
|
||||
|
||||
payload, resp = util.build_payload_from_request(request)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
logger.info(f"payload for serializer: {payload}")
|
||||
serializer = RegistrationSchoolFileMasterSerializer(data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
obj = serializer.save()
|
||||
return Response(RegistrationSchoolFileMasterSerializer(obj).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
logger.error(f"serializer errors: {serializer.errors}")
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationSchoolFileMasterSimpleView(APIView):
|
||||
@ -62,11 +93,19 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
|
||||
def put(self, request, id):
|
||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||
if master is None:
|
||||
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = RegistrationSchoolFileMasterSerializer(master, data=request.data)
|
||||
return JsonResponse({'erreur': "Le master de template n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Normaliser payload (supporte form-data avec champ 'data' JSON ou fichier JSON)
|
||||
payload, resp = util.build_payload_from_request(request)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
logger.info(f"payload for update serializer: {payload}")
|
||||
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
logger.error(f"serializer errors on put: {serializer.errors}")
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@swagger_auto_schema(
|
||||
@ -80,17 +119,33 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
|
||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||
if master is not None:
|
||||
master.delete()
|
||||
return JsonResponse({'message': 'La suppression du master de template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
return JsonResponse({'message': 'La suppression du master de template a été effectuée avec succès'}, safe=False, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
class RegistrationSchoolFileTemplateView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les templates d'inscription",
|
||||
operation_description="Récupère tous les templates d'inscription pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationSchoolFileTemplateSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
templates = RegistrationSchoolFileTemplate.objects.all()
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les templates liés à l'établissement via master.groups.establishment
|
||||
templates = RegistrationSchoolFileTemplate.objects.filter(
|
||||
master__groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(templates, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@ -153,6 +208,17 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
def delete(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
||||
if template is not None:
|
||||
# Suppression du fichier PDF associé
|
||||
if template.file and template.file.name:
|
||||
file_path = template.file.path
|
||||
template.file.delete(save=False)
|
||||
# Vérification post-suppression
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
logger.info(f"Fichier supprimé manuellement: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la suppression manuelle du fichier: {e}")
|
||||
template.delete()
|
||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
@ -160,11 +226,27 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
|
||||
class RegistrationParentFileMasterView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les fichiers parents",
|
||||
operation_description="Récupère tous les fichiers parents pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationParentFileMasterSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
templates = RegistrationParentFileMaster.objects.all()
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les fichiers parents liés à l'établissement
|
||||
templates = RegistrationParentFileMaster.objects.filter(
|
||||
groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationParentFileMasterSerializer(templates, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@ -234,11 +316,27 @@ class RegistrationParentFileMasterSimpleView(APIView):
|
||||
|
||||
class RegistrationParentFileTemplateView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les templates d'inscription",
|
||||
operation_description="Récupère tous les templates parents pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
templates = RegistrationParentFileTemplate.objects.all()
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
|
||||
templates = RegistrationParentFileTemplate.objects.filter(
|
||||
master__groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@ -302,6 +400,17 @@ class RegistrationParentFileTemplateSimpleView(APIView):
|
||||
def delete(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is not None:
|
||||
# Suppression du fichier PDF associé
|
||||
if template.file and template.file.name:
|
||||
file_path = template.file.path
|
||||
template.file.delete(save=False)
|
||||
# Vérification post-suppression
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
logger.info(f"Fichier supprimé manuellement: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la suppression manuelle du fichier: {e}")
|
||||
template.delete()
|
||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
@ -4,8 +4,9 @@ from rest_framework import status
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
||||
from django.utils.decorators import method_decorator
|
||||
from Subscriptions.models import StudentCompetency, Student, BilanCompetence
|
||||
from Subscriptions.models import StudentCompetency, Student
|
||||
from Common.models import Domain
|
||||
from Subscriptions.models import BilanCompetence
|
||||
from datetime import date
|
||||
from N3wtSchool.renderers import render_to_pdf
|
||||
from django.core.files import File
|
||||
|
||||
@ -1 +1 @@
|
||||
__version__ = "0.0.1"
|
||||
__version__ = "0.0.3"
|
||||
|
||||
@ -8,6 +8,7 @@ APPS = [
|
||||
"Planning",
|
||||
"GestionNotification",
|
||||
"GestionMessagerie",
|
||||
"GestionEmail",
|
||||
"Auth",
|
||||
"School",
|
||||
"Common"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -1,5 +1,6 @@
|
||||
import subprocess
|
||||
import os
|
||||
from watchfiles import run_process
|
||||
|
||||
def run_command(command):
|
||||
process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
@ -10,20 +11,33 @@ def run_command(command):
|
||||
print(f"stderr: {stderr.decode()}")
|
||||
return process.returncode
|
||||
|
||||
test_mode = os.getenv('TEST_MODE', 'False') == 'True'
|
||||
test_mode = os.getenv('test_mode', 'false').lower() == 'true'
|
||||
flush_data = os.getenv('flush_data', 'false').lower() == 'true'
|
||||
migrate_data = os.getenv('migrate_data', 'false').lower() == 'true'
|
||||
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
|
||||
|
||||
commands = [
|
||||
["python", "manage.py", "collectstatic", "--noinput"],
|
||||
["python", "manage.py", "flush", "--noinput"],
|
||||
collect_static_cmd = [
|
||||
["python", "manage.py", "collectstatic", "--noinput"]
|
||||
]
|
||||
|
||||
flush_data_cmd = [
|
||||
["python", "manage.py", "flush", "--noinput"]
|
||||
]
|
||||
|
||||
migrate_commands = [
|
||||
["python", "manage.py", "makemigrations", "Common", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Settings", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Planning", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Auth", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "School", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "School", "--noinput"]
|
||||
]
|
||||
|
||||
commands = [
|
||||
["python", "manage.py", "migrate", "--noinput"]
|
||||
]
|
||||
|
||||
@ -31,22 +45,70 @@ test_commands = [
|
||||
["python", "manage.py", "init_mock_datas"]
|
||||
]
|
||||
|
||||
for command in commands:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
def run_daphne():
|
||||
try:
|
||||
result = subprocess.run([
|
||||
"daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"
|
||||
])
|
||||
return result.returncode
|
||||
except KeyboardInterrupt:
|
||||
print("Arrêt de Daphne (KeyboardInterrupt)")
|
||||
return 0
|
||||
|
||||
if test_mode:
|
||||
for test_command in test_commands:
|
||||
if run_command(test_command) != 0:
|
||||
if __name__ == "__main__":
|
||||
|
||||
for command in collect_static_cmd:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
# Lancer les processus en parallèle
|
||||
processes = [
|
||||
subprocess.Popen(["python", "manage.py", "runserver", "0.0.0.0:8080"]),
|
||||
subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]),
|
||||
subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
|
||||
]
|
||||
if flush_data:
|
||||
for command in flush_data_cmd:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
# Attendre la fin des processus
|
||||
for process in processes:
|
||||
process.wait()
|
||||
if migrate_data:
|
||||
for command in migrate_commands:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
for command in commands:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if test_mode:
|
||||
for test_command in test_commands:
|
||||
if run_command(test_command) != 0:
|
||||
exit(1)
|
||||
|
||||
if watch_mode:
|
||||
celery_worker = subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"])
|
||||
celery_beat = subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
|
||||
try:
|
||||
run_process(
|
||||
'.',
|
||||
target=run_daphne
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("Arrêt demandé (KeyboardInterrupt)")
|
||||
finally:
|
||||
celery_worker.terminate()
|
||||
celery_beat.terminate()
|
||||
celery_worker.wait()
|
||||
celery_beat.wait()
|
||||
else:
|
||||
processes = [
|
||||
subprocess.Popen([
|
||||
"daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"
|
||||
]),
|
||||
subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]),
|
||||
subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
|
||||
]
|
||||
try:
|
||||
for process in processes:
|
||||
process.wait()
|
||||
except KeyboardInterrupt:
|
||||
print("Arrêt demandé (KeyboardInterrupt)")
|
||||
for process in processes:
|
||||
process.terminate()
|
||||
for process in processes:
|
||||
process.wait()
|
||||
@ -1,8 +1,42 @@
|
||||
<svg width="38" height="120" viewBox="0 0 38 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M38 79.0995V40L29.9458 49.0478V72.3137L1.27073 99.134C-2.59496 103.012 3.20408 109.151 7.39397 106.243L38 79.0995Z" fill="#D9006D" fill-opacity="0.8"/>
|
||||
<path d="M0 79.0995V40L8.05422 49.0478V72.3137L36.7293 99.134C40.595 103.012 34.7959 109.151 30.606 106.243L0 79.0995Z" fill="#038ECE" fill-opacity="0.8"/>
|
||||
<path d="M19 83L12 89.6585L19 96L26 89.6585L19 83Z" fill="#011922"/>
|
||||
<path d="M14 28.8736H24V72.1264H14V28.8736Z" fill="#001821"/>
|
||||
<path d="M24 28.8736C24 31.5652 21.7614 33.7471 19 33.7471C16.2386 33.7471 14 31.5652 14 28.8736C14 26.182 16.2386 24 19 24C21.7614 24 24 26.182 24 28.8736Z" fill="#001821"/>
|
||||
<path d="M24 72.1264C24 74.818 21.7614 77 19 77C16.2386 77 14 74.818 14 72.1264C14 69.4348 16.2386 67.2529 19 67.2529C21.7614 67.2529 24 69.4348 24 72.1264Z" fill="#001821"/>
|
||||
<svg width="565" height="609" viewBox="0 0 565 609" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M170.999 374.501C167.799 383.301 172.332 402.834 174.999 411.501C189.998 452.002 218.216 463.317 223 464C237 466 238.5 464 246.5 459.5C248.1 451.9 238.333 445 234 443C224 439.8 213.833 431 210 427C246.4 422.6 255.833 416.167 256 413.5C246.8 395.9 213.5 396.834 198 399.501C181.2 372.301 172.999 371.501 170.999 374.501Z" fill="#003625"/>
|
||||
<path d="M166 84.5L208 94.5L206 112.5C203 121 196.9 138.5 196.5 140.5C196.1 142.5 174 145.333 163 146.5L112 166.5C96.3333 177.833 64.7 200.8 63.5 202C62.3 203.2 42.3333 237.5 32.5 254.5L23 320L21 389.5C29.8333 416.333 47.7 470.6 48.5 473C49.5 476 66.9995 505.5 65.9995 505.5C65.1995 505.5 92.6662 529.5 106.5 541.5C116.666 549.667 137.4 566.2 139 567C140.6 567.8 156.333 574.667 164 578L221 584.5H247.5L291.5 572.5L341 546L388 490.5L397 475L411.5 447.5L414.5 425L406.5 400L386.5 377.5L366 371L367.5 381L375 402L377 419L367.5 440.5L360.5 456.5L337 475C327.166 480.167 307.5 489.9 307.5 487.5C307.5 484.5 269 494 264 494.5C259 495 231 493 227.5 490.5C224.7 488.5 183 471.667 162.5 463.5L104 282L199 314C219.5 315.5 261.2 318.5 264 318.5C266.8 318.5 275.5 315.5 279.5 314L276 307.5L272.5 299L264 289.5C266.833 288.167 273.9 285.5 279.5 285.5C285.1 285.5 281.833 279.5 279.5 276.5L272.5 266.5L236 261L227.5 248.5V227L264 185L279.5 157.5L288.5 142.5L295 130L307.5 120.5L328 112.5L392.5 103.5L457.5 87.5H476L481 84.5V73L476 60.5L470.5 50C467 47.6667 459.5 42.4 457.5 40C455.5 37.6 449.666 33 447 31H435.5L425.5 19.5L416 16L406 11H392.5L377 16H366.5L314 19.5L264 25.5L199 55L166 84.5Z" fill="#10B981"/>
|
||||
<path d="M215 304C195.4 294 147.167 248.5 125.5 227L119.5 230.5C116.5 235.333 110.5 245.3 110.5 246.5C110.5 247.7 109.833 257 109.5 261.5C112 266.167 117.5 276.4 119.5 280C122 284.5 131.5 284.5 142 290C152.5 295.5 171 297 173.5 298C176 299 192 311 194.5 313C196.5 314.6 200.667 313.667 202.5 313L215 304Z" fill="#059669"/>
|
||||
<path d="M33.5 381.5C32 386.167 29 396.9 29 402.5V415V427.5C41.1667 449.167 65.9 493 67.5 495C69.5 497.5 84 521 87.5 524.5C91 528 105 542.5 107.5 546C109.5 548.8 124.667 560.833 132 566.5L184.5 575C281.7 595 343 545 361.5 517.5L359.5 511C275.1 571.8 199.333 562.333 172 550C178.4 550 184.333 546.333 186.5 544.5C83.3 509.3 41.5 421.167 33.5 381.5Z" fill="#059669"/>
|
||||
<path d="M346 184C315.2 203.2 291.5 245.333 283.5 264C283.5 259.599 239.5 254.166 217.5 252C232.064 267.745 261.219 267.834 273.898 267.992C273.587 267.653 273.741 267.591 274.5 268C274.304 267.997 274.103 267.995 273.898 267.992C274.473 268.621 276.642 270.201 279.5 271.5C283.9 273.5 279.5 278 274.5 276C272.1 276 265.5 279.333 262.5 281H258C250 283.8 229 272.167 219.5 266C167.1 228.8 137.333 222.5 129 224L145.5 214C149.5 210.4 184.5 230.5 201.5 241C205.9 203.4 251 177.333 273 169C251.4 135 283 116.167 301.5 111C308.7 107.4 404.5 96.1665 451.5 90.9997C464.3 86.5997 465.5 92.833 464.5 96.4997C444.9 129.7 377.334 168.666 346 184Z" fill="white"/>
|
||||
<path d="M299 33.9991C400.2 9.99913 455.167 48.3325 470 70.4991C471 68.3328 473.1 63.7 473.5 62.5C474 61 467 48.5 466 47C465 45.5 459 43 457.5 42C456 41 449.5 37.5 448 36C446.5 34.5 437 32.4987 436 32.4987C435 32.4987 430 28.4987 428.5 28.4987C427 28.4987 424 22.5 422.5 20.9987C421 19.4974 411.5 15 410.5 14C409.5 13 391.5 13 389 13C387 13 381.167 15.9991 378.5 17.4987C374.833 17.9987 366.9 18.6987 364.5 17.4987C361.5 15.9987 334 17.4987 331.5 17.4987C329 17.4987 300 20.9987 299 20.9987C298 20.9987 273 27.4987 270 28.4987C267.6 29.2987 249.667 31.4987 241 32.4987L188.5 65.9987V70.4991C223.3 46.4995 254 40.499 265 40.4987C250.2 48.8987 242.833 65.332 241 72.4987C255 53.2987 285.5 38.8323 299 33.9991Z" fill="white"/>
|
||||
<path d="M273 295.001C268.2 296.601 269.667 300.667 271 302.501C259.001 296.9 259 286.168 260.5 281.502C273.5 276.502 274.5 275.501 275 272.502C275.4 270.102 257.833 270.502 249 271.002C239.8 271.802 224.5 259.335 218 253.002C224 253.002 245.833 256.001 256 257.5C264.8 257.5 278.333 260.833 284 262.5C303.2 218.9 334.667 191 348 182.5C447.6 126.099 466.833 98.9995 464 92.5C466 88.5 454.833 90.8333 449 92.5C379 113.7 318.167 118 296.5 117.5L318.5 107C338.899 107 428.333 82.3333 470.5 70C469.7 44.8 439.834 32.5 425.001 29.5C407.001 11.5 385.834 16 377.501 20.5C341.901 19.3 323.334 21 318.5 22C226.1 32.8 185.333 67.1667 176.5 83C206.1 80.2 220.167 85.8333 223.5 89C213.5 90.6 209.667 99.3333 209 103.5C206.2 108.3 201.5 129.5 199.5 139.5L188.501 145.5C96.9026 141.9 49.001 215.668 36.5 253.002C15.7 329.003 27.8333 401.334 36.5 428C74.9 542.799 158.167 574.5 195 576C276.6 586.8 339.333 541.167 360.5 517L358.5 510C389.7 488.4 403.167 450 406 433.5C410.799 391.9 382.666 377.833 368 376C397.999 417.999 367.834 457.833 349.001 472.5C289.226 524.9 200.428 504 163.5 487C43.5 421 68.1667 314.834 95.5 270.002C95.9 203.203 141 199.834 163.5 206.5C140.7 210.9 125 227 120 234.5C97.2 262.5 125.833 278.5 143 283.001C145 281.8 167.5 290.5 178.5 295.001C179.7 293.001 193 304.501 199.5 310.501L202 309.501C207.6 295.503 218.667 301.334 223.5 306L246 348.5C248.8 360.1 242.167 362 238.5 361.5C225.3 361.9 210.667 337.667 205 325.5L206.5 350C207.7 360 202.333 363.167 199.5 363.5C185.9 363.5 180.833 340.167 180 328.5C182 313.7 173.167 311.667 168.5 312.5C156.1 322.899 153 357.5 153 373.5C157 445.899 206.667 474.333 231 479.5C311.8 500.7 354.334 451.333 365.501 424C369.101 377.2 332.334 364.167 313.5 363.5C329.899 351.1 348.667 350.667 356.001 352C422.401 360 433.001 425.667 430.001 457.5C409.201 575.899 293.667 607.833 238.5 609C96.1 601.4 31.1676 490.5 16.5015 436C-33.0985 282.8 46.1681 182.833 92.0015 152C124.001 130.4 159.668 126.333 173.501 127C185.501 127.8 188.501 122 188.501 119C192.901 106.2 165.668 105.667 151.501 107C134.701 109 134.501 98.1667 136.501 92.5C202.101 14.1 314.501 5.49998 362.501 11C404.501 -15 432.667 12.5 441.501 29.5C481.101 33.1 489.667 60 489.001 73C486.201 106.2 465.834 129.5 456.001 137C449.201 145 419.167 165.333 405.001 174.5L344.001 209L344.501 211C356.501 219 371.501 242.334 377.501 253.002C386.301 251.402 402.167 254.335 409.001 256.002C419.001 260.002 427.167 267.002 430.001 270.002V275.002C429.601 276.202 426.501 277.168 425.001 277.502C422.601 278.702 407.334 273.668 400.001 271.002H398.001V272.502L409.001 281.502C415.801 287.102 420.501 295.835 422.001 299.501C424.001 305.102 422.167 308.501 421.001 309.501C418.201 312.702 414.501 311.501 413.001 310.501L386.001 284.502L385.001 284.002L392.001 303.001C394.001 307.001 394.167 315.335 394.001 319.001C391.201 331.001 384.167 327.001 381.001 323.501L365.501 286.501L365.001 310.501C363.801 318.901 358.501 322.335 356.001 323.001C351.201 323.001 349.334 320.335 349.001 319.001C347.401 316.202 346.667 301.168 346.501 294.001C344.101 286.801 334.834 281.001 330.501 279.001C322.501 274.201 302.167 274.667 293.001 275.501C293.001 279.901 292.334 282.334 292.001 283.001L273 295.001Z" fill="#003625"/>
|
||||
<path d="M255 296.5C239.8 285.3 233.667 289.5 232.5 293C226.1 301.8 235.167 312.667 240.5 317C251.3 327 272.667 334.167 282 336.5C295.6 337.3 296 327.833 294.5 323C290.1 316.2 266.333 302.5 255 296.5Z" fill="#003625"/>
|
||||
<path d="M283.499 263.5C278.299 258.7 255.999 254.5 245.499 253C241.899 249.401 294.666 208.834 321.499 189L303.499 185.5C331.899 183.5 362.333 153.334 373.999 138.5C417.499 142 460.999 87.5004 462.999 90.5004C464.599 92.9004 464.333 95.1671 463.999 96.0004C448.799 127.2 382.666 165 351.499 180C321.499 194.8 293.666 241.834 283.499 263.5Z" fill="#EEEDE8"/>
|
||||
<path d="M201.5 147L198.5 139C191.7 143.8 184.5 143.833 182 144.5C141.6 139.3 104.5 164.5 91 176.5C37.8 218.1 21.6667 289.833 21 320.5C36.6 234.1 88.8333 186.5 112 173.5C135.2 156.3 171.667 153 187 153.5C189.8 153.5 197.833 149.167 201.5 147Z" fill="white"/>
|
||||
<path d="M331.248 39C354.848 39.4 364.414 59.5 366.248 69.5C364.081 68.8333 359.848 67.6 360.248 68C360.648 68.4 359.415 72.1667 358.748 74C352.748 89.2 336.582 93 329.248 93C311.248 92.2 303.081 77.3333 301.248 70C298.448 46 320.081 39.3333 331.248 39Z" fill="#003625"/>
|
||||
<path d="M310.749 63.4997C316.749 48.2997 331.582 50.1664 338.249 52.9997C325.849 55.7994 323.748 58.4998 324.248 59.5C329.448 70.3 323.748 73.3333 320.249 73.5C311.849 73.5 310.415 66.8331 310.749 63.4997Z" fill="white"/>
|
||||
<circle cx="226" cy="107" r="10" fill="#003625"/>
|
||||
<circle cx="226" cy="107" r="10" fill="#003625"/>
|
||||
<circle cx="432.5" cy="60.5" r="7.5" fill="#003625"/>
|
||||
<circle cx="432.5" cy="60.5" r="7.5" fill="#003625"/>
|
||||
<circle cx="209" cy="147" r="10" fill="#003625"/>
|
||||
<circle cx="209" cy="147" r="10" fill="#003625"/>
|
||||
<circle cx="214.5" cy="126.5" r="4.5" fill="#003625"/>
|
||||
<circle cx="214.5" cy="126.5" r="4.5" fill="#003625"/>
|
||||
<circle cx="220.5" cy="77.5" r="4.5" fill="#003625"/>
|
||||
<circle cx="220.5" cy="77.5" r="4.5" fill="#003625"/>
|
||||
<circle cx="228.5" cy="59.5" r="4.5" fill="#003625"/>
|
||||
<circle cx="228.5" cy="59.5" r="4.5" fill="#003625"/>
|
||||
<ellipse cx="213" cy="63.5" rx="3" ry="3.5" fill="#003625"/>
|
||||
<ellipse cx="213" cy="63.5" rx="3" ry="3.5" fill="#003625"/>
|
||||
<ellipse cx="198.5" cy="74.5" rx="5.5" ry="3.5" fill="#003625"/>
|
||||
<ellipse cx="198.5" cy="74.5" rx="5.5" ry="3.5" fill="#003625"/>
|
||||
<circle cx="189.5" cy="161.5" r="3.5" fill="#003625"/>
|
||||
<circle cx="189.5" cy="161.5" r="3.5" fill="#003625"/>
|
||||
<circle cx="169.5" cy="159.5" r="3.5" fill="#003625"/>
|
||||
<circle cx="169.5" cy="159.5" r="3.5" fill="#003625"/>
|
||||
<circle cx="189.5" cy="161.5" r="4.5" fill="#003625"/>
|
||||
<circle cx="189.5" cy="161.5" r="4.5" fill="#003625"/>
|
||||
<circle cx="133.5" cy="173.5" r="3.5" fill="#003625"/>
|
||||
<circle cx="133.5" cy="173.5" r="3.5" fill="#003625"/>
|
||||
<circle cx="117.5" cy="187.5" r="3.5" fill="#003625"/>
|
||||
<circle cx="117.5" cy="187.5" r="3.5" fill="#003625"/>
|
||||
<circle cx="102.5" cy="186.5" r="3.5" fill="#003625"/>
|
||||
<circle cx="102.5" cy="186.5" r="3.5" fill="#003625"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 9.6 KiB |
267
CHANGELOG.md
267
CHANGELOG.md
@ -0,0 +1,267 @@
|
||||
# Changelog
|
||||
|
||||
Toutes les modifications notables apportées à ce projet seront documentées dans ce fichier.
|
||||
|
||||
### [0.0.3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/compare/0.0.2...0.0.3) (2025-06-01)
|
||||
|
||||
|
||||
### Corrections de bugs
|
||||
|
||||
* Ajout d'un '/' en fin d'URL ([67cea2f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/67cea2f1c6edae8eed5e024c79b1e19d08788d4c))
|
||||
|
||||
### 0.0.2 (2025-06-01)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* mise à jour de la doc swagger ([11fc446](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/11fc446b904cc64d63154ad5c6711a8296a7fc51))
|
||||
|
||||
|
||||
### Refactorisations
|
||||
|
||||
* "registerFilesTemplates" -> "registrerFileTemplate" ([83f4d67](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/83f4d67a6fc3f786803343957b276f8419f3058d))
|
||||
* adaptation mobile ([4b8f85e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4b8f85e68dc95585d96a4cbad219ad068cbc8acf))
|
||||
* Affichage des notifications dans la partie "Users" ([af30ae3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/af30ae33b5660c55fa6824498f4325aab3de3c5a))
|
||||
* Affichage des notifications dans la partie "Users" ([e509625](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e5096258110faac37b9457705dd1b51bc231983f))
|
||||
* Augmentation du nombre de données ([95c154a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/95c154a4a2d746c4350887bb697af142152ed8d7))
|
||||
* changement de la philosophie de logging ([c7723ec](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c7723eceee650de86eea3263d44d374ad9844282))
|
||||
* Changement des IconTextInput en TextInput, modification du composant step ([a248898](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a248898203286213c3447333611e1a9981dff64a))
|
||||
* Composant *InscriptionForm* ([56e2762](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/56e27628f897920659a6ce186539ddec7e94a05a))
|
||||
* Creation d'un provider et d'un systeme de middleware ([5088479](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/508847940c8c35fd982ab935f4d69371869eed5a))
|
||||
* Création de composants et uniformisation des modales ([#2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/2)) ([d51778b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d51778ba54e95283aa6ad7821fda673813c7c7a0))
|
||||
* Création de nouveaux composants / update formulaire de ([7acae47](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7acae479da658707fb3e073ebcdfee023d18500b)), closes [#2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/2)
|
||||
* Deplacement du JWT dans le back ([eb89a32](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/eb89a324abbdf69091e5c78530ec62f2c2ccbcd1))
|
||||
* Document Ecole/Parent ([7564865](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7564865d8f414fbefa0731c4ca472a100efb6036))
|
||||
* gestion des erreurs ([f3490a4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f3490a4e9584b959450ca45c8e74e430396425b3))
|
||||
* Injection des env var dans le frontend ([aae5d27](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/aae5d27d8c556c5687951f3a04e01d42f69f3085))
|
||||
* je suis une merde ([c4d4542](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c4d45426b520a498f409be8617c7936224195290))
|
||||
* Mise à jour de la doc swagger / URL ([4c95b6a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4c95b6a83f15a9989fac5f69a9386664d25ec9f6))
|
||||
* Modification de l'url de l'api Auth ([9bf9c5f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9bf9c5f62df1a6482ba27b897da498592b57e04f))
|
||||
* Modification de la construction docker ([2d128aa](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2d128aaf30e60813c0c5caa244a93ff46e3985f3))
|
||||
* Partie "School" ([58fe509](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/58fe509734a3b5dc6e0b5c6aa3fd713fd4dc821e))
|
||||
* Partie FRONT / School ([24352ef](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/24352efad304dee7418dc846681a4b38047431f6))
|
||||
* Refactoring de la section ClassSection ([1a8ef26](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1a8ef26f5883abe4855949a54aa50defb98c852d))
|
||||
* refactoring du FRONT page subscribe ([427b6c7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/427b6c758892469d07579159511e7ce1ceed20d0))
|
||||
* Refactorisation du login et de admin/subscription ([41aa9d5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/41aa9d55d388c0ddf189c7b9ab6057487f86484b))
|
||||
* Remplacement de quelques popup par les notifications ([ce83e02](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ce83e02f7b3e53ef2b859436432784d6eb69200d))
|
||||
* Renommage du menu "Eleves" en "Inscriptions" ([692e845](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/692e8454bf9840ada3f8e052d7ef13cbf1b0d9c0))
|
||||
* Revue de la modale permettant de créer un dossier ([cb3f909](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/cb3f909fa4e7a53148cd13cf190c13b0670d35de))
|
||||
* Revue de la modale permettant de créer un dossier ([665625e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/665625e0280683fef056e9c950fc6555d889643e))
|
||||
* SpecialitySection + TeacherSection (en cours) ([72dd769](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/72dd7699d6bd61e17b4c3dc0098ca0989a94b2c8))
|
||||
* Suppression des paramètres mail mot de passes des settings ([ec2630a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ec2630a6e40dedaaa8f41a04b44e5ec1f6b2a1e0))
|
||||
* Traduction en anglais des modules "GestionInscription" et ([2b414b8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2b414b83913c2f0f81cf226b78577ad522443d7b))
|
||||
* Transformation des requetes vers le back en action ajout des ([147a701](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/147a70135d2f10ac16961c098d85da0a1bcafb38))
|
||||
* Utilisation d'une application "Common" pour tous les modèles ([e65e310](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e65e31014d2b89afe1e5f077e8d4109f07d40d0b))
|
||||
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
* A la signature d'un document, on récupère l'URL du PDF [[#22](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/22)] ([2ac4832](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2ac48329851f91f6bb02a44e02ad5a90b4ae504c))
|
||||
* Affichage d'icones dans le tableau des inscriptions dans la ([9559db5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9559db59eb418d233682217ef72f315bccc6fe1d))
|
||||
* Ajout d'un composant permettant de visualiser les fichiers signés ([7f442b9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7f442b9cae008dab4f18438f9ee46be21ed037b0))
|
||||
* Ajout d'un nouveau status avec envoi de mandat SEPA + envoi de ([4c2e2f8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4c2e2f87565dc6e2be501839c274a5aa6969a9ec))
|
||||
* Ajout d'un nouvel état dans l'automatique lorsqu'un mandat SEPA ([545349c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/545349c7db7d0f653f3ae06b10d441ef975b0cc0))
|
||||
* Ajout d'une colonne dans le tableau des pièces jointes indiquant ([3c0806e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3c0806e26c116dbccd808bd8c8b170c5c4d9bc5b))
|
||||
* Ajout d'une fonction de dissociation entre un responsable et un ([3bcc620](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3bcc620ee103690a2ee5f79e6203aba880bda9b7))
|
||||
* Ajout d'une fonction de logout ([c2bba1a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c2bba1abbfafbb7aca1bb07e8019d7fa244a808e))
|
||||
* Ajout d'une fonction de logout ([0ef6a2b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0ef6a2b1192dbd3ecc59ce0e8cbba233ccc9c821))
|
||||
* Ajout de l'emploi du temps sur la page parent ([78d96f8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/78d96f82f91ed777073250b960eee8f326cccb43))
|
||||
* Ajout de l'envoie de mail [[#17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/17)] ([99a882a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/99a882a64acfd9340d6849edd1766de5173a2341))
|
||||
* Ajout de l'option d'envoi automatique [[#1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/1)] ([a77dd8e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a77dd8ec64bd78ab9c42aad3f93a181e64719d06))
|
||||
* Ajout de la configuration des tarifs de l'école [[#18](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/18)] ([5a0e65b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5a0e65bb752a80781517394d7b2a673788f7595e))
|
||||
* Ajout de la fratrie [[#27](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/27)] ([4a382d5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4a382d523ccfd4cf8fa7e672e9315b86dbdbbb14))
|
||||
* Ajout de la fratrie / Gestion des index de fratrie / Gestion des ([2ab1684](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2ab1684791377804dd03c8467a94dbc1244e102f))
|
||||
* Ajout de la gestion des fichier d'inscription [[#1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/1)] ([3c27133](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3c27133cdb9943c5e20b81c03f9e2fa47077dbbb))
|
||||
* Ajout de la photo pour le dossier de l'élève + correction ([5851341](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5851341235998647a4142bdf1996ddc9db21762d))
|
||||
* Ajout de la possibilité de supprimer une association ([c9350a7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c9350a796b65ea4eef0e38390ab9fb1d88196210))
|
||||
* Ajout de la sélection des modes de paiements / refactoring de ([5a7661d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5a7661db93454b9a73b9f6bd46646c6135a0f203))
|
||||
* Ajout des Bundles de fichiers [[#24](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/24)] ([ffc6ce8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ffc6ce8de835e9caf547b6c4a893436aa93513ba))
|
||||
* ajout des documents d'inscription [[#20](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/20)] ([b8ef34a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/b8ef34a04b14c8a8fb980fcd9255296ceb699ec6))
|
||||
* Ajout des évenements à venir ([c03fa0b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c03fa0ba42d69918501beb5bb98637a449eb2da0))
|
||||
* Ajout des frais d'inscription lors de la création d'un RF [[#18](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/18)] ([ece23de](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ece23deb19483c50d9999541a482e3378db19d23))
|
||||
* Ajout des frais de scolarité dans le dossier d'inscription [[#18](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/18)] ([0c2e0b9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0c2e0b92f43f223adc22db36ecad7fd864737a98))
|
||||
* Ajout des modes de paiements + création d'une commande dans le ([0c5e3aa](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0c5e3aa0988a16b6f9f8c0b411c2c1b443c972a7))
|
||||
* Ajout des payementPlans dans le formulaire / ajout de la photo ([d37aed5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d37aed5f6496b8c8ca5519689dfc811d9626e09e))
|
||||
* Ajout du logo de l'école ([6a0b90e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/6a0b90e98fbcc707756ae7fbbff921e480f2c695))
|
||||
* Ajout du logo N3wt dans les mails ([8a71fa1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8a71fa1830d0c0fb11467208bc98dc4f71598199))
|
||||
* Ajout du suivi de version dans le footer du Front ([fb7fbaf](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/fb7fbaf8394ebf41e6f3f31897e6d009c537a481))
|
||||
* Amélioration de la fiche élève pour y ajouter la fratrie et les ([256f995](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/256f995698e79572eb3d51ea60b96b6fad47d553))
|
||||
* Amélioration du dashboard ([eb48523](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/eb48523f7d466698faa268b8b25e6f1ed90bdfd7))
|
||||
* Amorçage de la gestion des absences [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([cb4fe74](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/cb4fe74a9e316a92c6b5e1d2550aaf2b1036a744))
|
||||
* Aussi pour la table des parents tant qu'à faire ([a3182c0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a3182c0ba7c6ea9f99a4fe34a4a00079b4676d59))
|
||||
* **backend:** Ajout du logger django [[#7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/7)] ([b8511f9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/b8511f94b633b9bf5bd764b3706c53b74b3a6648))
|
||||
* Bilan de compétence d'un élève [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([5760c89](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5760c89105f38f4481e2cc6fa788bb0c39e8caa8))
|
||||
* Champ de recherche de l'élève [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([eb7805e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/eb7805e54e41f6eaefad81fea1616f0613365e8c))
|
||||
* Configuration des compétences par cycle [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([4e5aab6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4e5aab6db74a8d1dfdfb4928f60ad47da52c89e8))
|
||||
* Configuration et gestion du planning [[#2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/2)] ([830d9a4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/830d9a48c003e1cca469b1cf4082305e16685181))
|
||||
* Création d'un annuaire / mise à jour du subscribe ([6bd5704](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/6bd5704983282264bc50c73677495740f7d7e8a9))
|
||||
* Création d'un profile selector [[#37](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/37),[#38](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/38)] ([89b01b7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/89b01b79db884c393db29332b95f570e47d20ed1))
|
||||
* création d'une tooltip pour les informations supplémentaires de ([9197615](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/91976157e44e319b23fd35fa89859164bab71202))
|
||||
* création de 4 JSON de compétences en attendant de les mettre en ([69405c5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/69405c577e7af3d07654fca96015d21f475e700d))
|
||||
* Création de clones lors de la création de RF [[#22](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/22)] ([d1a0067](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d1a0067f7b7125e453ff6fc75efead881a7af37d))
|
||||
* Création nouveau style / pagination profils annuaires ([760ee00](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/760ee0009e983776dfd500df7465ae66593dc85d))
|
||||
* Dockerisation d'un serveur docuseal + initialisation d'un compte ([8897d52](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8897d523dc23fd2d89a0ec66b5cc7fa15b69db5b))
|
||||
* Envoie d'un mail de bienvue au directeur ([5be5f9f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5be5f9f70d4fcf56da29afb19187806ff2e6e428))
|
||||
* Evolution des modèles pour intégrer un planning et du m2m ([85d4c00](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/85d4c007cb2091ae1911ca1998f1b830470b8310))
|
||||
* Formulaire de création RF sur une seule pag ([76f9a7d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/76f9a7dd14d7065f4add01718fda499fbb9183c7))
|
||||
* Génération d'une page de suivi pédagogique + fix utilisation ([2a6b3bd](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2a6b3bdf63ddc13509b66690ea5d76eac77d1090))
|
||||
* Génération du bilan de compétence en PDF [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([0fe6c76](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0fe6c761892097d043902f4f051b9fdb5fef29d0))
|
||||
* Gestion de la création d'un nouveau guardian, de l'association ([fb73f9e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/fb73f9e9a86430d7498aa8a10e5abc46325b7b2c))
|
||||
* Gestion de la mise à jour des profiles / roles / lors de l'édition ([dfd707d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/dfd707d7a0c7f4514f5583f07803d20e3c2d6bd7))
|
||||
* Gestion de la sauvegarde du fichier d'inscription / affichage du ([d6edf25](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d6edf250bbc1cc1a9862e26174bc24ca4f9ee4c1))
|
||||
* Gestion de la validation du dossier d'inscription ([b23264c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/b23264c0d4008a4317c009a73ae11f57ee6917e2))
|
||||
* Gestion des absences du jour [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([030d19d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/030d19d411af8f0d87a3cb72cb401d9dd5fa96ce))
|
||||
* Gestion des documents nécessitant des signatures électroniques et ([e3879f5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e3879f516b81b7e4b784049668b2507f12e8155f)), closes [#22](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/22)
|
||||
* Gestion des documents parent ([59aee80](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/59aee80c2e7592a7cdb119d1d30a5ad2c8bb20b0))
|
||||
* Gestion des documents signés durant l'inscription / possibilité de ([905b95f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/905b95f3a364f0d1ce8348d086870045d942bf92))
|
||||
* gestion des no data dans les table [[#33](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/33)] ([2888f8d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2888f8dcce8d593df8f81a635eaac94af4603829))
|
||||
* Gestion des pièces à fournir par les parents (configuration école) ([a65bd47](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a65bd47905cc33c44416c1def0413579b96d820d))
|
||||
* Gestion des profils ADMIN/ECOLE (création des enseignants) ([e0bfd3e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e0bfd3e11579c512aa6ad63c73e00e40be4eaf06))
|
||||
* Gestion des profils des enseignants / Visualisation d'une classe [[#4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/4)] ([81d1dfa](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/81d1dfa9a70d0cd8d80e7d951a74c9355bba5238))
|
||||
* Gestion des rattachements de Guardian à des RF déjà existants ([7d1b9c5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7d1b9c5657439d2fff287f60b9aba79a5dfdf089))
|
||||
* Gestion du planning [3] ([58144ba](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/58144ba0d0f1b53e9313f4cd4d3fbc3e6bfdd274))
|
||||
* Gestion multi-profil multi-école ([1617829](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/16178296ec4dd4843be26b6e09b9c0f080df7ee4))
|
||||
* Harmonisation des fees / ajout de type de réduction / mise à jour ([5462306](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5462306a6020493cf747ea3bb8edb3240c36286f)), closes [#18](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/18)
|
||||
* Merge remote-tracking branch 'origin/WIP_style' into develop ([f887ae1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f887ae18862b740fa904d8ca04a3932eec455908))
|
||||
* Messagerie WIP [[#17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/17)] ([23a593d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/23a593dbc77b6f544a17de5a451ff60316f50292))
|
||||
* Mise à jour des Dockerfile préparation d'un environnement de démo [[#12](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/12)] ([32a77c7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/32a77c780abe8c0aa9846843ac81d13e4b8cf73a))
|
||||
* Mise à jour des Teacher ([173ac47](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/173ac47fb26ba2f101802571621fc4112adb1a9f))
|
||||
* Mise à jour du modèle (possibilité d'associer une réduciton à un ([8d1a41e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8d1a41e2693c3704b68e8d75bd32c4a89a6389e5)), closes [#18](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/18)
|
||||
* mise en place de la messagerie [[#17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/17)] ([d37145b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d37145b73e2012f21a329ee97a565189233ca0f8))
|
||||
* Mise en place des actions pour chaque state du RF, possibilité ([8fc9478](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8fc947878665ca04e1697fa6df140e0d80c5a672))
|
||||
* Mise en place des paiements en plusieurs fois - partie BACK [[#25](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/25)] ([274db24](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/274db249aa25f2a0281638c318a68cf88a721a45))
|
||||
* Mise en place des paiements en plusieurs fois (partie BACK) [[#25](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/25)] ([23203c0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/23203c0397f6247d32462cceca33d964898223a9))
|
||||
* Mise en place du Backend-messagerie [[#17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/17)] ([c6bc0d0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c6bc0d0b515a5be7b0bf930ff628a5e9b5ebbb33))
|
||||
* Nommage des templates / Intégration dans formulaire d'inscription ([eb81bbb](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/eb81bbba9265b9f3a71e500737436ee5301b7a5e)), closes [#22](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/22)
|
||||
* Ordonnancement de l'inscription sur plusieurs pages + contrôle des ([daad12c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/daad12cf40ce3b628581892f9a894a0841baa5e3))
|
||||
* Oubli fichier [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([d7fca9e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d7fca9e942412a8d2fe379f38052f3b41ed9c0f9))
|
||||
* passage des mail au format HTML ([b97cf6e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/b97cf6e02b92ba6750662bbf9e9c3af6ad19ab38))
|
||||
* Passage par une variable d'environnement pour les CORS et CSRF ([f9e870e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f9e870e11fe3041be53f4c357427d8060f50199f))
|
||||
* Peuplement de la BDD avec les JSON d'entrée [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([c6d7528](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c6d75281a1fab0d9dc27d4da80f91c6fffb1bc0e))
|
||||
* planning events ([c9b0f0d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c9b0f0d77a5ec61a239deb71959738f3b0e82d37))
|
||||
* Pre cablage du dashboard [#] ([1911f79](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1911f79f4578f8bc3b455308182c46d2d59e5580))
|
||||
* Préparation de la gestion des compétences en énumérant les élèves ([1c75927](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1c75927bbab497cfc86fc3a9aea11d436318be69)), closes [#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)
|
||||
* Preparation des modèles Settings pour l'enregistrement SMTP [[#17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/17)] ([eda6f58](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/eda6f587fb21bf041784209228518c8a6f03b1b5))
|
||||
* preparation du dockerfile pour le frontend [[#13](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/13)] ([9716373](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9716373fa2d754177d4e71082b9079b71daab971))
|
||||
* Rattachement d'un dossier de compétences à une période scolaire ([7de839e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7de839ee5c9b09f7874575bdaf57436ec11b293f)), closes [#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)
|
||||
* Refactoring de la fonction de création de profil sur guardian côté ([753a8d6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/753a8d647ec3e45c8aabecba6d38b1a19741e0c0))
|
||||
* Sauvegarde des compétences d'un élève [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([0513603](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/05136035ab1d811c904e35d99ddb884c68b7fd74))
|
||||
* Sauvegarde des fichiers migration ([017c029](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/017c0290dd1fab8afa3a05541e57a321733ff5c9))
|
||||
* Signatures électroniques docuseal [[#22](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/22)] ([c8c8941](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c8c8941ec875b541cfb55c3504a0e951f36163ef))
|
||||
* Sortie des calculs des montants totaux de la partie configuration + revue du rendu [[#18](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/18)] ([799e1c6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/799e1c6717fceec4b29edfbdd0af52268b7e8fce))
|
||||
* Suite de la gestion des sessions ([8ea68bb](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8ea68bbad0646d99209d1821a2b71364630005b3))
|
||||
* Suppression de l'ancienne POPUP de RF ([5927e48](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5927e48e6544e8819a29766562107834d44e7a5d))
|
||||
* Suppression des localStorage ([023b46e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/023b46e16e8da56971a8c55c0930e6ab4fbf53ec))
|
||||
* Suppression des templates docuseal [[#22](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/22)] ([081dc06](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/081dc060014ee15ca6881fc83b779679a271326d))
|
||||
* Upload du SEPA par les parents / Création d'un composant header ([8417d3e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8417d3eb141b116e2e9f8c6038831ce1bbe30e2a))
|
||||
* Utilisation d'une clef API Docuseal par établissement ([23ab7d0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/23ab7d04ef0c940b7008e8bc7d4b43b373d16d40))
|
||||
* Utilisation de l'établissement en variable de session / gestion de ([f2ad1de](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f2ad1de5a4215395f3aa7a0e04ac2eb3edc5ec51))
|
||||
* Utilisation des nouvelles alertes dans la page admin de la gestion ([67193a8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/67193a8b3601784f37563094f3fdede943523b53))
|
||||
* Validation du dossier d'inscription en affectant l'élève à une ([0f49236](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0f49236965f575f6af17837b9860fa4481227785))
|
||||
|
||||
|
||||
### Corrections de bugs
|
||||
|
||||
* correction des redirections vers la login page ([2e0fe86](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2e0fe86c71e9f02e8ee77ccbd80533a63a31ef63))
|
||||
* Ajout d'un champ is_required pour les documents parents facultatifs ([5866427](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5866427544e24c8e79cb773d38bda683f63f4531))
|
||||
* Ajout d'un message de confirmation lors de la suppression d'un ([9248480](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/92484804f6483eab401612315b5513cc78e6a726))
|
||||
* ajout de credential include dans get CSRF ([c161fa7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c161fa7e7568437ba501a565ad53192b9cb3b6f3))
|
||||
* Ajout de l'établissement dans la requête KPI récupérant les ([ada2a44](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ada2a44c3ec9ba45462bd7e78984dfa38008e231))
|
||||
* Ajout des niveaux scolaires dans le back [[#27](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/27)] ([05542df](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/05542dfc40649fd194ee551f0298f1535753f219))
|
||||
* ajout des urls prod et demo ([043d93d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/043d93dcc476e5eb3962fdbe0f6a81b937122647))
|
||||
* Ajout du % ou € en mode édition de réduction ([f2628bb](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f2628bb45a14da42d014e42b1521820ffeedfb33))
|
||||
* Ajout du controle sur le format des dates ([e538ac3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e538ac3d56294d4e647a38d730168ea567c76f04))
|
||||
* Ajout du mode Visu ([e1c6073](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e1c607308c12cf75695e9d4593dc27ebe74e6a4f))
|
||||
* ajustement du handlePhoneChange [[#41](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/41)] ([31fdc61](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/31fdc612b10843ce694b55696f67bd2a80d56769))
|
||||
* Application des périodes à un studentCompetency lors de la création ([d65b171](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d65b171da8a310acca15936a39e44239763c88b9))
|
||||
* application des recommandations linter es pour générer un build de prod ([d1aa8b5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d1aa8b54fb71bb946e95a19105f51f7f29c75fda))
|
||||
* Application du formattage sur les fichiers modifiés ([001a5bc](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/001a5bc83c0bf54061b2b04967da3fc11e2cd8dc))
|
||||
* boucle inifinie dans UseEffect ([f3c4284](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f3c428477879729d36760bb61dac015311c84fec))
|
||||
* Bug lorsqu'on déselectionne un paiementPlan ([d64500f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d64500f4022710423c77d023476065816ecd061d))
|
||||
* build error ([65d5b8c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/65d5b8c424bf0e0a9da1b39500c8252f683725c7))
|
||||
* Calcul du montant total des tarif par RF + affichage des tarifs ([c269b89](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c269b89d3d58cc65f254b75f6d713c4fd15f6320)), closes [#26](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/26)
|
||||
* calcul nombre de pages dans chaque tab ([5440f5c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5440f5cbdbca8b9435a17914c7e7c4ecc34e6bb3))
|
||||
* Champs requis sur les teachers and classes ([42b4c99](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/42b4c99be86f050ccd76302caf725af5df413d17))
|
||||
* Changement d'icone associé aux documents soumis à validation ([500b6e9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/500b6e9af7ac76dafa35bd830cd0767cece47d27))
|
||||
* code mort ([4fc061f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4fc061fc255b4174f794ac58da1b6849419e9f1a))
|
||||
* Condition de validation d'ajout d'un nouveau document parent / ([9e69790](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9e69790683fd83b0e48a9f70150661cb06a7b556))
|
||||
* conflits + closeModal lors de la création d'un RF ([1617b13](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1617b132c4f67fdbaf261808a0a9596b7a72a4dc))
|
||||
* coquille ([c9c7e77](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c9c7e7715efde8766c3b2ad2c355dc9a9960b19f))
|
||||
* coquille dans les imports ([4ecf25a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4ecf25a6ab90a57da0013f6ed603d6cd5bd4eeeb))
|
||||
* Correction de l'affichage des numéros de téléphone [[#41](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/41)] ([4f774c1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4f774c18e47fc57e081022a03ea352638e7211d2))
|
||||
* correction de l'ouverture du dashbord [[#39](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/39)] ([a157d53](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a157d53932d5576fc9768f5c063cf9aafa214d43))
|
||||
* Correction de la désactivation des spécialités lorsqu'on ([afc1632](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/afc1632797c0d35df7da03432eba9ab0f1875f55)), closes [#2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/2)
|
||||
* Correction dépendances circulaires ([fc9a1ed](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/fc9a1ed252e1e115e4a2f7c4a3a04ee6757be683))
|
||||
* Correction des Protected Routes avec multi role ([dd0884b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/dd0884bbce6b6549f0f3fca991045f7170889710))
|
||||
* correction des refresh des protected routes [[#36](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/36)] ([839a262](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/839a26257b659a86903d3f982548884cc87366b9))
|
||||
* Correction du Establishment context au refresh ([43e301e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/43e301ed641a742323a98c430e30e134babc4aa4))
|
||||
* correction fileGroup lors de l'enregistrement d'un nouveau responsable ([dce2114](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/dce2114a7940310e2c4241c2cdbd7e3fd060fb60))
|
||||
* Correction option fusion ([e61cd51](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e61cd51ce2b0665c18f9497e6d2b1f7b8196723e))
|
||||
* Correction sur le calcul du nombre total de pages [[#1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/1)] ([5946cbd](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5946cbdee661527317dac66f99f0abce021c835a))
|
||||
* correction titre mail reset mdp ([cac1519](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/cac1519bf311b660831222d76d4d5165ee4f4d7e))
|
||||
* Correction URL ([170f7c4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/170f7c4fa80e1cd40079ac861e7e633c62f143df))
|
||||
* Corrrection typo dans description des tableaux frais/réduction ([175932f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/175932ffa3fb2747cafd158b8142df9b7010a3d4))
|
||||
* csrf ([59a0d40](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/59a0d401301fe77226fd5f294a3cd7e589d46fad))
|
||||
* Division par 0 ([a42cf34](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a42cf348a0a3cc43c6c6b643b1da158690d67cb8))
|
||||
* double confirmation sur les popup ([677cec1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/677cec1ec2f7a3582327f4747d088c6bccbd2560))
|
||||
* entrypoint access right ([a041ffa](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a041ffaee75b74e0d559fb14bc79fbcfae98da14))
|
||||
* faire plaisir à LSO ([9374b00](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9374b001c9944cb6af1e10451f0e5f236a7890e8))
|
||||
* formulaire sur toute la larguer + initiation à un autre style de bg ([4fd40ac](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4fd40ac5fc90ea1ddda9d73ea290b588074c6e2f))
|
||||
* Fusion documents ([857b8b2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/857b8b26c3722171007399dc66cd9980b33151c5))
|
||||
* Generation d'une fiche d'élève avec le nouveau modèle PayementMode ([4f40d1f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4f40d1f29d7abd1e0c6bf889b10f811f184ff10d))
|
||||
* Génération uniquement des compétences évaluées dans le PDF ([eca8d7a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/eca8d7a8d59f39313123166859f4c4bf548d150e))
|
||||
* gestion des codes retours ([7f35527](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7f3552764979e098ca2e8c3547354c8ae6feaa23))
|
||||
* Gestion des listes d'inscription "vides" [[#1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/1)] ([edc9724](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/edc97242f2219f48233441b8c7ec97ef9551c60c))
|
||||
* gestion du jour d'échéance ([2576d21](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2576d2173460267664d927bd093580a21c18725b))
|
||||
* import du Loader ([e2a39ff](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e2a39ff74dd9671bb1d00de2b6cec1cd3e4ff614))
|
||||
* inject env var ([fc337b1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/fc337b1e0b4605f3490435f4819b01d38f921156))
|
||||
* Limite du nombre de responsables légaux à 2 [[#27](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/27)] ([1ced4a1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1ced4a10696057b8df114dc95adf9868e8d7aa43))
|
||||
* Link documents with establishments ([2f6d30b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2f6d30b85b90508cae49081a82eadea5039f60b2))
|
||||
* load the school image eorrectly ([6bc2405](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/6bc24055cd5a79d59d2b56c7e767ac1b30d99fff))
|
||||
* Lors de la création d'un clone, on prend le nom de l'élève et pas ([db8e1d8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/db8e1d8ab320222370c64d7b7fde3e43c59921e8))
|
||||
* Messages de retour reset/new password ([4a6b7ce](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4a6b7ce379747565c77205728e7b0d9c8a7c9585))
|
||||
* Mise à jour correcte du fichier après avoir été signé ([5ea3cbb](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5ea3cbb0790840d823e799cc64766a99ef5591a9))
|
||||
* Mise à jour des upcomming events ([f93c428](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f93c42825964d91d11af26541eecb9ba5f01e801))
|
||||
* mise à jour settings pour la prod / correction CORS ([25e2799](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/25e2799c0f0e46b1a6d78bcc849cc777e67a01f1))
|
||||
* Mise en page des inscriptions (boutons ajout / barre de recherche) ([cf14431](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/cf144310a13fa4cbd01a292002d8a9963acc4598))
|
||||
* Modèle créé 2 fois par erreur ([49907d7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/49907d7ec8847017115191e937ba9f68350c92bd))
|
||||
* Modification d'un guardian sans changer d'adresse mail (même ([95b449d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/95b449ddfde4160a55237e0c50e6bed604dcdfe5))
|
||||
* Ne pas dissocier de responsable s'il n'y en a pas d'autre rattaché ([ac0672f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ac0672f3349aa2ac62db0e3927658a3f2d66cebf))
|
||||
* Ne pas retourner d'erreur si pas de dossier d'inscription ([be27fe1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/be27fe1232c3808b0a65d5f1b265ef454eb35e74))
|
||||
* Nouvelle amélioration ([8b3f963](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8b3f9637a91fe817c87427015a89ba3e469d525d))
|
||||
* On attend que la session soit mise à jour pour intiialiser le ([ccecd78](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ccecd78704c3e6db58724401b92dd065a7e733ab))
|
||||
* On commence à la page 1 ([3c62cc9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3c62cc9ad2cfa691ee798d27ee6b377676e50bb7))
|
||||
* On empêche la sauvegarde d'un document à signer tant qu'aucun ([be013f0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/be013f07864345024320114bd734508a033fd5db))
|
||||
* On ne peut sélectionner que les élèves inscrits [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([56c223f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/56c223f3cc0498b4a6619d68f0185c36482c4ec9))
|
||||
* Ordre des guardians lors de leur création / déselection correcte si ([3b667d3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3b667d3b150684a685d7d76cf06d050049ee07cd))
|
||||
* pagination annuaire ([980f169](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/980f169c1d1a46f0d47f4b9ff65fa940ac610023))
|
||||
* PieChart ([fe2d4d4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/fe2d4d45137df3b1ead4d21c29722fec0bd0fbab))
|
||||
* Positionnement de la variable isSepa ([82573f1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/82573f1b2333a01675cebf575f33ab77e70e138b))
|
||||
* Possibilité d'ajouter un 2ème guardian, même si son mail est ([8cf2290](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8cf22905e533a23ee679107cc0bcae1198badb4a))
|
||||
* Récupération d'un template donné ([9b13d52](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9b13d52e8d8926bcc6f756ff4d2c9d278a0cc387))
|
||||
* Refresh par profil role ([24069b8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/24069b894ef2009d9fe0ad884e7a39c29a5a9504))
|
||||
* refresh token ([053d524](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/053d524a513adfec8bd9b3467fc358c257776a85))
|
||||
* régression CORS_ALLOWED_ORIGINS ([a69498d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a69498dd06649b601a16a509c7a80c9f67c7872e))
|
||||
* régression lors de l'uniformisation des modales ([00f7bfd](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/00f7bfde4abd10d080dc2035d3607d6c35e7db14))
|
||||
* Remise du message de confirmation supprimé par erreur ([efcc5e6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/efcc5e66722c829dcdf522a6903c616901a14604))
|
||||
* Remise en état du bouton Submit ([e9650c9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e9650c992e6c8339d7acde4000bf4f3dd8e98bac))
|
||||
* Remise en place de l'API_KEY docuseal dans le back ([6d80594](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/6d805940fe5524cae1864c0beebcd136bda84eda))
|
||||
* remove lint error ([aef6c19](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/aef6c193b1eaaedbb0642ce7929b2cfe8f47d682))
|
||||
* Remplacement des enum par des modèles pour les payementModes et les ([7fe5346](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7fe53465acc2df53351f713ccacd12223d6eff1a))
|
||||
* restore du start.py suite à des tests ([de5f7cd](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/de5f7cd41e52b27ee3d8f47cf47fbfdad78216ac))
|
||||
* right ([05f1f16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/05f1f16727c2510385425b23fa6ab98fa62d07be))
|
||||
* Scroll de l'emploi du temps élève ([f38a441](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f38a4414c28ec52981201c15b7eda0dccc1f932f))
|
||||
* searchTerm inscription ([8f0cf16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8f0cf16f707ac1cd51f0d619fa1c5ea0ba023f68))
|
||||
* Session storage selectedEstablishmentEvaluationFrequency et selectedEstablishmentTotalCapacity ([e30753f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e30753f1d6d2911d51bb9dfbf32fbae6f2b62b5d))
|
||||
* Suite du commit précédent ([cd9c10a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/cd9c10a88af2a05350570c424fb284280c0f65ee))
|
||||
* Suppression d'un profil uniquement s'il ne contient aucun guardian ([330018e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/330018edfdbdc071c15838bc22b8a4e726773204))
|
||||
* Suppression de la top bar admin [[#34](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/34)] ([3990d75](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3990d75e521bea007a8f479924507498d9586a71))
|
||||
* Suppression de print inutiles ([43874f8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/43874f8b9e76b3e9f131f240ee895d958cd73fab))
|
||||
* Suppression event planning ([c117f96](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c117f96e528244ee68ce69b7685880e171976e32))
|
||||
* Unicité des fees + utilisation de l'establishmentID [[#44](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/44)] ([d37e6c3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d37e6c384d0ef16053ed9fcc1e979f7f902cc8d8))
|
||||
* Uniformisation des Modales et Popup [[#35](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/35)] ([f252efd](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f252efdef4fce1f2d19ac7ca1eb9c049706c0d9f))
|
||||
* Utilisation des bonnes colonnes pour les fees et discounts selon si ([9f1f97e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9f1f97e0c56771208305b28a740504c220287053))
|
||||
* Utilisation du signal "post-migrate" pour créer la spécialité par ([e1202c6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e1202c6e6d4061552fa7d530e3e09b11384843c3))
|
||||
* Variables booléennes par défaut ([6bedf71](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/6bedf715ccf9bb9bae4f92d735e3d7b714c96849))
|
||||
* variables csrf ([789816e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/789816e9868685e2ae08b536b6b6ada1a6a64595))
|
||||
* warning sur ouverture modale de fichiers ([889a3a4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/889a3a48c5c2a3f6cb65de8ede0efbe639408011))
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
NEXT_PUBLIC_USE_FAKE_DATA='false'
|
||||
AUTH_SECRET='false'
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
DOCUSEAL_API_KEY="LRvUTQCbMSSpManYKshdQk9Do6rBQgjHyPrbGfxU3Jg"
|
||||
340
Front-End/docs/api-messagerie-technique.md
Normal file
340
Front-End/docs/api-messagerie-technique.md
Normal file
@ -0,0 +1,340 @@
|
||||
# API Messagerie Instantanée - Guide Développeur
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Cette documentation technique présente l'implémentation du système de messagerie instantanée, incluant les APIs WebSocket et REST, l'architecture des composants React et les fonctions utilitaires.
|
||||
|
||||
## API WebSocket
|
||||
|
||||
### Connexion
|
||||
|
||||
**URL de connexion :**
|
||||
|
||||
```javascript
|
||||
// Développement
|
||||
ws://localhost:8000/ws/chat/{userId}/
|
||||
|
||||
// Production
|
||||
wss://[domaine]/ws/chat/{userId}/
|
||||
```
|
||||
|
||||
### Messages WebSocket
|
||||
|
||||
#### Messages entrants (serveur → client)
|
||||
|
||||
```javascript
|
||||
// Liste des conversations
|
||||
{
|
||||
"type": "conversations_list",
|
||||
"conversations": [...]
|
||||
}
|
||||
|
||||
// Nouveau message reçu
|
||||
{
|
||||
"type": "new_message",
|
||||
"message": {
|
||||
"id": 123,
|
||||
"conversation_id": 456,
|
||||
"sender_id": 789,
|
||||
"content": "Contenu du message",
|
||||
"timestamp": "2024-01-01T12:00:00Z"
|
||||
}
|
||||
}
|
||||
|
||||
// Utilisateur en train d'écrire
|
||||
{
|
||||
"type": "typing_start",
|
||||
"conversation_id": 456,
|
||||
"user_id": 789
|
||||
}
|
||||
|
||||
// Utilisateur a arrêté d'écrire
|
||||
{
|
||||
"type": "typing_stop",
|
||||
"conversation_id": 456,
|
||||
"user_id": 789
|
||||
}
|
||||
```
|
||||
|
||||
#### Messages sortants (client → serveur)
|
||||
|
||||
```javascript
|
||||
// Envoyer un message
|
||||
{
|
||||
"type": "chat_message",
|
||||
"conversation_id": 456,
|
||||
"message": "Contenu du message"
|
||||
}
|
||||
|
||||
// Signaler début de frappe
|
||||
{
|
||||
"type": "typing_start",
|
||||
"conversation_id": 456
|
||||
}
|
||||
|
||||
// Signaler fin de frappe
|
||||
{
|
||||
"type": "typing_stop",
|
||||
"conversation_id": 456
|
||||
}
|
||||
|
||||
// Marquer comme lu
|
||||
{
|
||||
"type": "mark_as_read",
|
||||
"conversation_id": 456
|
||||
}
|
||||
|
||||
// Rejoindre une conversation
|
||||
{
|
||||
"type": "join_conversation",
|
||||
"conversation_id": 456
|
||||
}
|
||||
```
|
||||
|
||||
## API REST
|
||||
|
||||
### Endpoints disponibles
|
||||
|
||||
```javascript
|
||||
// Récupérer les conversations
|
||||
GET /api/messagerie/conversations/{userId}/
|
||||
Response: Array<Conversation>
|
||||
|
||||
// Récupérer les messages d'une conversation
|
||||
GET /api/messagerie/messages/{conversationId}/
|
||||
Response: Array<Message>
|
||||
|
||||
// Rechercher des destinataires
|
||||
GET /api/messagerie/search/{establishmentId}/?q={query}
|
||||
Response: Array<User>
|
||||
|
||||
// Créer une conversation
|
||||
POST /api/messagerie/conversations/create/
|
||||
Body: { "participants": [userId1, userId2] }
|
||||
Response: Conversation
|
||||
|
||||
// Envoyer un email (séparé de la messagerie instantanée)
|
||||
POST /api/email/send/
|
||||
Body: { "recipients": [...], "subject": "...", "content": "..." }
|
||||
```
|
||||
|
||||
## Composants React
|
||||
|
||||
### InstantChat
|
||||
|
||||
**Props :**
|
||||
|
||||
```javascript
|
||||
{
|
||||
userProfileId: number, // ID de l'utilisateur connecté
|
||||
establishmentId: number // ID de l'établissement
|
||||
}
|
||||
```
|
||||
|
||||
**États principaux :**
|
||||
|
||||
- `conversations` : Liste des conversations
|
||||
- `selectedConversation` : Conversation active
|
||||
- `messages` : Messages de la conversation active
|
||||
- `searchQuery` : Terme de recherche
|
||||
- `searchResults` : Résultats de recherche de contacts
|
||||
|
||||
### useWebSocket Hook
|
||||
|
||||
**Paramètres :**
|
||||
|
||||
```javascript
|
||||
useWebSocket(
|
||||
userProfileId, // ID utilisateur
|
||||
onMessage, // Callback pour messages reçus
|
||||
onConnectionChange // Callback changement de connexion
|
||||
);
|
||||
```
|
||||
|
||||
**Valeurs retournées :**
|
||||
|
||||
```javascript
|
||||
{
|
||||
isConnected: boolean,
|
||||
connectionStatus: string,
|
||||
sendChatMessage: (conversationId, content) => boolean,
|
||||
sendTypingStart: (conversationId) => void,
|
||||
sendTypingStop: (conversationId) => void,
|
||||
markAsRead: (conversationId) => void,
|
||||
joinConversation: (conversationId) => void,
|
||||
reconnect: () => void
|
||||
}
|
||||
```
|
||||
|
||||
## Actions Redux/State
|
||||
|
||||
### messagerieAction.js
|
||||
|
||||
```javascript
|
||||
// Récupérer les conversations
|
||||
fetchConversations(userId): Promise<Array<Conversation>>
|
||||
|
||||
// Récupérer les messages
|
||||
fetchMessages(conversationId): Promise<Array<Message>>
|
||||
|
||||
// Rechercher des destinataires
|
||||
searchMessagerieRecipients(establishmentId, query): Promise<Array<User>>
|
||||
|
||||
// Créer une conversation
|
||||
createConversation(participants): Promise<Conversation>
|
||||
```
|
||||
|
||||
### emailAction.js
|
||||
|
||||
```javascript
|
||||
// Envoyer un email
|
||||
sendEmail(recipients, subject, content, csrfToken): Promise<Response>
|
||||
|
||||
// Rechercher des destinataires email
|
||||
searchEmailRecipients(establishmentId, query): Promise<Array<User>>
|
||||
```
|
||||
|
||||
## Modèles de Données
|
||||
|
||||
### Conversation
|
||||
|
||||
```javascript
|
||||
{
|
||||
conversation_id: number,
|
||||
participants: Array<User>,
|
||||
last_message: Message,
|
||||
created_at: string,
|
||||
updated_at: string
|
||||
}
|
||||
```
|
||||
|
||||
### Message
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: number,
|
||||
conversation_id: number,
|
||||
sender_id: number,
|
||||
content: string,
|
||||
timestamp: string,
|
||||
is_read: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### User
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: number,
|
||||
first_name: string,
|
||||
last_name: string,
|
||||
email: string,
|
||||
role: string
|
||||
}
|
||||
```
|
||||
|
||||
## Gestion des Erreurs
|
||||
|
||||
### WebSocket
|
||||
|
||||
```javascript
|
||||
// Reconnexion automatique
|
||||
const reconnectWebSocket = () => {
|
||||
setConnectionStatus('reconnecting');
|
||||
// Logique de reconnexion avec backoff exponentiel
|
||||
};
|
||||
|
||||
// Gestion des erreurs de connexion
|
||||
wsRef.current.onerror = (error) => {
|
||||
logger.error('Erreur WebSocket:', error);
|
||||
setIsConnected(false);
|
||||
};
|
||||
```
|
||||
|
||||
### API REST
|
||||
|
||||
```javascript
|
||||
// Wrapper avec gestion d'erreur
|
||||
const apiCall = async (url, options) => {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
logger.error('Erreur API:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Configuration des Tests
|
||||
|
||||
### Jest Setup
|
||||
|
||||
```javascript
|
||||
// jest.setup.js
|
||||
global.WebSocket = class MockWebSocket {
|
||||
// Mock complet du WebSocket pour les tests
|
||||
};
|
||||
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### Tests des Composants
|
||||
|
||||
```javascript
|
||||
// Exemple de test
|
||||
test('renders InstantChat component', async () => {
|
||||
await act(async () => {
|
||||
render(<InstantChat userProfileId={1} establishmentId={123} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Messages')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
## Intégration Backend
|
||||
|
||||
### Consumer Django
|
||||
|
||||
```python
|
||||
# consumers.py
|
||||
class ChatConsumer(AsyncWebsocketConsumer):
|
||||
async def connect(self):
|
||||
# Logique de connexion
|
||||
|
||||
async def chat_message(self, event):
|
||||
# Traitement des messages
|
||||
```
|
||||
|
||||
### URLs Configuration
|
||||
|
||||
```python
|
||||
# routing.py
|
||||
websocket_urlpatterns = [
|
||||
re_path(r'ws/chat/(?P<user_id>\w+)/$', ChatConsumer.as_asgi()),
|
||||
]
|
||||
```
|
||||
|
||||
## Optimisations
|
||||
|
||||
### Performance
|
||||
|
||||
- Pagination des messages anciens (load on scroll)
|
||||
- Debounce pour la recherche de contacts (300ms)
|
||||
- Memoization des composants avec React.memo
|
||||
- Lazy loading des conversations
|
||||
|
||||
### UX
|
||||
|
||||
- Reconnexion automatique avec feedback visuel
|
||||
- Sauvegarde locale des messages en cours de frappe
|
||||
- Indicateurs de livraison des messages
|
||||
- Scrolling automatique vers les nouveaux messages
|
||||
126
Front-End/docs/messagerie-instantanee.md
Normal file
126
Front-End/docs/messagerie-instantanee.md
Normal file
@ -0,0 +1,126 @@
|
||||
# Système de Messagerie Instantanée
|
||||
|
||||
## Présentation
|
||||
|
||||
Le système de messagerie instantanée de N3WT-SCHOOL permet aux utilisateurs de l'établissement (administrateurs, professeurs, parents, étudiants) de communiquer en temps réel via une interface chat moderne et intuitive.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Chat en Temps Réel
|
||||
|
||||
- Envoi et réception de messages instantanés
|
||||
- Notification de statut de frappe (utilisateur en train d'écrire)
|
||||
- Indicateur de statut de connexion WebSocket
|
||||
- Reconnexion automatique en cas de perte de connexion
|
||||
|
||||
### Gestion des Conversations
|
||||
|
||||
- Liste des conversations existantes
|
||||
- Création de nouvelles conversations
|
||||
- Recherche de destinataires par nom ou email
|
||||
- Compteur de messages non lus
|
||||
|
||||
### Interface Utilisateur
|
||||
|
||||
- Interface moderne en deux panneaux (conversations + chat)
|
||||
- Bulles de messages différenciées (expéditeur/destinataire)
|
||||
- Indicateurs visuels de statut de connexion
|
||||
- Recherche temps réel de contacts
|
||||
|
||||
## Utilisation
|
||||
|
||||
### Accès au Chat
|
||||
|
||||
Le système de messagerie est accessible via les pages suivantes :
|
||||
|
||||
- **Parents** : `/[locale]/parents/messagerie`
|
||||
- **Administrateurs** : Intégré dans le panneau d'administration
|
||||
|
||||
### Créer une Conversation
|
||||
|
||||
1. Cliquer sur le bouton "+" en haut à droite de la liste des conversations
|
||||
2. Rechercher un contact en tapant son nom ou email
|
||||
3. Sélectionner le destinataire dans les résultats
|
||||
4. La conversation se crée automatiquement
|
||||
|
||||
### Envoyer un Message
|
||||
|
||||
1. Sélectionner une conversation dans la liste de gauche
|
||||
2. Taper le message dans le champ de saisie en bas
|
||||
3. Appuyer sur Entrée ou cliquer sur le bouton d'envoi
|
||||
|
||||
## Architecture Technique
|
||||
|
||||
### Frontend (React/Next.js)
|
||||
|
||||
**Composants principaux :**
|
||||
|
||||
- `InstantChat` : Composant principal du chat
|
||||
- `ConnectionStatus` : Affichage du statut de connexion
|
||||
- `ConversationItem` : Élément de liste de conversation
|
||||
- `MessageBubble` : Bulle de message individuelle
|
||||
- `MessageInput` : Zone de saisie de message
|
||||
- `TypingIndicator` : Indicateur de frappe
|
||||
|
||||
**Hook personnalisé :**
|
||||
|
||||
- `useWebSocket` : Gestion de la connexion WebSocket et des événements
|
||||
|
||||
### Backend (Django)
|
||||
|
||||
**Module GestionMessagerie :**
|
||||
|
||||
- `consumers.py` : Consumer WebSocket pour la messagerie temps réel
|
||||
- `routing.py` : Configuration des routes WebSocket
|
||||
- `urls.py` : URLs API REST pour les conversations et messages
|
||||
|
||||
**Module GestionEmail :**
|
||||
|
||||
- `views.py` : Vues pour l'envoi d'emails classiques
|
||||
- `urls.py` : URLs pour les fonctions email
|
||||
|
||||
### Communication
|
||||
|
||||
- **WebSocket** : Communication bidirectionnelle temps réel
|
||||
- **REST API** : Chargement initial des données et recherche
|
||||
- **Channels** : Gestion des groupes de conversation Django
|
||||
|
||||
## Configuration
|
||||
|
||||
### URLs WebSocket
|
||||
|
||||
Les URLs sont configurées automatiquement selon l'environnement :
|
||||
|
||||
- **Développement** : `ws://localhost:8000/ws/chat/`
|
||||
- **Production** : `wss://[domaine]/ws/chat/`
|
||||
|
||||
### Variables d'Environnement
|
||||
|
||||
Le système utilise les configurations standard de l'application pour :
|
||||
|
||||
- Base de données (conversations, messages, utilisateurs)
|
||||
- Authentification (sessions Django)
|
||||
- Établissements (filtrage par établissement)
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Authentification requise pour accéder au chat
|
||||
- Filtrage des conversations par établissement
|
||||
- Validation côté serveur de tous les messages
|
||||
- Gestion des permissions selon le rôle utilisateur
|
||||
|
||||
## Tests
|
||||
|
||||
Le système dispose de tests unitaires Jest couvrant :
|
||||
|
||||
- Rendu des composants
|
||||
- Gestion des connexions WebSocket
|
||||
- Recherche de contacts
|
||||
- Envoi de messages
|
||||
- Indicateurs de frappe
|
||||
|
||||
Exécution des tests :
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
33
Front-End/jest.config.js
Normal file
33
Front-End/jest.config.js
Normal file
@ -0,0 +1,33 @@
|
||||
const nextJest = require('next/jest');
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files
|
||||
dir: './',
|
||||
});
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,jsx}',
|
||||
'!src/**/*.stories.{js,jsx}',
|
||||
'!src/pages/_app.js',
|
||||
'!src/pages/_document.js',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 70,
|
||||
functions: 70,
|
||||
lines: 70,
|
||||
statements: 70,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||
module.exports = createJestConfig(customJestConfig);
|
||||
95
Front-End/jest.setup.js
Normal file
95
Front-End/jest.setup.js
Normal file
@ -0,0 +1,95 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Supprimer les avertissements React act() en environnement de test
|
||||
global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = class IntersectionObserver {
|
||||
constructor() {}
|
||||
observe() {
|
||||
return null;
|
||||
}
|
||||
disconnect() {
|
||||
return null;
|
||||
}
|
||||
unobserve() {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Mock WebSocket
|
||||
global.WebSocket = class WebSocket {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = WebSocket.CONNECTING;
|
||||
setTimeout(() => {
|
||||
this.readyState = WebSocket.OPEN;
|
||||
if (this.onopen) this.onopen();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
send(data) {
|
||||
// Mock send
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
if (this.onclose) {
|
||||
this.onclose({
|
||||
code: 1000,
|
||||
reason: 'Normal closure',
|
||||
wasClean: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static get CONNECTING() {
|
||||
return 0;
|
||||
}
|
||||
static get OPEN() {
|
||||
return 1;
|
||||
}
|
||||
static get CLOSING() {
|
||||
return 2;
|
||||
}
|
||||
static get CLOSED() {
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
// Mock global pour fetch
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
})
|
||||
);
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
constructor() {}
|
||||
observe() {
|
||||
return null;
|
||||
}
|
||||
disconnect() {
|
||||
return null;
|
||||
}
|
||||
unobserve() {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"dashboard": "Tableau de bord",
|
||||
"totalStudents": "Total des étudiants",
|
||||
"totalStudents": "Total d'étudiants inscrits",
|
||||
"pendingRegistrations": "Inscriptions en attente",
|
||||
"reInscriptionRate": "Taux de réinscription",
|
||||
"structureCapacity": "Capacité de la structure",
|
||||
|
||||
@ -18,23 +18,33 @@ const nextConfig = {
|
||||
protocol: 'https',
|
||||
hostname: 'www.gravatar.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'api.demo.n3wtschool.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'api.prod.n3wtschool.com',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
port: '8080',
|
||||
},
|
||||
],
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_VERSION: pkg.version,
|
||||
NEXT_PUBLIC_API_URL:
|
||||
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
|
||||
NEXT_PUBLIC_WSAPI_URL:
|
||||
process.env.NEXT_PUBLIC_WSAPI_URL || 'ws://localhost:8080',
|
||||
NEXT_PUBLIC_USE_FAKE_DATA: process.env.NEXT_PUBLIC_USE_FAKE_DATA || 'false',
|
||||
AUTH_SECRET: process.env.AUTH_SECRET || 'false',
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3000',
|
||||
DOCUSEAL_API_KEY: process.env.DOCUSEAL_API_KEY,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/documents/:path*',
|
||||
destination: 'https://api.docuseal.com/v1/documents/:path*',
|
||||
},
|
||||
{
|
||||
source: '/api/auth/:path*',
|
||||
destination: '/api/auth/:path*', // Exclure les routes NextAuth des réécritures de proxy
|
||||
|
||||
7478
Front-End/package-lock.json
generated
7478
Front-End/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n3wt-school-front-end",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@ -8,10 +8,12 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint-light": "next lint --quiet",
|
||||
"check-strings": "node scripts/check-hardcoded-strings.js"
|
||||
"check-strings": "node scripts/check-hardcoded-strings.js",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docuseal/react": "^1.0.56",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"date-fns": "^4.1.0",
|
||||
@ -32,14 +34,20 @@
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-international-phone": "^4.5.0",
|
||||
"react-quill": "^2.0.0",
|
||||
"react-tooltip": "^5.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.11",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14"
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
NEXT_PUBLIC_API_URL=_NEXT_PUBLIC_API_URL_
|
||||
NEXT_PUBLIC_WSAPI_URL=_NEXT_PUBLIC_WSAPI_URL_
|
||||
NEXT_PUBLIC_USE_FAKE_DATA=_NEXT_PUBLIC_USE_FAKE_DATA_
|
||||
AUTH_SECRET=_AUTH_SECRET_
|
||||
NEXTAUTH_URL=_NEXTAUTH_URL_
|
||||
DOCUSEAL_API_KEY=_DOCUSEAL_API_KEY_
|
||||
NEXTAUTH_URL=_NEXTAUTH_URL_
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user