feat: Ajout de la possibilité de supprimer une association

guardian/student + ajout de la possibilité de créer un guardian pour un
student + tri chrologique
This commit is contained in:
N3WT DE COMPET
2025-03-22 12:28:12 +01:00
parent 43ed495a9a
commit c9350a796b
12 changed files with 326 additions and 93 deletions

View File

@ -27,6 +27,7 @@ class ProfileRole(models.Model):
role_type = models.IntegerField(choices=RoleType.choices, default=RoleType.PROFIL_UNDEFINED)
establishment = models.ForeignKey(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()} - {self.establishment.name}"

View File

@ -3,6 +3,9 @@ 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)
@ -69,10 +72,11 @@ class ProfileRoleSerializer(serializers.ModelSerializer):
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']
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)
@ -143,3 +147,10 @@ class ProfileRoleSerializer(serializers.ModelSerializer):
"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

@ -538,7 +538,7 @@ class ProfileRoleView(APIView):
profiles_roles_List = bdd.getAllObjects(_objectName=ProfileRole)
if profiles_roles_List:
profiles_roles_List = profiles_roles_List.filter(establishment=establishment_id).distinct()
profiles_roles_List = profiles_roles_List.filter(establishment=establishment_id).distinct().order_by('-updated_date')
profile_roles_serializer = ProfileRoleSerializer(profiles_roles_List, many=True)
return JsonResponse(profile_roles_serializer.data, safe=False)

View File

@ -67,7 +67,6 @@ class TeacherSerializer(serializers.ModelSerializer):
specialities_data = validated_data.pop('specialities', None)
associated_profile_email = validated_data.pop('associated_profile_email')
establishment_id = validated_data.get('establishment')
print(f'debug : {validated_data}')
role_type = validated_data.pop('role_type')
profile, created = Profile.objects.get_or_create(

View File

@ -26,7 +26,6 @@ def getStateMachineObjectState(etat):
def updateStateMachine(rf, transition) :
automateModel = load_config('Subscriptions/Configuration/automate.json')
state_machine = getStateMachineObject(rf.status)
print(f'etat DI : {state_machine.state}')
if state_machine.trigger(transition, automateModel):
rf.status = state_machine.state
rf.save()

View File

@ -29,7 +29,7 @@ class Guardian(models.Model):
address = models.CharField(max_length=200, default="", blank=True)
phone = models.CharField(max_length=200, default="", blank=True)
profession = models.CharField(max_length=200, default="", blank=True)
profile_role = models.OneToOneField(ProfileRole, on_delete=models.CASCADE, related_name='guardian_profile', null=True, blank=True)
profile_role = models.OneToOneField(ProfileRole, on_delete=models.CASCADE, related_name='guardian_profile', blank=True)
def __str__(self):
return self.last_name + "_" + self.first_name

View File

@ -5,6 +5,7 @@ from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from Subscriptions.models import Guardian, Student
from Auth.models import ProfileRole
from N3wtSchool import bdd
class GuardianView(APIView):
@ -50,12 +51,34 @@ class DissociateGuardianView(APIView):
# Supprimer la relation entre le student et le guardian
student.guardians.remove(guardian)
if guardian.profile_role:
guardian.profile_role.save()
isGuardianDeleted = False
# Vérifier si le guardian n'est plus associé à aucun élève
if guardian.student_set.count() == 0: # Utilise la relation ManyToMany inverse
print(f'Le guardian {guardian} n\'est plus rattaché à aucun élève : on le supprime')
isGuardianDeleted = True
# Vérifier si le guardian a un ProfileRole associé
if guardian.profile_role:
print(f'Suppression du ProfileRole associé au guardian {guardian}')
guardian.profile_role.delete()
# Vérifier si le Profile n'a plus de ProfileRole associés
profile = guardian.profile_role.profile
if not ProfileRole.objects.filter(profile=profile).exists():
print(f'Le profile {profile} n\'a plus de rôle associé : on le supprime')
profile.delete()
# Supprimer le guardian
guardian.delete()
return JsonResponse(
{"message": f"Le guardian {guardian.last_name} {guardian.first_name} a été dissocié de l'étudiant {student.last_name} {student.first_name}."},
{
"message": f"Le guardian {guardian.last_name} {guardian.first_name} a été dissocié de l'étudiant {student.last_name} {student.first_name}.",
"isGuardianDeleted": isGuardianDeleted
},
status=status.HTTP_200_OK
)
except Student.DoesNotExist:

View File

@ -99,8 +99,7 @@ class RegisterFormView(APIView):
registerForms_List = None
if registerForms_List:
print(f'filtrate sur lestablishment : {establishment_id}')
registerForms_List = registerForms_List.filter(establishment=establishment_id)
registerForms_List = registerForms_List.filter(establishment=establishment_id).order_by('-last_update')
if not registerForms_List:
return JsonResponse({'error': 'aucune donnée trouvée', 'count': 0}, safe=False)

View File

@ -10,6 +10,7 @@ import ProfileDirectory from '@/components/ProfileDirectory';
export default function Page() {
const [profileRoles, setProfileRoles] = useState([]);
const [reloadFetch, setReloadFetch] = useState(false);
const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment();
@ -19,7 +20,7 @@ export default function Page() {
// Fetch data for profileRoles
handleProfiles();
}
}, [selectedEstablishmentId]);
}, [selectedEstablishmentId, reloadFetch]);
const handleProfiles = () => {
fetchProfileRoles(selectedEstablishmentId)
@ -27,6 +28,7 @@ export default function Page() {
setProfileRoles(data);
})
.catch(error => logger.error('Error fetching profileRoles:', error));
setReloadFetch(false);
};
const handleEdit = (profileRole) => {
@ -56,8 +58,37 @@ export default function Page() {
const handleDissociate = (studentId, guardianId) => {
return dissociateGuardian(studentId, guardianId)
.then(() => {
.then((response) => {
logger.debug("Guardian dissociated successfully:", guardianId);
// Vérifier si le Guardian a été supprimé
const isGuardianDeleted = response?.isGuardianDeleted;
// Mettre à jour le modèle profileRoles
setProfileRoles(prevState =>
prevState.map(profileRole => {
if (profileRole.associated_person?.id === guardianId) {
if (isGuardianDeleted) {
// Si le Guardian est supprimé, retirer le profileRole
return null;
} else {
// Si le Guardian n'est pas supprimé, mettre à jour les élèves associés
const updatedStudents = profileRole.associated_person.students.filter(
student => student.id !== studentId
);
return {
...profileRole,
associated_person: {
...profileRole.associated_person,
students: updatedStudents, // Mettre à jour les élèves associés
},
};
}
}
setReloadFetch(true);
return profileRole; // Conserver les autres profileRoles
}).filter(Boolean) // Supprimer les entrées nulles
);
})
.catch(error => {
logger.error('Error dissociating guardian:', error);

View File

@ -15,7 +15,6 @@ import { MoreVertical, Send, Edit, Trash2, FileText, CheckCircle, Plus } from '
import Modal from '@/components/Modal';
import InscriptionForm from '@/components/Inscription/InscriptionForm'
import AffectationClasseForm from '@/components/AffectationClasseForm'
import { getSession } from 'next-auth/react';
import { useEstablishment } from '@/context/EstablishmentContext';
import {
@ -87,6 +86,7 @@ export default function Page({ params: { locale } }) {
const [tuitionFees, setTuitionFees] = useState([]);
const [groups, setGroups] = useState([]);
const [profiles, setProfiles] = useState([]);
const [isOpenAddGuardian, setIsOpenAddGuardian] = useState(false);
const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment();
@ -100,6 +100,15 @@ export default function Page({ params: { locale } }) {
}
const handleOpenAddGuardian = (eleveSelected) => {
setIsOpenAddGuardian(true);
setStudent(eleveSelected);
};
const handleCloseAddGuardian = () => {
setIsOpenAddGuardian(false);
};
const openModalAssociationEleve = (eleveSelected) => {
setIsOpenAffectationClasse(true);
setStudent(eleveSelected);
@ -480,10 +489,94 @@ useEffect(()=>{
});
}
const updateRF = (updatedData) => {
logger.debug('updateRF updatedData:', updatedData);
const data = {
student: {
guardians: updatedData.selectedGuardians.length !== 0
? updatedData.selectedGuardians.map(guardianId => ({ id: guardianId }))
: (() => {
if (updatedData.isExistingParentProfile) {
return [{
profile_role_data: {
establishment: selectedEstablishmentId,
role_type: 2,
is_active: false,
profile: updatedData.existingProfileId, // Associer au profil existant
},
last_name: updatedData.guardianLastName,
first_name: updatedData.guardianFirstName,
birth_date: updatedData.guardianBirthDate,
address: updatedData.guardianAddress,
phone: updatedData.guardianPhone,
profession: updatedData.guardianProfession
}];
}
// Si aucun profil existant n'est trouvé, créer un nouveau profil
return [{
profile_role_data: {
establishment: selectedEstablishmentId,
role_type: 2,
is_active: false,
profile_data: {
email: updatedData.guardianEmail,
password: 'Provisoire01!',
username: updatedData.guardianEmail,
}
},
last_name: updatedData.guardianLastName,
first_name: updatedData.guardianFirstName,
birth_date: updatedData.guardianBirthDate,
address: updatedData.guardianAddress,
phone: updatedData.guardianPhone,
profession: updatedData.guardianProfession
}];
})(),
},
establishment: selectedEstablishmentId
};
editRegisterForm(student.id, data, csrfToken)
.then(data => {
// Mise à jour immédiate des données
setRegistrationFormsDataPending(prevState => [...(prevState || []), data]);
setTotalPending(prev => prev + 1);
if (updatedData.autoMail) {
sendConfirmRegisterForm(data.student.id, updatedData.studentLastName, updatedData.studentFirstName);
}
handleCloseAddGuardian();
// Forcer le rechargement complet des données
setReloadFetch(true);
})
.catch(error => {
logger.error('Error during updating registration form:', error);
});
}
const columns = [
{ name: t('studentName'), transform: (row) => row.student.last_name },
{ name: t('studentFistName'), transform: (row) => row.student.first_name },
{ name: t('mainContactMail'), transform: (row) => (row.student.guardians && row.student.guardians.length > 0) ? row.student.guardians[0].associated_profile_email : '' },
{
name: t('mainContactMail'),
transform: (row) => (
row.student.guardians && row.student.guardians.length > 0
? row.student.guardians[0].associated_profile_email
: (
<div className="flex justify-center h-full">
<button
className="flex items-center gap-2 text-blue-600 font-semibold hover:text-blue-800 transition duration-200 underline decoration-blue-600 hover:decoration-blue-800"
onClick={() => handleOpenAddGuardian(row.student)}
>
<span className="px-3 py-1 bg-blue-100 rounded-full hover:bg-blue-200 transition duration-200">
Ajouter un responsable
</span>
</button>
</div>
)
)
},
{ name: t('phone'), transform: (row) => formatPhoneNumber(row.student.guardians[0]?.phone) },
{ name: t('lastUpdateDate'), transform: (row) => row.formatted_last_update},
{ name: t('registrationFileStatus'), transform: (row) => (
@ -742,13 +835,29 @@ const columnsSubscribed = [
title="Affectation à une classe"
ContentComponent={() => (
<AffectationClasseForm
student={student}
students={students}
onSubmit={affectationClassFormSubmitHandler}
classes={classes}
/>
)}
/>
)}
{isOpenAddGuardian && (
<Modal
isOpen={isOpenAddGuardian}
setIsOpen={setIsOpenAddGuardian}
title="Ajouter un responsable"
ContentComponent={() => (
<InscriptionForm
students={students}
profiles={profiles}
onSubmit={updateRF}
currentStep={2}
showOnlyStep2={true}
/>
)}
/>
)}
</div>
);
}

View File

@ -11,8 +11,19 @@ import ProgressStep from '@/components/ProgressStep';
import logger from '@/utils/logger';
import Popup from '@/components/Popup';
const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, registrationFees, tuitionFees, profiles, onSubmit, currentStep, groups }) => {
const [formData, setFormData] = useState({
const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, registrationFees, tuitionFees, profiles, onSubmit, currentStep, groups, showOnlyStep2 = false }) => {
const [formData, setFormData] = useState(() => {
if (showOnlyStep2) {
return {
guardianLastName: '',
guardianFirstName: '',
guardianEmail: '',
guardianPhone: '',
selectedGuardians: [],
responsableType: 'new',
};
}
return {
studentLastName: '',
studentFirstName: '',
guardianLastName: '',
@ -27,6 +38,7 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
selectedTuitionDiscounts: [],
selectedTuitionFees: [],
selectedFileGroup: null // Ajout du groupe de fichiers sélectionné
};
});
const [step, setStep] = useState(currentStep || 1);
@ -55,8 +67,8 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
formData.selectedGuardians.length > 0 ||
(!formData.emailError && formData.guardianEmail.length > 0 && filteredStudents.length === 0)
);
const isStep3Valid = formData.selectedRegistrationFees.length > 0;
const isStep4Valid = formData.selectedTuitionFees.length > 0;
const isStep3Valid = formData.selectedRegistrationFees?.length > 0;
const isStep4Valid = formData.selectedTuitionFees?.length > 0;
const isStep5Valid = formData.selectedFileGroup !== null;
const isStep6Valid = isStep1Valid && isStep2Valid && isStep3Valid && isStep4Valid && isStep5Valid;
@ -80,12 +92,14 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
};
useEffect(() => {
if (!showOnlyStep2) {
// Calcul du montant total des frais d'inscription lors de l'initialisation
const initialTotalRegistrationAmount = calculateFinalRegistrationAmount(
registrationFees.map(fee => fee.id),
[]
);
setTotalRegistrationAmount(initialTotalRegistrationAmount);
}
}, [registrationDiscounts, registrationFees]);
@ -123,13 +137,13 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
}
}
if (step < steps.length) {
if (!showOnlyStep2 && step < steps.length) {
setStep(step + 1);
}
};
const prevStep = () => {
if (step > 1) {
if (!showOnlyStep2 && step > 1) {
setStep(step - 1);
}
};
@ -270,6 +284,7 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
return (
<div className="space-y-4 mt-6">
{!showOnlyStep2 && (
<ProgressStep
steps={steps}
stepTitles={stepTitles}
@ -277,6 +292,7 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
setStep={setStep}
isStepValid={isStepValid}
/>
)}
{step === 1 && (
<div className="mt-6">
@ -308,7 +324,7 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
name="guardianLastName"
type="text"
IconItem={User}
placeholder="Nom du responsable (optionnel)"
placeholder="Nom du responsable"
value={formData.guardianLastName}
onChange={handleChange}
className="w-full mt-4"
@ -317,7 +333,7 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
name="guardianFirstName"
type="text"
IconItem={User}
placeholder="Prénom du responsable (optionnel)"
placeholder="Prénom du responsable"
value={formData.guardianFirstName}
onChange={handleChange}
className="w-full mt-4"
@ -663,15 +679,49 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
)}
<div className="flex justify-end mt-4 space-x-4">
{showOnlyStep2 ? (
<>
<Button
text="Valider"
onClick={submit}
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${
(
(step === 1 && !isStep1Valid) ||
(step === 2 && !isStep2Valid) ||
(step === 3 && !isStep3Valid) ||
(step === 4 && !isStep4Valid) ||
(step === 5 && !isStep5Valid)
)
? "bg-gray-300 text-gray-700 cursor-not-allowed"
: "bg-emerald-500 text-white hover:bg-emerald-600"
}`}
disabled={
(
(step === 1 && !isStep1Valid) ||
(step === 2 && !isStep2Valid) ||
(step === 3 && !isStep3Valid) ||
(step === 4 && !isStep4Valid) ||
(step === 5 && !isStep5Valid)
)
}
primary
name="Validate"
/>
</>
) : (
<>
{step > 1 && (
<Button text="Précédent"
<Button
text="Précédent"
onClick={prevStep}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md shadow-sm hover:bg-gray-400 focus:outline-none"
secondary
name="Previous" />
name="Previous"
/>
)}
{step < steps.length ? (
<Button text="Suivant"
<Button
text="Suivant"
onClick={nextStep}
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${
(
@ -694,13 +744,18 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
)
}
primary
name="Next" />
name="Next"
/>
) : (
<Button text="Valider"
<Button
text="Valider"
onClick={submit}
className="px-4 py-2 bg-emerald-500 text-white rounded-md shadow-sm hover:bg-emerald-600 focus:outline-none"
primary
name="Create" />
name="Create"
/>
)}
</>
)}
</div>

View File

@ -107,6 +107,7 @@ const ProfileDirectory = ({ profileRoles, handleActivateProfile, handleDeletePro
const parentColumns = [
{ name: 'Identifiant', transform: (row) => row.associated_profile_email },
{ name: 'Mise à jour', transform: (row) => row.updated_date_formatted },
{ name: 'Rôle', transform: (row) => (
<span className={`px-2 py-1 rounded-full font-bold ${roleTypeToBadgeClass(row.role_type)}`}>
{roleTypeToLabel(row.role_type)}
@ -124,11 +125,15 @@ const ProfileDirectory = ({ profileRoles, handleActivateProfile, handleDeletePro
onMouseEnter={() => handleTooltipVisibility(row.id)} // Afficher la tooltip pour cette ligne
onMouseLeave={handleTooltipHide} // Cacher la tooltip
>
<button className="text-blue-500 hover:text-blue-700">
<Info className="w-5 h-5" />
<button className="relative text-blue-500 hover:text-blue-700 flex items-center justify-center">
<div className="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center font-bold">
{row.associated_person?.students?.length || 0}
</div>
</button>
{visibleTooltipId === row.id && ( // Afficher uniquement si l'ID correspond
<div className="absolute z-50 w-96 p-4 bg-white border border-gray-200 rounded shadow-lg left-0 -translate-x-1/2 top-full">
<div
className="fixed z-50 w-96 p-4 bg-white border border-gray-200 rounded shadow-lg -translate-x-1/2"
>
<div className="mb-2">
<strong>Elève(s) associé(s):</strong>
<div className="flex flex-col justify-center space-y-2 mt-4">
@ -183,6 +188,7 @@ const ProfileDirectory = ({ profileRoles, handleActivateProfile, handleDeletePro
const schoolAdminColumns = [
{ name: 'Identifiant', transform: (row) => row.associated_profile_email },
{ name: 'Mise à jour', transform: (row) => row.updated_date_formatted },
{ name: 'Rôle', transform: (row) => (
<span className={`px-2 py-1 rounded-full font-bold ${roleTypeToBadgeClass(row.role_type)}`}>
{roleTypeToLabel(row.role_type)}