feat: ajouter l'envoi par e-mail du planning hebdomadaire global aux éducateurs
AS Talange CI/CD Pipeline / Build & Run Unit Tests (push) Successful in 5m3s
AS Talange CI/CD Pipeline / Deploy to Test Environment (push) Successful in 7m7s

This commit is contained in:
2026-08-09 00:28:16 +02:00
parent 1e2bc23603
commit cb281aa3e0
5 changed files with 408 additions and 4 deletions
+5
View File
@@ -33,3 +33,8 @@ Thumbs.db
.env
.env.local
.env.*.local
# Database / Local Scripts
anonymize_db.sql
refresh_test_db.sh
@@ -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<Educateur> educateurs = educateurRepository.findAllWithCategorieAndEquipe();
List<Educateur> 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<CreneauEntrainement> creneaux = creneauRepository.findBySaisonFetch(saisonActive);
// Map creneau -> list of matching educateurs names
Map<Long, String> creneauEducateursNames = new HashMap<>();
for (CreneauEntrainement c : creneaux) {
List<String> 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<JourSemaine, List<CreneauEntrainement>> creneauxParJour = new LinkedHashMap<>();
for (JourSemaine jour : JourSemaine.values()) {
List<CreneauEntrainement> 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<JourSemaine, List<CreneauEntrainement>> creneauxParJour, Map<Long, String> creneauEducateursNames) {
StringBuilder contentBuilder = new StringBuilder();
for (Map.Entry<JourSemaine, List<CreneauEntrainement>> entry : creneauxParJour.entrySet()) {
JourSemaine jour = entry.getKey();
List<CreneauEntrainement> creneaux = entry.getValue();
contentBuilder.append("<div class=\"day-section\">");
contentBuilder.append("<h2 class=\"day-title\">").append(jour.getLibelle()).append("</h2>");
if (creneaux.isEmpty()) {
contentBuilder.append("<p class=\"no-training\">Aucun entraînement planifié ce jour.</p>");
} else {
contentBuilder.append("<table class=\"planning-table\">");
contentBuilder.append("<thead><tr>")
.append("<th>Horaires</th>")
.append("<th>Catégorie / Équipe</th>")
.append("<th>Terrain & Zone</th>")
.append("<th>Éducateurs référents</th>")
.append("</tr></thead><tbody>");
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("<tr>")
.append("<td class=\"time-cell\">").append(horaire).append("</td>")
.append("<td class=\"cat-cell\"><strong>").append(catStr).append("</strong></td>")
.append("<td class=\"terrain-cell\">").append(terrainZone).append("</td>")
.append("<td class=\"educ-cell\">").append(educateursStr).append("</td>")
.append("</tr>");
}
contentBuilder.append("</tbody></table>");
}
contentBuilder.append("</div>");
}
return """
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<style>
body { font-family: 'Segoe UI', Helvetica, Arial, sans-serif; background-color: #f4f6f8; margin: 0; padding: 20px; color: #1e293b; }
.container { max-width: 800px; margin: 0 auto; background: #ffffff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.header { background-color: #1e3a8a; color: #ffffff; padding: 28px 24px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.5px; }
.header p { margin: 8px 0 0 0; font-size: 14px; opacity: 0.9; }
.content { padding: 24px; }
.day-section { margin-bottom: 24px; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }
.day-title { background: #f8fafc; border-bottom: 2px solid #1e3a8a; color: #1e3a8a; margin: 0; padding: 12px 18px; font-size: 16px; font-weight: 700; }
.no-training { padding: 14px 18px; margin: 0; font-size: 13px; color: #64748b; font-style: italic; }
.planning-table { width: 100%%; border-collapse: collapse; font-size: 13px; text-align: left; }
.planning-table th { background: #f1f5f9; color: #475569; font-weight: 600; padding: 10px 14px; border-bottom: 1px solid #e2e8f0; }
.planning-table td { padding: 10px 14px; border-bottom: 1px solid #f1f5f9; }
.planning-table tr:last-child td { border-bottom: none; }
.time-cell { font-weight: 600; color: #2563eb; white-space: nowrap; width: 120px; }
.cat-cell { color: #0f172a; }
.terrain-cell { color: #475569; }
.educ-cell { color: #059669; font-weight: 500; }
.footer { background: #f8fafc; border-top: 1px solid #e2e8f0; padding: 18px; text-align: center; font-size: 12px; color: #64748b; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>AS TALANGE - Planning Global des Entraînements</h1>
<p>Saison %s - Vue d'ensemble hebdomadaire pour le corps éducatif</p>
</div>
<div class="content">
<p style="font-size: 14px; line-height: 1.6; margin-bottom: 24px;">
Chers éducateurs,<br>
Voici le planning général d'occupation des terrains et des créneaux d'entraînement de l'<strong>AS Talange</strong> pour toute la semaine.
</p>
%s
<p style="margin-top: 24px; font-size: 13px; color: #475569;">
En cas de changement ou de modification sur les créneaux, merci d'en informer le responsable technique du club.<br><br>
Sportivement,<br>
<strong>Le Bureau & la Direction Technique - AS Talange</strong>
</p>
</div>
<div class="footer">
Cet e-mail automatique est destiné à l'ensemble du corps éducatif de l'AS Talange.
</div>
</div>
</body>
</html>
""".formatted(saison.getNom(), contentBuilder.toString());
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -48,14 +48,27 @@
<div th:if="${success}" class="bg-green-50 border-l-4 border-green-500 p-4 mb-6 rounded-lg">
<p class="text-sm text-green-700 font-medium" th:text="${success}"></p>
</div>
<div th:if="${error}" class="bg-red-50 border-l-4 border-red-500 p-4 mb-6 rounded-lg">
<p class="text-sm text-red-700 font-medium" th:text="${error}"></p>
</div>
<!-- Page actions -->
<div class="flex justify-between items-center mb-6" th:if="${saisonActive != null}">
<h3 class="text-xl font-bold text-gray-900">Planning Hebdomadaire</h3>
<div class="flex items-center space-x-3">
<form th:action="@{/planning/envoyer-educateurs(jour=${selectedDay != null ? selectedDay.name() : 'LUNDI'})}" method="post" onsubmit="return confirm('Êtes-vous sûr de vouloir envoyer le planning global de la semaine par e-mail à tous les éducateurs ?');">
<button type="submit" class="bg-emerald-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-emerald-700 shadow transition-colors flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
<span>Envoyer le planning aux éducateurs</span>
</button>
</form>
<a th:href="@{/planning/new}" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 shadow transition-colors">
+ Ajouter un Créneau
</a>
</div>
</div>
<!-- Tabs: Days of the week -->
<div class="mb-6 border-b border-gray-200" th:if="${saisonActive != null}">