Merge remote-tracking branch 'origin/develop'

This commit is contained in:
N3WT DE COMPET
2025-06-01 13:37:08 +02:00
424 changed files with 60158 additions and 11132 deletions

58
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,58 @@
# Instructions Copilot - Projet N3WT-SCHOOL
## Objectif
Corriger ou améliorer le projet N3WT-SCHOOL de manière minimaliste et fonctionnelle, sans dépendances inutiles.
## Architecture du projet
### Structure
- **Backend** : Python Django (dossier `Back-End/`)
- **Frontend** : NextJS (dossier `Front-End/`)
- **Tests frontend** : `Front-End/src/test/`
- **Code frontend** : `Front-End/src/`
## Gestion des tickets
### Règles générales
- Chaque **nouvelle fonctionnalité** ou **correction** nécessite un ticket Gitea
- **Exemptions** : modifications documentaires, refactoring, chore, style
### Cycle de vie d'un ticket
1. **Création** → label `etat/En Pause`
2. **Affectation** → label `etat/En Cours`
3. **Développement terminé** → label `etat/Codé`
4. **Tests validés** → label `etat/Testé`
### Gestion des branches
- **Base** : branche `develop`
- **Nomenclature** : `<type>-<nom_ticket>-<numero>` (ex: `feat-ma_super_feat-1234`)
- **Types** : feat, fix, docs, style, refactor, test, chore
## 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é
- Localisation : `Front-End/src/test/`
### Documentation
- Documentation en français pour les nouvelles fonctionnalités (si applicable)
- Référence : [documentation guidelines](./instructions/documentation.instruction.md)
## Références
- **Tickets** : [issues guidelines](./instructions/issues.instruction.md)
- **Commits** : [commit guidelines](./instructions/general-commit.instruction.md)

View File

@ -0,0 +1,6 @@
La documentation doit être en français et claire pour les utilisateurs francophones.
Toutes la documentation doit être dans le dossier docs/
Seul les fichiers README.md, CHANGELOG.md doivent être à la racine.
La documentation doit être conscise et pertinente, sans répétitions inutiles entre les documents.
Tout ce qui concerne la gestion de projet, roadmap ne doit pas apparaître dans la documentation.
Tout ce qui concerne les modifications de type chore n'a pas besoin d'être documenté.

View File

View File

@ -0,0 +1,28 @@
### **Commit Guidelines (Conventionnel)**
Les messages de commits se basent sur **Conventional Commits** (https://www.conventionalcommits.org/en/v1.0.0/) , pour une meilleure gestion des versions et une génération automatique du changelog.
#### **Format standard** :
```
<type>(<scope>): <description> [#<ticket-id>]
```
- **Types autorisés** :
- `feat` : Nouvelle fonctionnalité.
- `fix` : Correction dun bug.
- `docs` : Modifications liées à la documentation.
- `style` : Mise en forme du code (pas de changements fonctionnels).
- `refactor` : Refactorisation sans ajout de fonctionnalités ni correction de bugs.
- `test` : Ajout ou modification de tests.
- `chore` : Maintenance ou tâches diverses (ex. mise à jour des dépendances).
- **Scope (optionnel)** : Précisez une partie spécifique du projet (`backend`, `frontend`, `API`, etc.).
- **Exemples** :
```
feat(frontend): ajout de la gestion des utilisateurs dans le dashboard [#1]
fix(backend): correction du bug lié à l'authentification JWT [#1]
docs: mise à jour du README avec les nouvelles instructions dinstallation [#2]
```

View File

@ -0,0 +1,22 @@
- Chaque nouveau ticket doit faire l'objet d'une analyse pour définir les modifications à effectuer.
- l'analyse doit être présente dans la description du ticket au format:
# Description de la modification
Définir la modification
# Solution envisagée
Définir l'implementation avec l'impact dans le code
# Modification documentaire
Définir les documents à modifier si il existe il peut ne pas y en avoir.
# Tests
Définir ici comment va être tester la fonctionnalité et les cas de test.
- Une fois créer son label doit passer à `etat/En Pause`
- Les labels 'subject' sont ensuites ajouté en fonction du sujet de modification.
- le label 'workload' est défini en fonction de si la modification de code est longue (gros impact) à faire ou rapide (faible impact)

8
.gitignore vendored
View File

@ -1,10 +1,4 @@
Back-End/*/Configuration/application.json
.venv/
__pycache__/
.env
node_modules/
Back-End/*/migrations/*
Back-End/documents
Back-End/data
Back-End/*.dmp
Back-End/staticfiles
hardcoded-strings-report.md

View File

@ -0,0 +1 @@
cd $(dirname "$0")/../Front-End/ && npm run lint-light

View File

@ -1,4 +1 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
node scripts/prepare-commit-msg.js "$1" "$2"

13
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,13 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"webRoot": "${workspaceFolder}/Front-End/",
"url": "http://localhost:3000",
},
]
}

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"eslint.workingDirectories": ["./Front-End"],
"giteaCopilotTools.owner": "n3wt-innov",
"giteaCopilotTools.repo": "n3wt-school",
"giteaCopilotTools.autoRefreshInterval": 15
}

13
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,13 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Start Frontend Dev Server",
"type": "shell",
"command": "npm run dev",
"group": "build",
"isBackground": true,
"problemMatcher": []
}
]
}

7
Back-End/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
__pycache__
/*/migrations/*
documents
data
*.dmp
staticfiles
/*/Configuration/application*.json

View File

@ -0,0 +1,75 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
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),
),
]

View File

View File

@ -4,22 +4,37 @@ from django.utils.translation import gettext_lazy as _
from django.core.validators import EmailValidator
class Profile(AbstractUser):
class Droits(models.IntegerChoices):
PROFIL_UNDEFINED = -1, _('NON DEFINI')
PROFIL_ECOLE = 0, _('ECOLE')
PROFIL_ADMIN = 1, _('ADMIN')
PROFIL_PARENT = 2, _('PARENT')
email = models.EmailField(max_length=255, unique=True, default="", validators=[EmailValidator()])
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ('password', )
roleIndexLoginDefault = models.IntegerField(default=0)
code = models.CharField(max_length=200, default="", blank=True)
datePeremption = models.CharField(max_length=200, default="", blank=True)
droit = models.IntegerField(choices=Droits, default=Droits.PROFIL_UNDEFINED)
estConnecte = models.BooleanField(default=False, blank=True)
def __str__(self):
return self.email + " - " + str(self.droit)
return self.email
class ProfileRole(models.Model):
class RoleType(models.IntegerChoices):
PROFIL_UNDEFINED = -1, _('NON DEFINI')
PROFIL_ECOLE = 0, _('ECOLE')
PROFIL_ADMIN = 1, _('ADMIN')
PROFIL_PARENT = 2, _('PARENT')
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()}"
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})"

View File

@ -0,0 +1,20 @@
from rest_framework.pagination import PageNumberPagination
from N3wtSchool import settings
class CustomProfilesPagination(PageNumberPagination):
page_size_query_param = 'page_size'
max_page_size = settings.NB_MAX_PAGE
page_size = settings.NB_RESULT_PROFILES_PER_PAGE
def get_paginated_response(self, data):
return ({
'links': {
'next': self.get_next_link(),
'previous': self.get_previous_link()
},
'count': self.page.paginator.count,
'page_size': self.page_size,
'max_page_size' : self.max_page_size,
'profilesRoles': data }
)

View File

@ -1,51 +1,160 @@
from rest_framework import serializers
from Auth.models import Profile
from django.core.exceptions import ValidationError
from Auth.models import Profile, ProfileRole
from Establishment.models import Establishment
from Subscriptions.models import Guardian, RegistrationForm
from School.models import Teacher
from N3wtSchool import settings
from django.utils import timezone
import pytz
class ProfileSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
password = serializers.CharField(write_only=True)
roles = serializers.SerializerMethodField()
class Meta:
model = Profile
fields = ['id', 'password', 'email', 'code', 'datePeremption', 'estConnecte', 'droit', 'username', 'is_active']
fields = ['id', 'password', 'email', 'code', 'datePeremption', 'username', 'roles', 'roleIndexLoginDefault']
extra_kwargs = {'password': {'write_only': True}}
def get_roles(self, obj):
roles = ProfileRole.objects.filter(profile=obj)
roles_data = []
for role in roles:
# Récupérer l'ID de l'associated_person en fonction du type de rôle
if role.role_type == ProfileRole.RoleType.PROFIL_PARENT:
guardian = Guardian.objects.filter(profile_role=role).first()
id_associated_person = guardian.id if guardian else None
else:
teacher = Teacher.objects.filter(profile_role=role).first()
id_associated_person = teacher.id if teacher else None
roles_data.append({
'id_associated_person': id_associated_person,
'role_type': role.role_type,
'establishment': role.establishment.id,
'establishment_name': role.establishment.name,
'is_active': role.is_active,
})
return roles_data
def create(self, validated_data):
user = Profile(
username=validated_data['username'],
email=validated_data['email'],
is_active=validated_data['is_active'],
droit=validated_data['droit']
code=validated_data.get('code', ''),
datePeremption=validated_data.get('datePeremption', '')
)
user.set_password(validated_data['password'])
user.full_clean()
user.save()
return user
def to_representation(self, instance):
ret = super().to_representation(instance)
ret['password'] = '********'
return ret
class ProfilUpdateSerializer(serializers.ModelSerializer):
class Meta:
model = Profile
fields = ['id', 'password', 'email', 'code', 'datePeremption', 'estConnecte', 'droit', 'username', 'is_active']
extra_kwargs = {
'password': {'write_only': True, 'required': False}
}
def update(self, instance, validated_data):
password = validated_data.pop('password', None)
instance = super().update(instance, validated_data)
if password:
instance.set_password(password)
instance.save()
instance.full_clean()
instance.save()
return instance
def to_representation(self, instance):
ret = super().to_representation(instance)
ret['password'] = '********'
ret['roles'] = self.get_roles(instance)
return ret
class ProfileRoleSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
profile = serializers.PrimaryKeyRelatedField(queryset=Profile.objects.all(), required=False)
profile_data = ProfileSerializer(write_only=True, required=False)
associated_profile_email = serializers.SerializerMethodField()
associated_person = serializers.SerializerMethodField()
updated_date_formatted = serializers.SerializerMethodField()
class Meta:
model = ProfileRole
fields = ['id', 'role_type', 'establishment', 'is_active', 'profile', 'profile_data', 'associated_profile_email', 'associated_person', 'updated_date_formatted']
def create(self, validated_data):
profile_data = validated_data.pop('profile_data', None)
profile = validated_data.pop('profile', None)
if profile_data:
profile_serializer = ProfileSerializer(data=profile_data)
profile_serializer.is_valid(raise_exception=True)
profile = profile_serializer.save()
elif profile:
profile = Profile.objects.get(id=profile.id)
profile_role = ProfileRole.objects.create(profile=profile, **validated_data)
return profile_role
def update(self, instance, validated_data):
profile_data = validated_data.pop('profile_data', None)
profile = validated_data.pop('profile', None)
if profile_data:
profile_serializer = ProfileSerializer(instance.profile, data=profile_data)
profile_serializer.is_valid(raise_exception=True)
profile = profile_serializer.save()
elif profile:
profile = Profile.objects.get(id=profile.id)
if profile:
instance.profile = profile
instance.role_type = validated_data.get('role_type', instance.role_type)
instance.establishment_id = validated_data.get('establishment', instance.establishment.id)
instance.is_active = validated_data.get('is_active', instance.is_active)
instance.save()
return instance
def get_associated_profile_email(self, obj):
if obj.profile:
return obj.profile.email
return None
def get_associated_person(self, obj):
if obj.role_type == ProfileRole.RoleType.PROFIL_PARENT:
guardian = Guardian.objects.filter(profile_role=obj).first()
if guardian:
students = guardian.student_set.all()
students_list = []
for student in students:
registration_form = RegistrationForm.objects.filter(student=student).first()
registration_status = registration_form.status if registration_form else None
students_list.append({
"id": f"{student.id}",
"student_name": f"{student.last_name} {student.first_name}",
"registration_status": registration_status
})
return {
"id": guardian.id,
"guardian_name": f"{guardian.last_name} {guardian.first_name}",
"students": students_list
}
else:
teacher = Teacher.objects.filter(profile_role=obj).first()
if teacher:
classes = teacher.schoolclass_set.all()
classes_list = [{"id": classe.id, "name": classe.atmosphere_name} for classe in classes]
specialities = teacher.specialities.all()
specialities_list = [{"name": speciality.name, "color_code": speciality.color_code} for speciality in specialities]
return {
"id": teacher.id,
"teacher_name": f"{teacher.last_name} {teacher.first_name}",
"classes": classes_list,
"specialities": specialities_list
}
return None
def get_updated_date_formatted(self, obj):
utc_time = timezone.localtime(obj.updated_date)
local_tz = pytz.timezone(settings.TZ_APPLI)
local_time = utc_time.astimezone(local_tz)
return local_time.strftime("%d-%m-%Y %H:%M")

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -2,21 +2,21 @@ from django.urls import path, re_path
from . import views
import Auth.views
from Auth.views import ProfileView, ProfileListView, SessionView, LoginView, SubscribeView, NewPasswordView, ResetPasswordView
from Auth.views import ProfileRoleView, ProfileRoleSimpleView, ProfileSimpleView, ProfileView, SessionView, LoginView, RefreshJWTView, SubscribeView, NewPasswordView, ResetPasswordView
urlpatterns = [
re_path(r'^csrf$', Auth.views.csrf, name='csrf'),
re_path(r'^login$', LoginView.as_view(), name="login"),
re_path(r'^refreshJWT$', RefreshJWTView.as_view(), name="refresh_jwt"),
re_path(r'^subscribe$', SubscribeView.as_view(), name='subscribe'),
re_path(r'^newPassword$', NewPasswordView.as_view(), name='newPassword'),
re_path(r'^resetPassword/([a-zA-Z]+)$', ResetPasswordView.as_view(), name='resetPassword'),
re_path(r'^infoSession$', Auth.views.infoSession, name='infoSession'),
re_path(r'^resetPassword/(?P<code>[a-zA-Z]+)$', ResetPasswordView.as_view(), name='resetPassword'),
re_path(r'^infoSession$', SessionView.as_view(), name='infoSession'),
re_path(r'^profiles$', ProfileListView.as_view(), name="profile"),
re_path(r'^profile$', ProfileView.as_view(), name="profile"),
re_path(r'^profile/([0-9]+)$', ProfileView.as_view(), name="profile"),
re_path(r'^profiles$', ProfileView.as_view(), name="profile"),
re_path(r'^profiles/(?P<id>[0-9]+)$', ProfileSimpleView.as_view(), name="profile"),
# Test SESSION VIEW
re_path(r'^session$', SessionView.as_view(), name="session"),
re_path(r'^profileRoles$', ProfileRoleView.as_view(), name="profileRoles"),
re_path(r'^profileRoles/(?P<id>[0-9]+)$', ProfileRoleSimpleView.as_view(), name="profileRoles"),
]

View File

@ -4,47 +4,81 @@ from django.http.response import JsonResponse
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt, csrf_protect
from django.utils.decorators import method_decorator
from django.core.exceptions import ValidationError
from django.core.cache import cache
from django.middleware.csrf import get_token
from rest_framework.views import APIView
from rest_framework.parsers import JSONParser
from rest_framework import status
from Auth.pagination import CustomProfilesPagination
from datetime import datetime
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from datetime import datetime, timedelta
import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
import json
from . import validator
from .models import Profile
from .models import Profile, ProfileRole
from rest_framework.decorators import action, api_view
from django.db.models import Q
from Auth.serializers import ProfileSerializer, ProfilUpdateSerializer
from Subscriptions.models import RegistrationForm
from Subscriptions.signals import clear_cache
import Subscriptions.mailManager as mailer
from Auth.serializers import ProfileSerializer, ProfileRoleSerializer
from Subscriptions.models import RegistrationForm, Guardian
import N3wtSchool.mailManager as mailer
import Subscriptions.util as util
import logging
from N3wtSchool import bdd, error, settings
from N3wtSchool import bdd, error
from rest_framework_simplejwt.authentication import JWTAuthentication
logger = logging.getLogger("AuthViews")
@swagger_auto_schema(
method='get',
operation_description="Obtenir un token CSRF",
responses={200: openapi.Response('Token CSRF', schema=openapi.Schema(type=openapi.TYPE_OBJECT, properties={
'csrfToken': openapi.Schema(type=openapi.TYPE_STRING)
}))}
)
@api_view(['GET'])
def csrf(request):
token = get_token(request)
return JsonResponse({'csrfToken': token})
class SessionView(APIView):
def post(self, request):
@swagger_auto_schema(
operation_description="Vérifier une session utilisateur",
manual_parameters=[openapi.Parameter('Authorization', openapi.IN_HEADER, type=openapi.TYPE_STRING, description='Bearer token')],
responses={
200: openapi.Response('Session valide', schema=openapi.Schema(type=openapi.TYPE_OBJECT, properties={
'user': openapi.Schema(type=openapi.TYPE_OBJECT, properties={
'id': openapi.Schema(type=openapi.TYPE_INTEGER),
'email': openapi.Schema(type=openapi.TYPE_STRING),
'roleIndexLoginDefault': openapi.Schema(type=openapi.TYPE_INTEGER),
'roles': openapi.Schema(type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_OBJECT, properties={
'role_type': openapi.Schema(type=openapi.TYPE_STRING),
'establishment': openapi.Schema(type=openapi.TYPE_STRING)
}))
})
})),
401: openapi.Response('Session invalide')
}
)
def get(self, request):
token = request.META.get('HTTP_AUTHORIZATION', '').split('Bearer ')[-1]
try:
decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
print(f'decode : {decoded_token}')
user_id = decoded_token.get('id')
user = Profile.objects.get(id=user_id)
userid = decoded_token.get('user_id')
user = Profile.objects.get(id=userid)
roles = ProfileRole.objects.filter(profile=user).values('role_type', 'establishment__name')
response_data = {
'user': {
'id': user.id,
'email': user.email,
'role': user.droit, # Assure-toi que le champ 'droit' existe et contient le rôle
'roleIndexLoginDefault': user.roleIndexLoginDefault,
'roles': list(roles)
}
}
return JsonResponse(response_data, status=status.HTTP_200_OK)
@ -53,208 +87,433 @@ class SessionView(APIView):
except jwt.InvalidTokenError:
return JsonResponse({"error": "Invalid token"}, status=status.HTTP_401_UNAUTHORIZED)
class ProfileListView(APIView):
class ProfileView(APIView):
@swagger_auto_schema(
operation_description="Obtenir la liste des profils",
responses={200: ProfileSerializer(many=True)}
)
def get(self, request):
profilsList = bdd.getAllObjects(_objectName=Profile)
profils_serializer = ProfileSerializer(profilsList, many=True)
return JsonResponse(profils_serializer.data, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class ProfileView(APIView):
def get(self, request, _id):
profil=bdd.getObject(Profile, "id", _id)
profil_serializer=ProfileSerializer(profil)
return JsonResponse(profil_serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Créer un nouveau profil",
request_body=ProfileSerializer,
responses={
200: ProfileSerializer,
400: 'Données invalides'
}
)
def post(self, request):
profil_data=JSONParser().parse(request)
print(f'{profil_data}')
profil_data = JSONParser().parse(request)
profil_serializer = ProfileSerializer(data=profil_data)
if profil_serializer.is_valid():
profil_serializer.save()
profil = profil_serializer.save()
return JsonResponse(profil_serializer.data, safe=False)
return JsonResponse(profil_serializer.errors, safe=False)
def put(self, request, _id):
data=JSONParser().parse(request)
profil = Profile.objects.get(id=_id)
profil_serializer = ProfilUpdateSerializer(profil, data=data)
if profil_serializer.is_valid():
profil_serializer.save()
return JsonResponse("Updated Successfully", safe=False)
return JsonResponse(profil_serializer.errors, safe=False)
def delete(self, request, _id):
return bdd.delete_object(Profile, _id)
def infoSession(request):
profilCache = cache.get('session_cache')
if profilCache:
return JsonResponse({"cacheSession":True,"typeProfil":profilCache.droit, "username":profilCache.email}, safe=False)
else:
return JsonResponse({"cacheSession":False,"typeProfil":Profile.Droits.PROFIL_UNDEFINED, "username":""}, safe=False)
return JsonResponse(profil_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class ProfileSimpleView(APIView):
@swagger_auto_schema(
operation_description="Obtenir un profil par son ID",
responses={200: ProfileSerializer}
)
def get(self, request, id):
profil = bdd.getObject(Profile, "id", id)
profil_serializer = ProfileSerializer(profil)
return JsonResponse(profil_serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Mettre à jour un profil",
request_body=ProfileSerializer,
responses={
200: 'Mise à jour réussie',
400: 'Données invalides'
}
)
def put(self, request, id):
data = JSONParser().parse(request)
profil = Profile.objects.get(id=id)
profil_serializer = ProfileSerializer(profil, data=data)
if profil_serializer.is_valid():
profil_serializer.save()
return JsonResponse(profil_serializer.data, safe=False)
return JsonResponse(profil_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprimer un profil",
responses={200: 'Suppression réussie'}
)
def delete(self, request, id):
return bdd.delete_object(Profile, id)
@method_decorator(csrf_exempt, name='dispatch')
class LoginView(APIView):
def get(self, request):
return JsonResponse({
'errorFields':'',
'errorMessage':'',
'profil':0,
}, safe=False)
@swagger_auto_schema(
operation_description="Connexion utilisateur",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['email', 'password'],
properties={
'email': openapi.Schema(type=openapi.TYPE_STRING),
'password': openapi.Schema(type=openapi.TYPE_STRING)
}
),
responses={
200: openapi.Response('Connexion réussie', schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'token': openapi.Schema(type=openapi.TYPE_STRING),
'refresh': openapi.Schema(type=openapi.TYPE_STRING)
}
)),
400: openapi.Response('Connexion échouée', schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'errorFields': openapi.Schema(type=openapi.TYPE_OBJECT),
'errorMessage': openapi.Schema(type=openapi.TYPE_STRING)
}
))
}
)
def post(self, request):
data=JSONParser().parse(request)
data = JSONParser().parse(request)
validatorAuthentication = validator.ValidatorAuthentication(data=data)
retour = error.returnMessage[error.WRONG_ID]
validationOk, errorFields = validatorAuthentication.validate()
user = None
if validationOk:
user = authenticate(
email=data.get('email'),
password=data.get('password'),
)
if user is not None:
if user.is_active:
login(request, user)
user.estConnecte = True
user.save()
clear_cache()
retour = ''
else:
retour = error.returnMessage[error.PROFIL_INACTIVE]
# Vérifier si l'utilisateur a un role actif
has_active_role = ProfileRole.objects.filter(profile=user, is_active=True).first()
if not has_active_role:
return JsonResponse({"errorMessage": "Profil inactif"}, status=status.HTTP_401_UNAUTHORIZED)
login(request, user)
user.save()
retour = ''
access_token, refresh_token = makeToken(user)
return JsonResponse({
'token': access_token,
'refresh': refresh_token
}, safe=False)
# Génération du token JWT
# jwt_token = jwt.encode({
# 'id': user.id,
# 'email': user.email,
# 'role': "admin"
# }, settings.SECRET_KEY, algorithm='HS256')
else:
retour = error.returnMessage[error.WRONG_ID]
return JsonResponse({
'errorFields':errorFields,
'errorMessage':retour,
'profil':user.id if user else -1,
'droit':user.droit if user else -1,
#'jwtToken':jwt_token if profil != -1 else ''
}, safe=False)
'errorFields': errorFields,
'errorMessage': retour,
}, safe=False, status=status.HTTP_400_BAD_REQUEST)
def makeToken(user):
"""
Fonction pour créer un token JWT pour l'utilisateur donné.
"""
try:
# Récupérer tous les rôles de l'utilisateur actifs
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__api_docuseal": role.establishment.api_docuseal,
"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': roles,
'type': 'access',
'exp': datetime.utcnow() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'],
'iat': datetime.utcnow(),
}
access_token = jwt.encode(access_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM'])
# Générer le Refresh Token (exp: 7 jours)
refresh_payload = {
'user_id': user.id,
'type': 'refresh',
'exp': datetime.utcnow() + settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'],
'iat': datetime.utcnow(),
}
refresh_token = jwt.encode(refresh_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM'])
return access_token, refresh_token
except Exception as e:
logger.error(f"Erreur lors de la création du token: {str(e)}")
return None
class RefreshJWTView(APIView):
@swagger_auto_schema(
operation_description="Rafraîchir le token d'accès",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['refresh'],
properties={
'refresh': openapi.Schema(type=openapi.TYPE_STRING)
}
),
responses={
200: openapi.Response('Token rafraîchi avec succès', schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'token': openapi.Schema(type=openapi.TYPE_STRING),
'refresh': openapi.Schema(type=openapi.TYPE_STRING),
}
)),
400: openapi.Response('Échec du rafraîchissement', schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'errorMessage': openapi.Schema(type=openapi.TYPE_STRING)
}
))
}
)
@method_decorator(csrf_exempt, name='dispatch')
def post(self, request):
data = JSONParser().parse(request)
refresh_token = data.get("refresh")
logger.info(f"Token reçu: {refresh_token[:20]}...") # Ne pas logger le token complet pour la sécurité
if not refresh_token:
return JsonResponse({'errorMessage': 'Refresh token manquant'}, status=400)
try:
# Décoder le Refresh Token
logger.info("Tentative de décodage du token")
logger.info(f"Algorithme utilisé: {settings.SIMPLE_JWT['ALGORITHM']}")
# Vérifier le format du token avant décodage
token_parts = refresh_token.split('.')
if len(token_parts) != 3:
logger.error("Format de token invalide - pas 3 parties")
return JsonResponse({'errorMessage': 'Format de token invalide'}, status=400)
payload = jwt.decode(
refresh_token,
settings.SIMPLE_JWT['SIGNING_KEY'],
algorithms=[settings.SIMPLE_JWT['ALGORITHM']] # Noter le passage en liste
)
logger.info(f"Token décodé avec succès. Type: {payload.get('type')}")
# Vérifier s'il s'agit bien d'un Refresh Token
if payload.get('type') != 'refresh':
return JsonResponse({'errorMessage': 'Token invalide'}, status=400)
# Récupérer les informations utilisateur
user = Profile.objects.get(id=payload['user_id'])
if not user:
return JsonResponse({'errorMessage': 'Utilisateur non trouvé'}, status=404)
new_access_token, new_refresh_token = makeToken(user)
return JsonResponse({'token': new_access_token, 'refresh': new_refresh_token}, status=200)
except ExpiredSignatureError as e:
logger.error(f"Token expiré: {str(e)}")
return JsonResponse({'errorMessage': 'Refresh token expiré'}, status=400)
except InvalidTokenError as e:
logger.error(f"Token invalide: {str(e)}")
return JsonResponse({'errorMessage': f'Token invalide: {str(e)}'}, status=400)
except Exception as e:
logger.error(f"Erreur inattendue: {str(e)}")
return JsonResponse({'errorMessage': f'Erreur inattendue: {str(e)}'}, status=400)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class SubscribeView(APIView):
def get(self, request):
return JsonResponse({
'message':'',
'errorFields':'',
'errorMessage':''
}, safe=False)
@swagger_auto_schema(
operation_description="Inscription utilisateur",
manual_parameters=[
openapi.Parameter(
'establishment_id', openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['email', 'password1', 'password2'],
properties={
'email': openapi.Schema(type=openapi.TYPE_STRING),
'password1': openapi.Schema(type=openapi.TYPE_STRING),
'password2': openapi.Schema(type=openapi.TYPE_STRING)
}
),
responses={
200: openapi.Response('Inscription réussie', schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'message': openapi.Schema(type=openapi.TYPE_STRING),
'errorMessage': openapi.Schema(type=openapi.TYPE_STRING),
'errorFields': openapi.Schema(type=openapi.TYPE_OBJECT),
'id': openapi.Schema(type=openapi.TYPE_INTEGER)
}
))
}
)
def post(self, request):
retourErreur = error.returnMessage[error.BAD_URL]
retourErreur = ''
retour = ''
newProfilConnection=JSONParser().parse(request)
newProfilConnection = JSONParser().parse(request)
establishment_id = newProfilConnection['establishment_id']
validatorSubscription = validator.ValidatorSubscription(data=newProfilConnection)
validationOk, errorFields = validatorSubscription.validate()
if validationOk:
# On vérifie que l'email existe : si ce n'est pas le cas, on retourne une erreur
profil = bdd.getProfile(Profile.objects.all(), newProfilConnection.get('email'))
if profil == None:
if profil is None:
retourErreur = error.returnMessage[error.PROFIL_NOT_EXISTS]
else:
if profil.is_active:
retourErreur=error.returnMessage[error.PROFIL_ACTIVE]
return JsonResponse({'message':retour,'errorMessage':retourErreur, "errorFields":errorFields, "id":profil.id}, safe=False)
# Vérifier si le profil a déjà un rôle actif pour l'établissement donné
active_roles = ProfileRole.objects.filter(profile=profil, establishment=establishment_id, is_active=True)
if active_roles.exists():
retourErreur = error.returnMessage[error.PROFIL_ACTIVE]
return JsonResponse({'message': retour, 'errorMessage': retourErreur, "errorFields": errorFields, "id": profil.id}, safe=False)
else:
try:
profil.set_password(newProfilConnection.get('password1'))
profil.is_active = True
profil.full_clean()
profil.save()
clear_cache()
# Récupérer le ProfileRole existant pour l'établissement et le profil
profile_role = ProfileRole.objects.filter(profile=profil, establishment=establishment_id).first()
if profile_role:
profile_role.is_active = True
profile_role.save()
else:
# Si aucun ProfileRole n'existe, en créer un nouveau
role_data = {
'profile': profil.id,
'establishment': establishment_id,
'is_active': True
}
role_serializer = ProfileRoleSerializer(data=role_data)
if role_serializer.is_valid():
role_serializer.save()
else:
return JsonResponse(role_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
retour = error.returnMessage[error.MESSAGE_ACTIVATION_PROFILE]
retourErreur=''
return JsonResponse({'message':retour,'errorMessage':retourErreur, "errorFields":errorFields, "id":profil.id}, safe=False)
retourErreur = ''
return JsonResponse({'message': retour, 'errorMessage': retourErreur, "errorFields": errorFields, "id": profil.id}, safe=False)
except ValidationError as e:
retourErreur = error.returnMessage[error.WRONG_MAIL_FORMAT]
return JsonResponse({'message':retour,'errorMessage':retourErreur, "errorFields":errorFields}, safe=False)
return JsonResponse({'message':retour, 'errorMessage':retourErreur, "errorFields":errorFields, "id":-1}, safe=False)
return JsonResponse({'message': retour, 'errorMessage': retourErreur, "errorFields": errorFields}, safe=False)
return JsonResponse({'message': retour, 'errorMessage': retourErreur, "errorFields": errorFields, "id": -1}, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class NewPasswordView(APIView):
def get(self, request):
return JsonResponse({
'message':'',
'errorFields':'',
'errorMessage':''
}, safe=False)
@swagger_auto_schema(
operation_description="Demande de nouveau mot de passe",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['email'],
properties={
'email': openapi.Schema(type=openapi.TYPE_STRING)
}
),
responses={
200: openapi.Response('Demande réussie', schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'message': openapi.Schema(type=openapi.TYPE_STRING),
'errorMessage': openapi.Schema(type=openapi.TYPE_STRING),
'errorFields': openapi.Schema(type=openapi.TYPE_OBJECT)
}
))
}
)
def post(self, request):
retourErreur = error.returnMessage[error.BAD_URL]
retourErreur = ''
retour = ''
newProfilConnection=JSONParser().parse(request)
newProfilConnection = JSONParser().parse(request)
validatorNewPassword = validator.ValidatorNewPassword(data=newProfilConnection)
validationOk, errorFields = validatorNewPassword.validate()
if validationOk:
profil = bdd.getProfile(Profile.objects.all(), newProfilConnection.get('email'))
if profil == None:
if profil is None:
retourErreur = error.returnMessage[error.PROFIL_NOT_EXISTS]
else:
# Génération d'une URL provisoire pour modifier le mot de passe
profil.code = util.genereRandomCode(12)
profil.datePeremption = util.calculeDatePeremption(util._now(), settings.EXPIRATION_URL_NB_DAYS)
profil.save()
clear_cache()
retourErreur = ''
retour = error.returnMessage[error.MESSAGE_REINIT_PASSWORD]%(newProfilConnection.get('email'))
mailer.envoieReinitMotDePasse(newProfilConnection.get('email'), profil.code)
try:
# Génération d'une URL provisoire pour modifier le mot de passe
profil.code = util.genereRandomCode(12)
profil.datePeremption = util.calculeDatePeremption(util._now(), settings.EXPIRATION_URL_NB_DAYS)
profil.save()
retourErreur = ''
retour = error.returnMessage[error.MESSAGE_REINIT_PASSWORD] % (newProfilConnection.get('email'))
mailer.envoieReinitMotDePasse(newProfilConnection.get('email'), profil.code)
except ValidationError as e:
retourErreur = error.returnMessage[error.WRONG_MAIL_FORMAT]
return JsonResponse({'message': retour, 'errorMessage': retourErreur, "errorFields": errorFields}, safe=False)
return JsonResponse({'message':retour, 'errorMessage':retourErreur, "errorFields":errorFields}, safe=False)
return JsonResponse({'message': retour, 'errorMessage': retourErreur, "errorFields": errorFields}, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class ResetPasswordView(APIView):
def get(self, request, _uuid):
return JsonResponse({
'message':'',
'errorFields':'',
'errorMessage':''
}, safe=False)
def post(self, request, _uuid):
retourErreur = error.returnMessage[error.BAD_URL]
@swagger_auto_schema(
operation_description="Réinitialisation du mot de passe",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['password1', 'password2'],
properties={
'password1': openapi.Schema(type=openapi.TYPE_STRING),
'password2': openapi.Schema(type=openapi.TYPE_STRING)
}
),
responses={
200: openapi.Response('Réinitialisation réussie', schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'message': openapi.Schema(type=openapi.TYPE_STRING),
'errorMessage': openapi.Schema(type=openapi.TYPE_STRING),
'errorFields': openapi.Schema(type=openapi.TYPE_OBJECT)
}
))
}
)
def post(self, request, code):
retourErreur = ''
retour = ''
newProfilConnection=JSONParser().parse(request)
newProfilConnection = JSONParser().parse(request)
validatorResetPassword = validator.ValidatorResetPassword(data=newProfilConnection)
validationOk, errorFields = validatorResetPassword.validate()
profil = bdd.getObject(Profile, "code", _uuid)
profil = bdd.getObject(Profile, "code", code)
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]
@ -262,7 +521,149 @@ class ResetPasswordView(APIView):
profil.code = ''
profil.datePeremption = ''
profil.save()
clear_cache()
retourErreur=''
retourErreur = ''
return JsonResponse({'message':retour, "errorMessage":retourErreur, "errorFields":errorFields}, safe=False)
return JsonResponse({'message': retour, "errorMessage": retourErreur, "errorFields": errorFields}, safe=False)
class ProfileRoleView(APIView):
pagination_class = CustomProfilesPagination
@swagger_auto_schema(
operation_description="Obtenir la liste des profile_roles",
responses={200: ProfileRoleSerializer(many=True)}
)
def get(self, request):
filter = request.GET.get('filter', '').strip()
page_size = request.GET.get('page_size', None)
establishment_id = request.GET.get('establishment_id', None)
# Gestion du page_size
if page_size is not None:
try:
page_size = int(page_size)
except ValueError:
page_size = settings.NB_RESULT_PROFILES_PER_PAGE
if establishment_id is None:
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
# Récupérer les ProfileRole en fonction du filtre
profiles_roles_List = ProfileRole.objects.filter(establishment_id=establishment_id)
if filter == 'parents':
profiles_roles_List = profiles_roles_List.filter(role_type=ProfileRole.RoleType.PROFIL_PARENT)
elif filter == 'school':
profiles_roles_List = profiles_roles_List.filter(
Q(role_type=ProfileRole.RoleType.PROFIL_ECOLE) |
Q(role_type=ProfileRole.RoleType.PROFIL_ADMIN)
)
else:
return JsonResponse({'error': 'Filtre invalide'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
# Trier les résultats par date de mise à jour
profiles_roles_List = profiles_roles_List.distinct().order_by('-updated_date')
if not profiles_roles_List:
return JsonResponse({'error': 'aucune donnée trouvée', 'count': 0}, safe=False)
# Pagination
paginator = self.pagination_class()
page = paginator.paginate_queryset(profiles_roles_List, request)
if page is not None:
profile_roles_serializer = ProfileRoleSerializer(page, many=True)
response_data = paginator.get_paginated_response(profile_roles_serializer.data)
return JsonResponse(response_data, safe=False)
return JsonResponse({'error': 'aucune donnée trouvée', 'count': 0}, safe=False)
@swagger_auto_schema(
operation_description="Créer un nouveau profile_role",
request_body=ProfileRoleSerializer,
responses={
200: ProfileRoleSerializer,
400: 'Données invalides'
}
)
def post(self, request):
profile_role_data = JSONParser().parse(request)
profile_role_serializer = ProfileRoleSerializer(data=profile_role_data)
if profile_role_serializer.is_valid():
profile_role = profile_role_serializer.save()
return JsonResponse(profile_role_serializer.data, safe=False)
return JsonResponse(profile_role_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class ProfileRoleSimpleView(APIView):
@swagger_auto_schema(
operation_description="Obtenir un profile_role par son ID",
responses={200: ProfileRoleSerializer}
)
def get(self, request, id):
profile_role = bdd.getObject(ProfileRole, "id", id)
profile_role_serializer = ProfileRoleSerializer(profile_role)
return JsonResponse(profile_role_serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Mettre à jour un profile_role",
request_body=ProfileRoleSerializer,
responses={
200: 'Mise à jour réussie',
400: 'Données invalides'
}
)
def put(self, request, id):
data = JSONParser().parse(request)
profile_role = ProfileRole.objects.get(id=id)
profile_role_serializer = ProfileRoleSerializer(profile_role, data=data)
if profile_role_serializer.is_valid():
profile_role_serializer.save()
return JsonResponse(profile_role_serializer.data, safe=False)
return JsonResponse(profile_role_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprimer un profile_role",
responses={200: 'Suppression réussie'}
)
def delete(self, request, id):
try:
# Récupérer le ProfileRole
profile_role = ProfileRole.objects.get(id=id)
profile = profile_role.profile
# Vérifier si le ProfileRole est de type PARENT
if profile_role.role_type == ProfileRole.RoleType.PROFIL_PARENT:
guardian = Guardian.objects.filter(profile_role=profile_role).first()
if guardian:
# Vérifier si ce Guardian est rattaché à des élèves
for student in guardian.student_set.all():
# Vérifier si l'élève n'a pas d'autres Guardians
other_guardians = student.guardians.exclude(id=guardian.id)
if not other_guardians.exists():
return JsonResponse(
{"error": f"Impossible de supprimer ce profil car l'élève {student.first_name} {student.last_name} n'aura plus de responsable légal."},
status=status.HTTP_400_BAD_REQUEST
)
# Supprimer le ProfileRole
profile_role.delete()
# Vérifier si le profil n'a plus de rôles associés
if not ProfileRole.objects.filter(profile=profile).exists():
profile.delete()
return JsonResponse({'message': 'Suppression réussie'}, safe=False)
except ProfileRole.DoesNotExist:
return JsonResponse(
{"error": "ProfileRole non trouvé."},
status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
return JsonResponse(
{"error": f"Une erreur est survenue : {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View File

3
Back-End/Common/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

9
Back-End/Common/apps.py Normal file
View File

@ -0,0 +1,9 @@
from django.apps import AppConfig
class CommonConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'Common'
def ready(self):
import Common.signals

View File

@ -0,0 +1,63 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
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')),
],
),
]

View File

43
Back-End/Common/models.py Normal file
View File

@ -0,0 +1,43 @@
from django.db import models
class Domain(models.Model):
name = models.CharField(max_length=255)
cycle = models.IntegerField(choices=[(1, 'Cycle 1'), (2, 'Cycle 2'), (3, 'Cycle 3'), (4, 'Cycle 4')])
def __str__(self):
return f"{self.name} (Cycle {self.cycle})"
class Category(models.Model):
name = models.CharField(max_length=255)
domain = models.ForeignKey(Domain, on_delete=models.CASCADE, related_name='categories')
def __str__(self):
return self.name
class PaymentPlanType(models.Model):
code = models.CharField(max_length=50, unique=True)
label = models.CharField(max_length=255)
def __str__(self):
return self.label
class PaymentModeType(models.Model):
code = models.CharField(max_length=50, unique=True)
label = models.CharField(max_length=255)
def __str__(self):
return self.label
class Cycle(models.Model):
number = models.IntegerField(unique=True)
label = models.CharField(max_length=50)
def __str__(self):
return f"Cycle {self.number} - {self.label}"
class Level(models.Model):
name = models.CharField(max_length=50)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name='levels')
def __str__(self):
return self.name

View File

@ -0,0 +1,15 @@
from rest_framework import serializers
from Common.models import (
Domain,
Category
)
class DomainSerializer(serializers.ModelSerializer):
class Meta:
model = Domain
fields = '__all__'
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = '__all__'

View File

@ -0,0 +1,98 @@
import json
import os
from django.db.models.signals import post_migrate
from django.dispatch import receiver
from Common.models import Domain, Category, PaymentModeType, PaymentPlanType, Cycle, Level
from School.models import Competency
@receiver(post_migrate)
def common_post_migrate(sender, **kwargs):
if sender.name != "School":
return
# Chemin absolu vers le répertoire Back-End
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Chemins vers les fichiers JSON
json_files = [
("Cycle1.json", 1),
("Cycle2.json", 2),
("Cycle3.json", 3),
("Cycle4.json", 4),
]
for file_name, cycle in json_files:
json_file_path = os.path.join(backend_dir, "competences", file_name)
if not os.path.exists(json_file_path):
print(f"Fichier JSON introuvable : {json_file_path}")
continue
with open(json_file_path, 'r', encoding='utf-8') as file:
data = json.load(file)
for domain_data in data['domaines']:
# Vérifiez si le domaine existe déjà
domain, _ = Domain.objects.get_or_create(name=domain_data['nom'], cycle=cycle)
for category_data in domain_data['categories']:
# Vérifiez si la catégorie existe déjà
category, _ = Category.objects.get_or_create(name=category_data['nom'], domain=domain)
for competency_data in category_data['competences']:
# Vérifiez si la compétence existe déjà
competency, _ = Competency.objects.get_or_create(
name=competency_data['nom'],
end_of_cycle=competency_data.get('fin_cycle', False),
level=competency_data.get('niveau'),
category=category
)
print(f"Données importées depuis : {json_file_path}")
payment_mode_types = [
{"code": "SEPA", "label": "Prélèvement SEPA"},
{"code": "TRANSFER", "label": "Virement"},
{"code": "CHECK", "label": "Chèque"},
{"code": "CASH", "label": "Espèce"},
]
for mode in payment_mode_types:
PaymentModeType.objects.get_or_create(code=mode["code"], defaults={"label": mode["label"]})
payment_plan_types = [
{"code": "ONE_TIME", "label": "1 fois"},
{"code": "THREE_TIMES", "label": "3 fois"},
{"code": "TEN_TIMES", "label": "10 fois"},
{"code": "TWELVE_TIMES", "label": "12 fois"},
]
for plan in payment_plan_types:
PaymentPlanType.objects.get_or_create(code=plan["code"], defaults={"label": plan["label"]})
# Création des cycles
cycles_data = [
{"number": 1, "label": "Cycle 1"},
{"number": 2, "label": "Cycle 2"},
{"number": 3, "label": "Cycle 3"},
{"number": 4, "label": "Cycle 4"},
]
cycle_objs = {}
for cycle in cycles_data:
obj, _ = Cycle.objects.get_or_create(number=cycle["number"], defaults={"label": cycle["label"]})
cycle_objs[cycle["number"]] = obj
# Création des niveaux et association au cycle
levels_data = [
{"name": "TPS", "cycle": 1},
{"name": "PS", "cycle": 1},
{"name": "MS", "cycle": 1},
{"name": "GS", "cycle": 1},
{"name": "CP", "cycle": 2},
{"name": "CE1", "cycle": 2},
{"name": "CE2", "cycle": 2},
{"name": "CM1", "cycle": 3},
{"name": "CM2", "cycle": 3},
]
for level in levels_data:
Level.objects.get_or_create(
name=level["name"],
defaults={"cycle": cycle_objs[level["cycle"]]}
)

3
Back-End/Common/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

14
Back-End/Common/urls.py Normal file
View File

@ -0,0 +1,14 @@
from django.urls import path, re_path
from .views import (
DomainListCreateView, DomainDetailView,
CategoryListCreateView, CategoryDetailView,
)
urlpatterns = [
re_path(r'^domains$', DomainListCreateView.as_view(), name="domain_list_create"),
re_path(r'^domains/(?P<id>[0-9]+)$', DomainDetailView.as_view(), name="domain_detail"),
re_path(r'^categories$', CategoryListCreateView.as_view(), name="category_list_create"),
re_path(r'^categories/(?P<id>[0-9]+)$', CategoryDetailView.as_view(), name="category_detail"),
]

110
Back-End/Common/views.py Normal file
View File

@ -0,0 +1,110 @@
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.views import APIView
from rest_framework import status
from .models import (
Domain,
Category
)
from .serializers import (
DomainSerializer,
CategorySerializer
)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class DomainListCreateView(APIView):
def get(self, request):
domains = Domain.objects.all()
serializer = DomainSerializer(domains, many=True)
return JsonResponse(serializer.data, safe=False)
def post(self, request):
data = JSONParser().parse(request)
serializer = DomainSerializer(data=data)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False, status=status.HTTP_201_CREATED)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class DomainDetailView(APIView):
def get(self, request, id):
try:
domain = Domain.objects.get(id=id)
serializer = DomainSerializer(domain)
return JsonResponse(serializer.data, safe=False)
except Domain.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
def put(self, request, id):
try:
domain = Domain.objects.get(id=id)
except Domain.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
data = JSONParser().parse(request)
serializer = DomainSerializer(domain, data=data, partial=True)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, id):
try:
domain = Domain.objects.get(id=id)
domain.delete()
return JsonResponse({'message': 'Deleted'}, safe=False)
except Domain.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
# Répète la même logique pour Category, Competency, EstablishmentCompetency
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class CategoryListCreateView(APIView):
def get(self, request):
categories = Category.objects.all()
serializer = CategorySerializer(categories, many=True)
return JsonResponse(serializer.data, safe=False)
def post(self, request):
data = JSONParser().parse(request)
serializer = CategorySerializer(data=data)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False, status=status.HTTP_201_CREATED)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class CategoryDetailView(APIView):
def get(self, request, id):
try:
category = Category.objects.get(id=id)
serializer = CategorySerializer(category)
return JsonResponse(serializer.data, safe=False)
except Category.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
def put(self, request, id):
try:
category = Category.objects.get(id=id)
except Category.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
data = JSONParser().parse(request)
serializer = CategorySerializer(category, data=data, partial=True)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, id):
try:
category = Category.objects.get(id=id)
category.delete()
return JsonResponse({'message': 'Deleted'}, safe=False)
except Category.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)

View File

@ -3,6 +3,7 @@
# The first instruction is what image we want to base our container on
# We Use an official Python runtime as a parent image
FROM python:3.12.7
WORKDIR /Back-End
# Allows docker to cache installed dependencies between builds
COPY requirements.txt requirements.txt
@ -10,11 +11,5 @@ RUN pip install -r requirements.txt
# Mounts the application code to the image
COPY . .
WORKDIR /Back-End
EXPOSE 8080
ENV DJANGO_SETTINGS_MODULE N3wtSchool.settings
ENV DJANGO_SUPERUSER_PASSWORD=admin
ENV DJANGO_SUPERUSER_USERNAME=admin
ENV DJANGO_SUPERUSER_EMAIL=admin@n3wtschool.com

View File

@ -0,0 +1 @@
# This file is intentionally left blank to make this directory a Python package.

View File

@ -0,0 +1,9 @@
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')
]

200
Back-End/DocuSeal/views.py Normal file
View File

@ -0,0 +1,200 @@
from django.conf import settings
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
from Establishment.models import Establishment
@csrf_exempt
@api_view(['POST'])
def generate_jwt_token(request):
# Récupérer l'établissement concerné (par ID ou autre info transmise)
establishment_id = request.data.get('establishment_id')
if not establishment_id:
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
try:
establishment = Establishment.objects.get(id=establishment_id)
except Establishment.DoesNotExist:
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Vérifier la clé API reçue dans le header
api_key = request.headers.get('X-Auth-Token')
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
return Response({'error': 'Clé API invalide'}, 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', [])
template_id = request.data.get('id')
if not user_email:
return Response({'error': 'User email is required'}, status=status.HTTP_400_BAD_REQUEST)
# Utiliser la clé API de l'établissement comme secret JWT
jwt_secret = establishment.api_docuseal
jwt_algorithm = settings.DOCUSEAL_JWT['ALGORITHM']
expiration_delta = settings.DOCUSEAL_JWT['EXPIRATION_DELTA']
payload = {
'user_email': user_email,
'documents_urls': documents_urls,
'template_id': template_id,
'exp': datetime.datetime.utcnow() + expiration_delta
}
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):
# Récupérer l'établissement concerné
establishment_id = request.data.get('establishment_id')
print(f"establishment_id : {establishment_id}")
if not establishment_id:
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
try:
establishment = Establishment.objects.get(id=establishment_id)
except Establishment.DoesNotExist:
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Vérifier la clé API reçue dans le header
api_key = request.headers.get('X-Auth-Token')
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
return Response({'error': 'Clé API invalide'}, 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': establishment.api_docuseal
})
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'
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': establishment.api_docuseal
})
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):
# Récupérer l'établissement concerné
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
try:
establishment = Establishment.objects.get(id=establishment_id)
except Establishment.DoesNotExist:
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Vérifier la clé API reçue dans le header
api_key = request.headers.get('X-Auth-Token')
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED)
# URL de l'API de DocuSeal pour supprimer le template
clone_url = f'https://docuseal.com/api/templates/{id}'
try:
response = requests.delete(clone_url, headers={
'X-Auth-Token': establishment.api_docuseal
})
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):
# Récupérer l'établissement concerné
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
try:
establishment = Establishment.objects.get(id=establishment_id)
except Establishment.DoesNotExist:
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Vérifier la clé API reçue dans le header
api_key = request.headers.get('X-Auth-Token')
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
return Response({'error': 'Clé API invalide'}, 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 télécharger le template
download_url = f'https://docuseal.com/submitters/{slug}/download'
try:
response = requests.get(download_url, headers={
'Content-Type': 'application/json',
'X-Auth-Token': establishment.api_docuseal
})
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)

View File

@ -0,0 +1 @@
default_app_config = 'Establishment.apps.EstablishmentConfig'

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class EstablishmentConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'Establishment'

View File

@ -0,0 +1,29 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
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)),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2025-05-30 07:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('Establishment', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='establishment',
name='api_docuseal',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.1.3 on 2025-05-31 09:56
import Establishment.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('Establishment', '0002_establishment_api_docuseal'),
]
operations = [
migrations.AddField(
model_name='establishment',
name='logo',
field=models.FileField(blank=True, null=True, upload_to=Establishment.models.registration_logo_upload_to),
),
]

View File

@ -0,0 +1,38 @@
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')
SECONDAIRE = 3, _('Secondaire')
class EvaluationFrequency(models.IntegerChoices):
TRIMESTER = 1, _("Trimestre")
SEMESTER = 2, _("Semestre")
YEAR = 3, _("Année")
class Establishment(models.Model):
name = models.CharField(max_length=255)
address = models.CharField(max_length=255)
total_capacity = models.IntegerField()
establishment_type = ArrayField(models.IntegerField(choices=StructureType.choices))
evaluation_frequency = models.IntegerField(choices=EvaluationFrequency.choices, default=EvaluationFrequency.TRIMESTER)
licence_code = models.CharField(max_length=100, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
api_docuseal = models.CharField(max_length=255, blank=True, null=True)
logo = models.FileField(
upload_to=registration_logo_upload_to,
null=True,
blank=True
)
def __str__(self):
return self.name

View File

@ -0,0 +1,91 @@
from rest_framework import serializers
from .models import Establishment
from School.models import SchoolClass, Teacher, Speciality, Fee, Discount, PaymentMode, PaymentPlan
from Subscriptions.models import RegistrationForm, RegistrationFileGroup
from Auth.models import Profile
class EstablishmentSerializer(serializers.ModelSerializer):
profile_count = serializers.SerializerMethodField()
profiles = serializers.SerializerMethodField()
school_class_count = serializers.SerializerMethodField()
school_classes = serializers.SerializerMethodField()
teacher_count = serializers.SerializerMethodField()
teachers = serializers.SerializerMethodField()
speciality_count = serializers.SerializerMethodField()
specialities = serializers.SerializerMethodField()
fee_count = serializers.SerializerMethodField()
fees = serializers.SerializerMethodField()
discount_count = serializers.SerializerMethodField()
discounts = serializers.SerializerMethodField()
active_payment_mode_count = serializers.SerializerMethodField()
active_payment_modes = serializers.SerializerMethodField()
active_payment_plan_count = serializers.SerializerMethodField()
active_payment_plans = serializers.SerializerMethodField()
file_group_count = serializers.SerializerMethodField()
file_groups = serializers.SerializerMethodField()
registration_form_count = serializers.SerializerMethodField()
registration_forms = serializers.SerializerMethodField()
class Meta:
model = Establishment
fields = '__all__'
def get_profile_count(self, obj):
return Profile.objects.filter(roles__establishment=obj).distinct().count()
def get_profiles(self, obj):
return list(Profile.objects.filter(roles__establishment=obj).distinct().values_list('email', flat=True))
def get_school_class_count(self, obj):
return SchoolClass.objects.filter(establishment=obj).distinct().count()
def get_school_classes(self, obj):
return list(SchoolClass.objects.filter(establishment=obj).distinct().values_list('atmosphere_name', flat=True))
def get_teacher_count(self, obj):
return Teacher.objects.filter(profile_role__establishment=obj).distinct().count()
def get_teachers(self, obj):
return list(Teacher.objects.filter(profile_role__establishment=obj).distinct().values_list('last_name', 'first_name'))
def get_speciality_count(self, obj):
return Speciality.objects.filter(establishment=obj).distinct().count()
def get_specialities(self, obj):
return list(Speciality.objects.filter(establishment=obj).distinct().values_list('name', flat=True))
def get_fee_count(self, obj):
return Fee.objects.filter(establishment=obj).distinct().count()
def get_fees(self, obj):
return list(Fee.objects.filter(establishment=obj).distinct().values_list('name', flat=True))
def get_discount_count(self, obj):
return Discount.objects.filter(establishment=obj).distinct().count()
def get_discounts(self, obj):
return list(Discount.objects.filter(establishment=obj).distinct().values_list('name', flat=True))
def get_active_payment_mode_count(self, obj):
return PaymentMode.objects.filter(establishment=obj).distinct().count()
def get_active_payment_modes(self, obj):
return list(PaymentMode.objects.filter(establishment=obj).distinct().values_list('mode', flat=True))
def get_active_payment_plan_count(self, obj):
return PaymentPlan.objects.filter(establishment=obj).distinct().count()
def get_active_payment_plans(self, obj):
return list(PaymentPlan.objects.filter(establishment=obj).distinct().values_list('plan_type', flat=True))
def get_file_group_count(self, obj):
return RegistrationFileGroup.objects.filter(establishment=obj).distinct().count()
def get_file_groups(self, obj):
return list(RegistrationFileGroup.objects.filter(establishment=obj).distinct().values_list('name', flat=True))
def get_registration_form_count(self, obj):
return RegistrationForm.objects.filter(establishment=obj).distinct().count()
def get_registration_forms(self, obj):
return list(RegistrationForm.objects.filter(establishment=obj).distinct().values_list('student__last_name', 'student__first_name'))

View File

@ -0,0 +1,7 @@
from django.urls import path, re_path
from .views import EstablishmentListCreateView, EstablishmentDetailView
urlpatterns = [
re_path(r'^establishments$', EstablishmentListCreateView.as_view(), name='establishment_list_create'),
re_path(r'^establishments/(?P<id>[0-9]+)$', EstablishmentDetailView.as_view(), name="establishment_detail"),
]

View File

@ -0,0 +1,132 @@
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, 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, 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')
class EstablishmentListCreateView(APIView):
def get(self, request):
establishments = getAllObjects(Establishment)
establishments_serializer = EstablishmentSerializer(establishments, many=True)
return JsonResponse(establishments_serializer.data, safe=False, status=status.HTTP_200_OK)
def post(self, request):
establishment_data = JSONParser().parse(request)
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)
)
for competency in competencies:
EstablishmentCompetency.objects.get_or_create(
establishment=establishment,
competency=competency,
defaults={'is_required': True}
)
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)
establishment_serializer = EstablishmentSerializer(establishment)
return JsonResponse(establishment_serializer.data, safe=False)
except Establishment.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
def put(self, request, id):
"""
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)
# 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, 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)
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

View File

@ -0,0 +1 @@
default_app_config = 'GestionEmail.apps.GestionEmailConfig'

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class GestionEmailConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'GestionEmail'

View 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'),
]

View 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)

View 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)}")

View 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)

View File

@ -0,0 +1,30 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
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='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)),
],
),
]

View File

@ -0,0 +1,87 @@
# Generated by Django 5.1.3 on 2025-05-30 07:40
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):
dependencies = [
('GestionMessagerie', '0001_initial'),
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='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')},
},
),
]

View File

@ -1,15 +1,113 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.conf import settings
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)
emetteur = models.ForeignKey(Profile, on_delete=models.PROTECT, related_name='messages_envoyes')
destinataire = models.ForeignKey(Profile, on_delete=models.PROTECT, related_name='messages_recus')
corpus = models.CharField(max_length=200, default="", blank=True)
date_envoi = models.DateTimeField(auto_now_add=True) # Date d'envoi du message
is_read = models.BooleanField(default=False) # Statut lu/non lu
conversation_id = models.CharField(max_length=100, blank=True, default="") # Pour regrouper les messages par conversation
def __str__(self):
return 'Messagerie_'+self.id
return f'Messagerie_{self.id}'

View 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()),
]

View File

@ -1,14 +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

View File

@ -1,9 +1,22 @@
from django.urls import path, re_path
from GestionMessagerie.views import MessagerieView, MessageView
from django.urls import path
from .views import (
InstantConversationListView, InstantConversationCreateView, InstantConversationDeleteView,
InstantMessageListView, InstantMessageCreateView,
InstantMarkAsReadView, FileUploadView,
InstantRecipientSearchView
)
urlpatterns = [
re_path(r'^messagerie/([0-9]+)$', MessagerieView.as_view(), name="messagerie"),
re_path(r'^message$', MessageView.as_view(), name="message"),
re_path(r'^message/([0-9]+)$', MessageView.as_view(), name="message"),
# 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'),
]

View File

@ -1,32 +1,455 @@
from django.http.response import JsonResponse
from rest_framework.views import APIView
from rest_framework.parsers import JSONParser
from rest_framework.response import Response
from rest_framework import status
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 (
ConversationSerializer, MessageSerializer,
ConversationCreateSerializer, UserPresenceSerializer,
ProfileSimpleSerializer
)
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
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
from .models import *
logger = logging.getLogger(__name__)
from GestionMessagerie.serializers import MessageSerializer
# ====================== MESSAGERIE INSTANTANÉE ======================
from N3wtSchool import bdd
class InstantConversationListView(APIView):
"""
API pour lister les conversations instantanées d'un utilisateur
"""
@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:
user = Profile.objects.get(id=user_id)
class MessagerieView(APIView):
def get(self, request, _idProfile):
messagesList = bdd.getObjects(_objectName=Messagerie, _columnName='destinataire__id', _value=_idProfile)
messages_serializer = MessageSerializer(messagesList, many=True)
return JsonResponse(messages_serializer.data, safe=False)
conversations = Conversation.objects.filter(
participants__participant=user,
participants__is_active=True,
is_active=True
).distinct().order_by('-last_activity')
class MessageView(APIView):
def get(self, request, _id):
message=bdd.getObject(Messagerie, "id", _id)
message_serializer=MessageSerializer(message)
return JsonResponse(message_serializer.data, safe=False)
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 InstantConversationCreateView(APIView):
"""
API pour créer une nouvelle conversation instantanée
"""
@swagger_auto_schema(
operation_description="Crée une nouvelle conversation instantanée",
request_body=ConversationCreateSerializer,
responses={201: ConversationSerializer}
)
def post(self, request):
message_data=JSONParser().parse(request)
message_serializer = MessageSerializer(data=message_data)
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)
if message_serializer.is_valid():
message_serializer.save()
return JsonResponse('Nouveau Message ajouté', safe=False)
class InstantMessageListView(APIView):
"""
API pour lister les messages d'une conversation
"""
@swagger_auto_schema(
operation_description="Liste les messages d'une conversation",
responses={200: MessageSerializer(many=True)}
)
def get(self, request, conversation_id):
try:
conversation = Conversation.objects.get(id=conversation_id)
messages = conversation.messages.filter(is_deleted=False).order_by('created_at')
# 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):
"""
API pour envoyer un nouveau message instantané
"""
@swagger_auto_schema(
operation_description="Envoie un nouveau message instantané",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'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=['conversation_id', 'sender_id', 'content']
),
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):
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)
return JsonResponse(message_serializer.errors, safe=False)

View File

@ -0,0 +1,28 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
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)),
],
),
]

View File

@ -4,20 +4,20 @@ from .models import Notification, TypeNotif
from GestionMessagerie.models import Messagerie
from Subscriptions.models import RegistrationForm
@receiver(post_save, sender=Messagerie)
def notification_MESSAGE(sender, instance, created, **kwargs):
if created:
Notification.objects.create(
user=instance.destinataire,
message=(TypeNotif.NOTIF_MESSAGE).label,
typeNotification=TypeNotif.NOTIF_MESSAGE
)
# @receiver(post_save, sender=Messagerie)
# def notification_MESSAGE(sender, instance, created, **kwargs):
# if created:
# Notification.objects.create(
# user=instance.destinataire,
# message=(TypeNotif.NOTIF_MESSAGE).label,
# typeNotification=TypeNotif.NOTIF_MESSAGE
# )
@receiver(post_save, sender=RegistrationForm)
def notification_DI(sender, instance, created, **kwargs):
for responsable in instance.student.guardians.all():
Notification.objects.create(
user=responsable.associated_profile,
message=(TypeNotif.NOTIF_DI).label,
typeNotification=TypeNotif.NOTIF_DI
)
# @receiver(post_save, sender=RegistrationForm)
# def notification_DI(sender, instance, created, **kwargs):
# for responsable in instance.student.guardians.all():
# Notification.objects.create(
# user=responsable.associated_profile,
# message=(TypeNotif.NOTIF_DI).label,
# typeNotification=TypeNotif.NOTIF_DI
# )

View File

@ -3,5 +3,5 @@ from django.urls import path, re_path
from GestionNotification.views import NotificationView
urlpatterns = [
re_path(r'^notification$', NotificationView.as_view(), name="notification"),
re_path(r'^notifications$', NotificationView.as_view(), name="notifications"),
]

View File

@ -0,0 +1,8 @@
{
"hostSMTP": "",
"portSMTP": 25,
"username": "",
"password": "",
"useSSL": false,
"useTLS": false
}

View File

@ -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)
)
),
})

View File

@ -2,7 +2,8 @@ import logging
from django.db.models import Q
from django.http import JsonResponse
from django.core.exceptions import ObjectDoesNotExist
from Subscriptions.models import RegistrationForm, Profile, Student
from Subscriptions.models import RegistrationForm, Student
from Auth.models import Profile
logger = logging.getLogger('N3wtSchool')
@ -92,6 +93,7 @@ def searchObjects(_objectName, _searchTerm=None, _excludeStates=None):
def delete_object(model_class, object_id, related_field=None):
try:
obj = model_class.objects.get(id=object_id)
if related_field and hasattr(obj, related_field):
related_obj = getattr(obj, related_field)
if related_obj:
@ -103,5 +105,3 @@ def delete_object(model_class, object_id, related_field=None):
return JsonResponse({'error': f'L\'objet {model_class.__name__} n\'existe pas avec cet ID'}, status=404, safe=False)
except Exception as e:
return JsonResponse({'error': f'Une erreur est survenue : {str(e)}'}, status=500, safe=False)

View File

@ -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',

View File

@ -0,0 +1,201 @@
from django.core.mail import send_mail, get_connection, EmailMultiAlternatives, EmailMessage
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
import re
from rest_framework.response import Response
from rest_framework import status
from rest_framework.exceptions import NotFound
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)
try:
# Récupérer les paramètres SMTP associés à l'établissement
smtp_settings = SMTPSettings.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.smtp_user,
password=smtp_settings.smtp_password,
use_tls=smtp_settings.use_tls,
use_ssl=smtp_settings.use_ssl
)
return connection
except SMTPSettings.DoesNotExist:
# Aucun paramètre SMTP spécifique, retournera None
return None
except Establishment.DoesNotExist:
raise NotFound(f"Aucun établissement trouvé avec l'ID {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)
if connection is not None:
from_email = username
else:
from_email = settings.EMAIL_HOST_USER
logger.info(f"From email: {from_email}")
email = EmailMultiAlternatives(
subject=subject,
body=plain_message,
from_email=from_email,
to=recipients,
cc=cc,
bcc=bcc,
connection=connection
)
email.attach_alternative(message, "text/html")
for attachment in attachments:
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"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):
errorMessage = ''
try:
EMAIL_REINIT_SUBJECT = 'Réinitialisation du mot de passe'
context = {
'BASE_URL': settings.BASE_URL,
'code': str(code)
}
subject = EMAIL_REINIT_SUBJECT
html_message = render_to_string('emails/resetPassword.html', context)
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)
return errorMessage
def sendRegisterForm(recipients, establishment_id):
errorMessage = ''
try:
# Préparation du contexte pour le template
EMAIL_INSCRIPTION_SUBJECT = '[N3WT-SCHOOL] Dossier Inscription'
context = {
'BASE_URL': settings.BASE_URL,
'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=subject, message=html_message, recipients=recipients, connection=connection)
except Exception as e:
errorMessage = str(e)
return errorMessage
def sendMandatSEPA(recipients, establishment_id):
errorMessage = ''
try:
# Préparation du contexte pour le template
EMAIL_INSCRIPTION_SUBJECT = '[N3WT-SCHOOL] Mandat de prélèvement SEPA'
context = {
'BASE_URL': settings.BASE_URL,
'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/sepa.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
except Exception as e:
errorMessage = str(e)
return errorMessage
def envoieRelanceDossierInscription(recipients, code):
EMAIL_RELANCE_SUBJECT = '[N3WT-SCHOOL] Relance - Dossier Inscription'
EMAIL_RELANCE_CORPUS = 'Bonjour,\nN\'ayant pas eu de retour de votre part, nous vous renvoyons le lien vers le formulaire d\'inscription : ' + BASE_URL + '/users/login\nCordialement'
errorMessage = ''
try:
sendMail(EMAIL_RELANCE_SUBJECT, EMAIL_RELANCE_CORPUS%str(code), recipients)
except Exception as e:
errorMessage = str(e)
return errorMessage
def isValid(message, fiche_inscription):
# Est-ce que la référence du dossier est VALIDATED
subject = message.subject
print ("++++ " + subject)
responsableMail = message.from_header
result = re.search('<(.*)>', responsableMail)
if result:
responsableMail = result.group(1)
result = re.search(r'.*\[Ref(.*)\].*', subject)
idMail = -1
if result:
idMail = result.group(1).strip()
eleve = fiche_inscription.eleve
responsable = eleve.getMainGuardian()
mailReponsableAVerifier = responsable.mail
return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id)

View File

@ -0,0 +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'] = f"frame-ancestors 'self' {settings.BASE_URL}"
return response

View File

@ -4,11 +4,22 @@ from django.template.loader import get_template
from xhtml2pdf import pisa
class PDFResult:
def __init__(self, content):
self.content = content
def render_to_pdf(template_src, context_dict={}):
"""
Génère un PDF à partir d'un template HTML et retourne le contenu en mémoire.
"""
template = get_template(template_src)
html = template.render(context_dict)
html = template.render(context_dict)
result = BytesIO()
pdf = pisa.pisaDocument(BytesIO(html.encode("UTF-8")), result)
if pdf.err:
return HttpResponse("Invalid PDF", status_code=400, content_type='text/plain')
return HttpResponse(result.getvalue(), content_type='application/pdf')
# Lever une exception ou retourner None en cas d'erreur
raise ValueError("Erreur lors de la génération du PDF.")
# Retourner le contenu du PDF en mémoire
return PDFResult(result.getvalue())

View File

@ -13,33 +13,43 @@ https://docs.djangoproject.com/en/5.0/ref/settings/
from pathlib import Path
import json
import os
from datetime import timedelta
import logging
# Configuration du logger
logger = logging.getLogger(__name__)
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
MEDIA_URL = '/data/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'data')
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 = ['*']
# Application definition
INSTALLED_APPS = [
'Common.apps.CommonConfig',
'Subscriptions.apps.GestioninscriptionsConfig',
'Auth.apps.GestionloginConfig',
'GestionMessagerie.apps.GestionMessagerieConfig',
'GestionEmail.apps.GestionEmailConfig',
'GestionNotification.apps.GestionNotificationConfig',
'School.apps.SchoolConfig',
'Planning.apps.PlanningConfig',
'Establishment.apps.EstablishmentConfig',
'Settings.apps.SettingsConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@ -51,17 +61,20 @@ INSTALLED_APPS = [
'django_celery_beat',
'N3wtSchool',
'drf_yasg',
'rest_framework_simplejwt',
'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',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'N3wtSchool.middleware.ContentSecurityPolicyMiddleware'
]
@ -148,6 +161,11 @@ 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"),
@ -191,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 = [
@ -211,67 +227,71 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
#################### Application Settings ##############################
########################################################################
with open('Subscriptions/Configuration/application.json', 'r') as f:
jsonObject = json.load(f)
DJANGO_SUPERUSER_PASSWORD='admin'
DJANGO_SUPERUSER_USERNAME='admin'
DJANGO_SUPERUSER_EMAIL='admin@n3wtschool.com'
EMAIL_HOST='smtp.gmail.com'
EMAIL_PORT=587
EMAIL_HOST_USER=jsonObject['mailFrom']
EMAIL_HOST_PASSWORD=jsonObject['password']
# Configuration de l'email de l'application
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 = True
EMAIL_USE_SSL = False
EMAIL_INSCRIPTION_SUBJECT = '[N3WT-SCHOOL] Dossier Inscription'
EMAIL_INSCRIPTION_CORPUS = """Bonjour,
Afin de procéder à l'inscription de votre petit bout, vous trouverez ci-joint le lien vers la page d'authentification : http://localhost:3000/users/login
S'il s'agit de votre première connexion, veuillez procéder à l'activation de votre compte : http://localhost:3000/users/subscribe
identifiant = %s
Cordialement,
"""
EMAIL_RELANCE_SUBJECT = '[N3WT-SCHOOL] Relance - Dossier Inscription'
EMAIL_RELANCE_CORPUS = 'Bonjour,\nN\'ayant pas eu de retour de votre part, nous vous renvoyons le lien vers le formulaire d\'inscription : http://localhost:3000/users/login\nCordialement'
EMAIL_REINIT_SUBJECT = 'Réinitialisation du mot de passe'
EMAIL_REINIT_CORPUS = 'Bonjour,\nVous trouverez ci-joint le lien pour réinitialiser votre mot de passe : http://localhost:3000/users/password/reset?uuid=%s\nCordialement'
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
CORS_ALLOWED_ORIGINS = [
"http://localhost:3000"
# 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 = [
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
'X-Auth-Token',
]
CSRF_TRUSTED_ORIGINS = [
"http://localhost:3000", # Front Next.js
"http://localhost:8080" # Insomnia
# 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,
}
}
@ -285,12 +305,17 @@ DATE_FORMAT = '%d-%m-%Y %H:%M'
EXPIRATION_SESSION_NB_SEC = 10
NB_RESULT_PER_PAGE = 8
NB_RESULT_SUBSCRIPTIONS_PER_PAGE = 8
NB_RESULT_PROFILES_PER_PAGE = 15
NB_MAX_PAGE = 100
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'Subscriptions.pagination.CustomPagination',
'PAGE_SIZE': NB_RESULT_PER_PAGE
'DEFAULT_PAGINATION_CLASS': 'Subscriptions.pagination.CustomSubscriptionPagination',
'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE,
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
}
CELERY_BROKER_URL = 'redis://redis:6379/0'
@ -301,11 +326,44 @@ 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),
'ROTATE_REFRESH_TOKENS': False,
'BLACKLIST_AFTER_ROTATION': True,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'VERIFYING_KEY': None,
'AUTH_HEADER_TYPES': ('Bearer',),
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_TYPE_CLAIM': 'token_type',
}
# Configuration for DocuSeal JWT
DOCUSEAL_JWT = {
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'EXPIRATION_DELTA': timedelta(hours=1)
}
# Django Channels Configuration
ASGI_APPLICATION = 'N3wtSchool.asgi.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('redis', 6379)],
},
},
}

View File

@ -39,11 +39,17 @@ schema_view = get_schema_view(
urlpatterns = [
path('admin/', admin.site.urls),
path("Common/", include(("Common.urls", 'Common'), namespace='Common')),
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')),
# Documentation Api
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),

View File

@ -0,0 +1 @@
default_app_config = 'Planning.apps.PlanningConfig'

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class PlanningConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'Planning'

View File

@ -0,0 +1,44 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
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')),
],
),
]

View File

View File

@ -0,0 +1,47 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from School.models import SchoolClass
from Establishment.models import Establishment
class RecursionType(models.IntegerChoices):
RECURSION_NONE = 0, _('Aucune')
RECURSION_DAILY = 1, _('Quotidienne')
RECURSION_WEEKLY = 2, _('Hebdomadaire')
RECURSION_MONTHLY = 3, _('Mensuel')
RECURSION_CUSTOM = 4, _('Personnalisé')
class Planning(models.Model):
establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE)
school_class = models.ForeignKey(
SchoolClass,
on_delete=models.CASCADE,
related_name="planning",
null=True, # Permet des valeurs nulles
blank=True # Rend le champ facultatif dans les formulaires
)
name = models.CharField(max_length=255)
description = models.TextField(default="", blank=True, null=True)
color= models.CharField(max_length=255, default="#000000")
def __str__(self):
return f'Planning {self.name}'
class Events(models.Model):
planning = models.ForeignKey(Planning, on_delete=models.CASCADE)
title = models.CharField(max_length=255)
description = models.TextField(default="", blank=True, null=True)
start = models.DateTimeField()
end = models.DateTimeField()
recursionType = models.IntegerField(choices=RecursionType, default=0)
recursionEnd = models.DateTimeField(default=None, blank=True, null=True)
color= models.CharField(max_length=255)
location = models.CharField(max_length=255, default="", blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'Event {self.title}'

View File

@ -0,0 +1,13 @@
from rest_framework import serializers
from .models import Planning, Events
class PlanningSerializer(serializers.ModelSerializer):
class Meta:
model = Planning
fields = '__all__'
class EventsSerializer(serializers.ModelSerializer):
class Meta:
model = Events
fields = '__all__'

11
Back-End/Planning/urls.py Normal file
View File

@ -0,0 +1,11 @@
from django.urls import path, re_path
from Planning.views import PlanningView,PlanningWithIdView,EventsView,EventsWithIdView,UpcomingEventsView
urlpatterns = [
re_path(r'^plannings$', PlanningView.as_view(), name="planning"),
re_path(r'^plannings/(?P<id>[0-9]+)$', PlanningWithIdView.as_view(), name="planning"),
re_path(r'^events$', EventsView.as_view(), name="events"),
re_path(r'^events/(?P<id>[0-9]+)$', EventsWithIdView.as_view(), name="events"),
re_path(r'^events/upcoming', UpcomingEventsView.as_view(), name="events"),
]

169
Back-End/Planning/views.py Normal file
View File

@ -0,0 +1,169 @@
from django.http.response import JsonResponse
from rest_framework.views import APIView
from django.utils import timezone
from dateutil.relativedelta import relativedelta
from .models import Planning, Events, RecursionType
from .serializers import PlanningSerializer, EventsSerializer
from N3wtSchool import bdd
class PlanningView(APIView):
def get(self, request):
establishment_id = request.GET.get('establishment_id', None)
planning_mode = request.GET.get('planning_mode', None)
plannings = bdd.getAllObjects(Planning)
if establishment_id is not None:
plannings = plannings.filter(establishment=establishment_id)
# Filtrer en fonction du planning_mode
if planning_mode == "classSchedule":
plannings = plannings.filter(school_class__isnull=False)
elif planning_mode == "planning":
plannings = plannings.filter(school_class__isnull=True)
planning_serializer = PlanningSerializer(plannings.distinct(), many=True)
return JsonResponse(planning_serializer.data, safe=False)
def post(self, request):
planning_serializer = PlanningSerializer(data=request.data)
if planning_serializer.is_valid():
planning_serializer.save()
return JsonResponse(planning_serializer.data, status=201)
return JsonResponse(planning_serializer.errors, status=400)
class PlanningWithIdView(APIView):
def get(self, request,id):
planning = Planning.objects.get(pk=id)
if planning is None:
return JsonResponse({"errorMessage":'Le dossier d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
planning_serializer=PlanningSerializer(planning)
return JsonResponse(planning_serializer.data, safe=False)
def put(self, request, id):
try:
planning = Planning.objects.get(pk=id)
except Planning.DoesNotExist:
return JsonResponse({'error': 'Planning not found'}, status=404)
planning_serializer = PlanningSerializer(planning, data=request.data)
if planning_serializer.is_valid():
planning_serializer.save()
return JsonResponse(planning_serializer.data)
return JsonResponse(planning_serializer.errors, status=400)
def delete(self, request, id):
try:
planning = Planning.objects.get(pk=id)
except Planning.DoesNotExist:
return JsonResponse({'error': 'Planning not found'}, status=404)
planning.delete()
return JsonResponse({'message': 'Planning deleted'}, status=204)
class EventsView(APIView):
def get(self, request):
establishment_id = request.GET.get('establishment_id', None)
planning_mode = request.GET.get('planning_mode', None)
filterParams = {}
plannings=[]
events = Events.objects.all()
if establishment_id is not None :
filterParams['establishment'] = establishment_id
if planning_mode is not None:
filterParams['school_class__isnull'] = (planning_mode!="classSchedule")
if filterParams:
plannings = Planning.objects.filter(**filterParams)
events = Events.objects.filter(planning__in=plannings)
events_serializer = EventsSerializer(events, many=True)
return JsonResponse(events_serializer.data, safe=False)
def post(self, request):
events_serializer = EventsSerializer(data=request.data)
if events_serializer.is_valid():
event = events_serializer.save()
# Gérer les événements récurrents
if event.recursionType != RecursionType.RECURSION_NONE:
self.create_recurring_events(event)
return JsonResponse(events_serializer.data, status=201)
return JsonResponse(events_serializer.errors, status=400)
def create_recurring_events(self, event):
current_start = event.start
current_end = event.end
while current_start < event.recursionEnd:
if event.recursionType == RecursionType.RECURSION_DAILY:
current_start += relativedelta(days=1)
current_end += relativedelta(days=1)
elif event.recursionType == RecursionType.RECURSION_WEEKLY:
current_start += relativedelta(weeks=1)
current_end += relativedelta(weeks=1)
elif event.recursionType == RecursionType.RECURSION_MONTHLY:
current_start += relativedelta(months=1)
current_end += relativedelta(months=1)
else:
break # Pour d'autres types de récurrence non gérés
# Créer une nouvelle occurrence
Events.objects.create(
planning=event.planning,
title=event.title,
description=event.description,
start=current_start,
end=current_end,
recursionEnd=event.recursionEnd,
recursionType=event.recursionType, # Les occurrences ne sont pas récurrentes
color=event.color,
location=event.location,
)
class EventsWithIdView(APIView):
def put(self, request, id):
try:
event = Events.objects.get(pk=id)
except Events.DoesNotExist:
return JsonResponse({'error': 'Event not found'}, status=404)
events_serializer = EventsSerializer(event, data=request.data)
if events_serializer.is_valid():
events_serializer.save()
return JsonResponse(events_serializer.data)
return JsonResponse(events_serializer.errors, status=400)
def delete(self, request, id):
try:
event = Events.objects.get(pk=id)
except Events.DoesNotExist:
return JsonResponse({'error': 'Event not found'}, status=404)
event.delete()
return JsonResponse({'message': 'Event deleted'}, status=200)
class UpcomingEventsView(APIView):
def get(self, request):
current_date = timezone.now()
establishment_id = request.GET.get('establishment_id', None)
if establishment_id is not None:
# Filtrer les plannings par establishment_id et sans school_class
plannings = Planning.objects.filter(establishment=establishment_id, school_class__isnull=True)
# Filtrer les événements associés à ces plannings et qui sont à venir
upcoming_events = Events.objects.filter(planning__in=plannings, start__gte=current_date)
else:
# Récupérer tous les événements à venir si aucun establishment_id n'est fourni
# et les plannings ne doivent pas être rattachés à une school_class
plannings = Planning.objects.filter(school_class__isnull=True)
upcoming_events = Events.objects.filter(planning__in=plannings, start__gte=current_date)
events_serializer = EventsSerializer(upcoming_events, many=True)
return JsonResponse(events_serializer.data, safe=False)

View File

@ -1,14 +1,5 @@
from django.apps import AppConfig
from django.db.models.signals import post_migrate
def create_speciality(sender, **kwargs):
from .models import Speciality
if not Speciality.objects.filter(name='GROUPE').exists():
Speciality.objects.create(name='GROUPE', color_code='#FF0000')
class SchoolConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'School'
def ready(self):
post_migrate.connect(create_speciality, sender=self)
name = 'School'

View File

@ -0,0 +1,427 @@
from django.core.management.base import BaseCommand
from Subscriptions.models import (
RegistrationForm,
RegistrationFileGroup
)
from Auth.models import Profile, ProfileRole
from Common.models import (
PaymentModeType,
PaymentPlanType,
Level
)
from School.models import (
FeeType,
Speciality,
Teacher,
DiscountType,
Fee,
Discount,
)
from django.utils import timezone
from dateutil.relativedelta import relativedelta
import os
from django.conf import settings
from faker import Faker
import random
import json
from School.serializers import (
FeeSerializer,
DiscountSerializer,
PaymentModeSerializer,
PaymentPlanSerializer,
SpecialitySerializer,
TeacherSerializer,
SchoolClassSerializer
)
from Auth.serializers import ProfileSerializer, ProfileRoleSerializer
from Establishment.serializers import EstablishmentSerializer
from Subscriptions.serializers import StudentSerializer
from Subscriptions.util import getCurrentSchoolYear, getNextSchoolYear # Import des fonctions nécessaires
# Définir le chemin vers le dossier mock_datas
MOCK_DATAS_PATH = os.path.join(settings.BASE_DIR, 'School', 'management', 'mock_datas')
class Command(BaseCommand):
help = 'Initialise toutes les données mock'
def handle(self, *args, **kwargs):
self.init_establishments()
self.init_profiles()
self.init_fees()
self.init_discounts()
self.init_payment_modes()
self.init_payment_plans()
self.init_specialities()
self.init_teachers()
self.init_school_classes()
self.init_file_group()
self.init_register_form()
def load_data(self, filename):
with open(os.path.join(MOCK_DATAS_PATH, filename), 'r') as file:
return json.load(file)
def init_establishments(self):
establishments_data = self.load_data('establishments.json')
self.establishments = []
for establishment_data in establishments_data:
serializer = EstablishmentSerializer(data=establishment_data)
if serializer.is_valid():
establishment = serializer.save()
self.establishments.append(establishment)
self.stdout.write(self.style.SUCCESS(f'Establishment {establishment.name} created or updated successfully'))
else:
self.stdout.write(self.style.ERROR(f'Error in data for establishment: {serializer.errors}'))
def init_profiles(self):
fake = Faker()
for _ in range(50):
# Générer des données fictives pour le profil
profile_data = {
"username": fake.user_name(),
"email": fake.email(),
"password": "Provisoire01!",
"code": "",
"datePeremption": ""
}
# Créer le profil
profile_serializer = ProfileSerializer(data=profile_data)
if profile_serializer.is_valid():
profile = profile_serializer.save()
profile.set_password(profile_data["password"])
profile.save()
self.stdout.write(self.style.SUCCESS(f'Profile {profile.email} created successfully'))
# Créer entre 1 et 3 ProfileRole pour chaque profil
num_roles = random.randint(1, 3)
created_roles = set()
for _ in range(num_roles):
establishment = random.choice(self.establishments)
role_type = random.choice([ProfileRole.RoleType.PROFIL_ECOLE, ProfileRole.RoleType.PROFIL_ADMIN, ProfileRole.RoleType.PROFIL_PARENT])
# Vérifier si le rôle existe déjà pour cet établissement
if (establishment.id, role_type) in created_roles:
continue
profile_role_data = {
"profile": profile.id,
"establishment": establishment.id,
"role_type": role_type,
"is_active": random.choice([True, False])
}
profile_role_serializer = ProfileRoleSerializer(data=profile_role_data)
if profile_role_serializer.is_valid():
profile_role_serializer.save()
created_roles.add((establishment.id, role_type))
self.stdout.write(self.style.SUCCESS(f'ProfileRole for {profile.email} created successfully with role type {role_type}'))
else:
self.stdout.write(self.style.ERROR(f'Error in data for profile role: {profile_role_serializer.errors}'))
else:
self.stdout.write(self.style.ERROR(f'Error in data for profile: {profile_serializer.errors}'))
def init_fees(self):
fees_data = self.load_data('fees.json')
for fee_data in fees_data:
establishment = random.choice(self.establishments)
print(f'establishment : {establishment}')
fee_data["name"] = fee_data['name']
fee_data["establishment"] = establishment.id
fee_data["type"] = random.choice([FeeType.REGISTRATION_FEE, FeeType.TUITION_FEE])
serializer = FeeSerializer(data=fee_data)
if serializer.is_valid():
fee = serializer.save()
self.stdout.write(self.style.SUCCESS(f'Fee {fee.name} created successfully'))
else:
self.stdout.write(self.style.ERROR(f'Error in data for fee: {serializer.errors}'))
def init_discounts(self):
discounts_data = self.load_data('discounts.json')
for discount_data in discounts_data:
establishment = random.choice(self.establishments)
discount_data["name"] = discount_data['name']
discount_data["establishment"] = establishment.id
discount_data["type"] = random.choice([FeeType.REGISTRATION_FEE, FeeType.TUITION_FEE])
discount_data["discount_type"] = random.choice([DiscountType.CURRENCY, DiscountType.PERCENT])
serializer = DiscountSerializer(data=discount_data)
if serializer.is_valid():
discount = serializer.save()
self.stdout.write(self.style.SUCCESS(f'Discount {discount.name} created successfully'))
else:
self.stdout.write(self.style.ERROR(f'Error in data for discount: {serializer.errors}'))
def init_payment_modes(self):
modes = list(PaymentModeType.objects.filter(code__in=["SEPA", "TRANSFER", "CHECK", "CASH"]))
types = [FeeType.REGISTRATION_FEE, FeeType.TUITION_FEE]
for establishment in self.establishments:
for mode in modes:
for type in types:
payment_mode_data = {
"mode": mode.pk,
"type": type,
"establishment": establishment.id
}
serializer = PaymentModeSerializer(data=payment_mode_data)
if serializer.is_valid():
payment_mode = serializer.save()
self.stdout.write(self.style.SUCCESS(f'Payment Mode {payment_mode} created successfully'))
else:
self.stdout.write(self.style.ERROR(f'Error in data for payment mode: {serializer.errors}'))
def init_payment_plans(self):
frequencies = list(PaymentPlanType.objects.filter(code__in=["ONE_TIME", "THREE_TIMES", "TEN_TIMES", "TWELVE_TIMES"]))
types = [FeeType.REGISTRATION_FEE, FeeType.TUITION_FEE]
current_date = timezone.now().date()
for establishment in self.establishments:
for frequency in frequencies:
for type in types:
payment_plan_data = {
"frequency": frequency.pk,
"type": type,
"establishment": establishment.id,
"due_dates": self.generate_due_dates(frequency, current_date)
}
serializer = PaymentPlanSerializer(data=payment_plan_data)
if serializer.is_valid():
payment_plan = serializer.save()
self.stdout.write(self.style.SUCCESS(f'Payment Plan {payment_plan} created successfully'))
else:
self.stdout.write(self.style.ERROR(f'Error in data for payment plan: {serializer.errors}'))
def generate_due_dates(self, frequency, start_date):
if frequency.code == "ONE_TIME":
return [start_date + relativedelta(months=1)]
elif frequency.code == "THREE_TIMES":
return [start_date + relativedelta(months=1+4*i) for i in range(3)]
elif frequency.code == "TEN_TIMES":
return [start_date + relativedelta(months=1+i) for i in range(10)]
elif frequency.code == "TWELVE_TIMES":
return [start_date + relativedelta(months=1+i) for i in range(12)]
def init_specialities(self):
specialities_data = self.load_data('specialities.json')
for speciality_data in specialities_data:
establishment = random.choice(self.establishments)
speciality_data["name"] = speciality_data['name']
speciality_data["establishment"] = establishment.id
serializer = SpecialitySerializer(data=speciality_data)
if serializer.is_valid():
speciality = serializer.save()
self.stdout.write(self.style.SUCCESS(f'Speciality {speciality.name} created successfully'))
else:
self.stdout.write(self.style.ERROR(f'Error in data for speciality: {serializer.errors}'))
def init_teachers(self):
fake = Faker()
# Récupérer tous les profils existants avec un rôle ECOLE ou ADMIN
profiles_with_roles = Profile.objects.filter(roles__role_type__in=[ProfileRole.RoleType.PROFIL_ECOLE, ProfileRole.RoleType.PROFIL_ADMIN]).distinct()
if not profiles_with_roles.exists():
self.stdout.write(self.style.ERROR('No profiles with role_type ECOLE or ADMIN found'))
return
used_profiles = set()
for _ in range(15):
# Récupérer un profil aléatoire qui n'a pas encore été utilisé
available_profiles = profiles_with_roles.exclude(id__in=used_profiles)
if not available_profiles.exists():
self.stdout.write(self.style.ERROR('Not enough profiles with role_type ECOLE or ADMIN available'))
break
profile = random.choice(available_profiles)
used_profiles.add(profile.id)
# Récupérer les ProfileRole associés au profil avec les rôles ECOLE ou ADMIN
profile_roles = ProfileRole.objects.filter(profile=profile, role_type__in=[ProfileRole.RoleType.PROFIL_ECOLE, ProfileRole.RoleType.PROFIL_ADMIN])
if not profile_roles.exists():
self.stdout.write(self.style.ERROR(f'No ProfileRole with role_type ECOLE or ADMIN found for profile {profile.email}'))
continue
profile_role = random.choice(profile_roles)
# Générer des données fictives pour l'enseignant
teacher_data = {
"last_name": fake.last_name(),
"first_name": fake.first_name(),
"profile_role": profile_role.id
}
establishment_specialities = list(Speciality.objects.filter(establishment=profile_role.establishment))
num_specialities = min(random.randint(1, 3), len(establishment_specialities))
selected_specialities = random.sample(establishment_specialities, num_specialities)
# Créer l'enseignant si il n'existe pas
teacher_serializer = TeacherSerializer(data=teacher_data)
if teacher_serializer.is_valid():
teacher = teacher_serializer.save()
# Associer les spécialités
teacher.specialities.set(selected_specialities)
teacher.save()
self.stdout.write(self.style.SUCCESS(f'Teacher {teacher.last_name} created successfully for establishment {profile_role.establishment.name}'))
else:
self.stdout.write(self.style.ERROR(f'Error in data for teacher: {teacher_serializer.errors}'))
self.stdout.write(self.style.SUCCESS('Teachers initialized or updated successfully'))
def init_school_classes(self):
school_classes_data = self.load_data('school_classes.json')
levels = list(Level.objects.all())
for index, class_data in enumerate(school_classes_data, start=1):
# Randomize establishment
establishment = random.choice(self.establishments)
class_data["atmosphere_name"] = f"Classe {index}"
class_data["establishment"] = establishment.id
# Randomize levels
class_data["levels"] = [level.id for level in random.sample(levels, random.randint(1, min(5, len(levels))))]
# Randomize teachers
establishment_teachers = list(Teacher.objects.filter(profile_role__establishment=establishment))
if len(establishment_teachers) > 0:
num_teachers = min(2, len(establishment_teachers))
selected_teachers = random.sample(establishment_teachers, num_teachers)
teachers_ids = [teacher.id for teacher in selected_teachers]
else:
teachers_ids = []
# Use the serializer to create or update the school class
class_data["teachers"] = teachers_ids
serializer = SchoolClassSerializer(data=class_data)
if serializer.is_valid():
school_class = serializer.save()
self.stdout.write(self.style.SUCCESS(f'SchoolClass {school_class.atmosphere_name} created or updated successfully'))
else:
self.stdout.write(self.style.ERROR(f'Error in data for school class: {serializer.errors}'))
self.stdout.write(self.style.SUCCESS('SchoolClasses initialized or updated successfully'))
def init_file_group(self):
fake = Faker()
for establishment in self.establishments:
for i in range(1, 4): # Créer 3 groupes de fichiers par établissement
name = f"Fichiers d'inscription - {fake.word()}"
description = fake.sentence()
group_data = {
"name": name,
"description": description,
"establishment": establishment
}
RegistrationFileGroup.objects.update_or_create(name=name, defaults=group_data)
self.stdout.write(self.style.SUCCESS(f'RegistrationFileGroup {name} initialized or updated successfully'))
self.stdout.write(self.style.SUCCESS('All RegistrationFileGroups initialized or updated successfully'))
def init_register_form(self):
fake = Faker('fr_FR') # Utiliser le locale français pour Faker
file_group_count = RegistrationFileGroup.objects.count()
levels = list(Level.objects.all())
# Récupérer tous les profils existants avec un ProfileRole Parent
profiles_with_parent_role = Profile.objects.filter(roles__role_type=ProfileRole.RoleType.PROFIL_PARENT).distinct()
if not profiles_with_parent_role.exists():
self.stdout.write(self.style.ERROR('No profiles with ProfileRole Parent found'))
return
used_profiles = set()
for _ in range(50):
# Récupérer un profil aléatoire qui n'a pas encore été utilisé
available_profiles = profiles_with_parent_role.exclude(id__in=used_profiles)
if not available_profiles.exists():
self.stdout.write(self.style.ERROR('Not enough profiles with ProfileRole Parent available'))
break
profile = random.choice(available_profiles)
used_profiles.add(profile.id)
# Récupérer le ProfileRole Parent associé au profil
profile_roles = ProfileRole.objects.filter(profile=profile, role_type=ProfileRole.RoleType.PROFIL_PARENT)
profile_role = random.choice(profile_roles)
# Générer des données fictives pour le guardian
guardian_data = {
"profile_role": profile_role.id,
"last_name": fake.last_name(),
"first_name": fake.first_name(),
"birth_date": fake.date_of_birth().strftime('%Y-%m-%d'),
"address": fake.address(),
"phone": "+33122334455",
"profession": fake.job()
}
# Générer des données fictives pour l'étudiant
student_data = {
"last_name": fake.last_name(),
"first_name": fake.first_name(),
"address": fake.address(),
"birth_date": fake.date_of_birth(),
"birth_place": fake.city(),
"birth_postal_code": fake.postcode(),
"nationality": fake.country(),
"attending_physician": fake.name(),
"level": random.choice(levels).id,
"guardians": [guardian_data],
"sibling": []
}
# Créer ou mettre à jour l'étudiant
student_serializer = StudentSerializer(data=student_data)
if student_serializer.is_valid():
student = student_serializer.save()
self.stdout.write(self.style.SUCCESS(f'Student {student.last_name} created successfully'))
else:
self.stdout.write(self.style.ERROR(f'Error in data for student: {student_serializer.errors}'))
continue
# Récupérer les frais et les réductions
fees = Fee.objects.filter(id__in=[1, 2, 3, 4])
discounts = Discount.objects.filter(id__in=[1])
# Déterminer l'année scolaire (soit l'année en cours, soit l'année prochaine)
school_year = random.choice([getCurrentSchoolYear(), getNextSchoolYear()])
# Créer les données du formulaire d'inscription
register_form_data = {
"fileGroup": RegistrationFileGroup.objects.get(id=fake.random_int(min=1, max=file_group_count)),
"establishment": profile_role.establishment,
"status": fake.random_int(min=1, max=3),
"school_year": school_year # Ajouter l'année scolaire
}
# Créer ou mettre à jour le formulaire d'inscription
register_form, created = RegistrationForm.objects.get_or_create(
student=student,
establishment=profile_role.establishment,
defaults=register_form_data
)
if created:
register_form.fees.set(fees)
register_form.discounts.set(discounts)
self.stdout.write(self.style.SUCCESS(f'RegistrationForm for student {student.last_name} created successfully with school year {school_year}'))
else:
self.stdout.write(self.style.SUCCESS(f'RegistrationForm for student {student.last_name} already exists'))
self.stdout.write(self.style.SUCCESS('50 RegistrationForms initialized or updated successfully'))

View File

@ -0,0 +1,42 @@
[
{
"name": "Parrainage",
"amount": "10.00",
"description": "Réduction pour parrainage"
},
{
"name": "Réinscription",
"amount": "100.00",
"description": "Réduction pour Réinscription"
},
{
"name": "Famille nombreuse",
"amount": "50.00",
"description": "Réduction pour les familles nombreuses"
},
{
"name": "Excellence académique",
"amount": "200.00",
"description": "Réduction pour les élèves ayant des résultats académiques exceptionnels"
},
{
"name": "Sportif de haut niveau",
"amount": "150.00",
"description": "Réduction pour les élèves pratiquant un sport de haut niveau"
},
{
"name": "Artiste talentueux",
"amount": "100.00",
"description": "Réduction pour les élèves ayant des talents artistiques"
},
{
"name": "Bourse d'études",
"amount": "300.00",
"description": "Réduction pour les élèves bénéficiant d'une bourse d'études"
},
{
"name": "Réduction spéciale",
"amount": "75.00",
"description": "Réduction spéciale pour des occasions particulières"
}
]

View File

@ -0,0 +1,23 @@
[
{
"name": "Ecole A",
"address": "Adresse de l'Ecole A",
"total_capacity": 69,
"establishment_type": [1, 2],
"licence_code": ""
},
{
"name": "Ecole B",
"address": "Adresse de l'Ecole B",
"total_capacity": 100,
"establishment_type": [2, 3],
"licence_code": ""
},
{
"name": "Ecole C",
"address": "Adresse de l'Ecole C",
"total_capacity": 50,
"establishment_type": [1],
"licence_code": ""
}
]

View File

@ -0,0 +1,32 @@
[
{
"name": "Frais d'inscription",
"base_amount": "150.00",
"description": "Montant de base",
"is_active": true
},
{
"name": "Matériel",
"base_amount": "85.00",
"description": "Livres / jouets",
"is_active": true
},
{
"name": "Sorties périscolaires",
"base_amount": "120.00",
"description": "Sorties",
"is_active": true
},
{
"name": "Les colibris",
"base_amount": "4500.00",
"description": "TPS / PS / MS / GS",
"is_active": true
},
{
"name": "Les butterflies",
"base_amount": "5000.00",
"description": "CP / CE1 / CE2 / CM1 / CM2",
"is_active": true
}
]

View File

@ -0,0 +1,52 @@
[
{
"age_range": "3-6",
"number_of_students": 14,
"teaching_language": "",
"school_year": "2024-2025",
"levels": [2, 3, 4],
"type": 1,
"time_range": ["08:30", "17:30"],
"opening_days": [1, 2, 4, 5]
},
{
"age_range": "2-3",
"number_of_students": 5,
"teaching_language": "",
"school_year": "2024-2025",
"levels": [1],
"type": 1,
"time_range": ["08:30", "17:30"],
"opening_days": [1, 2, 4, 5]
},
{
"age_range": "6-12",
"number_of_students": 21,
"teaching_language": "",
"school_year": "2024-2025",
"levels": [5, 6, 7, 8, 9],
"type": 1,
"time_range": ["08:30", "17:30"],
"opening_days": [1, 2, 4, 5]
},
{
"age_range": "4-6",
"number_of_students": 18,
"teaching_language": "",
"school_year": "2024-2025",
"levels": [4, 5],
"type": 1,
"time_range": ["08:30", "17:30"],
"opening_days": [1, 2, 4, 5]
},
{
"age_range": "7-9",
"number_of_students": 20,
"teaching_language": "",
"school_year": "2024-2025",
"levels": [6, 7],
"type": 1,
"time_range": ["08:30", "17:30"],
"opening_days": [1, 2, 4, 5]
}
]

Some files were not shown because too many files have changed in this diff Show More