From cb281aa3e095d6c332e2647be5d3b03daf1bd2e5 Mon Sep 17 00:00:00 2001 From: Youssef Date: Sun, 9 Aug 2026 00:28:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ajouter=20l'envoi=20par=20e-mail=20du?= =?UTF-8?q?=20planning=20hebdomadaire=20global=20aux=20=C3=A9ducateurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + .../core/service/PlanningEmailService.java | 245 ++++++++++++++++++ .../service/PlanningEmailServiceTest.java | 125 +++++++++ .../web/controller/PlanningController.java | 18 +- .../templates/planning/planning.html | 19 +- 5 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 as-talange-core/src/main/java/com/astalange/core/service/PlanningEmailService.java create mode 100644 as-talange-core/src/test/java/com/astalange/core/service/PlanningEmailServiceTest.java diff --git a/.gitignore b/.gitignore index 0a99a60..72c202e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,8 @@ Thumbs.db .env .env.local .env.*.local + +# Database / Local Scripts +anonymize_db.sql +refresh_test_db.sh + diff --git a/as-talange-core/src/main/java/com/astalange/core/service/PlanningEmailService.java b/as-talange-core/src/main/java/com/astalange/core/service/PlanningEmailService.java new file mode 100644 index 0000000..06122a5 --- /dev/null +++ b/as-talange-core/src/main/java/com/astalange/core/service/PlanningEmailService.java @@ -0,0 +1,245 @@ +package com.astalange.core.service; + +import com.astalange.core.entity.*; +import com.astalange.core.repository.CreneauEntrainementRepository; +import com.astalange.core.repository.EducateurRepository; +import com.astalange.core.repository.SaisonRepository; +import jakarta.mail.internet.MimeMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +@Service +public class PlanningEmailService { + + private static final Logger log = LoggerFactory.getLogger(PlanningEmailService.class); + + private final JavaMailSender mailSender; + private final SaisonRepository saisonRepository; + private final CreneauEntrainementRepository creneauRepository; + private final EducateurRepository educateurRepository; + + @Value("${app.mail.from:${spring.mail.username:astalange1933@gmail.com}}") + private String fromEmail = "astalange1933@gmail.com"; + + public PlanningEmailService(@Autowired(required = false) JavaMailSender mailSender, + SaisonRepository saisonRepository, + CreneauEntrainementRepository creneauRepository, + EducateurRepository educateurRepository) { + this.mailSender = mailSender; + this.saisonRepository = saisonRepository; + this.creneauRepository = creneauRepository; + this.educateurRepository = educateurRepository; + } + + private String getEffectiveFromEmail() { + if (fromEmail != null && !fromEmail.trim().isEmpty() && fromEmail.contains("@")) { + return fromEmail.trim(); + } + return "astalange1933@gmail.com"; + } + + /** + * Envoie le planning global hebdomadaire à tous les éducateurs enregistrés possédant un e-mail. + * @return Le nombre d'éducateurs à qui l'e-mail a été envoyé (ou planifié). + */ + public int sendPlanningGlobalToEducateurs() { + Saison saisonActive = saisonRepository.findByEstActiveTrue().orElse(null); + if (saisonActive == null) { + log.warn("Impossible d'envoyer le planning : aucune saison active."); + return 0; + } + + List educateurs = educateurRepository.findAllWithCategorieAndEquipe(); + List recipientEducateurs = educateurs.stream() + .filter(e -> e.getEmail() != null && !e.getEmail().trim().isEmpty()) + .collect(Collectors.toList()); + + if (recipientEducateurs.isEmpty()) { + log.warn("Aucun éducateur avec adresse e-mail valide trouvé."); + return 0; + } + + List creneaux = creneauRepository.findBySaisonFetch(saisonActive); + + // Map creneau -> list of matching educateurs names + Map creneauEducateursNames = new HashMap<>(); + for (CreneauEntrainement c : creneaux) { + List names = new ArrayList<>(); + for (Educateur ed : educateurs) { + boolean match = false; + if (c.getEquipe() != null) { + if (ed.getEquipe() != null && ed.getEquipe().getId().equals(c.getEquipe().getId())) { + match = true; + } + } else if (c.getCategorie() != null) { + if (ed.getCategorie() != null && ed.getCategorie().getId().equals(c.getCategorie().getId())) { + match = true; + } else if (ed.getEquipe() != null && ed.getEquipe().getCategorie() != null + && ed.getEquipe().getCategorie().getId().equals(c.getCategorie().getId())) { + match = true; + } + } + if (match) { + names.add(ed.getPrenom() + " " + ed.getNom()); + } + } + creneauEducateursNames.put(c.getId(), names.isEmpty() ? "-" : String.join(", ", names)); + } + + // Group creneaux by JourSemaine + Map> creneauxParJour = new LinkedHashMap<>(); + for (JourSemaine jour : JourSemaine.values()) { + List listForJour = creneaux.stream() + .filter(c -> c.getJourSemaine() == jour) + .sorted(Comparator.comparing(CreneauEntrainement::getHeureDebut)) + .collect(Collectors.toList()); + creneauxParJour.put(jour, listForJour); + } + + String subject = "AS Talange - Planning Hebdomadaire des Entraînements (" + saisonActive.getNom() + ")"; + String htmlBody = buildPlanningGlobalHtml(saisonActive, creneauxParJour, creneauEducateursNames); + + for (Educateur ed : recipientEducateurs) { + String recipientEmail = ed.getEmail().trim(); + CompletableFuture.runAsync(() -> { + if (mailSender != null) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + helper.setFrom(getEffectiveFromEmail()); + helper.setTo(recipientEmail); + helper.setSubject(subject); + helper.setText(htmlBody, true); + + mailSender.send(message); + log.info("Planning global envoyé avec succès par e-mail à {} ({})", recipientEmail, ed.getPrenom() + " " + ed.getNom()); + } catch (Exception e) { + log.warn("Erreur d'envoi du planning par e-mail à {}: {}. Simulation log.", recipientEmail, e.getMessage()); + logDevEmail(recipientEmail, subject, htmlBody); + } + } else { + log.info("Mode DEV (MailSender non configuré) : simulation d'envoi du planning global à {}.", recipientEmail); + logDevEmail(recipientEmail, subject, htmlBody); + } + }); + } + + return recipientEducateurs.size(); + } + + private void logDevEmail(String recipient, String subject, String body) { + log.info("==================== [SIMULATION E-MAIL PLANNING GLOBAL] ===================="); + log.info("Destinataire: {}", recipient); + log.info("Sujet: {}", subject); + log.info("Contenu:\n{}", body); + log.info("============================================================================="); + } + + private String buildPlanningGlobalHtml(Saison saison, Map> creneauxParJour, Map creneauEducateursNames) { + StringBuilder contentBuilder = new StringBuilder(); + + for (Map.Entry> entry : creneauxParJour.entrySet()) { + JourSemaine jour = entry.getKey(); + List creneaux = entry.getValue(); + + contentBuilder.append("
"); + contentBuilder.append("

").append(jour.getLibelle()).append("

"); + + if (creneaux.isEmpty()) { + contentBuilder.append("

Aucun entraînement planifié ce jour.

"); + } else { + contentBuilder.append(""); + contentBuilder.append("") + .append("") + .append("") + .append("") + .append("") + .append(""); + + for (CreneauEntrainement c : creneaux) { + String horaire = (c.getHeureDebut() != null ? c.getHeureDebut().toString() : "") + " - " + (c.getHeureFin() != null ? c.getHeureFin().toString() : ""); + String catStr = c.getCategorie() != null ? c.getCategorie().getNom() : ""; + if (c.getEquipe() != null) { + catStr += " (" + c.getEquipe().getNom() + ")"; + } + String terrainZone = (c.getTerrain() != null ? c.getTerrain().getNom() : "") + " - " + (c.getZone() != null ? c.getZone().getLibelle() : ""); + String educateursStr = creneauEducateursNames.getOrDefault(c.getId(), "-"); + + contentBuilder.append("") + .append("") + .append("") + .append("") + .append("") + .append(""); + } + + contentBuilder.append("
HorairesCatégorie / ÉquipeTerrain & ZoneÉducateurs référents
").append(horaire).append("").append(catStr).append("").append(terrainZone).append("").append(educateursStr).append("
"); + } + contentBuilder.append("
"); + } + + return """ + + + + + + + +
+
+

AS TALANGE - Planning Global des Entraînements

+

Saison %s - Vue d'ensemble hebdomadaire pour le corps éducatif

+
+
+

+ Chers éducateurs,
+ Voici le planning général d'occupation des terrains et des créneaux d'entraînement de l'AS Talange pour toute la semaine. +

+ + %s + +

+ En cas de changement ou de modification sur les créneaux, merci d'en informer le responsable technique du club.

+ Sportivement,
+ Le Bureau & la Direction Technique - AS Talange +

+
+ +
+ + + """.formatted(saison.getNom(), contentBuilder.toString()); + } +} diff --git a/as-talange-core/src/test/java/com/astalange/core/service/PlanningEmailServiceTest.java b/as-talange-core/src/test/java/com/astalange/core/service/PlanningEmailServiceTest.java new file mode 100644 index 0000000..a5ecd92 --- /dev/null +++ b/as-talange-core/src/test/java/com/astalange/core/service/PlanningEmailServiceTest.java @@ -0,0 +1,125 @@ +package com.astalange.core.service; + +import com.astalange.core.entity.*; +import com.astalange.core.repository.CreneauEntrainementRepository; +import com.astalange.core.repository.EducateurRepository; +import com.astalange.core.repository.SaisonRepository; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mail.javamail.JavaMailSender; + +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class PlanningEmailServiceTest { + + private PlanningEmailService planningEmailService; + private JavaMailSender mailSenderMock; + private SaisonRepository saisonRepositoryMock; + private CreneauEntrainementRepository creneauRepositoryMock; + private EducateurRepository educateurRepositoryMock; + + @BeforeEach + void setUp() { + mailSenderMock = mock(JavaMailSender.class); + MimeMessage mimeMessage = new MimeMessage((jakarta.mail.Session) null); + when(mailSenderMock.createMimeMessage()).thenReturn(mimeMessage); + + saisonRepositoryMock = mock(SaisonRepository.class); + creneauRepositoryMock = mock(CreneauEntrainementRepository.class); + educateurRepositoryMock = mock(EducateurRepository.class); + + planningEmailService = new PlanningEmailService( + mailSenderMock, + saisonRepositoryMock, + creneauRepositoryMock, + educateurRepositoryMock + ); + } + + @Test + void testSendPlanningGlobalToEducateurs_SansSaisonActive() { + when(saisonRepositoryMock.findByEstActiveTrue()).thenReturn(Optional.empty()); + + int count = planningEmailService.sendPlanningGlobalToEducateurs(); + + assertEquals(0, count); + verify(mailSenderMock, never()).send(any(MimeMessage.class)); + } + + @Test + void testSendPlanningGlobalToEducateurs_SansEducateursAvecEmail() { + Saison saison = new Saison(); + saison.setNom("2026/2027"); + when(saisonRepositoryMock.findByEstActiveTrue()).thenReturn(Optional.of(saison)); + + Educateur ed1 = new Educateur(); + ed1.setNom("DUPONT"); + ed1.setPrenom("Jean"); + ed1.setEmail(null); + + Educateur ed2 = new Educateur(); + ed2.setNom("MARTIN"); + ed2.setPrenom("Paul"); + ed2.setEmail(""); + + when(educateurRepositoryMock.findAllWithCategorieAndEquipe()).thenReturn(List.of(ed1, ed2)); + + int count = planningEmailService.sendPlanningGlobalToEducateurs(); + + assertEquals(0, count); + verify(mailSenderMock, never()).send(any(MimeMessage.class)); + } + + @Test + void testSendPlanningGlobalToEducateurs_Succes() { + Saison saison = new Saison(); + saison.setNom("2026/2027"); + when(saisonRepositoryMock.findByEstActiveTrue()).thenReturn(Optional.of(saison)); + + Educateur ed1 = new Educateur(); + ed1.setId(1L); + ed1.setNom("DUPONT"); + ed1.setPrenom("Jean"); + ed1.setEmail("jean.dupont@example.com"); + + Educateur ed2 = new Educateur(); + ed2.setId(2L); + ed2.setNom("BERNARD"); + ed2.setPrenom("Luc"); + ed2.setEmail("luc.bernard@example.com"); + + when(educateurRepositoryMock.findAllWithCategorieAndEquipe()).thenReturn(List.of(ed1, ed2)); + + Categorie cat = new Categorie(); + cat.setId(10L); + cat.setNom("U13"); + + Terrain terrain = new Terrain(); + terrain.setId(100L); + terrain.setNom("Terrain Synthétique"); + + CreneauEntrainement creneau = new CreneauEntrainement(); + creneau.setId(50L); + creneau.setSaison(saison); + creneau.setCategorie(cat); + creneau.setTerrain(terrain); + creneau.setZone(ZoneTerrain.DEMI_A); + creneau.setJourSemaine(JourSemaine.MERCREDI); + creneau.setHeureDebut(LocalTime.of(14, 0)); + creneau.setHeureFin(LocalTime.of(16, 0)); + + when(creneauRepositoryMock.findBySaisonFetch(saison)).thenReturn(List.of(creneau)); + + int count = planningEmailService.sendPlanningGlobalToEducateurs(); + + assertEquals(2, count); + verify(mailSenderMock, timeout(2000).times(2)).send(any(MimeMessage.class)); + } +} diff --git a/as-talange-web/src/main/java/com/astalange/web/controller/PlanningController.java b/as-talange-web/src/main/java/com/astalange/web/controller/PlanningController.java index f2ee7ce..0c7d762 100644 --- a/as-talange-web/src/main/java/com/astalange/web/controller/PlanningController.java +++ b/as-talange-web/src/main/java/com/astalange/web/controller/PlanningController.java @@ -7,6 +7,7 @@ import com.astalange.core.repository.EducateurRepository; import com.astalange.core.repository.EquipeRepository; import com.astalange.core.repository.SaisonRepository; import com.astalange.core.repository.TerrainRepository; +import com.astalange.core.service.PlanningEmailService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; @@ -25,19 +26,22 @@ public class PlanningController { private final SaisonRepository saisonRepository; private final EquipeRepository equipeRepository; private final EducateurRepository educateurRepository; + private final PlanningEmailService planningEmailService; public PlanningController(CreneauEntrainementRepository creneauRepository, TerrainRepository terrainRepository, CategorieRepository categorieRepository, SaisonRepository saisonRepository, EquipeRepository equipeRepository, - EducateurRepository educateurRepository) { + EducateurRepository educateurRepository, + PlanningEmailService planningEmailService) { this.creneauRepository = creneauRepository; this.terrainRepository = terrainRepository; this.categorieRepository = categorieRepository; this.saisonRepository = saisonRepository; this.equipeRepository = equipeRepository; this.educateurRepository = educateurRepository; + this.planningEmailService = planningEmailService; } @GetMapping @@ -202,4 +206,16 @@ public class PlanningController { redirectAttributes.addFlashAttribute("success", "Créneau d'entraînement supprimé avec succès."); return "redirect:/planning?jour=" + jour.name(); } + + @PostMapping("/envoyer-educateurs") + public String envoyerPlanningEducateurs(@RequestParam(required = false) JourSemaine jour, RedirectAttributes redirectAttributes) { + int count = planningEmailService.sendPlanningGlobalToEducateurs(); + if (count > 0) { + redirectAttributes.addFlashAttribute("success", "Le planning global de la semaine a été envoyé par e-mail à " + count + " éducateur(s)."); + } else { + redirectAttributes.addFlashAttribute("error", "Aucun e-mail n'a pu être envoyé. Vérifiez qu'une saison est active et que des éducateurs possèdent un e-mail valide."); + } + String returnJour = (jour != null) ? jour.name() : "LUNDI"; + return "redirect:/planning?jour=" + returnJour; + } } diff --git a/as-talange-web/src/main/resources/templates/planning/planning.html b/as-talange-web/src/main/resources/templates/planning/planning.html index 08d3878..2c40730 100644 --- a/as-talange-web/src/main/resources/templates/planning/planning.html +++ b/as-talange-web/src/main/resources/templates/planning/planning.html @@ -48,13 +48,26 @@

+
+

+

Planning Hebdomadaire

- - + Ajouter un Créneau - +
+
+ +
+ + + Ajouter un Créneau + +