319 Commits

Author SHA1 Message Date
4f7d7d0024 feat: Gestion de l'affichage des documents validés et non validés sur la page parent [N3WTS-2] 2026-02-22 18:34:00 +01:00
8fd1b62ec0 feat: Validation document par document [N3WTS-2] 2026-02-19 18:53:33 +01:00
3779a47417 feat: Ajout bouton de refus de dossier avec zone de saisie de motif [N3WTS-2] 2026-02-16 17:54:20 +01:00
05c68ebfaa feat: Page Structure : suppression de la possibilité de faire des actions d'admin [N3WTS-8] 2026-02-15 18:40:14 +01:00
195579e217 feat: Page Inscriptions : suppression de la possibilité de créer un nouveau DI [N3WTS-8] 2026-02-15 18:08:07 +01:00
ddcaba382e feat: Gestion de la sidebar [N3WTS-8] 2026-02-15 18:02:57 +01:00
a82483f3bd chore: Suppression code mort [N3WTS-8] 2026-02-15 17:57:48 +01:00
26d4b5633f fix: Changement des niveaux de logs [N3WTS-1] 2026-02-15 17:39:19 +01:00
d66db1b019 feat: Envoi mail d'inscription au second responsable [N3WTS-1] 2026-02-15 17:36:55 +01:00
bd7dc2b0c2 feat: Envoi mail d'inscription aux enseignants [N3WTS-1] 2026-02-15 16:37:43 +01:00
176edc5c45 fix: Edition d'un teacher, champ email désactivé [N3WTS-1] 2026-02-15 15:47:51 +01:00
92c6a31740 fix: Suppression d'un PROFILE si aucun PROFILE_ROLE n'y est associé [N3WTS-1] 2026-02-14 17:58:47 +01:00
9dff32b388 feat: WIP finalisation partie signature des parents [N3WTS-17] 2026-02-13 17:06:21 +01:00
abb4b525b2 feat: Gestion de l'arborescence des documents d'école en fonction des requêtes CRUD [N3WTS-17] 2026-01-25 11:01:22 +01:00
b4f70e6bad feat: Sauvegarde des formulaires d'école dans les bons dossiers /
utilisation des bons composants dans les modales [N3WTS-17]
2026-01-18 18:44:13 +01:00
8549699dec feat: Réorganisation items dans la page [N3WTS-17] 2026-01-05 14:56:36 +01:00
a034149eae fix: Coquille [N3WTS-17] 2026-01-03 17:53:53 +01:00
12f5fc7aa9 feat: Changement du rendu de la page des documents + gestion des
formulaires d'école déjà existants [N3WTS-17]
2026-01-03 17:49:25 +01:00
2dc0dfa268 fix: Lint 2025-12-14 16:49:48 +01:00
dd00cba385 feat: Précablage du formulaire dynamique [N3WTS-17] 2025-11-30 17:24:25 +01:00
7486f6c5ce feat: Traitement de clonages des templates de documents dans le back
uniquement [#N3WTS-17]
2025-11-29 16:43:51 +01:00
1e5bc6ccba feat: Début de suppression de docuseal côté Front [#N3WTS-17] 2025-11-29 12:20:14 +01:00
0fb668b212 feat: push test [#N3WTS-17] 2025-11-29 11:33:21 +01:00
5e62ee5100 feat: Ajout des composants manquant dans le FormTemplateBuilder [N3WTS-17] 2025-09-01 12:09:19 +02:00
e89d2fc4c3 feat: Ajout FormTemplateBuilder [N3WTS-17] 2025-09-01 11:08:21 +02:00
9481a0132d feat: creation d'un FormRenderer.js pour creer un formulaire dynamique [NEWTS-17] 2025-08-31 12:26:04 +02:00
482e8c1357 Merge pull request 'fix: Mise en place de l'auto reload pour Daphne [#65]' (#67) from A65_ReloadBackEnd into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/67
2025-06-05 18:08:48 +00:00
0e0141d155 Merge remote-tracking branch 'origin/main' into develop 2025-06-05 19:59:08 +02:00
7f002e2e6a fix: Mise en place de l'auto reload pour Daphne [#65] 2025-06-05 19:55:35 +02:00
0064b8d35a chore(release): 0.0.3 2025-06-01 14:45:56 +02:00
ec2c1daebc Merge remote-tracking branch 'origin/develop' 2025-06-01 14:45:53 +02:00
67cea2f1c6 fix: Ajout d'un '/' en fin d'URL 2025-06-01 14:45:09 +02:00
5785bfae46 chore: Mise à jour des docker compose 2025-06-01 14:31:02 +02:00
a17078709b chore(release): 0.0.2 2025-06-01 13:47:20 +02:00
d58155da06 Merge remote-tracking branch 'origin/develop' 2025-06-01 13:47:18 +02:00
043d93dcc4 fix: ajout des urls prod et demo 2025-06-01 13:21:14 +02:00
6bc24055cd fix: load the school image eorrectly 2025-06-01 08:41:12 +02:00
2f6d30b85b fix: Link documents with establishments 2025-06-01 07:54:33 +02:00
c161fa7e75 fix: ajout de credential include dans get CSRF 2025-05-31 18:53:04 +02:00
789816e986 fix: variables csrf 2025-05-31 18:31:46 +02:00
6bedf715cc fix: Variables booléennes par défaut 2025-05-31 18:14:05 +02:00
59a0d40130 fix: csrf 2025-05-31 17:45:32 +02:00
25e2799c0f fix: mise à jour settings pour la prod / correction CORS 2025-05-31 17:16:32 +02:00
017c0290dd feat: Sauvegarde des fichiers migration 2025-05-31 15:02:21 +02:00
fe2d4d4513 fix: PieChart 2025-05-31 14:48:06 +02:00
f93c428259 fix: Mise à jour des upcomming events 2025-05-31 14:38:32 +02:00
e61cd51ce2 fix: Correction option fusion 2025-05-31 14:17:41 +02:00
6a0b90e98f feat: Ajout du logo de l'école 2025-05-31 13:22:40 +02:00
8a71fa1830 feat: Ajout du logo N3wt dans les mails 2025-05-31 11:37:15 +02:00
f265540da2 chore: Amélioration de la fiche d'élève 2025-05-31 11:08:21 +02:00
5be5f9f70d feat: Envoie d'un mail de bienvue au directeur 2025-05-31 11:07:37 +02:00
68a6a63c4f chore: desactivation du AnnouncementScheduler 2025-05-31 10:48:48 +02:00
af30ae33b5 refactor: Affichage des notifications dans la partie "Users"
(subscribe)
2025-05-31 09:34:28 +02:00
e509625811 refactor: Affichage des notifications dans la partie "Users"
(login/new/reset)
2025-05-31 09:22:35 +02:00
3a2455f918 chore: On ne fait pas disparaitre les notifications en "erreur" 2025-05-31 09:21:09 +02:00
e74f9c98a2 chore: Suppression fonctions inutilisées 2025-05-31 08:52:26 +02:00
8f0cf16f70 fix: searchTerm inscription 2025-05-31 03:03:51 +02:00
78d96f82f9 feat: Ajout de l'emploi du temps sur la page parent 2025-05-31 02:00:00 +02:00
c117f96e52 fix: Suppression event planning
feat: Planning mode SchoolClass
2025-05-30 22:59:23 +02:00
e4668ef1e5 chore: Suppression log inutile 2025-05-30 22:16:18 +02:00
ec2630a6e4 refactor: Suppression des paramètres mail mot de passes des settings
admin / parent
2025-05-30 22:14:51 +02:00
d65b171da8 fix: Application des périodes à un studentCompetency lors de la création
d'une nouvelle compétence
2025-05-30 22:07:37 +02:00
4a6b7ce379 fix: Messages de retour reset/new password 2025-05-30 21:44:13 +02:00
170f7c4fa8 fix: Correction URL
chore: Ajout de notifications
2025-05-30 21:11:47 +02:00
ce83e02f7b refactor: Remplacement de quelques popup par les notifications 2025-05-30 16:15:28 +02:00
a69498dd06 fix: régression CORS_ALLOWED_ORIGINS 2025-05-30 15:31:02 +02:00
23ab7d04ef feat: Utilisation d'une clef API Docuseal par établissement 2025-05-30 14:19:01 +02:00
8cf22905e5 fix: Possibilité d'ajouter un 2ème guardian, même si son mail est
associé à un profil existant
2025-05-30 14:16:00 +02:00
314c31fab1 chore: ménage 2025-05-30 13:12:42 +02:00
be27fe1232 fix: Ne pas retourner d'erreur si pas de dossier d'inscription 2025-05-30 09:52:08 +02:00
8b54cedcab Merge pull request 'feat: mise en place de la messagerie [#17]' (#59) from feat-gestion_messagerie-17 into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/59
2025-05-29 13:06:32 +00:00
d37145b73e feat: mise en place de la messagerie [#17] 2025-05-29 15:09:22 +02:00
e2df29d851 chore: Application du prettier 2025-05-29 10:37:55 +02:00
eb48523f7d feat: Amélioration du dashboard 2025-05-29 10:36:38 +02:00
e30753f1d6 fix: Session storage selectedEstablishmentEvaluationFrequency et selectedEstablishmentTotalCapacity 2025-05-29 10:07:51 +02:00
a42cf348a0 fix: Division par 0 2025-05-29 08:43:59 +02:00
55cb20bf8c chore: Lors de la création d'un établissement, création d'un directeur
et d'un SMTP Settings (utilisé pour les envois de dossiers)
2025-05-28 14:28:21 +02:00
677cec1ec2 fix: double confirmation sur les popup 2025-05-28 14:27:38 +02:00
82573f1b23 fix: Positionnement de la variable isSepa 2025-05-28 14:26:29 +02:00
c5248d9fd3 Merge branch 'develop' of ssh://git.v0id.ovh:5022/n3wt-innov/n3wt-school into develop 2025-05-26 19:46:17 +02:00
49907d7ec8 fix: Modèle créé 2 fois par erreur 2025-05-26 18:27:28 +02:00
87701cb154 chore: instruction copilot 2025-05-26 09:42:08 +02:00
d877c956b7 chore: Ajout des présences dans le dashboard 2025-05-25 19:33:20 +02:00
98763dc90a chore: câblage des absences/retard dans le suivi pédagogique 2025-05-25 19:18:17 +02:00
fd6348fd6b chore: Amélioration du rendu de l'appel 2025-05-25 18:32:46 +02:00
3e5cebef44 chore: Suppression de champs requis lors de la création d'une classe
(non obligatoire pour la tranche d'age par exemple)
2025-05-22 19:08:40 +02:00
87b8cf6c05 chore: Capacité de l'établissement retourné en variable de session /
login sur la home page
2025-05-22 18:47:19 +02:00
3b667d3b15 fix: Ordre des guardians lors de leur création / déselection correcte si
plusieurs guardians
2025-05-22 18:15:05 +02:00
eca8d7a8d5 fix: Génération uniquement des compétences évaluées dans le PDF 2025-05-22 16:53:23 +02:00
de5f7cd41e fix: restore du start.py suite à des tests 2025-05-22 01:28:48 +02:00
7de839ee5c feat: Rattachement d'un dossier de compétences à une période scolaire
(configuration dans l'établissement) [#16]
2025-05-22 01:25:34 +02:00
0fe6c76189 feat: Génération du bilan de compétence en PDF [#16] 2025-05-21 20:44:37 +02:00
eb7805e54e feat: Champ de recherche de l'élève [#16] 2025-05-20 20:22:58 +02:00
56c223f3cc fix: On ne peut sélectionner que les élèves inscrits [#16] 2025-05-20 18:50:19 +02:00
4f40d1f29d fix: Generation d'une fiche d'élève avec le nouveau modèle PayementMode
et PayementPlans
2025-05-20 18:31:40 +02:00
95b449ddfd fix: Modification d'un guardian sans changer d'adresse mail (même
profil)
2025-05-20 17:43:34 +02:00
05136035ab feat: Sauvegarde des compétences d'un élève [#16] 2025-05-20 17:31:50 +02:00
c9c7e7715e fix: coquille 2025-05-18 17:15:14 +02:00
5760c89105 feat: Bilan de compétence d'un élève [#16] 2025-05-18 17:10:49 +02:00
e65e31014d refactor: Utilisation d'une application "Common" pour tous les modèles
de référence
2025-05-18 15:42:21 +02:00
7fe53465ac fix: Remplacement des enum par des modèles pour les payementModes et les
payementPlans
2025-05-18 10:45:00 +02:00
00f7bfde4a fix: régression lors de l'uniformisation des modales 2025-05-18 10:44:24 +02:00
d7fca9e942 feat: Oubli fichier [#16] 2025-05-18 00:47:00 +02:00
4e5aab6db7 feat: Configuration des compétences par cycle [#16] 2025-05-18 00:45:49 +02:00
2888f8dcce feat: gestion des no data dans les table [#33] 2025-05-17 14:35:33 +02:00
3990d75e52 fix: Suppression de la top bar admin [#34] 2025-05-17 12:23:56 +02:00
f252efdef4 fix: Uniformisation des Modales et Popup [#35] 2025-05-17 11:38:26 +02:00
c6bc0d0b51 feat: Mise en place du Backend-messagerie [#17] 2025-05-17 11:35:57 +02:00
fc9a1ed252 fix: Correction dépendances circulaires 2025-05-12 15:16:46 +02:00
52bba46cbb Merge pull request 'feat-17-Messagerie_WIP' (#58) from feat-17-Messagerie_WIP into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/58
2025-05-12 12:00:36 +00:00
425e6d73e5 chore: Application du linter 2025-05-12 14:03:47 +02:00
23a593dbc7 feat: Messagerie WIP [#17] 2025-05-12 14:02:48 +02:00
c6d75281a1 feat: Peuplement de la BDD avec les JSON d'entrée [#16] 2025-05-11 20:29:53 +02:00
43874f8b9e fix: Suppression de print inutiles 2025-05-11 19:37:20 +02:00
500b6e9af7 fix: Changement d'icone associé aux documents soumis à validation 2025-05-11 19:25:17 +02:00
67193a8b36 feat: Utilisation des nouvelles alertes dans la page admin de la gestion
des documents
2025-05-11 18:50:33 +02:00
3c0806e26c feat: Ajout d'une colonne dans le tableau des pièces jointes indiquant
aux parents les fichiers obligatoires
2025-05-11 18:31:56 +02:00
175932ffa3 fix: Corrrection typo dans description des tableaux frais/réduction 2025-05-11 18:19:50 +02:00
5866427544 fix: Ajout d'un champ is_required pour les documents parents facultatifs 2025-05-09 09:38:47 +02:00
6d805940fe fix: Remise en place de l'API_KEY docuseal dans le back 2025-05-08 18:51:27 +02:00
69405c577e feat: création de 4 JSON de compétences en attendant de les mettre en
base
2025-05-08 18:44:59 +02:00
eda6f587fb feat: Preparation des modèles Settings pour l'enregistrement SMTP [#17] 2025-05-07 00:46:39 +02:00
99a882a64a feat: Ajout de l'envoie de mail [#17] 2025-05-07 00:46:38 +02:00
f38a4414c2 fix: Scroll de l'emploi du temps élève 2025-05-06 23:13:53 +02:00
980f169c1d fix: pagination annuaire 2025-05-06 22:57:52 +02:00
9e69790683 fix: Condition de validation d'ajout d'un nouveau document parent /
remise en forme de l'ajout d'un document école
2025-05-06 21:39:16 +02:00
f887ae1886 feat: Merge remote-tracking branch 'origin/WIP_style' into develop 2025-05-06 21:02:24 +02:00
d64500f402 fix: Bug lorsqu'on déselectionne un paiementPlan 2025-05-06 21:00:51 +02:00
760ee0009e feat: Création nouveau style / pagination profils annuaires 2025-05-06 19:54:46 +02:00
4fd40ac5fc fix: formulaire sur toute la larguer + initiation à un autre style de bg 2025-05-06 14:26:21 +02:00
dfd707d7a0 feat: Gestion de la mise à jour des profiles / roles / lors de l'édition
du formulaire RF
2025-05-06 13:59:31 +02:00
e2a39ff74d fix: import du Loader 2025-05-06 09:48:26 +02:00
8fc9478786 feat: Mise en place des actions pour chaque state du RF, possibilité
d'éditer le formulaire de création de RF (reste à submit un PUT)
2025-05-06 00:53:45 +02:00
4fc061fc25 fix: code mort 2025-05-05 21:51:00 +02:00
5440f5cbdb fix: calcul nombre de pages dans chaque tab 2025-05-05 21:39:10 +02:00
5927e48e65 feat: Suppression de l'ancienne POPUP de RF 2025-05-05 20:59:25 +02:00
76f9a7dd14 feat: Formulaire de création RF sur une seule pag 2025-05-05 20:57:51 +02:00
2a6b3bdf63 feat: Génération d'une page de suivi pédagogique + fix utilisation
certains des composants
2025-05-04 15:45:28 +02:00
e9650c992e fix: Remise en état du bouton Submit 2025-05-04 15:03:51 +02:00
dc402df58b Merge branch 'develop' of ssh://git.v0id.ovh:5022/n3wt-innov/n3wt-school into develop 2025-05-04 14:32:29 +02:00
e1c607308c fix: Ajout du mode Visu 2025-05-04 14:32:06 +02:00
0b5ebb9b32 chore: husky update 2025-05-04 13:37:05 +02:00
4ecf25a6ab fix: coquille dans les imports 2025-05-04 12:11:30 +02:00
030d19d411 feat: Gestion des absences du jour [#16] 2025-05-04 12:08:05 +02:00
1bccc85951 Merge pull request 'feat: Gestion du planning [3]' (#57) from feat-3-Gestion_du_planning into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/57
2025-05-04 10:05:59 +00:00
58144ba0d0 feat: Gestion du planning [3] 2025-05-04 12:03:11 +02:00
cb4fe74a9e feat: Amorçage de la gestion des absences [#16] 2025-05-03 23:32:19 +02:00
1c75927bba feat: Préparation de la gestion des compétences en énumérant les élèves
par classe [#16]
2025-05-03 22:01:38 +02:00
0f49236965 feat: Validation du dossier d'inscription en affectant l'élève à une
classe de son niveau / création d'une fenêtre de visualisation d'une
classe (en cours)
2025-05-03 21:37:41 +02:00
256f995698 feat: Amélioration de la fiche élève pour y ajouter la fratrie et les
modalités de paimenet (problème affichage photo)
2025-05-03 17:34:36 +02:00
e538ac3d56 fix: Ajout du controle sur le format des dates 2025-05-03 16:52:02 +02:00
889a3a48c5 fix: warning sur ouverture modale de fichiers 2025-05-03 16:30:54 +02:00
f3c4284778 fix: boucle inifinie dans UseEffect 2025-05-03 16:27:21 +02:00
330018edfd fix: Suppression d'un profil uniquement s'il ne contient aucun guardian
rattaché à un élève qui n'en a pas d'autre
2025-05-03 15:53:19 +02:00
ac0672f334 fix: Ne pas dissocier de responsable s'il n'y en a pas d'autre rattaché
à l'élève
2025-05-03 15:35:52 +02:00
2ab1684791 feat: Ajout de la fratrie / Gestion des index de fratrie / Gestion des
required
2025-05-03 14:45:10 +02:00
9374b001c9 fix: faire plaisir à LSO 2025-05-03 10:29:32 +02:00
053d524a51 fix: refresh token 2025-05-02 18:28:26 +02:00
4a382d523c feat: Ajout de la fratrie [#27] 2025-05-02 17:44:35 +02:00
1ced4a1069 fix: Limite du nombre de responsables légaux à 2 [#27] 2025-05-02 15:49:46 +02:00
05542dfc40 fix: Ajout des niveaux scolaires dans le back [#27] 2025-05-02 15:45:10 +02:00
af60675dc5 Merge pull request 'chore: Ajout Hook Husky' (#54) from chore-Ajout_Hook_Husky into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/54
2025-05-02 09:26:04 +00:00
266f509d65 chore: Ajout Hook Husky 2025-05-02 11:28:54 +02:00
857b8b26c3 fix: Fusion documents 2025-05-01 21:01:12 +02:00
d37aed5f64 feat: Ajout des payementPlans dans le formulaire / ajout de la photo 2025-05-01 20:44:57 +02:00
5851341235 feat: Ajout de la photo pour le dossier de l'élève + correction
sauvegarde des datas des responsables
2025-05-01 14:59:19 +02:00
d37e6c384d fix: Unicité des fees + utilisation de l'establishmentID [#44] 2025-05-01 14:46:17 +02:00
24069b894e fix: Refresh par profil role 2025-05-01 14:12:21 +02:00
43e301ed64 fix: Correction du Establishment context au refresh 2025-05-01 12:19:07 +02:00
31fdc612b1 fix: ajustement du handlePhoneChange [#41] 2025-04-28 11:07:47 +02:00
5ea3cbb079 fix: Mise à jour correcte du fichier après avoir été signé 2025-04-27 19:22:59 +02:00
db8e1d8ab3 fix: Lors de la création d'un clone, on prend le nom de l'élève et pas
du responsable
2025-04-27 18:49:36 +02:00
92484804f6 fix: Ajout d'un message de confirmation lors de la suppression d'un
master template docuseal
2025-04-27 18:33:08 +02:00
be013f0786 fix: On empêche la sauvegarde d'un document à signer tant qu'aucun
dossier d'inscription n'est sélectionné
2025-04-27 18:17:23 +02:00
b23264c0d4 feat: Gestion de la validation du dossier d'inscription 2025-04-27 16:34:41 +02:00
ada2a44c3e fix: Ajout de l'établissement dans la requête KPI récupérant les
inscriptions en attente
2025-04-27 15:01:44 +02:00
7f442b9cae feat: Ajout d'un composant permettant de visualiser les fichiers signés
par les parents
2025-04-27 14:56:57 +02:00
545349c7db feat: Ajout d'un nouvel état dans l'automatique lorsqu'un mandat SEPA
doit être envoyé aux parent
2025-04-27 13:40:48 +02:00
3c62cc9ad2 fix: On commence à la page 1 2025-04-27 09:50:10 +02:00
001a5bc83c fix: Application du formattage sur les fichiers modifiés 2025-04-27 09:48:28 +02:00
905b95f3a3 feat: Gestion des documents signés durant l'inscription / possibilité de
visualiser un document signer
2025-04-26 18:05:00 +02:00
daad12cf40 feat: Ordonnancement de l'inscription sur plusieurs pages + contrôle des
champs remplis dans le formulaire
2025-04-26 16:43:25 +02:00
1617b132c4 fix: conflits + closeModal lors de la création d'un RF 2025-04-26 10:37:28 +02:00
10f66c69dd Merge remote-tracking branch 'origin/WIP_Inscriptions' into develop 2025-04-25 11:27:34 +02:00
9f1f97e0c5 fix: Utilisation des bonnes colonnes pour les fees et discounts selon si
on est dans le mode subscription ou non (fix d'une régression)
2025-04-24 18:35:58 +02:00
8417d3eb14 feat: Upload du SEPA par les parents / Création d'un composant header
pour les titres de tableau
2025-04-20 19:19:27 +02:00
59aee80c2e feat: Gestion des documents parent 2025-04-17 19:06:28 +02:00
2e0fe86c71 fix: correction des redirections vers la login page 2025-04-17 17:23:17 +02:00
7564865d8f refactor: Document Ecole/Parent 2025-04-17 16:20:48 +02:00
f7666c894b chore: application prettier 2025-04-15 19:41:42 +02:00
a65bd47905 feat: Gestion des pièces à fournir par les parents (configuration école) 2025-04-15 18:00:58 +02:00
dd0884bbce fix: Correction des Protected Routes avec multi role 2025-04-14 18:53:35 +02:00
89b01b79db feat: Création d'un profile selector [#37,#38] 2025-04-12 16:07:30 +02:00
4c2e2f8756 feat: Ajout d'un nouveau status avec envoi de mandat SEPA + envoi de
mail
2025-04-11 20:02:03 +02:00
4f774c18e4 fix: Correction de l'affichage des numéros de téléphone [#41] 2025-04-11 16:59:15 +02:00
a157d53932 fix: correction de l'ouverture du dashbord [#39] 2025-04-07 20:09:10 +02:00
839a26257b fix: correction des refresh des protected routes [#36] 2025-04-07 17:44:51 +02:00
5a7661db93 feat: Ajout de la sélection des modes de paiements / refactoring de
l'automate
2025-04-06 20:45:41 +02:00
9559db59eb feat: Affichage d'icones dans le tableau des inscriptions dans la
colonne actions
2025-03-31 20:24:05 +02:00
d6edf250bb feat: Gestion de la sauvegarde du fichier d'inscription / affichage du
fichier avec le bon nom / possibilité de refuser un DI
2025-03-31 20:13:10 +02:00
e0bfd3e115 feat: Gestion des profils ADMIN/ECOLE (création des enseignants) 2025-03-22 17:20:49 +01:00
c9350a796b 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
2025-03-22 12:28:12 +01:00
43ed495a9a Merge pull request 'Merge suite à un push sur la mauvaise branche' (#47) from refactoring into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/47
2025-03-21 16:01:42 +00:00
3bcc620ee1 feat: Ajout d'une fonction de dissociation entre un responsable et un
élève
2025-03-20 20:28:12 +01:00
e38604efd6 Merge pull request 'refactoring' (#46) from refactoring into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/46
2025-03-19 11:55:47 +00:00
fb73f9e9a8 feat: Gestion de la création d'un nouveau guardian, de l'association
avec un guardian dumême établissement, et de l'association avec un
guardian d'un autre établissement
2025-03-18 21:06:44 +01:00
173ac47fb2 feat: Mise à jour des Teacher 2025-03-16 19:33:07 +01:00
a3182c0ba7 feat: Aussi pour la table des parents tant qu'à faire 2025-03-14 21:27:29 +01:00
91976157e4 feat: création d'une tooltip pour les informations supplémentaires de
l'enseignant
2025-03-14 21:21:12 +01:00
6bd5704983 feat: Création d'un annuaire / mise à jour du subscribe 2025-03-14 19:51:35 +01:00
cd9c10a88a fix: Suite du commit précédent 2025-03-12 14:21:35 +01:00
ccecd78704 fix: On attend que la session soit mise à jour pour intiialiser le
contexte
2025-03-12 14:10:18 +01:00
7d1b9c5657 feat: Gestion des rattachements de Guardian à des RF déjà existants 2025-03-12 14:04:35 +01:00
753a8d647e feat: Refactoring de la fonction de création de profil sur guardian côté
FRONT
2025-03-11 21:58:09 +01:00
023b46e16e feat: Suppression des localStorage 2025-03-09 16:44:37 +01:00
16178296ec feat: Gestion multi-profil multi-école 2025-03-09 16:22:28 +01:00
95c154a4a2 refactor: Augmentation du nombre de données 2025-03-06 20:38:09 +01:00
f2ad1de5a4 feat: Utilisation de l'établissement en variable de session / gestion de
la page des inscriptions en fonction de l'établissement / mise à jour du
mock_data à l'init
2025-03-06 20:08:16 +01:00
c03fa0ba42 feat: Ajout des évenements à venir 2025-03-02 16:08:26 +01:00
c9b0f0d77a feat: planning events 2025-03-02 15:41:58 +01:00
e3879f516b feat: Gestion des documents nécessitant des signatures électroniques et
ceux ne nécessitant pas les signatures électroniques [#22]
2025-03-02 12:35:53 +01:00
2ac4832985 feat: A la signature d'un document, on récupère l'URL du PDF [#22] 2025-03-01 23:55:48 +01:00
eb81bbba92 feat: Nommage des templates / Intégration dans formulaire d'inscription
parent [#22]
2025-03-01 22:08:00 +01:00
b52b265835 refacto: Mise à jour des espacements intercomposants pour les pages logins 2025-03-01 21:35:51 +01:00
cac1519bf3 fix: correction titre mail reset mdp 2025-03-01 18:02:43 +01:00
f3490a4e95 refactor: gestion des erreurs 2025-03-01 17:52:47 +01:00
d1a0067f7b feat: Création de clones lors de la création de RF [#22] 2025-03-01 17:50:54 +01:00
b97cf6e02b feat: passage des mail au format HTML 2025-03-01 16:09:05 +01:00
9b13d52e8d fix: Récupération d'un template donné 2025-03-01 16:05:16 +01:00
085c086ec7 chore: Création d'un mock data de 50 RF 2025-03-01 15:41:12 +01:00
081dc06001 feat: Suppression des templates docuseal [#22] 2025-03-01 15:06:57 +01:00
4b8f85e68d refactor: adaptation mobile 2025-03-01 14:54:25 +01:00
d62be6b309 chore: restauration des fichiers 2025-03-01 11:00:09 +01:00
dce2114a79 fix: correction fileGroup lors de l'enregistrement d'un nouveau responsable 2025-03-01 10:30:44 +01:00
c8c8941ec8 feat: Signatures électroniques docuseal [#22] 2025-02-28 18:30:18 +01:00
8897d523dc feat: Dockerisation d'un serveur docuseal + initialisation d'un compte
par défaut
2025-02-23 21:11:45 +01:00
445cf35382 chore: merge conflicts 2025-02-23 19:08:13 +01:00
1911f79f45 feat: Pre cablage du dashboard [#] 2025-02-22 17:06:11 +01:00
c7723eceee refactor: changement de la philosophie de logging 2025-02-22 15:28:20 +01:00
508847940c refactor: Creation d'un provider et d'un systeme de middleware 2025-02-22 13:05:01 +01:00
c861239d48 chore: ajustement JWT 2025-02-22 10:52:50 +01:00
eb89a324ab refactor: Deplacement du JWT dans le back 2025-02-21 19:22:33 +01:00
214fb186fa chore: Création d'un RF de test au démarrage avec Template de documents 2025-02-21 15:05:48 +01:00
de86e923cf Merge branch 'refactoring' of ssh://git.v0id.ovh:5022/n3wt-innov/n3wt-school into refactoring 2025-02-17 18:37:47 +01:00
c2bba1abbf feat: Ajout d'une fonction de logout 2025-02-17 18:37:18 +01:00
0ef6a2b119 feat: Ajout d'une fonction de logout 2025-02-17 18:18:52 +01:00
8ea68bbad0 feat: Suite de la gestion des sessions 2025-02-17 16:11:15 +01:00
65d5b8c424 fix: build error 2025-02-17 11:18:28 +01:00
fc337b1e0b fix: inject env var 2025-02-17 10:55:27 +01:00
44c28d0aa0 Merge branch 'refactoring' of ssh://git.v0id.ovh:5022/n3wt-innov/n3wt-school into refactoring 2025-02-17 09:26:56 +01:00
ef1b036dcc chore: WIP uilisant d'un CSRF global à l'appli 2025-02-17 09:26:12 +01:00
05f1f16727 fix: right 2025-02-16 15:03:55 +01:00
a041ffaee7 fix: entrypoint access right 2025-02-16 14:38:13 +01:00
aae5d27d8c refactor: Injection des env var dans le frontend 2025-02-16 14:23:41 +01:00
cccb5efa2c Merge branch 'refactoring' of ssh://git.v0id.ovh:5022/n3wt-innov/n3wt-school into refactoring 2025-02-16 11:11:06 +01:00
f9e870e11f feat: Passage par une variable d'environnement pour les CORS et CSRF 2025-02-16 11:10:06 +01:00
2d128aaf30 refactor: Modification de la construction docker 2025-02-16 11:07:19 +01:00
32a77c780a feat: Mise à jour des Dockerfile préparation d'un environnement de démo [#12] 2025-02-15 15:16:10 +01:00
aef6c193b1 fix: remove lint error 2025-02-15 14:06:06 +01:00
d9655772b4 Revert "fix: application des recommandations linter es pour générer un build de prod"
This reverts commit d1aa8b54fb.
2025-02-15 13:41:06 +01:00
a571d80482 chore: séparation des gitignores 2025-02-15 13:10:06 +01:00
d1aa8b54fb fix: application des recommandations linter es pour générer un build de prod 2025-02-15 13:02:16 +01:00
9716373fa2 feat: preparation du dockerfile pour le frontend [#13] 2025-02-15 13:01:02 +01:00
ee7fef01ce Merge branch 'refactoring' of ssh://git.v0id.ovh:5022/n3wt-innov/n3wt-school into refactoring 2025-02-13 22:00:45 +01:00
4c95b6a83f refactor: Mise à jour de la doc swagger / URL 2025-02-13 22:00:19 +01:00
11fc446b90 docs: mise à jour de la doc swagger 2025-02-13 18:10:36 +01:00
cce78355a3 chore: commit qui sert à rien 2025-02-13 17:13:31 +01:00
9bf9c5f62d refactor: Modification de l'url de l'api Auth 2025-02-13 17:06:09 +01:00
8b3f9637a9 fix: Nouvelle amélioration 2025-02-12 19:01:47 +01:00
efcc5e6672 fix: Remise du message de confirmation supprimé par erreur 2025-02-12 18:50:47 +01:00
2576d21734 fix: gestion du jour d'échéance 2025-02-12 18:46:55 +01:00
d3f1ae3d11 chore: Ajout d'un mode test au lancement du serveur pour ajouter des
datas de test
2025-02-12 17:44:28 +01:00
0c5e3aa098 feat: Ajout des modes de paiements + création d'une commande dans le
back permettant d'initialiser des données de test (pour les tarifs)
2025-02-12 15:13:15 +01:00
23203c0397 feat: Mise en place des paiements en plusieurs fois (partie BACK) [#25] 2025-02-11 20:48:11 +01:00
ffc6ce8de8 feat: Ajout des Bundles de fichiers [#24] 2025-02-10 18:35:24 +01:00
fb7fbaf839 feat: Ajout du suivi de version dans le footer du Front 2025-02-10 08:58:02 +01:00
274db249aa feat: Mise en place des paiements en plusieurs fois - partie BACK [#25] 2025-02-09 19:20:43 +01:00
c269b89d3d fix: Calcul du montant total des tarif par RF + affichage des tarifs
actifs lors de la création d'un RF [#26]
2025-02-07 20:36:02 +01:00
f2628bb45a fix: Ajout du % ou € en mode édition de réduction 2025-02-07 17:55:53 +01:00
7f35527649 fix: gestion des codes retours 2025-02-07 17:40:30 +01:00
42b4c99be8 fix: Champs requis sur les teachers and classes 2025-02-07 16:43:29 +01:00
1a8ef26f58 refactor: Refactoring de la section ClassSection 2025-02-07 08:01:47 +01:00
72dd7699d6 refactor: SpecialitySection + TeacherSection (en cours) 2025-01-31 15:41:23 +01:00
a248898203 refactor: Changement des IconTextInput en TextInput, modification du composant step 2025-01-27 11:20:44 +01:00
6f1631a75b Merge branch 'refactoring' of ssh://git.v0id.ovh:5022/n3wt-innov/n3wt-school into refactoring 2025-01-26 22:49:06 +01:00
cb3f909fa4 refactor: Revue de la modale permettant de créer un dossier
d'inscription
2025-01-26 22:48:05 +01:00
665625e028 refactor: Revue de la modale permettant de créer un dossier
d'inscription
2025-01-26 15:43:11 +01:00
1c4d96d4c3 Merge branch 'refactoring' of ssh://git.v0id.ovh:5022/n3wt-innov/n3wt-school into refactoring 2025-01-25 18:01:31 +01:00
0c2e0b92f4 feat: Ajout des frais de scolarité dans le dossier d'inscription [#18] 2025-01-25 17:23:15 +01:00
ece23deb19 feat: Ajout des frais d'inscription lors de la création d'un RF [#18] 2025-01-25 16:40:08 +01:00
b8ef34a04b feat: ajout des documents d'inscription [#20] 2025-01-25 12:34:40 +01:00
799e1c6717 feat: Sortie des calculs des montants totaux de la partie configuration + revue du rendu [#18] 2025-01-23 20:00:17 +01:00
5462306a60 feat: Harmonisation des fees / ajout de type de réduction / mise à jour
du calcul [#18]
2025-01-21 20:39:36 +01:00
8d1a41e269 feat: Mise à jour du modèle (possibilité d'associer une réduciton à un
frais d'inscription [#18]
2025-01-20 20:42:51 +01:00
5a0e65bb75 feat: Ajout de la configuration des tarifs de l'école [#18] 2025-01-20 13:45:14 +01:00
147a70135d refactor: Transformation des requetes vers le back en action ajout des
erreurs 400 et refresh lors d'un envoie de formulaire
2025-01-13 14:21:44 +01:00
c4d45426b5 refactor: je suis une merde 2025-01-12 16:47:40 +01:00
24352efad3 refactor: Partie FRONT / School 2025-01-12 16:45:41 +01:00
41aa9d55d3 refactor: Refactorisation du login et de admin/subscription 2025-01-12 16:34:32 +01:00
427b6c7588 refactor: refactoring du FRONT page subscribe 2025-01-12 14:37:49 +01:00
58fe509734 refactor: Partie "School" 2025-01-12 13:18:34 +01:00
83f4d67a6f refactor: "registerFilesTemplates" -> "registrerFileTemplate" 2025-01-12 10:36:16 +01:00
2b414b8391 refactor: Traduction en anglais des modules "GestionInscription" et
"GestionLogin"
2025-01-12 10:07:06 +01:00
830d9a48c0 feat: Configuration et gestion du planning [#2] 2025-01-11 19:37:29 +01:00
3c27133cdb feat: Ajout de la gestion des fichier d'inscription [#1] 2025-01-11 16:14:03 +01:00
fb5d485ce1 Merge branch 'develop' 2024-12-16 10:20:17 +01:00
7acae479da refactor: Création de nouveaux composants / update formulaire de
création de classe (#2)
2024-12-14 15:28:07 +01:00
cf144310a1 fix: Mise en page des inscriptions (boutons ajout / barre de recherche) 2024-11-27 18:34:27 +01:00
85d4c007cb feat: Evolution des modèles pour intégrer un planning et du m2m 2024-11-27 18:11:28 +01:00
e1202c6e6d fix: Utilisation du signal "post-migrate" pour créer la spécialité par
défaut
2024-11-27 16:43:06 +01:00
d51778ba54 refactor: Création de composants et uniformisation des modales (#2) 2024-11-24 18:42:42 +01:00
5946cbdee6 fix: Correction sur le calcul du nombre total de pages [#1] 2024-11-24 15:30:30 +01:00
a77dd8ec64 feat: Ajout de l'option d'envoi automatique [#1] 2024-11-24 14:58:54 +01:00
afc1632797 fix: Correction de la désactivation des spécialités lorsqu'on
sélectionne un enseignant [#2]
2024-11-24 12:51:32 +01:00
692e8454bf refactor: Renommage du menu "Eleves" en "Inscriptions" 2024-11-24 12:06:01 +01:00
56e27628f8 refactor: Composant *InscriptionForm* 2024-11-24 11:55:26 +01:00
edc97242f2 fix: Gestion des listes d'inscription "vides" [#1] 2024-11-24 10:25:36 +01:00
81d1dfa9a7 feat: Gestion des profils des enseignants / Visualisation d'une classe [#4] 2024-11-23 20:02:51 +01:00
b8511f94b6 feat(backend): Ajout du logger django [#7] 2024-11-18 16:21:24 +01:00
472 changed files with 68102 additions and 9927 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

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

10
.gitignore vendored
View File

@ -1,9 +1,5 @@
Back-End/*/Configuration/application.json
.venv/ .venv/
__pycache__/ .env
node_modules/ node_modules/
Back-End/*/migrations/* hardcoded-strings-report.md
Back-End/documents backend.env
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 #node scripts/prepare-commit-msg.js "$1" "$2"
. "$(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

View File

@ -3,5 +3,5 @@ from django.db.models.signals import post_migrate
class GestionloginConfig(AppConfig): class GestionloginConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'GestionLogin' name = 'Auth'

View File

@ -1,20 +1,20 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from GestionLogin.models import Profil from Auth.models import Profile
from N3wtSchool import bdd from N3wtSchool import bdd
class EmailBackend(ModelBackend): class EmailBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs): def authenticate(self, request, username=None, password=None, **kwargs):
if username is None: if username is None:
username = kwargs.get(Profil.USERNAME_FIELD) username = kwargs.get(Profile.USERNAME_FIELD)
try: try:
user = Profil.objects.get(email=username) user = Profile.objects.get(email=username)
# Vérifie le mot de passe de l'utilisateur # Vérifie le mot de passe de l'utilisateur
if user.check_password(password): if user.check_password(password):
return user return user
except Profil.DoesNotExist: except Profile.DoesNotExist:
return None return None

View File

@ -0,0 +1,75 @@
# Generated by Django 5.1.3 on 2025-11-30 11:02
import django.contrib.auth.models
import django.contrib.auth.validators
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('Establishment', '0001_initial'),
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='ProfileRole',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role_type', models.IntegerField(choices=[(-1, 'NON DEFINI'), (0, 'ECOLE'), (1, 'ADMIN'), (2, 'PARENT')], default=-1)),
('is_active', models.BooleanField(default=False)),
('updated_date', models.DateTimeField(auto_now=True)),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_roles', to='Establishment.establishment')),
],
),
migrations.CreateModel(
name='Directeur',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('last_name', models.CharField(max_length=100)),
('first_name', models.CharField(max_length=100)),
('profile_role', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='directeur_profile', to='Auth.profilerole')),
],
),
migrations.CreateModel(
name='Profile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(default='', max_length=255, unique=True, validators=[django.core.validators.EmailValidator()])),
('roleIndexLoginDefault', models.IntegerField(default=0)),
('code', models.CharField(blank=True, default='', max_length=200)),
('datePeremption', models.CharField(blank=True, default='', max_length=200)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.AddField(
model_name='profilerole',
name='profile',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roles', to=settings.AUTH_USER_MODEL),
),
]

View File

40
Back-End/Auth/models.py Normal file
View File

@ -0,0 +1,40 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.core.validators import EmailValidator
class Profile(AbstractUser):
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)
def __str__(self):
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, blank=True)
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

@ -2,10 +2,10 @@ from rest_framework.pagination import PageNumberPagination
from N3wtSchool import settings from N3wtSchool import settings
class CustomPagination(PageNumberPagination): class CustomProfilesPagination(PageNumberPagination):
page_size_query_param = 'page_size' page_size_query_param = 'page_size'
max_page_size = settings.NB_MAX_PAGE max_page_size = settings.NB_MAX_PAGE
page_size = settings.NB_RESULT_PER_PAGE page_size = settings.NB_RESULT_PROFILES_PER_PAGE
def get_paginated_response(self, data): def get_paginated_response(self, data):
return ({ return ({
@ -16,5 +16,5 @@ class CustomPagination(PageNumberPagination):
'count': self.page.paginator.count, 'count': self.page.paginator.count,
'page_size': self.page_size, 'page_size': self.page_size,
'max_page_size' : self.max_page_size, 'max_page_size' : self.max_page_size,
'fichesInscriptions': data } 'profilesRoles': data }
) )

View File

@ -0,0 +1,160 @@
from rest_framework import serializers
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', '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'],
code=validated_data.get('code', ''),
datePeremption=validated_data.get('datePeremption', '')
)
user.set_password(validated_data['password'])
user.full_clean()
user.save()
return user
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.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")

22
Back-End/Auth/urls.py Normal file
View File

@ -0,0 +1,22 @@
from django.urls import path, re_path
from . import views
import Auth.views
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/(?P<code>[a-zA-Z]+)$', ResetPasswordView.as_view(), name='resetPassword'),
re_path(r'^infoSession$', SessionView.as_view(), name='infoSession'),
re_path(r'^profiles$', ProfileView.as_view(), name="profile"),
re_path(r'^profiles/(?P<id>[0-9]+)$', ProfileSimpleView.as_view(), name="profile"),
re_path(r'^profileRoles$', ProfileRoleView.as_view(), name="profileRoles"),
re_path(r'^profileRoles/(?P<id>[0-9]+)$', ProfileRoleSimpleView.as_view(), name="profileRoles"),
]

668
Back-End/Auth/views.py Normal file
View File

@ -0,0 +1,668 @@
from django.conf import settings
from django.contrib.auth import login, authenticate, get_user_model
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.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 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, ProfileRole
from rest_framework.decorators import action, api_view
from django.db.models import Q
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 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):
@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'])
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,
'roleIndexLoginDefault': user.roleIndexLoginDefault,
'roles': list(roles)
}
}
return JsonResponse(response_data, status=status.HTTP_200_OK)
except jwt.ExpiredSignatureError:
return JsonResponse({"error": "Token has expired"}, status=status.HTTP_401_UNAUTHORIZED)
except jwt.InvalidTokenError:
return JsonResponse({"error": "Invalid token"}, status=status.HTTP_401_UNAUTHORIZED)
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)
@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)
profil_serializer = ProfileSerializer(data=profil_data)
if profil_serializer.is_valid():
profil = profil_serializer.save()
return JsonResponse(profil_serializer.data, 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):
@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)
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:
# 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)
else:
retour = error.returnMessage[error.WRONG_ID]
return JsonResponse({
'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__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):
@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 = ''
retour = ''
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 is None:
retourErreur = error.returnMessage[error.PROFIL_NOT_EXISTS]
else:
# 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.full_clean()
profil.save()
# 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)
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)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class NewPasswordView(APIView):
@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 = ''
retour = ''
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 is None:
retourErreur = error.returnMessage[error.PROFIL_NOT_EXISTS]
else:
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)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class ResetPasswordView(APIView):
@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)
validatorResetPassword = validator.ValidatorResetPassword(data=newProfilConnection)
validationOk, errorFields = validatorResetPassword.validate()
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]
elif validationOk:
retour = error.returnMessage[error.PASSWORD_CHANGED]
profil.set_password(newProfilConnection.get('password1'))
profil.code = ''
profil.datePeremption = ''
profil.save()
retourErreur = ''
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

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-11-30 11:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Cycle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('number', models.IntegerField(unique=True)),
('label', models.CharField(max_length=50)),
],
),
migrations.CreateModel(
name='Domain',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('cycle', models.IntegerField(choices=[(1, 'Cycle 1'), (2, 'Cycle 2'), (3, 'Cycle 3'), (4, 'Cycle 4')])),
],
),
migrations.CreateModel(
name='PaymentModeType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50, unique=True)),
('label', models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='PaymentPlanType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50, unique=True)),
('label', models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='Common.domain')),
],
),
migrations.CreateModel(
name='Level',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='levels', to='Common.cycle')),
],
),
]

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

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 # The first instruction is what image we want to base our container on
# We Use an official Python runtime as a parent image # We Use an official Python runtime as a parent image
FROM python:3.12.7 FROM python:3.12.7
WORKDIR /Back-End
# Allows docker to cache installed dependencies between builds # Allows docker to cache installed dependencies between builds
COPY requirements.txt requirements.txt COPY requirements.txt requirements.txt
@ -10,11 +11,5 @@ RUN pip install -r requirements.txt
# Mounts the application code to the image # Mounts the application code to the image
COPY . . COPY . .
WORKDIR /Back-End
EXPOSE 8080 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 @@
default_app_config = 'Establishment.apps.EstablishmentConfig'

View File

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

View File

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

View File

@ -0,0 +1,31 @@
# Generated by Django 5.1.3 on 2025-11-30 11:02
import Establishment.models
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Establishment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('address', models.CharField(max_length=255)),
('total_capacity', models.IntegerField()),
('establishment_type', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(choices=[(1, 'Maternelle'), (2, 'Primaire'), (3, 'Secondaire')]), size=None)),
('evaluation_frequency', models.IntegerField(choices=[(1, 'Trimestre'), (2, 'Semestre'), (3, 'Année')], default=1)),
('licence_code', models.CharField(blank=True, max_length=100)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('logo', models.FileField(blank=True, null=True, upload_to=Establishment.models.registration_logo_upload_to)),
],
),
]

View File

@ -0,0 +1,37 @@
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)
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

@ -1 +0,0 @@
default_app_config = 'GestionEnseignants.apps.GestionenseignantsConfig'

View File

@ -1,32 +0,0 @@
from django.db import models
class Specialite(models.Model):
nom = models.CharField(max_length=100)
dateCreation = models.DateTimeField(auto_now=True)
codeCouleur = models.CharField(max_length=7, default='#FFFFFF')
def __str__(self):
return self.nom
class Enseignant(models.Model):
nom = models.CharField(max_length=100)
prenom = models.CharField(max_length=100)
mail = models.EmailField(unique=True)
specialite = models.ForeignKey(Specialite, on_delete=models.SET_NULL, null=True, blank=True, related_name='enseignants')
def __str__(self):
return f"{self.nom} {self.prenom}"
class Classe(models.Model):
nom_ambiance = models.CharField(max_length=255)
tranche_age = models.JSONField()
nombre_eleves = models.PositiveIntegerField()
langue_enseignement = models.CharField(max_length=255)
annee_scolaire = models.CharField(max_length=9)
dateCreation = models.DateTimeField(auto_now_add=True)
specialites = models.ManyToManyField(Specialite, related_name='classes')
enseignant_principal = models.ForeignKey(Enseignant, on_delete=models.SET_NULL, null=True, blank=True, related_name='classes_principal')
def __str__(self):
return self.nom_ambiance

View File

@ -1,83 +0,0 @@
from rest_framework import serializers
from .models import Enseignant, Specialite, Classe
from N3wtSchool import settings
from django.utils import timezone
import pytz
class SpecialiteSerializer(serializers.ModelSerializer):
dateCreation_formattee = serializers.SerializerMethodField()
class Meta:
model = Specialite
fields = '__all__'
def get_dateCreation_formattee(self, obj):
utc_time = timezone.localtime(obj.dateCreation) # Convertir en heure locale
local_tz = pytz.timezone(settings.TZ_APPLI)
local_time = utc_time.astimezone(local_tz)
return local_time.strftime("%d-%m-%Y %H:%M")
class ClasseSerializer(serializers.ModelSerializer):
specialites = SpecialiteSerializer(many=True, read_only=True)
specialites_ids = serializers.PrimaryKeyRelatedField(queryset=Specialite.objects.all(), many=True, source='specialites')
dateCreation_formattee = serializers.SerializerMethodField()
enseignant_principal = serializers.SerializerMethodField()
enseignant_principal_id = serializers.PrimaryKeyRelatedField(queryset=Enseignant.objects.all(), source='enseignant_principal', write_only=False, read_only=False)
class Meta:
model = Classe
fields = ['id', 'nom_ambiance', 'tranche_age', 'nombre_eleves', 'langue_enseignement', 'specialites', 'specialites_ids', 'enseignant_principal', 'enseignant_principal_id', 'annee_scolaire', 'dateCreation', 'dateCreation_formattee']
def get_enseignant_principal(self, obj):
from .serializers import EnseignantDetailSerializer
if obj.enseignant_principal:
return EnseignantDetailSerializer(obj.enseignant_principal).data
return None
def create(self, validated_data):
specialites_data = validated_data.pop('specialites', [])
classe = Classe.objects.create(**validated_data)
classe.specialites.set(specialites_data)
return classe
def update(self, instance, validated_data):
specialites_data = validated_data.pop('specialites', [])
instance.nom_ambiance = validated_data.get('nom_ambiance', instance.nom_ambiance)
instance.tranche_age = validated_data.get('tranche_age', instance.tranche_age)
instance.nombre_eleves = validated_data.get('nombre_eleves', instance.nombre_eleves)
instance.langue_enseignement = validated_data.get('langue_enseignement', instance.langue_enseignement)
instance.annee_scolaire = validated_data.get('annee_scolaire', instance.annee_scolaire)
instance.enseignant_principal = validated_data.get('enseignant_principal', instance.enseignant_principal)
instance.save()
instance.specialites.set(specialites_data)
return instance
def get_dateCreation_formattee(self, obj):
utc_time = timezone.localtime(obj.dateCreation) # Convertir en heure locale
local_tz = pytz.timezone(settings.TZ_APPLI)
local_time = utc_time.astimezone(local_tz)
return local_time.strftime("%d-%m-%Y %H:%M")
class EnseignantSerializer(serializers.ModelSerializer):
specialite = SpecialiteSerializer(read_only=True)
specialite_id = serializers.PrimaryKeyRelatedField(queryset=Specialite.objects.all(), source='specialite', write_only=False, read_only=False)
classes_principal = ClasseSerializer(many=True, read_only=True)
class Meta:
model = Enseignant
fields = ['id', 'nom', 'prenom', 'mail', 'specialite', 'specialite_id', 'classes_principal']
def create(self, validated_data):
specialite = validated_data.pop('specialite', None)
enseignant = Enseignant.objects.create(**validated_data)
enseignant.specialite = specialite
enseignant.save()
return enseignant
class EnseignantDetailSerializer(serializers.ModelSerializer):
specialite = SpecialiteSerializer(read_only=True)
class Meta:
model = Enseignant
fields = ['id', 'nom', 'prenom', 'mail', 'specialite']

View File

@ -1,17 +0,0 @@
from django.urls import path, re_path
from GestionEnseignants.views import EnseignantsView, EnseignantView, SpecialitesView, SpecialiteView, ClassesView, ClasseView
urlpatterns = [
re_path(r'^enseignants$', EnseignantsView.as_view(), name="enseignants"),
re_path(r'^enseignant$', EnseignantView.as_view(), name="enseignant"),
re_path(r'^enseignant/([0-9]+)$', EnseignantView.as_view(), name="enseignant"),
re_path(r'^specialites$', SpecialitesView.as_view(), name="specialites"),
re_path(r'^specialite$', SpecialiteView.as_view(), name="specialite"),
re_path(r'^specialite/([0-9]+)$', SpecialiteView.as_view(), name="specialite"),
re_path(r'^classes$', ClassesView.as_view(), name="classes"),
re_path(r'^classe$', ClasseView.as_view(), name="classe"),
re_path(r'^classe/([0-9]+)$', ClasseView.as_view(), name="classe"),
]

View File

@ -1,180 +0,0 @@
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 django.core.cache import cache
from .models import Enseignant, Specialite, Classe
from .serializers import EnseignantSerializer, SpecialiteSerializer, ClasseSerializer
from N3wtSchool import bdd
class EnseignantsView(APIView):
def get(self, request):
enseignantsList=bdd.getAllObjects(Enseignant)
enseignants_serializer=EnseignantSerializer(enseignantsList, many=True)
return JsonResponse(enseignants_serializer.data, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class EnseignantView(APIView):
def get (self, request, _id):
enseignant = bdd.getObject(_objectName=Enseignant, _columnName='id', _value=_id)
enseignant_serializer=EnseignantSerializer(enseignant)
return JsonResponse(enseignant_serializer.data, safe=False)
def post(self, request):
enseignant_data=JSONParser().parse(request)
enseignant_serializer = EnseignantSerializer(data=enseignant_data)
if enseignant_serializer.is_valid():
enseignant_serializer.save()
return JsonResponse(enseignant_serializer.data, safe=False)
return JsonResponse(enseignant_serializer.errors, safe=False)
def put(self, request, _id):
enseignant_data=JSONParser().parse(request)
enseignant = bdd.getObject(_objectName=Enseignant, _columnName='id', _value=_id)
enseignant_serializer = EnseignantSerializer(enseignant, data=enseignant_data)
if enseignant_serializer.is_valid():
enseignant_serializer.save()
return JsonResponse(enseignant_serializer.data, safe=False)
return JsonResponse(enseignant_serializer.errors, safe=False)
def delete(self, request, _id):
enseignant = bdd.getObject(_objectName=Enseignant, _columnName='id', _value=_id)
if enseignant != None:
enseignant.delete()
return JsonResponse("La suppression de la spécialité a été effectuée avec succès", safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class SpecialitesView(APIView):
def get(self, request):
specialitesList=bdd.getAllObjects(Specialite)
specialites_serializer=SpecialiteSerializer(specialitesList, many=True)
return JsonResponse(specialites_serializer.data, safe=False)
def post(self, request):
specialites_data=JSONParser().parse(request)
all_valid = True
for specialite_data in specialites_data:
specialite_serializer = SpecialiteSerializer(data=specialite_data)
if specialite_serializer.is_valid():
specialite_serializer.save()
else:
all_valid = False
break
if all_valid:
specialitesList = bdd.getAllObjects(Specialite)
specialites_serializer = SpecialiteSerializer(specialitesList, many=True)
return JsonResponse(specialite_serializer.errors, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class SpecialiteView(APIView):
def get (self, request, _id):
specialite = bdd.getObject(_objectName=Specialite, _columnName='id', _value=_id)
specialite_serializer=SpecialiteSerializer(specialite)
return JsonResponse(specialite_serializer.data, safe=False)
def post(self, request):
specialite_data=JSONParser().parse(request)
specialite_serializer = SpecialiteSerializer(data=specialite_data)
if specialite_serializer.is_valid():
specialite_serializer.save()
return JsonResponse(specialite_serializer.data, safe=False)
return JsonResponse(specialite_serializer.errors, safe=False)
def put(self, request, _id):
specialite_data=JSONParser().parse(request)
specialite = bdd.getObject(_objectName=Specialite, _columnName='id', _value=_id)
specialite_serializer = SpecialiteSerializer(specialite, data=specialite_data)
if specialite_serializer.is_valid():
specialite_serializer.save()
return JsonResponse(specialite_serializer.data, safe=False)
return JsonResponse(specialite_serializer.errors, safe=False)
def delete(self, request, _id):
specialite = bdd.getObject(_objectName=Specialite, _columnName='id', _value=_id)
if specialite != None:
specialite.delete()
return JsonResponse("La suppression de la spécialité a été effectuée avec succès", safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class ClassesView(APIView):
def get(self, request):
classesList=bdd.getAllObjects(Classe)
classes_serializer=ClasseSerializer(classesList, many=True)
return JsonResponse(classes_serializer.data, safe=False)
def post(self, request):
all_valid = True
classes_data=JSONParser().parse(request)
for classe_data in classes_data:
classe_serializer = ClasseSerializer(data=classe_data)
if classe_serializer.is_valid():
classe_serializer.save()
else:
all_valid = False
break
if all_valid:
classesList = bdd.getAllObjects(Classe)
classes_serializer = ClasseSerializer(classesList, many=True)
return JsonResponse(classes_serializer.data, safe=False)
return JsonResponse(classe_serializer.errors, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class ClasseView(APIView):
def get (self, request, _id):
classe = bdd.getObject(_objectName=Classe, _columnName='id', _value=_id)
classe_serializer=ClasseSerializer(classe)
return JsonResponse(classe_serializer.data, safe=False)
def post(self, request):
classe_data=JSONParser().parse(request)
classe_serializer = ClasseSerializer(data=classe_data)
if classe_serializer.is_valid():
classe_serializer.save()
return JsonResponse(classe_serializer.data, safe=False)
return JsonResponse(classe_serializer.errors, safe=False)
def put(self, request, _id):
classe_data=JSONParser().parse(request)
classe = bdd.getObject(_objectName=Classe, _columnName='id', _value=_id)
classe_serializer = ClasseSerializer(classe, data=classe_data)
if classe_serializer.is_valid():
classe_serializer.save()
return JsonResponse(classe_serializer.data, safe=False)
return JsonResponse(classe_serializer.errors, safe=False)
def delete(self, request, _id):
classe = bdd.getObject(_objectName=Classe, _columnName='id', _value=_id)
if classe != None:
classe.delete()
return JsonResponse("La suppression de la classe a été effectuée avec succès", safe=False)

View File

@ -1,4 +0,0 @@
{
"mailFrom":"",
"password":""
}

View File

@ -1,63 +0,0 @@
{
"states": [
"ABSENT",
"CREE",
"ENVOYE",
"EN_VALIDATION",
"A_RELANCER",
"VALIDE",
"ARCHIVE"
],
"transitions": [
{
"name": "creationDI",
"from": "ABSENT",
"to": "CREE"
},
{
"name": "envoiDI",
"from": "CREE",
"to": "ENVOYE"
},
{
"name": "archiveDI",
"from": "CREE",
"to": "ARCHIVE"
},
{
"name": "saisiDI",
"from": "ENVOYE",
"to": "EN_VALIDATION"
},
{
"name": "relanceDI",
"from": "ENVOYE",
"to": "A_RELANCER"
},
{
"name": "archiveDI",
"from": "A_RELANCER",
"to": "ARCHIVE"
},
{
"name": "archiveDI",
"from": "ENVOYE",
"to": "ARCHIVE"
},
{
"name": "valideDI",
"from": "EN_VALIDATION",
"to": "VALIDE"
},
{
"name": "archiveDI",
"from": "EN_VALIDATION",
"to": "ARCHIVE"
},
{
"name": "archiveDI",
"from": "VALIDE",
"to": "ARCHIVE"
}
]
}

View File

@ -1,18 +0,0 @@
{
"activationMailRelance": "Oui",
"delaiRelance": "30",
"ambiances": [
"2-3 ans",
"3-6 ans",
"6-12 ans"
],
"genres": [
"Fille",
"Garçon"
],
"modesPaiement": [
"Chèque",
"Virement",
"Prélèvement SEPA"
]
}

View File

@ -1 +0,0 @@
default_app_config = 'GestionInscriptions.apps.GestionInscriptionsConfig'

View File

@ -1,45 +0,0 @@
# state_machine.py
import json
from GestionInscriptions.models import FicheInscription
from GestionInscriptions.signals import clear_cache
state_mapping = {
"ABSENT": FicheInscription.EtatDossierInscription.DI_ABSENT,
"CREE": FicheInscription.EtatDossierInscription.DI_CREE,
"ENVOYE": FicheInscription.EtatDossierInscription.DI_ENVOYE,
"EN_VALIDATION": FicheInscription.EtatDossierInscription.DI_EN_VALIDATION,
"A_RELANCER": FicheInscription.EtatDossierInscription.DI_A_RELANCER,
"VALIDE": FicheInscription.EtatDossierInscription.DI_VALIDE,
"ARCHIVE": FicheInscription.EtatDossierInscription.DI_ARCHIVE
}
def load_config(config_file):
with open(config_file, 'r') as file:
config = json.load(file)
return config
def getStateMachineObject(etat) :
return Automate_DI_Inscription(etat)
def getStateMachineObjectState(etat):
return Automate_DI_Inscription(etat).state
def updateStateMachine(di, transition) :
automateModel = load_config('GestionInscriptions/Configuration/automate.json')
state_machine = getStateMachineObject(di.etat)
print(f'etat DI : {state_machine.state}')
if state_machine.trigger(transition, automateModel):
di.etat = state_machine.state
di.save()
clear_cache()
class Automate_DI_Inscription:
def __init__(self, initial_state):
self.state = initial_state
def trigger(self, transition_name, config):
for transition in config["transitions"]:
if transition["name"] == transition_name and self.state == state_mapping[transition["from"]]:
self.state = state_mapping[transition["to"]]
return True
return False

View File

@ -1,74 +0,0 @@
from django.core.mail import send_mail
import re
from N3wtSchool import settings
def envoieReinitMotDePasse(recipients, code):
send_mail(
settings.EMAIL_REINIT_SUBJECT,
settings.EMAIL_REINIT_CORPUS%(str(code)),
settings.EMAIL_HOST_USER,
[recipients],
fail_silently=False,
)
def envoieDossierInscription(recipients):
errorMessage = ''
try:
print(f'{settings.EMAIL_HOST_USER}')
send_mail(
settings.EMAIL_INSCRIPTION_SUBJECT,
settings.EMAIL_INSCRIPTION_CORPUS%[recipients],
settings.EMAIL_HOST_USER,
[recipients],
fail_silently=False,
)
except Exception as e:
errorMessage = str(e)
return errorMessage
def envoieRelanceDossierInscription(recipients, code):
errorMessage = ''
try:
send_mail(
settings.EMAIL_RELANCE_SUBJECT,
settings.EMAIL_RELANCE_CORPUS%str(code),
settings.EMAIL_HOST_USER,
[recipients],
fail_silently=False,
)
except Exception as e:
errorMessage = str(e)
return errorMessage
def envoieSEPA(recipients, ref):
send_mail(
settings.EMAIL_SEPA_SUBJECT%str(ref),
settings.EMAIL_SEPA_CORPUS,
settings.EMAIL_HOST_USER,
[recipients],
fail_silently=False,
)
def isValid(message, fiche_inscription):
# Est-ce que la référence du dossier est VALIDE
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.getResponsablePrincipal()
mailReponsableAVerifier = responsable.mail
return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id)

View File

@ -1,123 +0,0 @@
from django.db import models
from django.utils.timezone import now
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from GestionLogin.models import Profil
class Langue(models.Model):
id = models.AutoField(primary_key=True)
libelle = models.CharField(max_length=200, default="")
def __str__(self):
return "LANGUE"
class Responsable(models.Model):
nom = models.CharField(max_length=200, default="")
prenom = models.CharField(max_length=200, default="")
dateNaissance = models.CharField(max_length=200, default="", blank=True)
adresse = models.CharField(max_length=200, default="", blank=True)
mail = models.CharField(max_length=200, default="", blank=True)
telephone = models.CharField(max_length=200, default="", blank=True)
profession = models.CharField(max_length=200, default="", blank=True)
profilAssocie = models.ForeignKey(Profil, on_delete=models.CASCADE)
def __str__(self):
return self.nom + "_" + self.prenom
class Frere(models.Model):
id = models.AutoField(primary_key=True)
nom = models.CharField(max_length=200, default="")
prenom = models.CharField(max_length=200, default="")
dateNaissance = models.CharField(max_length=200, default="", blank=True)
def __str__(self):
return "FRERE"
class Eleve(models.Model):
class GenreEleve(models.IntegerChoices):
NONE = 0, _('Sélection du genre')
MALE = 1, _('Garçon')
FEMALE = 2, _('Fille')
class NiveauEleve(models.IntegerChoices):
NONE = 0, _('Sélection du niveau')
TPS = 1, _('TPS - Très Petite Section')
PS = 2, _('PS - Petite Section')
MS = 3, _('MS - Moyenne Section')
GS = 4, _('GS - Grande Section')
class ModePaiement(models.IntegerChoices):
NONE = 0, _('Sélection du mode de paiement')
PRELEVEMENT_SEPA = 1, _('Prélèvement SEPA')
CHEQUE = 2, _('Chèque')
nom = models.CharField(max_length=200, default="")
prenom = models.CharField(max_length=200, default="")
genre = models.IntegerField(choices=GenreEleve, default=GenreEleve.NONE, blank=True)
niveau = models.IntegerField(choices=NiveauEleve, default=NiveauEleve.NONE, blank=True)
nationalite = models.CharField(max_length=200, default="", blank=True)
adresse = models.CharField(max_length=200, default="", blank=True)
dateNaissance = models.CharField(max_length=200, default="", blank=True)
lieuNaissance = models.CharField(max_length=200, default="", blank=True)
codePostalNaissance = models.IntegerField(default=0, blank=True)
medecinTraitant = models.CharField(max_length=200, default="", blank=True)
modePaiement = models.IntegerField(choices=ModePaiement, default=ModePaiement.NONE, blank=True)
# Relation N-N
profils = models.ManyToManyField(Profil, blank=True)
# Relation N-N
responsables = models.ManyToManyField(Responsable, blank=True)
# Relation N-N
freres = models.ManyToManyField(Frere, blank=True)
# Relation N-N
languesParlees = models.ManyToManyField(Langue, blank=True)
def __str__(self):
return self.nom + "_" + self.prenom
def getLanguesParlees(self):
return self.languesParlees.all()
def getResponsablePrincipal(self):
return self.responsables.all()[0]
def getResponsables(self):
return self.responsables.all()
def getProfils(self):
return self.profils.all()
def getFreres(self):
return self.freres.all()
def getNbFreres(self):
return self.freres.count()
class FicheInscription(models.Model):
class EtatDossierInscription(models.IntegerChoices):
DI_ABSENT = 0, _('Pas de dossier d\'inscription')
DI_CREE = 1, _('Dossier d\'inscription créé')
DI_ENVOYE = 2, _('Dossier d\'inscription envoyé')
DI_EN_VALIDATION = 3, _('Dossier d\'inscription en cours de validation')
DI_A_RELANCER = 4, _('Dossier d\'inscription à relancer')
DI_VALIDE = 5, _('Dossier d\'inscription validé')
DI_ARCHIVE = 6, _('Dossier d\'inscription archivé')
# Relation 1-1
eleve = models.OneToOneField(Eleve, on_delete=models.CASCADE, primary_key=True)
etat = models.IntegerField(choices=EtatDossierInscription, default=EtatDossierInscription.DI_ABSENT)
dateMAJ = models.DateTimeField(auto_now=True)
notes = models.CharField(max_length=200, blank=True)
codeLienInscription = models.CharField(max_length=200, default="", blank=True)
fichierInscription = models.FileField(upload_to=settings.DOCUMENT_DIR, default="", blank=True)
di_associe = models.CharField(max_length=200, default="", blank=True)
def __str__(self):
return "FI_" + self.eleve.nom + "_" + self.eleve.prenom

View File

@ -1,176 +0,0 @@
from rest_framework import serializers
from GestionInscriptions.models import FicheInscription, Eleve, Responsable, Frere, Langue
from GestionLogin.models import Profil
from GestionLogin.serializers import ProfilSerializer
from GestionMessagerie.models import Messagerie
from GestionNotification.models import Notification
from N3wtSchool import settings
from django.utils import timezone
import pytz
class LanguesSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
class Meta:
model = Langue
fields = '__all__'
class FrereSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
class Meta:
model = Frere
fields = '__all__'
class ResponsableSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
profil_associe = serializers.SerializerMethodField()
class Meta:
model = Responsable
fields = '__all__'
def get_profil_associe(self, obj):
return obj.profilAssocie.email
class EleveSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
responsables = ResponsableSerializer(many=True, required=False)
freres = FrereSerializer(many=True, required=False)
langues = LanguesSerializer(many=True, required=False)
class Meta:
model = Eleve
fields = '__all__'
def get_or_create_packages(self, responsables_data):
responsables_ids = []
for responsable_data in responsables_data:
responsable_instance, created = Responsable.objects.get_or_create( id=responsable_data.get('id'),
defaults=responsable_data)
responsables_ids.append(responsable_instance.id)
return responsables_ids
def create(self, validated_data):
responsables_data = validated_data.pop('responsables', [])
freres_data = validated_data.pop('freres', [])
langues_data = validated_data.pop('languesParlees', [])
eleve = Eleve.objects.create(**validated_data)
eleve.responsables.set(self.get_or_create_packages(responsables_data))
eleve.freres.set(self.get_or_create_packages(freres_data))
eleve.languesParlees.set(self.get_or_create_packages(langues_data))
return eleve
def create_or_update_packages(self, responsables_data):
responsables_ids = []
for responsable_data in responsables_data:
responsable_instance, created = Responsable.objects.update_or_create( id=responsable_data.get('id'),
defaults=responsable_data)
responsables_ids.append(responsable_instance.id)
return responsables_ids
def update(self, instance, validated_data):
responsables_data = validated_data.pop('responsables', [])
freres_data = validated_data.pop('freres', [])
langues_data = validated_data.pop('languesParlees', [])
if responsables_data:
instance.responsables.set(self.create_or_update_packages(responsables_data))
if freres_data:
instance.freres.set(self.create_or_update_packages(freres_data))
if langues_data:
instance.freres.set(self.create_or_update_packages(langues_data))
for field in self.fields:
try:
setattr(instance, field, validated_data[field])
except KeyError:
pass
instance.save()
return instance
class FicheInscriptionSerializer(serializers.ModelSerializer):
eleve = EleveSerializer(many=False, required=True)
fichierInscription = serializers.FileField(required=False)
etat_label = serializers.SerializerMethodField()
dateMAJ_formattee = serializers.SerializerMethodField()
class Meta:
model = FicheInscription
fields = '__all__'
def create(self, validated_data):
eleve_data = validated_data.pop('eleve')
eleve = EleveSerializer.create(EleveSerializer(), eleve_data)
ficheEleve = FicheInscription.objects.create(eleve=eleve, **validated_data)
return ficheEleve
def update(self, instance, validated_data):
eleve_data = validated_data.pop('eleve')
eleve = instance.eleve
eleve_serializer = EleveSerializer.update(EleveSerializer(), eleve, eleve_data)
for field in self.fields:
try:
setattr(instance, field, validated_data[field])
except KeyError:
pass
instance.save()
return instance
def get_etat_label(self, obj):
return obj.get_etat_display()
def get_dateMAJ_formattee(self, obj):
utc_time = timezone.localtime(obj.dateMAJ) # Convertir en heure locale
local_tz = pytz.timezone(settings.TZ_APPLI)
local_time = utc_time.astimezone(local_tz)
return local_time.strftime("%d-%m-%Y %H:%M")
class EleveByParentSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
class Meta:
model = Eleve
fields = ['id', 'nom', 'prenom']
def __init__(self, *args, **kwargs):
super(EleveByParentSerializer , self).__init__(*args, **kwargs)
for field in self.fields:
self.fields[field].required = False
class FicheInscriptionByParentSerializer(serializers.ModelSerializer):
eleve = EleveByParentSerializer(many=False, required=True)
class Meta:
model = FicheInscription
fields = ['eleve', 'etat']
def __init__(self, *args, **kwargs):
super(FicheInscriptionByParentSerializer, self).__init__(*args, **kwargs)
for field in self.fields:
self.fields[field].required = False
class ResponsableByDICreationSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
class Meta:
model = Responsable
fields = ['id', 'nom', 'prenom', 'mail', 'profilAssocie']
class EleveByDICreationSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
responsables = ResponsableByDICreationSerializer(many=True, required=False)
class Meta:
model = Eleve
fields = ['id', 'nom', 'prenom', 'responsables']
def __init__(self, *args, **kwargs):
super(EleveByDICreationSerializer , self).__init__(*args, **kwargs)
for field in self.fields:
self.fields[field].required = False
class NotificationSerializer(serializers.ModelSerializer):
typeNotification_label = serializers.ReadOnlyField()
class Meta:
model = Notification
fields = '__all__'

View File

@ -1,43 +0,0 @@
from django.db.models.signals import post_save, post_delete, m2m_changed
from django.dispatch import receiver
from django.core.cache import cache
from GestionInscriptions.models import FicheInscription, Eleve, Responsable
from GestionLogin.models import Profil
from N3wtSchool import settings
from N3wtSchool.redis_client import redis_client
def clear_cache():
# Préfixes des clés à supprimer
prefixes = ['N3WT_']
for prefix in prefixes:
# Utiliser le motif pour obtenir les clés correspondant au préfixe
pattern = f'*{prefix}*'
print(f'pattern : {pattern}')
for key in redis_client.scan_iter(pattern):
redis_client.delete(key)
print(f'deleting : {key}')
@receiver(post_save, sender=FicheInscription)
@receiver(post_delete, sender=FicheInscription)
def clear_cache_after_change(sender, instance, **kwargs):
clear_cache()
@receiver(m2m_changed, sender=Eleve.responsables.through)
def check_orphan_reponsables(sender, **kwargs):
action = kwargs.pop('action', None)
instance = kwargs.pop('instance', None)
# pre_clear : lors de la suppression d'une FI (on fait un "clear" sur chaque relation)
if action in ('post_remove', 'post_clear'):
if instance.responsables.all():
Responsable.objects.filter(eleve=None).delete()
@receiver(m2m_changed, sender=Eleve.profils.through)
def check_orphan_profils(sender, **kwargs):
action = kwargs.pop('action', None)
instance = kwargs.pop('instance', None)
# pre_clear : lors de la suppression d'une FI (on fait un "clear" sur chaque relation)
if action in ('post_remove', 'post_clear'):
if instance.profils.all():
Profil.objects.filter(eleve=None).delete()

View File

@ -1,44 +0,0 @@
# tasks.py
from celery import shared_task
from django.utils import timezone
from GestionInscriptions.automate import Automate_DI_Inscription, updateStateMachine
from .models import FicheInscription
from GestionMessagerie.models import Messagerie
from N3wtSchool import settings, bdd
import requests
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.WARNING)
@shared_task
def check_for_signature_deadlines():
now = timezone.now()
deadline = now - timezone.timedelta(days=settings.EXPIRATION_DI_NB_DAYS)
# deadline = now - timezone.timedelta(seconds=settings.EXPIRATION_DI_NB_DAYS)
dossiers_en_attente = FicheInscription.objects.filter(etat=FicheInscription.EtatDossierInscription.DI_ENVOYE, dateMAJ__lt=deadline)
for dossier in dossiers_en_attente:
send_notification(dossier)
def send_notification(dossier):
print(f'Dossier en attente.... {dossier} - Positionnement à l\'état A_RELANCER')
# Changer l'état de l'automate
updateStateMachine(dossier, 'relanceDI')
url = settings.URL_DJANGO + 'GestionMessagerie/message'
destinataires = dossier.eleve.profils.all()
for destinataire in destinataires:
message = {
"objet": "[RELANCE]",
"destinataire" : destinataire.id,
"corpus": "RELANCE pour le dossier d'inscription"
}
response = requests.post(url, json=message)
# subject = f"Dossier d'inscription non signé - {dossier.objet}"
# message = f"Le dossier d'inscription avec l'objet '{dossier.objet}' n'a pas été signé depuis {dossier.created_at}."
# send_mail(subject, message, settings.EMAIL_HOST_USER, [dossier.destinataire.email])

View File

@ -1,97 +0,0 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta charset="UTF-8">
<title>{{ pdf_title }}</title>
<style type="text/css">
body {
font-weight: 200;
font-size: 14px;
}
.header {
font-size: 20px;
font-weight: 100;
text-align: center;
color: #007cae;
}
.title {
font-size: 22px;
font-weight: 100;
/* text-align: right;*/
padding: 10px 20px 0px 20px;
}
.title span {
color: #007cae;
}
.details {
padding: 10px 20px 0px 20px;
text-align: left !important;
/*margin-left: 40%;*/
}
.hrItem {
border: none;
height: 1px;
/* Set the hr color */
color: #333; /* old IE */
background-color: #fff; /* Modern Browsers */
}
</style>
</head>
<body>
{% load myTemplateTag %}
<div class='wrapper'>
<div class='header'>
<p class='title'>{{ pdf_title }}</p>
</div>
<div>
<div class='details'>
Signé le : <b>{{ dateSignature }}</b> <br/>
A : <b>{{ heureSignature }}</b>
<hr class='hrItem' />
<h1>ELEVE</h1>
{% with niveau=eleve|recupereNiveauEleve %}
{% with genre=eleve|recupereGenreEleve %}
NOM : <b>{{ eleve.nom }}</b> <br/>
PRENOM : <b>{{ eleve.prenom }}</b> <br/>
ADRESSE : <b>{{ eleve.adresse }}</b> <br/>
GENRE : <b>{{ genre }}</b> <br/>
NE(E) LE : <b>{{ eleve.dateNaissance }}</b> <br/>
A : <b>{{ eleve.lieuNaissance }} ({{ eleve.codePostalNaissance }})</b> <br/>
NATIONALITE : <b>{{ eleve.nationalite }}</b> <br/>
NIVEAU : <b>{{ niveau }}</b> <br/>
MEDECIN TRAITANT : <b>{{ eleve.medecinTraitant }}</b> <br/>
{% endwith %}
{% endwith %}
<hr class='hrItem' />
<h1>RESPONSABLES</h1>
{% with responsables_List=eleve.getResponsables %}
{% with freres_List=eleve.getFreres %}
{% for responsable in responsables_List%}
<h2>Responsable {{ forloop.counter }}</h2>
NOM : <b>{{ responsable.nom }}</b> <br/>
PRENOM : <b>{{ responsable.prenom }}</b> <br/>
ADRESSE : <b>{{ responsable.adresse }}</b> <br/>
NE(E) LE : <b>{{ responsable.dateNaissance }}</b> <br/>
MAIL : <b>{{ responsable.mail }}</b> <br/>
TEL : <b>{{ responsable.telephone }}</b> <br/>
PROFESSION : <b>{{ responsable.profession }}</b> <br/>
{% endfor %}
<hr class='hrItem' />
<h1>FRATRIE</h1>
{% for frere in freres_List%}
<h2>Frère - Soeur {{ forloop.counter }}</h2>
NOM : <b>{{ frere.nom }}</b> <br/>
PRENOM : <b>{{ frere.prenom }}</b> <br/>
NE(E) LE : <b>{{ frere.dateNaissance }}</b> <br/>
{% endfor %}
<hr class='hrItem' />
<h1>MODALITES DE PAIEMENT</h1>
{% with modePaiement=eleve|recupereModePaiement %}
<b>{{ modePaiement }}</b> <br/>
{% endwith %}
{% endwith %}
{% endwith %}
</div>
</div>
</body>
</html>

View File

@ -1,23 +0,0 @@
from GestionInscriptions.models import FicheInscription, Eleve
from django import template
register = template.Library()
# @register.filter
# def recupereFichiersDossierInscription(pk):
# fichiers_list = FicheInscription.objects.filter(fiche_inscription=pk)
# return fichiers_list
@register.filter
def recupereModePaiement(pk):
ficheInscription = FicheInscription.objects.get(eleve=pk)
return Eleve.ModePaiement(int(ficheInscription.eleve.modePaiement)).label
@register.filter
def recupereNiveauEleve(pk):
ficheInscription = FicheInscription.objects.get(eleve=pk)
return Eleve.NiveauEleve(int(ficheInscription.eleve.niveau)).label
@register.filter
def recupereGenreEleve(pk):
ficheInscription = FicheInscription.objects.get(eleve=pk)
return Eleve.GenreEleve(int(ficheInscription.eleve.genre)).label

View File

@ -1,31 +0,0 @@
from django.urls import path, re_path
from . import views
from GestionInscriptions.views import ListFichesInscriptionView, FicheInscriptionView, EleveView, ResponsableView, ListeEnfantsView, ListeElevesView
urlpatterns = [
re_path(r'^fichesInscription/([a-zA-z]+)$', ListFichesInscriptionView.as_view(), name="listefichesInscriptions"),
re_path(r'^ficheInscription$', FicheInscriptionView.as_view(), name="fichesInscriptions"),
re_path(r'^ficheInscription/([0-9]+)$', FicheInscriptionView.as_view(), name="fichesInscriptions"),
# Page de formulaire d'inscription - ELEVE
re_path(r'^eleve/([0-9]+)$', EleveView.as_view(), name="eleves"),
# Page de formulaire d'inscription - RESPONSABLE
re_path(r'^recupereDernierResponsable$', ResponsableView.as_view(), name="recupereDernierResponsable"),
# Envoi d'un dossier d'inscription
re_path(r'^send/([0-9]+)$', views.send, name="send"),
# Archivage d'un dossier d'inscription
re_path(r'^archive/([0-9]+)$', views.archive, name="archive"),
# Envoi d'une relance de dossier d'inscription
re_path(r'^sendRelance/([0-9]+)$', views.relance, name="relance"),
# Page PARENT - Liste des enfants
re_path(r'^enfants/([0-9]+)$', ListeEnfantsView.as_view(), name="enfants"),
# Page INSCRIPTION - Liste des élèves
re_path(r'^eleves$', ListeElevesView.as_view(), name="enfants"),
]

View File

@ -1,181 +0,0 @@
from django.shortcuts import render,get_object_or_404,get_list_or_404
from .models import FicheInscription, Eleve, Responsable, Frere
import time
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from django.conf import settings
from N3wtSchool import renderers
from N3wtSchool import bdd
from io import BytesIO
from django.core.files import File
from pathlib import Path
import os
from enum import Enum
import random
import string
from rest_framework.parsers import JSONParser
def recupereListeFichesInscription():
context = {
"ficheInscriptions_list": bdd.getAllObjects(FicheInscription),
}
return context
def recupereListeFichesInscriptionEnAttenteSEPA():
ficheInscriptionsSEPA_list = FicheInscription.objects.filter(modePaiement="Prélèvement SEPA").filter(etat=FicheInscription.EtatDossierInscription['SEPA_ENVOYE'])
return ficheInscriptionsSEPA_list
def updateEleve(eleve, inputs, erase=False):
eleve.nom = inputs["nomEleve"]
eleve.prenom = inputs["prenomEleve"]
eleve.ambiance = inputs["ambiance"]
eleve.genre = inputs["genre"]
eleve.adresse = inputs["adresseEleve"]
eleve.dateNaissance = inputs["dateNaissanceEleve"]
eleve.lieuNaissance = inputs["lieuNaissanceEleve"]
eleve.codePostalNaissance = inputs["codePostalNaissanceEleve"]
eleve.nationalite = inputs["nationaliteEleve"]
eleve.medecinTraitant = inputs["medecinTraitantEleve"]
responsable=eleve.getResponsablePrincipal()
responsable.adresse = inputs["adresseResponsable1"]
responsable.dateNaissance = inputs["dateNaissanceResponsable1"]
responsable.profession = inputs["professionResponsable1"]
responsable.save()
# Création du 2ème responsable
if inputs["nomResponsable2"] != "" and inputs["prenomResponsable2"] != "":
responsable2 = Responsable.objects.create(nom=inputs["nomResponsable2"],
prenom=inputs["prenomResponsable2"],
dateNaissance=inputs["dateNaissanceResponsable2"],
adresse=inputs["adresseResponsable2"],
mail=inputs["mailResponsable2"],
telephone=inputs["telephoneResponsable2"],
profession=inputs["professionResponsable2"])
responsable2.save()
eleve.responsables.add(responsable2)
# Création du 1er frère
if inputs["nomFrere1"] != "" and inputs["prenomFrere1"] != "":
frere1 = Frere.objects.create(nom=inputs["nomFrere1"],
prenom=inputs["prenomFrere1"],
dateNaissance=inputs["dateNaissanceFrere1"])
frere1.save()
eleve.freres.add(frere1)
# Création du 2ème frère
if inputs["nomFrere2"] != "" and inputs["prenomFrere2"] != "":
frere2 = Frere.objects.create(nom=inputs["nomFrere2"],
prenom=inputs["prenomFrere2"],
dateNaissance=inputs["dateNaissanceFrere2"])
frere2.save()
eleve.freres.add(frere2)
eleve.save()
def _now():
return datetime.now(ZoneInfo(settings.TZ_APPLI))
def convertToStr(dateValue, dateFormat):
return dateValue.strftime(dateFormat)
def convertToDate(date_time):
format = '%d-%m-%Y %H:%M'
datetime_str = datetime.strptime(date_time, format)
return datetime_str
def convertTelephone(telephoneValue, separator='-'):
return f"{telephoneValue[:2]}{separator}{telephoneValue[2:4]}{separator}{telephoneValue[4:6]}{separator}{telephoneValue[6:8]}{separator}{telephoneValue[8:10]}"
def generePDF(ficheEleve):
data = {
'pdf_title': "Dossier d'inscription de %s"%ficheEleve.eleve.prenom,
'dateSignature': convertToStr(_now(), '%d-%m-%Y'),
'heureSignature': convertToStr(_now(), '%H:%M'),
'eleve':ficheEleve.eleve,
}
pdf = renderers.render_to_pdf('pdfs/dossier_inscription.html', data)
nomFichierPDF = "Dossier_Inscription_%s_%s.pdf"%(ficheEleve.eleve.nom, ficheEleve.eleve.prenom)
pathFichier = Path(settings.DOCUMENT_DIR + "/" + nomFichierPDF)
if os.path.exists(str(pathFichier)):
os.remove(str(pathFichier))
receipt_file = BytesIO(pdf.content)
# fichier = Fichier.objects.create(fiche_inscription=ficheEleve)
# fichier.document = File(receipt_file, nomFichierPDF)
# fichier.save()
def genereRandomCode(length):
return ''.join(random.choice(string.ascii_letters) for i in range(length))
def calculeDatePeremption(_start, nbDays):
return convertToStr(_start + timedelta(days=nbDays), settings.DATE_FORMAT)
# Fonction permettant de retourner la valeur du QueryDict
# QueryDict [ index ] -> Dernière valeur d'une liste
# dict (QueryDict [ index ]) -> Toutes les valeurs de la liste
def _(liste):
return liste[0]
def toNewEleveJSONRequest(jsonOrigin):
etat=FicheInscription.EtatDossierInscription.DI_CREE
telephone = convertTelephone(_(jsonOrigin['telephoneResponsable']))
finalJSON = {
"eleve":
{
"nom" : _(jsonOrigin['nomEleve']),
"prenom" : _(jsonOrigin['prenomEleve']),
"responsables" : [
{
"nom" : _(jsonOrigin['nomResponsable']),
"prenom" : _(jsonOrigin['prenomResponsable']),
"mail" : _(jsonOrigin['mailResponsable']),
"telephone" : telephone
}
],
"profils" : [
],
},
"etat": str(etat),
"dateMAJ": str(convertToStr(_now(), '%d-%m-%Y %H:%M')),
}
print(finalJSON)
return finalJSON
def toEditEleveJSONRequest(jsonOrigin):
telephone = convertTelephone(_(jsonOrigin['telephoneResponsable']), '.')
finalJSON = {
"eleve":
{
"id" : _(jsonOrigin['fiche_id']),
"nom" : _(jsonOrigin['nomEleve']),
"prenom" : _(jsonOrigin['prenomEleve']),
"responsables" : [
{
"id" : _(jsonOrigin['responsable_id']),
"nom" : _(jsonOrigin['nomResponsable']),
"prenom" : _(jsonOrigin['prenomResponsable']),
"mail" : _(jsonOrigin['mailResponsable']),
"telephone" : telephone
}
],
"profils" : [
],
},
"dateMAJ": str(convertToStr(_now(), '%d-%m-%Y %H:%M')),
}
print(finalJSON)
return finalJSON
def getArgFromRequest(_argument, _request):
resultat = None
data=JSONParser().parse(_request)
resultat = data[_argument]
return resultat

View File

@ -1,289 +0,0 @@
from django.http.response import JsonResponse
from django.contrib.auth import login, authenticate, get_user_model
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
from django.utils.decorators import method_decorator
from django.core.cache import cache
from django.core.paginator import Paginator
from django.core.files import File
from django.db.models import Q # Ajout de cet import
from rest_framework.parsers import JSONParser
from rest_framework.views import APIView
from rest_framework import status
import json
from pathlib import Path
import os
from io import BytesIO
import GestionInscriptions.mailManager as mailer
import GestionInscriptions.util as util
from GestionInscriptions.serializers import FicheInscriptionSerializer, EleveSerializer, FicheInscriptionByParentSerializer, EleveByDICreationSerializer
from GestionInscriptions.pagination import CustomPagination
from GestionInscriptions.signals import clear_cache
from .models import Eleve, Responsable, FicheInscription
from GestionInscriptions.automate import Automate_DI_Inscription, load_config, getStateMachineObjectState, updateStateMachine
from GestionLogin.models import Profil
from N3wtSchool import settings, renderers, bdd
class ListFichesInscriptionView(APIView):
pagination_class = CustomPagination
def get(self, request, _filter):
if _filter == 'all':
# Récupération des paramètres
search = request.GET.get('search', '').strip()
page_size = request.GET.get('page_size', None)
# Gestion du page_size
if page_size is not None:
try:
page_size = int(page_size)
except ValueError:
page_size = settings.NB_RESULT_PER_PAGE
cached_page_size = cache.get('N3WT_page_size')
if cached_page_size != page_size:
clear_cache()
cache.set('N3WT_page_size', page_size)
# Gestion du cache
page_number = request.GET.get('page', 1)
cache_key = f'N3WT_ficheInscriptions_page_{page_number}_search_{search}'
cached_page = cache.get(cache_key)
if cached_page:
return JsonResponse(cached_page, safe=False)
# Filtrage des résultats
if search:
# Utiliser la nouvelle fonction de recherche
ficheInscriptions_List = bdd.searchObjects(
FicheInscription,
search,
_excludeState=6 # Exclure les fiches archivées
)
else:
# Récupère toutes les fiches non archivées
ficheInscriptions_List = bdd.getObjects(FicheInscription, 'etat', 6, _reverseCondition=True)
# Pagination
paginator = self.pagination_class()
page = paginator.paginate_queryset(ficheInscriptions_List, request)
if page is not None:
ficheInscriptions_serializer = FicheInscriptionSerializer(page, many=True)
response_data = paginator.get_paginated_response(ficheInscriptions_serializer.data)
cache.set(cache_key, response_data, timeout=60*15)
return JsonResponse(response_data, safe=False)
elif _filter == 'archived' :
page_size = request.GET.get('page_size', None)
if page_size is not None:
try:
page_size = int(page_size)
except ValueError:
page_size = settings.NB_RESULT_PER_PAGE
cached_page_size = cache.get('N3WT_archived_page_size')
# Comparer avec le nouveau page_size
if cached_page_size != page_size:
# Appeler cached_page() et mettre à jour le cache
clear_cache()
cache.set('N3WT_archived_page_size',page_size)
page_number = request.GET.get('page', 1)
cache_key_page = f'N3WT_ficheInscriptions_archives_page_{page_number}'
cached_page = cache.get(cache_key_page)
if cached_page:
return JsonResponse(cached_page, safe=False)
ficheInscriptions_List=bdd.getObjects(FicheInscription, 'etat', 6)
paginator = self.pagination_class()
page = paginator.paginate_queryset(ficheInscriptions_List, request)
if page is not None:
ficheInscriptions_serializer = FicheInscriptionSerializer(page, many=True)
response_data = paginator.get_paginated_response(ficheInscriptions_serializer.data)
cache.set(cache_key_page, response_data, timeout=60*15)
return JsonResponse(response_data, safe=False)
return JsonResponse(status=status.HTTP_404_NOT_FOUND)
def post(self, request):
fichesEleve_data=JSONParser().parse(request)
for ficheEleve_data in fichesEleve_data:
# Ajout de la date de mise à jour
ficheEleve_data["dateMAJ"] = util.convertToStr(util._now(), '%d-%m-%Y %H:%M')
json.dumps(ficheEleve_data)
# Ajout du code d'inscription
code = util.genereRandomCode(12)
ficheEleve_data["codeLienInscription"] = code
ficheEleve_serializer = FicheInscriptionSerializer(data=ficheEleve_data)
if ficheEleve_serializer.is_valid():
ficheEleve_serializer.save()
return JsonResponse(ficheEleve_serializer.errors, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class FicheInscriptionView(APIView):
pagination_class = CustomPagination
def get(self, request, _id):
ficheInscription=bdd.getObject(FicheInscription, "eleve__id", _id)
fiche_serializer=FicheInscriptionSerializer(ficheInscription)
return JsonResponse(fiche_serializer.data, safe=False)
def post(self, request):
ficheEleve_data=JSONParser().parse(request)
# Ajout de la date de mise à jour
ficheEleve_data["dateMAJ"] = util.convertToStr(util._now(), '%d-%m-%Y %H:%M')
json.dumps(ficheEleve_data)
# Ajout du code d'inscription
code = util.genereRandomCode(12)
ficheEleve_data["codeLienInscription"] = code
responsablesId = ficheEleve_data.pop('idResponsables', [])
ficheEleve_serializer = FicheInscriptionSerializer(data=ficheEleve_data)
if ficheEleve_serializer.is_valid():
di = ficheEleve_serializer.save()
# Mise à jour de l'automate
updateStateMachine(di, 'creationDI')
# Récupération du reponsable associé
for responsableId in responsablesId:
responsable = Responsable.objects.get(id=responsableId)
di.eleve.responsables.add(responsable)
di.save()
ficheInscriptions_List=bdd.getAllObjects(FicheInscription)
return JsonResponse({'totalInscrits':len(ficheInscriptions_List)}, safe=False)
return JsonResponse(ficheEleve_serializer.errors, safe=False)
def put(self, request, id):
ficheEleve_data=JSONParser().parse(request)
admin = ficheEleve_data.pop('admin', 1)
ficheEleve_data["dateMAJ"] = str(util.convertToStr(util._now(), '%d-%m-%Y %H:%M'))
ficheEleve = bdd.getObject(_objectName=FicheInscription, _columnName='eleve__id', _value=id)
currentState = getStateMachineObjectState(ficheEleve.etat)
if admin == 0 and currentState == FicheInscription.EtatDossierInscription.DI_ENVOYE:
json.dumps(ficheEleve_data)
# Ajout du fichier d'inscriptions
data = {
'pdf_title': "Dossier d'inscription de %s"%ficheEleve.eleve.prenom,
'dateSignature': util.convertToStr(util._now(), '%d-%m-%Y'),
'heureSignature': util.convertToStr(util._now(), '%H:%M'),
'eleve':ficheEleve.eleve,
}
pdf = renderers.render_to_pdf('pdfs/dossier_inscription.html', data)
nomFichierPDF = "Dossier_Inscription_%s_%s.pdf"%(ficheEleve.eleve.nom, ficheEleve.eleve.prenom)
pathFichier = Path(settings.DOCUMENT_DIR + "/" + nomFichierPDF)
if os.path.exists(str(pathFichier)):
print(f'File exists : {str(pathFichier)}')
os.remove(str(pathFichier))
receipt_file = BytesIO(pdf.content)
ficheEleve.fichierInscription = File(receipt_file, nomFichierPDF)
# Mise à jour de l'automate
updateStateMachine(di, 'saisiDI')
ficheEleve_serializer = FicheInscriptionSerializer(ficheEleve, data=ficheEleve_data)
if ficheEleve_serializer.is_valid():
di = ficheEleve_serializer.save()
return JsonResponse("Updated Successfully", safe=False)
return JsonResponse(ficheEleve_serializer.errors, safe=False)
def delete(self, request, id):
fiche_inscription = bdd.getObject(_objectName=FicheInscription, _columnName='eleve__id', _value=id)
if fiche_inscription != None:
eleve = fiche_inscription.eleve
eleve.responsables.clear()
eleve.profils.clear()
eleve.delete()
clear_cache()
return JsonResponse("La suppression du dossier a été effectuée avec succès", safe=False)
return JsonResponse({"errorMessage":'Aucun dossier d\'inscription rattaché à l\'élève'}, safe=False)
class EleveView(APIView):
def get(self, request, _id):
eleve = bdd.getObject(_objectName=Eleve, _columnName='id', _value=_id)
eleve_serializer = EleveSerializer(eleve)
return JsonResponse(eleve_serializer.data, safe=False)
class ResponsableView(APIView):
def get(self, request):
lastResponsable = bdd.getLastId(Responsable)
return JsonResponse({"lastid":lastResponsable}, safe=False)
def send(request, id):
fiche_inscription = bdd.getObject(_objectName=FicheInscription, _columnName='eleve__id', _value=id)
if fiche_inscription != None:
eleve = fiche_inscription.eleve
responsable = eleve.getResponsablePrincipal()
mail = responsable.mail
errorMessage = mailer.envoieDossierInscription(mail)
if errorMessage == '':
fiche_inscription.dateMAJ=util.convertToStr(util._now(), '%d-%m-%Y %H:%M')
# Mise à jour de l'automate
updateStateMachine(fiche_inscription, 'envoiDI')
return JsonResponse({"errorMessage":errorMessage}, safe=False)
return JsonResponse({"errorMessage":'Aucun dossier d\'inscription rattaché à l\'élève'}, safe=False)
def archive(request, id):
fiche_inscription = bdd.getObject(_objectName=FicheInscription, _columnName='eleve__id', _value=id)
if fiche_inscription != None:
fiche_inscription.dateMAJ=util.convertToStr(util._now(), '%d-%m-%Y %H:%M')
# Mise à jour de l'automate
updateStateMachine(fiche_inscription, 'archiveDI')
return JsonResponse({"errorMessage":''}, safe=False)
return JsonResponse({"errorMessage":'Aucun dossier d\'inscription rattaché à l\'élève'}, safe=False)
def relance(request, id):
fiche_inscription = bdd.getObject(_objectName=FicheInscription, _columnName='eleve__id', _value=id)
if fiche_inscription != None:
eleve = fiche_inscription.eleve
responsable = eleve.getResponsablePrincipal()
mail = responsable.mail
errorMessage = mailer.envoieRelanceDossierInscription(mail, fiche_inscription.codeLienInscription)
if errorMessage == '':
fiche_inscription.etat=FicheInscription.EtatDossierInscription.DI_ENVOYE
fiche_inscription.dateMAJ=util.convertToStr(util._now(), '%d-%m-%Y %H:%M')
fiche_inscription.save()
return JsonResponse({"errorMessage":errorMessage}, safe=False)
return JsonResponse({"errorMessage":'Aucun dossier d\'inscription rattaché à l\'élève'}, safe=False)
# API utilisée pour la vue parent
class ListeEnfantsView(APIView):
# Récupération des élèves d'un parent
# idProfile : identifiant du profil connecté rattaché aux fiches d'élèves
def get(self, request, _idProfile):
students = bdd.getObjects(_objectName=FicheInscription, _columnName='eleve__responsables__profilAssocie__id', _value=_idProfile)
students_serializer = FicheInscriptionByParentSerializer(students, many=True)
return JsonResponse(students_serializer.data, safe=False)
# API utilisée pour la vue de création d'un DI
class ListeElevesView(APIView):
# Récupération de la liste des élèves inscrits ou en cours d'inscriptions
def get(self, request):
students = bdd.getAllObjects(_objectName=Eleve)
students_serializer = EleveByDICreationSerializer(students, many=True)
return JsonResponse(students_serializer.data, safe=False)

View File

@ -1,25 +0,0 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.core.validators import EmailValidator
class Profil(AbstractUser):
class Droits(models.IntegerChoices):
PROFIL_UNDEFINED = -1, _('Profil non défini')
PROFIL_ECOLE = 0, _('Profil école')
PROFIL_PARENT = 1, _('Profil parent')
PROFIL_ADMIN = 2, _('Profil administrateur')
email = models.EmailField(max_length=255, unique=True, default="", validators=[EmailValidator()])
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ('password', )
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)

View File

@ -1,28 +0,0 @@
from rest_framework import serializers
from GestionLogin.models import Profil
from django.core.exceptions import ValidationError
class ProfilSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
password = serializers.CharField(write_only=True)
class Meta:
model = Profil
fields = ['id', 'password', 'email', 'code', 'datePeremption', 'estConnecte', 'droit', 'username', 'is_active']
extra_kwargs = {'password': {'write_only': True}}
def create(self, validated_data):
user = Profil(
username=validated_data['username'],
email=validated_data['email'],
is_active=validated_data['is_active'],
droit=validated_data['droit']
)
user.set_password(validated_data['password'])
user.save()
return user
def to_representation(self, instance):
ret = super().to_representation(instance)
ret['password'] = '********'
return ret

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

@ -1,22 +0,0 @@
from django.urls import path, re_path
from . import views
import GestionLogin.views
from GestionLogin.views import ProfilView, ListProfilView, SessionView, LoginView, SubscribeView, NewPasswordView, ResetPasswordView
urlpatterns = [
re_path(r'^csrf$', GestionLogin.views.csrf, name='csrf'),
re_path(r'^login$', LoginView.as_view(), name="login"),
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$', GestionLogin.views.infoSession, name='infoSession'),
re_path(r'^profils$', ListProfilView.as_view(), name="profil"),
re_path(r'^profil$', ProfilView.as_view(), name="profil"),
re_path(r'^profil/([0-9]+)$', ProfilView.as_view(), name="profil"),
# Test SESSION VIEW
re_path(r'^session$', SessionView.as_view(), name="session"),
]

View File

@ -1,264 +0,0 @@
from django.conf import settings
from django.contrib.auth import login, authenticate, get_user_model
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 datetime import datetime
import jwt
import json
from . import validator
from .models import Profil
from GestionInscriptions.models import FicheInscription
from GestionInscriptions.serializers import ProfilSerializer
from GestionInscriptions.signals import clear_cache
import GestionInscriptions.mailManager as mailer
import GestionInscriptions.util as util
from N3wtSchool import bdd, error
def csrf(request):
token = get_token(request)
return JsonResponse({'csrfToken': token})
class SessionView(APIView):
def post(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 = Profil.objects.get(id=user_id)
response_data = {
'user': {
'id': user.id,
'email': user.email,
'role': user.droit, # Assure-toi que le champ 'droit' existe et contient le rôle
}
}
return JsonResponse(response_data, status=status.HTTP_200_OK)
except jwt.ExpiredSignatureError:
return JsonResponse({"error": "Token has expired"}, status=status.HTTP_401_UNAUTHORIZED)
except jwt.InvalidTokenError:
return JsonResponse({"error": "Invalid token"}, status=status.HTTP_401_UNAUTHORIZED)
class ListProfilView(APIView):
def get(self, request):
profilsList = bdd.getAllObjects(_objectName=Profil)
profils_serializer = ProfilSerializer(profilsList, many=True)
return JsonResponse(profils_serializer.data, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class ProfilView(APIView):
def get(self, request, _id):
profil=bdd.getObject(Profil, "id", _id)
profil_serializer=ProfilSerializer(profil)
return JsonResponse(profil_serializer.data, safe=False)
def post(self, request):
profil_data=JSONParser().parse(request)
print(f'{profil_data}')
profil_serializer = ProfilSerializer(data=profil_data)
if profil_serializer.is_valid():
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 = Profil.objects.get(id=_id)
profil_serializer = ProfilSerializer(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 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":Profil.Droits.PROFIL_UNDEFINED, "username":""}, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class LoginView(APIView):
def get(self, request):
return JsonResponse({
'errorFields':'',
'errorMessage':'',
'profil':0,
}, safe=False)
def post(self, 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]
# 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,
#'jwtToken':jwt_token if profil != -1 else ''
}, safe=False)
@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)
def post(self, request):
retourErreur = error.returnMessage[error.BAD_URL]
retour = ''
newProfilConnection=JSONParser().parse(request)
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(Profil.objects.all(), newProfilConnection.get('email'))
if profil == 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)
else:
try:
profil.set_password(newProfilConnection.get('password1'))
profil.is_active = True
profil.full_clean()
profil.save()
clear_cache()
retour = error.returnMessage[error.MESSAGE_ACTIVATION_PROFILE]
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)
@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)
def post(self, request):
retourErreur = error.returnMessage[error.BAD_URL]
retour = ''
newProfilConnection=JSONParser().parse(request)
validatorNewPassword = validator.ValidatorNewPassword(data=newProfilConnection)
validationOk, errorFields = validatorNewPassword.validate()
if validationOk:
profil = bdd.getProfile(Profil.objects.all(), newProfilConnection.get('email'))
if profil == 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)
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]
retour = ''
newProfilConnection=JSONParser().parse(request)
validatorResetPassword = validator.ValidatorResetPassword(data=newProfilConnection)
validationOk, errorFields = validatorResetPassword.validate()
profil = bdd.getObject(Profil, "code", _uuid)
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)
elif validationOk:
retour = error.returnMessage[error.PASSWORD_CHANGED]
profil.set_password(newProfilConnection.get('password1'))
profil.code = ''
profil.datePeremption = ''
profil.save()
clear_cache()
retourErreur=''
return JsonResponse({'message':retour, "errorMessage":retourErreur, "errorFields":errorFields}, 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,101 @@
# Generated by Django 5.1.3 on 2025-11-30 11:02
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Conversation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, max_length=255, null=True)),
('conversation_type', models.CharField(choices=[('private', 'Privée'), ('group', 'Groupe')], default='private', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('last_activity', models.DateTimeField(default=django.utils.timezone.now)),
('is_active', models.BooleanField(default=True)),
],
),
migrations.CreateModel(
name='Message',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('content', models.TextField()),
('message_type', models.CharField(choices=[('text', 'Texte'), ('file', 'Fichier'), ('image', 'Image'), ('system', 'Système')], default='text', max_length=10)),
('file_url', models.URLField(blank=True, null=True)),
('file_name', models.CharField(blank=True, max_length=255, null=True)),
('file_size', models.BigIntegerField(blank=True, null=True)),
('file_type', models.CharField(blank=True, max_length=100, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_edited', models.BooleanField(default=False)),
('is_deleted', models.BooleanField(default=False)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='GestionMessagerie.conversation')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='Messagerie',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('objet', models.CharField(blank=True, default='', max_length=200)),
('corpus', models.CharField(blank=True, default='', max_length=200)),
('date_envoi', models.DateTimeField(auto_now_add=True)),
('is_read', models.BooleanField(default=False)),
('conversation_id', models.CharField(blank=True, default='', max_length=100)),
('destinataire', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='messages_recus', to=settings.AUTH_USER_MODEL)),
('emetteur', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='messages_envoyes', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='UserPresence',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('online', 'En ligne'), ('away', 'Absent'), ('busy', 'Occupé'), ('offline', 'Hors ligne')], default='offline', max_length=10)),
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
('is_typing_in', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='typing_users', to='GestionMessagerie.conversation')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='presence', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ConversationParticipant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('joined_at', models.DateTimeField(auto_now_add=True)),
('last_read_at', models.DateTimeField(default=django.utils.timezone.now)),
('is_active', models.BooleanField(default=True)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='GestionMessagerie.conversation')),
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversation_participants', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('conversation', 'participant')},
},
),
migrations.CreateModel(
name='MessageRead',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('read_at', models.DateTimeField(auto_now_add=True)),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_by', to='GestionMessagerie.message')),
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_messages', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('message', 'participant')},
},
),
]

View File

@ -1,15 +1,113 @@
from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from Auth.models import Profile
from django.conf import settings from django.utils import timezone
from GestionLogin.models import Profil 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): class Messagerie(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
objet = models.CharField(max_length=200, default="", blank=True) objet = models.CharField(max_length=200, default="", blank=True)
emetteur = models.ForeignKey(Profil, on_delete=models.PROTECT, related_name='messages_envoyes') emetteur = models.ForeignKey(Profile, on_delete=models.PROTECT, related_name='messages_envoyes')
destinataire = models.ForeignKey(Profil, on_delete=models.PROTECT, related_name='messages_recus') destinataire = models.ForeignKey(Profile, on_delete=models.PROTECT, related_name='messages_recus')
corpus = models.CharField(max_length=200, default="", blank=True) 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): 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 rest_framework import serializers
from GestionLogin.models import Profil 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): 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() destinataire_profil = serializers.SerializerMethodField()
emetteur_profil = serializers.SerializerMethodField() emetteur_profil = serializers.SerializerMethodField()
class Meta: class Meta:
model = Messagerie model = Messagerie
fields = '__all__' fields = '__all__'
read_only_fields = ['date_envoi']
def get_destinataire_profil(self, obj): def get_destinataire_profil(self, obj):
return obj.destinataire.email return obj.destinataire.email

View File

@ -1,9 +1,22 @@
from django.urls import path, re_path from django.urls import path
from .views import (
from GestionMessagerie.views import MessagerieView, MessageView InstantConversationListView, InstantConversationCreateView, InstantConversationDeleteView,
InstantMessageListView, InstantMessageCreateView,
InstantMarkAsReadView, FileUploadView,
InstantRecipientSearchView
)
urlpatterns = [ urlpatterns = [
re_path(r'^messagerie/([0-9]+)$', MessagerieView.as_view(), name="messagerie"), # URLs pour messagerie instantanée
re_path(r'^message$', MessageView.as_view(), name="message"), path('conversations/', InstantConversationListView.as_view(), name='conversations'),
re_path(r'^message/([0-9]+)$', MessageView.as_view(), name="message"), 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.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): conversations = Conversation.objects.filter(
def get(self, request, _idProfile): participants__participant=user,
messagesList = bdd.getObjects(_objectName=Messagerie, _columnName='destinataire__id', _value=_idProfile) participants__is_active=True,
messages_serializer = MessageSerializer(messagesList, many=True) is_active=True
return JsonResponse(messages_serializer.data, safe=False) ).distinct().order_by('-last_activity')
class MessageView(APIView): serializer = ConversationSerializer(conversations, many=True, context={'user': user})
def get(self, request, _id): return Response(serializer.data, status=status.HTTP_200_OK)
message=bdd.getObject(Messagerie, "id", _id) except Profile.DoesNotExist:
message_serializer=MessageSerializer(message) return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
return JsonResponse(message_serializer.data, safe=False) 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): def post(self, request):
message_data=JSONParser().parse(request) serializer = ConversationCreateSerializer(data=request.data)
message_serializer = MessageSerializer(data=message_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(): class InstantMessageListView(APIView):
message_serializer.save() """
API pour lister les messages d'une conversation
return JsonResponse('Nouveau Message ajouté', safe=False) """
@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-11-30 11:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.CharField(max_length=255)),
('is_read', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('typeNotification', models.IntegerField(choices=[(0, 'Aucune notification'), (1, 'Un message a été reçu'), (2, "Le dossier d'inscription a été mis à jour")], default=0)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -2,7 +2,7 @@ from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.conf import settings from django.conf import settings
from GestionLogin.models import Profil from Auth.models import Profile
class TypeNotif(models.IntegerChoices): class TypeNotif(models.IntegerChoices):
NOTIF_NONE = 0, _('Aucune notification') NOTIF_NONE = 0, _('Aucune notification')
@ -10,7 +10,7 @@ class TypeNotif(models.IntegerChoices):
NOTIF_DI = 2, _('Le dossier d\'inscription a été mis à jour') NOTIF_DI = 2, _('Le dossier d\'inscription a été mis à jour')
class Notification(models.Model): class Notification(models.Model):
user = models.ForeignKey(Profil, on_delete=models.PROTECT) user = models.ForeignKey(Profile, on_delete=models.PROTECT)
message = models.CharField(max_length=255) message = models.CharField(max_length=255)
is_read = models.BooleanField(default=False) is_read = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)

View File

@ -2,22 +2,22 @@ from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from .models import Notification, TypeNotif from .models import Notification, TypeNotif
from GestionMessagerie.models import Messagerie from GestionMessagerie.models import Messagerie
from GestionInscriptions.models import FicheInscription from Subscriptions.models import RegistrationForm
@receiver(post_save, sender=Messagerie) # @receiver(post_save, sender=Messagerie)
def notification_MESSAGE(sender, instance, created, **kwargs): # def notification_MESSAGE(sender, instance, created, **kwargs):
if created: # if created:
Notification.objects.create( # Notification.objects.create(
user=instance.destinataire, # user=instance.destinataire,
message=(TypeNotif.NOTIF_MESSAGE).label, # message=(TypeNotif.NOTIF_MESSAGE).label,
typeNotification=TypeNotif.NOTIF_MESSAGE # typeNotification=TypeNotif.NOTIF_MESSAGE
) # )
@receiver(post_save, sender=FicheInscription) # @receiver(post_save, sender=RegistrationForm)
def notification_DI(sender, instance, created, **kwargs): # def notification_DI(sender, instance, created, **kwargs):
for responsable in instance.eleve.responsables.all(): # for responsable in instance.student.guardians.all():
Notification.objects.create( # Notification.objects.create(
user=responsable.profilAssocie, # user=responsable.associated_profile,
message=(TypeNotif.NOTIF_DI).label, # message=(TypeNotif.NOTIF_DI).label,
typeNotification=TypeNotif.NOTIF_DI # typeNotification=TypeNotif.NOTIF_DI
) # )

View File

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

View File

@ -3,7 +3,7 @@ from rest_framework.views import APIView
from .models import * from .models import *
from GestionInscriptions.serializers import NotificationSerializer from Subscriptions.serializers import NotificationSerializer
from N3wtSchool import bdd from N3wtSchool import bdd

View File

@ -8,9 +8,40 @@ https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
""" """
import os import os
from django.core.asgi import get_asgi_application 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') 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

@ -1,11 +1,16 @@
import logging import logging
from django.db.models import Q from django.db.models import Q
from GestionInscriptions.models import FicheInscription, Profil, Eleve from django.http import JsonResponse
from django.core.exceptions import ObjectDoesNotExist
from Subscriptions.models import RegistrationForm, Student
from Auth.models import Profile
logger = logging.getLogger('N3wtSchool')
def getAllObjects(_objectName): def getAllObjects(_objectName):
result = _objectName.objects.all() result = _objectName.objects.all()
if not result: if not result:
logging.warning("Aucun résultat n'a été trouvé - " + _objectName.__name__) logger.warning("Aucun résultat n'a été trouvé - " + _objectName.__name__)
return result return result
def getObject(_objectName, _columnName, _value): def getObject(_objectName, _columnName, _value):
@ -13,7 +18,7 @@ def getObject(_objectName, _columnName, _value):
try : try :
result = _objectName.objects.get(**{_columnName: _value}) result = _objectName.objects.get(**{_columnName: _value})
except _objectName.DoesNotExist: except _objectName.DoesNotExist:
logging.error("Aucun résultat n'a été trouvé - " + _objectName.__name__ + " (" + _columnName + "=" + str(_value) + ")") logger.error("Aucun résultat n'a été trouvé - " + _objectName.__name__ + " (" + _columnName + "=" + str(_value) + ")")
return result return result
@ -22,7 +27,7 @@ def getObjects(_objectName, _columnName, _value, _reverseCondition=False):
try : try :
results = _objectName.objects.filter(**{_columnName: _value}) if _reverseCondition == False else _objectName.objects.filter(~Q(**{_columnName: _value})) results = _objectName.objects.filter(**{_columnName: _value}) if _reverseCondition == False else _objectName.objects.filter(~Q(**{_columnName: _value}))
except _objectName.DoesNotExist: except _objectName.DoesNotExist:
logging.error("Aucun résultat n'a été trouvé - " + _objectName.__name__ + " (" + _columnName + "=" + str(_value) + ")") logger.error("Aucun résultat n'a été trouvé - " + _objectName.__name__ + " (" + _columnName + "=" + str(_value) + ")")
return results return results
@ -41,46 +46,62 @@ def getProfile(objectList, valueToCheck):
return result return result
def getEleveByCodeFI(_codeFI): def getEleveByCodeFI(_codeFI):
eleve = None student = None
ficheInscriptions_List=getAllObjects(FicheInscription) ficheInscriptions_List=getAllObjects(RegistrationForm)
for fi in ficheInscriptions_List: for rf in ficheInscriptions_List:
if fi.codeLienInscription == _codeFI: if rf.codeLienInscription == _codeFI:
eleve = fi.eleve student = rf.student
return eleve return student
def getLastId(_object): def getLastId(_object):
result = 1 result = 1
try: try:
result = _object.objects.latest('id').id result = _object.objects.latest('id').id
except: except:
logging.warning("Aucun résultat n'a été trouvé - ") logger.warning("Aucun résultat n'a été trouvé - ")
return result return result
def searchObjects(_objectName, _searchTerm, _excludeState=None): def searchObjects(_objectName, _searchTerm=None, _excludeStates=None):
""" """
Recherche générique sur les objets avec possibilité d'exclure certains états Recherche générique sur les objets avec possibilité d'exclure certains états
_objectName: Classe du modèle _objectName: SchoolClass du modèle
_searchTerm: Terme de recherche _searchTerm: Terme de recherche
_excludeState: État à exclure de la recherche (optionnel) _excludeStates: Liste d'état à exclure de la recherche (optionnel)
""" """
try: try:
query = _objectName.objects.all() query = _objectName.objects.all()
# Si on a un état à exclure # Si on a un état à exclure
if _excludeState is not None: if _excludeStates is not None:
query = query.filter(etat__lt=_excludeState) query = query.exclude(status__in=_excludeStates)
# Si on a un terme de recherche # Si on a un terme de recherche
if _searchTerm and _searchTerm.strip(): if _searchTerm and _searchTerm.strip():
terms = _searchTerm.lower().strip().split() terms = _searchTerm.lower().strip().split()
for term in terms: for term in terms:
query = query.filter( query = query.filter(
Q(eleve__nom__icontains=term) | Q(student__last_name__icontains=term) |
Q(eleve__prenom__icontains=term) Q(student__first_name__icontains=term)
) )
return query.order_by('eleve__nom', 'eleve__prenom') return query.order_by('student__last_name', 'student__first_name')
except _objectName.DoesNotExist: except _objectName.DoesNotExist:
logging.error(f"Aucun résultat n'a été trouvé - {_objectName.__name__} (recherche: {_searchTerm})") logging.error(f"Aucun résultat n'a été trouvé - {_objectName.__name__} (recherche: {_searchTerm})")
return None return 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:
related_obj.delete()
obj_name = str(obj) # Utiliser la méthode __str__
obj.delete()
return JsonResponse({'message': f'La suppression de l\'objet {obj_name} a été effectuée avec succès'}, safe=False)
except ObjectDoesNotExist:
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 typing import Final
from N3wtSchool import settings
WRONG_ID: Final = 1 WRONG_ID: Final = 1
INCOMPLETE: Final = 2 INCOMPLETE: Final = 2
@ -8,11 +9,14 @@ DIFFERENT_PASWWORD: Final = 5
PROFIL_NOT_EXISTS: Final = 6 PROFIL_NOT_EXISTS: Final = 6
MESSAGE_REINIT_PASSWORD: Final = 7 MESSAGE_REINIT_PASSWORD: Final = 7
EXPIRED_URL: Final = 8 EXPIRED_URL: Final = 8
PASSWORD_CHANGED: Final = 8 PASSWORD_CHANGED: Final = 9
WRONG_MAIL_FORMAT: Final = 9 WRONG_MAIL_FORMAT: Final = 10
PROFIL_INACTIVE: Final = 10 PROFIL_INACTIVE: Final = 11
MESSAGE_ACTIVATION_PROFILE: Final = 11 MESSAGE_ACTIVATION_PROFILE: Final = 12
PROFIL_ACTIVE: 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 = { returnMessage = {
WRONG_ID:'Identifiants invalides', WRONG_ID:'Identifiants invalides',
@ -22,7 +26,7 @@ returnMessage = {
DIFFERENT_PASWWORD: 'Les mots de passe ne correspondent pas', DIFFERENT_PASWWORD: 'Les mots de passe ne correspondent pas',
PROFIL_NOT_EXISTS: 'Aucun profil associé à cet utilisateur', PROFIL_NOT_EXISTS: 'Aucun profil associé à cet utilisateur',
MESSAGE_REINIT_PASSWORD: 'Un mail a été envoyé à l\'adresse \'%s\'', 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é', PASSWORD_CHANGED: 'Le mot de passe a été réinitialisé',
WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée', WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée',
PROFIL_INACTIVE: 'Le profil n\'est pas actif', PROFIL_INACTIVE: 'Le profil n\'est pas actif',

View File

@ -0,0 +1,228 @@
from django.core.mail import 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)
logger.info(f"Establishment trouvé: {establishment.name} (ID: {id_establishement})")
try:
# Récupérer les paramètres SMTP associés à l'établissement
smtp_settings = SMTPSettings.objects.get(establishment=establishment)
logger.info(f"Paramètres SMTP trouvés pour {establishment.name}: {smtp_settings.smtp_server}:{smtp_settings.smtp_port}")
# Créer une connexion SMTP avec les paramètres récupérés
connection = get_connection(
host=smtp_settings.smtp_server,
port=smtp_settings.smtp_port,
username=smtp_settings.smtp_user,
password=smtp_settings.smtp_password,
use_tls=smtp_settings.use_tls,
use_ssl=smtp_settings.use_ssl
)
return connection
except SMTPSettings.DoesNotExist:
logger.warning(f"Aucun paramètre SMTP spécifique trouvé pour l'établissement {establishment.name} (ID: {id_establishement})")
# Aucun paramètre SMTP spécifique, retournera None
return None
except Establishment.DoesNotExist:
logger.error(f"Aucun établissement trouvé avec l'ID {id_establishement}")
raise NotFound(f"Aucun établissement trouvé avec l'ID {id_establishement}")
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
logger.info(f"Utilisation de la connexion SMTP spécifique: {username}")
else:
from_email = settings.EMAIL_HOST_USER
logger.info(f"Utilisation de la configuration SMTP par défaut: {from_email}")
logger.info(f"From email: {from_email}")
logger.info(f"Configuration par défaut - Host: {settings.EMAIL_HOST}, Port: {settings.EMAIL_PORT}, Use TLS: {settings.EMAIL_USE_TLS}")
email = EmailMultiAlternatives(
subject=subject,
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"Settings : {connection}")
logger.error(f"Settings : {connection}")
logger.error(f"Type d'erreur: {type(e)}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def envoieReinitMotDePasse(recipients, code):
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)
def sendRegisterTeacher(recipients, establishment_id):
errorMessage = ''
try:
EMAIL_INSCRIPTION_SUBJECT = '[N3WT-SCHOOL] Bienvenue sur N3wt School (Enseignant)'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'email': recipients,
'establishment': establishment_id
}
connection = getConnection(establishment_id)
subject = EMAIL_INSCRIPTION_SUBJECT
html_message = render_to_string('emails/inscription_teacher.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
except Exception as e:
errorMessage = str(e)
return errorMessage

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 from xhtml2pdf import pisa
class PDFResult:
def __init__(self, content):
self.content = content
def render_to_pdf(template_src, context_dict={}): 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) template = get_template(template_src)
html = template.render(context_dict) html = template.render(context_dict)
result = BytesIO() result = BytesIO()
pdf = pisa.pisaDocument(BytesIO(html.encode("UTF-8")), result) pdf = pisa.pisaDocument(BytesIO(html.encode("UTF-8")), result)
if pdf.err: if pdf.err:
return HttpResponse("Invalid PDF", status_code=400, content_type='text/plain') # Lever une exception ou retourner None en cas d'erreur
return HttpResponse(result.getvalue(), content_type='application/pdf') 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,31 +13,43 @@ https://docs.djangoproject.com/en/5.0/ref/settings/
from pathlib import Path from pathlib import Path
import json import json
import os 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'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
MEDIA_URL = '/data/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'data')
LOGIN_REDIRECT_URL = '/GestionInscriptions/fichesInscriptions' BASE_URL = os.getenv('BASE_URL', 'http://localhost:3000')
LOGIN_REDIRECT_URL = '/Subscriptions/registerForms'
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ # 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! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = os.getenv('DJANGO_DEBUG', True)
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = ['*']
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'GestionInscriptions.apps.GestioninscriptionsConfig', 'Common.apps.CommonConfig',
'GestionLogin.apps.GestionloginConfig', 'Subscriptions.apps.GestioninscriptionsConfig',
'Auth.apps.GestionloginConfig',
'GestionMessagerie.apps.GestionMessagerieConfig', 'GestionMessagerie.apps.GestionMessagerieConfig',
'GestionEmail.apps.GestionEmailConfig',
'GestionNotification.apps.GestionNotificationConfig', 'GestionNotification.apps.GestionNotificationConfig',
'GestionEnseignants.apps.GestionenseignantsConfig', 'School.apps.SchoolConfig',
'Planning.apps.PlanningConfig',
'Establishment.apps.EstablishmentConfig',
'Settings.apps.SettingsConfig',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@ -49,17 +61,20 @@ INSTALLED_APPS = [
'django_celery_beat', 'django_celery_beat',
'N3wtSchool', 'N3wtSchool',
'drf_yasg', 'drf_yasg',
'rest_framework_simplejwt',
'channels',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', # Déplacez ici, avant CorsMiddleware 'django.middleware.common.CommonMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'N3wtSchool.middleware.ContentSecurityPolicyMiddleware'
] ]
@ -92,6 +107,73 @@ SESSION_CACHE_ALIAS = 'default'
WSGI_APPLICATION = 'N3wtSchool.wsgi.application' WSGI_APPLICATION = 'N3wtSchool.wsgi.application'
#LOGGING
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": { # Ajout des formateurs
"verbose": {
"format": "{asctime} [{levelname}] [{name}] {module}.{funcName} - {message}",
"style": "{",
"datefmt": "%Y-%m-%d %H:%M:%S"
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose", # Utilisation du formateur
},
},
"root": {
"handlers": ["console"],
"level": os.getenv("ROOT_LOG_LEVEL", "INFO"),
},
"loggers": {
"celery": {
"handlers": ["console"],
"level": os.getenv("CELERY_LOG_LEVEL", "INFO"),
"propagate": False,
},
"N3wtSchool": {
"handlers": ["console"],
"level": os.getenv("N3WTSCHOOL_LOG_LEVEL", "INFO"),
"propagate": False,
},
"GestionNotification": {
"handlers": ["console"],
"level": os.getenv("GESTION_NOTIFICATION_LOG_LEVEL", "INFO"),
"propagate": False,
},
"Auth": {
"handlers": ["console"],
"level": os.getenv("GESTION_LOGIN_LOG_LEVEL", "INFO"),
"propagate": False,
},
"Subscriptions": {
"handlers": ["console"],
"level": os.getenv("GESTION_INSCRIPTIONS_LOG_LEVEL", "DEBUG"),
"propagate": False,
},
"GestionMessagerie": {
"handlers": ["console"],
"level": os.getenv("GESTION_MESSAGERIE_LOG_LEVEL", "INFO"),
"propagate": False,
},
"GestionEmail": {
"handlers": ["console"],
"level": os.getenv("GESTION_EMAIL_LOG_LEVEL", "INFO"),
"propagate": False,
},
"School": {
"handlers": ["console"],
"level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"),
"propagate": False,
},
},
}
# Password validation # Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
@ -127,8 +209,6 @@ USE_I18N = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/ # https://docs.djangoproject.com/en/5.0/howto/static-files/
DEBUG = True
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
@ -147,72 +227,76 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
#################### Application Settings ############################## #################### Application Settings ##############################
######################################################################## ########################################################################
with open('GestionInscriptions/Configuration/application.json', 'r') as f:
jsonObject = json.load(f)
DJANGO_SUPERUSER_PASSWORD='admin' # Configuration de l'email de l'application
DJANGO_SUPERUSER_USERNAME='admin' EMAIL_HOST = os.getenv('EMAIL_HOST', 'smtp.example.com')
DJANGO_SUPERUSER_EMAIL='admin@n3wtschool.com' EMAIL_PORT = os.getenv('EMAIL_PORT', 587)
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
EMAIL_HOST='smtp.gmail.com' EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
EMAIL_PORT=587
EMAIL_HOST_USER=jsonObject['mailFrom']
EMAIL_HOST_PASSWORD=jsonObject['password']
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_USE_TLS = True EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'true').lower() == 'true'
EMAIL_USE_SSL = False EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'false').lower() == 'true'
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'
DOCUMENT_DIR = 'documents' DOCUMENT_DIR = 'documents'
CORS_ORIGIN_ALLOW_ALL = True # Configuration CORS temporaire pour debug
CORS_ALLOW_ALL_HEADERS = True
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True
CORS_ALLOWED_ORIGINS = [ # Configuration CORS spécifique pour la production
"http://localhost:3000" 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 = [ # Méthodes HTTP autorisées
"http://localhost:3000", # Front Next.js CORS_ALLOWED_METHODS = [
"http://localhost:8080" # Insomnia '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_HTTPONLY = False
CSRF_COOKIE_SECURE = False CSRF_COOKIE_SECURE = os.getenv('CSRF_COOKIE_SECURE', 'false').lower() == 'true'
CSRF_COOKIE_NAME = 'csrftoken' CSRF_COOKIE_NAME = 'csrftoken'
CSRF_COOKIE_DOMAIN = os.getenv('CSRF_COOKIE_DOMAIN', '')
USE_TZ = True USE_TZ = True
TZ_APPLI = 'Europe/Paris' 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 = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.postgresql', 'ENGINE': 'django.db.backends.postgresql',
"NAME": "school", "NAME": DB_NAME,
"USER": "postgres", "USER": DB_USER,
"PASSWORD": "postgres", "PASSWORD": DB_PASSWORD,
"HOST": "database", "HOST": DB_HOST,
"PORT": "5432", "PORT": DB_PORT,
} }
} }
AUTH_USER_MODEL = 'GestionLogin.Profil' AUTH_USER_MODEL = 'Auth.Profile'
AUTHENTICATION_BACKENDS = ('GestionLogin.backends.EmailBackend', ) AUTHENTICATION_BACKENDS = ('Auth.backends.EmailBackend', )
SILENCED_SYSTEM_CHECKS = ["auth.W004"] SILENCED_SYSTEM_CHECKS = ["auth.W004"]
EXPIRATION_URL_NB_DAYS = 7 EXPIRATION_URL_NB_DAYS = 7
@ -221,12 +305,17 @@ DATE_FORMAT = '%d-%m-%Y %H:%M'
EXPIRATION_SESSION_NB_SEC = 10 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 NB_MAX_PAGE = 100
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'GestionInscriptions.pagination.CustomPagination', 'DEFAULT_PAGINATION_CLASS': 'Subscriptions.pagination.CustomSubscriptionPagination',
'PAGE_SIZE': NB_RESULT_PER_PAGE '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' CELERY_BROKER_URL = 'redis://redis:6379/0'
@ -237,11 +326,37 @@ CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'Europe/Paris' CELERY_TIMEZONE = 'Europe/Paris'
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True 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_HOST = 'redis'
REDIS_PORT = 6379 REDIS_PORT = 6379
REDIS_DB = 0 REDIS_DB = 0
REDIS_PASSWORD = None 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',
}
# Django Channels Configuration
ASGI_APPLICATION = 'N3wtSchool.asgi.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('redis', 6379)],
},
},
}

View File

@ -16,6 +16,6 @@ def setup_periodic_tasks(sender, **kwargs):
PeriodicTask.objects.get_or_create( PeriodicTask.objects.get_or_create(
interval=schedule, # Utiliser l'intervalle défini ci-dessus interval=schedule, # Utiliser l'intervalle défini ci-dessus
name='Tâche périodique toutes les 5 secondes', name='Tâche périodique toutes les 5 secondes',
task='GestionInscriptions.tasks.check_for_signature_deadlines', # Remplacer par le nom de ta tâche task='Subscriptions.tasks.check_for_signature_deadlines', # Remplacer par le nom de ta tâche
kwargs=json.dumps({}) # Si nécessaire, ajoute kwargs=json.dumps({}) # Si nécessaire, ajoute
) )

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