From 76d7a28678428a31e1d583a77318402d390af3f7 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Thu, 11 Jul 2024 23:02:29 +0200 Subject: [PATCH 01/37] wip: club --- pom.xml | 6 + .../data/model/AffiliationRequestModel.java | 30 +-- .../domain/service/AffiliationService.java | 45 +++- .../ffsaf/domain/service/ClubService.java | 67 ++++++ .../ffsaf/net2/data/SimpleClubModel.java | 4 +- .../ffsaf/rest/AffiliationEndpoints.java | 72 ++++++- .../titionfire/ffsaf/rest/AssoEndpoints.java | 25 --- .../titionfire/ffsaf/rest/ClubEndpoints.java | 141 ++++++++++++- .../titionfire/ffsaf/rest/CombEndpoints.java | 38 +--- .../ffsaf/rest/data/SimpleAffiliation.java | 30 +++ .../ffsaf/rest/data/SimpleClub.java | 53 +++++ .../ffsaf/rest/from/AffiliationForm.java | 4 + .../rest/from/AffiliationRequestForm.java | 90 ++++---- .../ffsaf/rest/from/FullClubForm.java | 23 +++ .../fr/titionfire/ffsaf/utils/RoleAsso.java | 10 +- .../ffsaf/utils/StringSimilarity.java | 65 ++++++ .../java/fr/titionfire/ffsaf/utils/Utils.java | 54 ++++- src/main/webapp/index.html | 4 + src/main/webapp/package-lock.json | 30 +++ src/main/webapp/package.json | 2 + .../src/components/MemberCustomFiels.jsx | 16 +- src/main/webapp/src/components/Nav.jsx | 2 +- src/main/webapp/src/components/SearchBar.jsx | 43 ++++ src/main/webapp/src/pages/DemandeAff.jsx | 102 ++++++---- src/main/webapp/src/pages/MemberList.jsx | 36 +--- src/main/webapp/src/pages/admin/AdminRoot.jsx | 20 ++ .../admin/affiliation/AffiliationReqPage.jsx | 16 ++ .../src/pages/admin/club/AffiliationCard.jsx | 187 +++++++++++++++++ .../webapp/src/pages/admin/club/ClubList.jsx | 192 ++++++++++++++++++ .../webapp/src/pages/admin/club/ClubPage.jsx | 128 ++++++++++++ .../src/pages/admin/club/NewClubPage.jsx | 16 ++ .../pages/admin/member/InformationForm.jsx | 11 +- .../src/pages/club/member/InformationForm.jsx | 11 +- 33 files changed, 1346 insertions(+), 227 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/SimpleAffiliation.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationForm.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java create mode 100644 src/main/java/fr/titionfire/ffsaf/utils/StringSimilarity.java create mode 100644 src/main/webapp/src/components/SearchBar.jsx create mode 100644 src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx create mode 100644 src/main/webapp/src/pages/admin/club/AffiliationCard.jsx create mode 100644 src/main/webapp/src/pages/admin/club/ClubList.jsx create mode 100644 src/main/webapp/src/pages/admin/club/ClubPage.jsx create mode 100644 src/main/webapp/src/pages/admin/club/NewClubPage.jsx diff --git a/pom.xml b/pom.xml index 6c3aafc..059225b 100644 --- a/pom.xml +++ b/pom.xml @@ -109,6 +109,12 @@ io.quarkus quarkus-websockets + + + net.sf.jmimemagic + jmimemagic + 0.1.3 + diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/AffiliationRequestModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/AffiliationRequestModel.java index fc09800..1c6f38e 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/AffiliationRequestModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/AffiliationRequestModel.java @@ -1,5 +1,6 @@ package fr.titionfire.ffsaf.data.model; +import fr.titionfire.ffsaf.utils.RoleAsso; import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.*; import lombok.*; @@ -20,24 +21,27 @@ public class AffiliationRequestModel { Long id; String name; - String siren; + long siren; String RNA; String address; - String president_lname; - String president_fname; - String president_email; - int president_lincence; + String m1_lname; + String m1_fname; + String m1_email; + int m1_lincence; + RoleAsso m1_role; - String tresorier_lname; - String tresorier_fname; - String tresorier_email; - int tresorier_lincence; + String m2_lname; + String m2_fname; + String m2_email; + int m2_lincence; + RoleAsso m2_role; - String secretaire_lname; - String secretaire_fname; - String secretaire_email; - int secretaire_lincence; + String m3_lname; + String m3_fname; + String m3_email; + int m3_lincence; + RoleAsso m3_role; int saison; } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index 071b9a8..0a642b0 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -1,8 +1,11 @@ package fr.titionfire.ffsaf.domain.service; import fr.titionfire.ffsaf.data.model.AffiliationRequestModel; +import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.data.repository.AffiliationRequestRepository; import fr.titionfire.ffsaf.data.repository.CombRepository; +import fr.titionfire.ffsaf.rest.data.SimpleAffiliation; +import fr.titionfire.ffsaf.rest.from.AffiliationForm; import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm; import fr.titionfire.ffsaf.utils.Utils; import io.quarkus.hibernate.reactive.panache.Panache; @@ -12,6 +15,9 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import java.util.List; +import java.util.function.Consumer; + @WithSession @ApplicationScoped public class AffiliationService { @@ -29,32 +35,49 @@ public class AffiliationService { AffiliationRequestModel affModel = form.toModel(); affModel.setSaison(Utils.getSaison()); + // noinspection ResultOfMethodCallIgnored return Uni.createFrom().item(affModel) - .call(model -> ((model.getPresident_lincence() != 0) ? combRepository.find("licence", - model.getPresident_lincence()).count().invoke(count -> { + .call(model -> ((model.getM1_lincence() != 0) ? combRepository.find("licence", + model.getM1_lincence()).count().invoke(count -> { if (count == 0) { - throw new IllegalArgumentException("Licence président inconnue"); + throw new IllegalArgumentException("Licence membre n°1 inconnue"); } }) : Uni.createFrom().nullItem()) ) - .call(model -> ((model.getTresorier_lincence() != 0) ? combRepository.find("licence", - model.getTresorier_lincence()).count().invoke(count -> { + .call(model -> ((model.getM2_lincence() != 0) ? combRepository.find("licence", + model.getM2_lincence()).count().invoke(count -> { if (count == 0) { - throw new IllegalArgumentException("Licence trésorier inconnue"); + throw new IllegalArgumentException("Licence membre n°2 inconnue"); } }) : Uni.createFrom().nullItem()) ) - .call(model -> ((model.getSecretaire_lincence() != 0) ? combRepository.find("licence", - model.getSecretaire_lincence()).count().invoke(count -> { + .call(model -> ((model.getM3_lincence() != 0) ? combRepository.find("licence", + model.getM3_lincence()).count().invoke(count -> { if (count == 0) { - throw new IllegalArgumentException("Licence secrétaire inconnue"); + throw new IllegalArgumentException("Licence membre n°3 inconnue"); } }) : Uni.createFrom().nullItem()) ).chain(model -> Panache.withTransaction(() -> repository.persist(model))) - .call(model -> Uni.createFrom().future(Utils.replacePhoto(model.getId(), form.getLogo(), media, + .onItem().invoke(model -> Uni.createFrom().future(Utils.replacePhoto(model.getId(), form.getLogo(), media, "aff_request/logo"))) - .call(model -> Uni.createFrom().future(Utils.replacePhoto(model.getId(), form.getStatus(), media, + .onItem().invoke(model -> Uni.createFrom().future(Utils.replacePhoto(model.getId(), form.getStatus(), media, "aff_request/status"))) .map(__ -> "Ok"); } + + public Uni> getCurrentSaisonAffiliation() { + return Uni.createFrom().nullItem(); // TODO + } + + public Uni> getAffiliation(long id, Consumer checkPerm) { + return Uni.createFrom().nullItem(); // TODO + } + + public Uni setAffiliation(long id, AffiliationForm form) { + return Uni.createFrom().nullItem(); // TODO + } + + public Uni deleteAffiliation(long id) { + return Uni.createFrom().nullItem(); // TODO + } } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java index 4169c52..6bb1081 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java @@ -3,12 +3,20 @@ package fr.titionfire.ffsaf.domain.service; import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.data.repository.ClubRepository; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; +import fr.titionfire.ffsaf.rest.from.FullClubForm; +import fr.titionfire.ffsaf.utils.PageResult; import io.quarkus.hibernate.reactive.panache.Panache; +import io.quarkus.hibernate.reactive.panache.PanacheQuery; import io.quarkus.hibernate.reactive.panache.common.WithSession; +import io.quarkus.panache.common.Page; +import io.quarkus.panache.common.Sort; import io.quarkus.vertx.VertxContextSupport; import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.ws.rs.BadRequestException; +import org.hibernate.reactive.mutiny.Mutiny; import java.util.Collection; import java.util.List; @@ -39,4 +47,63 @@ public class ClubService { return Panache.withTransaction(() -> repository.persist(clubModel)); }); } + + public Uni> search(Integer limit, int page, String search, String country) { + if (search == null) + search = ""; + search = search + "%"; + + PanacheQuery query; + + if (country == null || country.isBlank()) + query = repository.find("name LIKE ?1", + Sort.ascending("name"), search).page(Page.ofSize(limit)); + else + query = repository.find("name LIKE ?1 AND country LIKE ?2", + Sort.ascending("name"), search, country + "%").page(Page.ofSize(limit)); + return getPageResult(query, limit, page); + } + + private Uni> getPageResult(PanacheQuery query, int limit, int page) { + return Uni.createFrom().item(new PageResult()) + .invoke(result -> result.setPage(page)) + .invoke(result -> result.setPage_size(limit)) + .call(result -> query.count().invoke(result::setResult_count)) + .call(result -> query.pageCount() + .invoke(Unchecked.consumer(pages -> { + if (page > pages) throw new BadRequestException(); + })) + .invoke(result::setPage_count)) + .call(result -> query.page(Page.of(page, limit)).list() + .map(membreModels -> membreModels.stream().map(SimpleClubModel::fromModel).toList()) + .invoke(result::setResult)); + } + + public Uni getById(long id) { + return repository.findById(id).call(m -> Mutiny.fetch(m.getContact())); + } + + public Uni getByClubId(String clubId) { + return repository.find("clubId", clubId).firstResult(); + } + + public Uni update(long id, FullClubForm input) { + /*return repository.findById(id) + .onItem().transformToUni(m -> { + m.setName(input.getName()); + m.setCountry(input.getCountry()); + m.setNo_affiliation(input.getNo_affiliation()); + m.setShieldURL(input.getShieldURL()); + return Panache.withTransaction(() -> repository.persist(m)); + });*/ + return Uni.createFrom().nullItem(); + } + + public Uni add(FullClubForm input) { + return Uni.createFrom().nullItem(); + } + + public Uni delete(long id) { + return Uni.createFrom().nullItem(); + } } diff --git a/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java b/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java index e36daef..17d406c 100644 --- a/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java +++ b/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java @@ -17,11 +17,13 @@ public class SimpleClubModel { String name; String country; String shieldURL; + String no_affiliation; public static SimpleClubModel fromModel(ClubModel model) { if (model == null) return null; - return new SimpleClubModel(model.getId(), model.getName(), model.getCountry(), model.getShieldURL()); + return new SimpleClubModel(model.getId(), model.getName(), model.getCountry(), model.getShieldURL(), + model.getNo_affiliation()); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java index dc2bbbc..d44c8c1 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java @@ -1,29 +1,79 @@ package fr.titionfire.ffsaf.rest; +import fr.titionfire.ffsaf.data.model.ClubModel; +import fr.titionfire.ffsaf.domain.service.AffiliationService; +import fr.titionfire.ffsaf.rest.data.SimpleAffiliation; +import fr.titionfire.ffsaf.rest.from.AffiliationForm; import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm; +import fr.titionfire.ffsaf.utils.GroupeUtils; +import io.quarkus.oidc.IdToken; +import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.List; +import java.util.function.Consumer; @Path("api/affiliation") public class AffiliationEndpoints { + @Inject + AffiliationService service; + @Inject + @IdToken + JsonWebToken idToken; + + @Inject + SecurityIdentity securityIdentity; + + Consumer checkPerm = Unchecked.consumer(clubModel -> { + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(clubModel.getId(), idToken)) + throw new ForbiddenException(); + }); @POST - @Path("save") @Produces(MediaType.TEXT_PLAIN) @Consumes(MediaType.MULTIPART_FORM_DATA) public Uni saveAffRequest(AffiliationRequestForm form) { - System.out.println(form); - return Uni.createFrom().item("OK"); - } - /*@POST - @Path("affiliation") - @Produces(MediaType.TEXT_PLAIN) - @Consumes(MediaType.MULTIPART_FORM_DATA) - public Uni saveAffRequest(AffiliationRequestForm form) { - System.out.println(form); return service.save(form); - }*/ + } + + @GET + @Path("/current") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni> getCurrentSaisonLicenceAdmin() { + return service.getCurrentSaisonAffiliation(); + } + + @GET + @Path("{id}") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni> getLicence(@PathParam("id") long id) { + return service.getAffiliation(id, checkPerm); + } + + @POST + @Path("{id}") + @RolesAllowed("federation_admin") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Uni setLicence(@PathParam("id") long id, AffiliationForm form) { + return service.setAffiliation(id, form); + } + + @DELETE + @Path("{id}") + @RolesAllowed("federation_admin") + @Produces(MediaType.TEXT_PLAIN) + public Uni deleteLicence(@PathParam("id") long id) { + return service.deleteAffiliation(id); + } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java index 6bfa442..5868f4f 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java @@ -1,35 +1,18 @@ package fr.titionfire.ffsaf.rest; -import fr.titionfire.ffsaf.domain.service.AffiliationService; import fr.titionfire.ffsaf.rest.client.SirenService; import fr.titionfire.ffsaf.rest.data.UniteLegaleRoot; -import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm; import io.smallrye.mutiny.Uni; -import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; -import jodd.net.MimeTypes; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.rest.client.inject.RestClient; -import java.io.*; -import java.net.URLConnection; -import java.nio.file.Files; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Future; - @Path("api/asso") public class AssoEndpoints { @RestClient SirenService sirenService; - @Inject - AffiliationService service; - - @ConfigProperty(name = "upload_dir") - String media; - @GET @Path("siren/{siren}") @Produces(MediaType.APPLICATION_JSON) @@ -42,12 +25,4 @@ public class AssoEndpoints { return throwable; }); } - - @POST - @Path("affiliation") - @Produces(MediaType.TEXT_PLAIN) - @Consumes(MediaType.MULTIPART_FORM_DATA) - public Uni saveAffRequest(AffiliationRequestForm form) { - return service.save(form); - } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index 20aee87..7b4c918 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -1,16 +1,29 @@ package fr.titionfire.ffsaf.rest; +import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.domain.service.ClubService; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; +import fr.titionfire.ffsaf.rest.data.SimpleClub; +import fr.titionfire.ffsaf.rest.from.FullClubForm; +import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.PageResult; +import fr.titionfire.ffsaf.utils.Utils; +import io.quarkus.oidc.IdToken; import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; +import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; +import java.net.URISyntaxException; import java.util.List; +import java.util.function.Consumer; @Path("api/club") public class ClubEndpoints { @@ -18,6 +31,22 @@ public class ClubEndpoints { @Inject ClubService clubService; + @Inject + @IdToken + JsonWebToken idToken; + + @Inject + SecurityIdentity securityIdentity; + + @ConfigProperty(name = "upload_dir") + String media; + + Consumer checkPerm = Unchecked.consumer(membreModel -> { + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getId(), + idToken)) + throw new ForbiddenException(); + }); + @GET @Path("/no_detail") @Authenticated @@ -25,4 +54,110 @@ public class ClubEndpoints { public Uni> getAll() { return clubService.getAll().map(clubModels -> clubModels.stream().map(SimpleClubModel::fromModel).toList()); } + + @GET + @Path("/find") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni> getFindAdmin(@QueryParam("limit") Integer limit, + @QueryParam("page") Integer page, + @QueryParam("search") String search, + @QueryParam("country") String country) { + if (limit == null) + limit = 50; + if (page == null || page < 1) + page = 1; + return clubService.search(limit, page - 1, search, country); + } + + + @GET + @Path("{id}") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni getById(@PathParam("id") long id) { + return clubService.getById(id).onItem().invoke(checkPerm).map(SimpleClub::fromModel); + } + + @PUT + @Path("{id}") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Uni setAdminClub(@PathParam("id") long id, FullClubForm input) { + return clubService.update(id, input) + .invoke(Unchecked.consumer(out -> { + if (!out.equals("OK")) throw new InternalError("Fail to update data: " + out); + })).chain(() -> { + if (input.getLogo().length > 0) + return Uni.createFrom().future(Utils.replacePhoto(id, input.getLogo(), media, "ppClub" + )).invoke(Unchecked.consumer(out -> { + if (!out.equals("OK")) throw new InternalError("Fail to get MimeType " + out); + })); + else + return Uni.createFrom().nullItem(); + }).chain(() -> { + if (input.getStatus().length > 0) + return Uni.createFrom().future(Utils.replacePhoto(id, input.getStatus(), media, "clubStatus" + )).invoke(Unchecked.consumer(out -> { + if (!out.equals("OK")) throw new InternalError("Fail to get MimeType " + out); + })); + else + return Uni.createFrom().nullItem(); + }); + } + + @POST + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Uni addAdminClub(FullClubForm input) { + return clubService.add(input) + .invoke(Unchecked.consumer(id -> { + if (id == null) throw new InternalError("Fail to create club data"); + })).call(id -> { + if (input.getLogo().length > 0) + return Uni.createFrom().future(Utils.replacePhoto(id, input.getLogo(), media, "ppClub" + )); + else + return Uni.createFrom().nullItem(); + }).call(id -> { + if (input.getStatus().length > 0) + return Uni.createFrom().future(Utils.replacePhoto(id, input.getStatus(), media, "clubStatus" + )); + else + return Uni.createFrom().nullItem(); + }); + } + + @DELETE + @Path("{id}") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.TEXT_PLAIN) + public Uni deleteAdminClub(@PathParam("id") long id) { + return clubService.delete(id); + } + + + @GET + @Path("{clubId}/logo") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) + public Uni getLogo(@PathParam("clubId") String clubId) { + return clubService.getByClubId(clubId).onItem().invoke(checkPerm).chain(Unchecked.function(clubModel -> { + try { + return Utils.getMediaFile((clubModel != null) ? clubModel.getId() : -1, media, "ppClub", + Uni.createFrom().nullItem()); + } catch (URISyntaxException e) { + throw new InternalError(); + } + })); + } + + @GET + @Path("{id}/status") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire"}) + public Uni getStatus(@PathParam("id") long id) throws URISyntaxException { + return Utils.getMediaFile(id, media, "clubStatus", clubService.getById(id).onItem().invoke(checkPerm)); + } + } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java index d5c5dfa..b7c60f7 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java @@ -7,7 +7,6 @@ import fr.titionfire.ffsaf.rest.from.ClubMemberForm; import fr.titionfire.ffsaf.rest.from.FullMemberForm; import fr.titionfire.ffsaf.utils.GroupeUtils; import fr.titionfire.ffsaf.utils.PageResult; -import fr.titionfire.ffsaf.utils.Pair; import fr.titionfire.ffsaf.utils.Utils; import io.quarkus.oidc.IdToken; import io.quarkus.security.Authenticated; @@ -17,7 +16,6 @@ import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.*; -import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jodd.net.MimeTypes; @@ -25,7 +23,6 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.jwt.JsonWebToken; import java.io.*; -import java.net.URI; import java.net.URISyntaxException; import java.net.URLConnection; import java.nio.file.Files; @@ -51,7 +48,8 @@ public class CombEndpoints { SecurityIdentity securityIdentity; Consumer checkPerm = Unchecked.consumer(membreModel -> { - if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup( + membreModel.getClub().getId(), idToken)) throw new ForbiddenException(); }); @@ -213,37 +211,7 @@ public class CombEndpoints { @Path("{id}/photo") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) public Uni getPhoto(@PathParam("id") long id) throws URISyntaxException { - Future> future = CompletableFuture.supplyAsync(() -> { - FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id)); - File[] files = new File(media, "ppMembre").listFiles(filter); - if (files != null && files.length > 0) { - File file = files[0]; - try { - byte[] data = Files.readAllBytes(file.toPath()); - return new Pair<>(file, data); - } catch (IOException ignored) { - } - } - return null; - }); - - URI uri = new URI("https://mdbcdn.b-cdn.net/img/Photos/new-templates/bootstrap-chat/ava2.webp"); - - return membreService.getById(id).onItem().invoke(checkPerm).chain(__ -> Uni.createFrom().future(future) - .map(filePair -> { - if (filePair == null) - return Response.temporaryRedirect(uri).build(); - - String mimeType = URLConnection.guessContentTypeFromName(filePair.getKey().getName()); - - Response.ResponseBuilder resp = Response.ok(filePair.getValue()); - resp.type(MediaType.APPLICATION_OCTET_STREAM); - resp.header(HttpHeaders.CONTENT_LENGTH, filePair.getValue().length); - resp.header(HttpHeaders.CONTENT_TYPE, mimeType); - resp.header(HttpHeaders.CONTENT_DISPOSITION, "inline; "); - - return resp.build(); - })); + return Utils.getMediaFile(id, media, "ppMembre", membreService.getById(id).onItem().invoke(checkPerm)); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleAffiliation.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleAffiliation.java new file mode 100644 index 0000000..e4621c2 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleAffiliation.java @@ -0,0 +1,30 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.AffiliationModel; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +@RegisterForReflection +public class SimpleAffiliation { + Long id; + Long club; + int saison; + boolean validate; + + public static SimpleAffiliation fromModel(AffiliationModel model, boolean validate) { + if (model == null) + return null; + + return new SimpleAffiliationBuilder() + .id(model.getId()) + .club(model.getClub().getId()) + .saison(model.getSaison()) + .validate(validate) + .build(); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java new file mode 100644 index 0000000..7941562 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java @@ -0,0 +1,53 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.ClubModel; +import fr.titionfire.ffsaf.utils.Contact; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.ToString; + +import java.util.Map; + +@Data +@Builder +@ToString +@AllArgsConstructor +@RegisterForReflection +public class SimpleClub { + private Long id; + private String clubId; + private String name; + private String country; + private String shieldURL; + private Map contact; + private String training_location; + private String training_day_time; + private String contact_intern; + private String RNA; + private String SIRET; + private String no_affiliation; + private boolean international; + + public static SimpleClub fromModel(ClubModel model) { + if (model == null) + return null; + + return new SimpleClubBuilder() + .id(model.getId()) + .clubId(model.getClubId()) + .name(model.getName()) + .country(model.getCountry()) + .shieldURL(model.getShieldURL()) + .contact(model.getContact()) + .training_location(model.getTraining_location()) + .training_day_time(model.getTraining_day_time()) + .contact_intern(model.getContact_intern()) + .RNA(model.getRNA()) + .SIRET(model.getSIRET()) + .no_affiliation(model.getNo_affiliation()) + .international(model.isInternational()) + .build(); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationForm.java new file mode 100644 index 0000000..cfda16d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationForm.java @@ -0,0 +1,4 @@ +package fr.titionfire.ffsaf.rest.from; + +public class AffiliationForm { +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java index 04f002d..2657a71 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java @@ -1,6 +1,7 @@ package fr.titionfire.ffsaf.rest.from; import fr.titionfire.ffsaf.data.model.AffiliationRequestModel; +import fr.titionfire.ffsaf.utils.RoleAsso; import jakarta.ws.rs.FormParam; import jakarta.ws.rs.core.MediaType; import lombok.Getter; @@ -14,7 +15,7 @@ public class AffiliationRequestForm { private String name = null; @FormParam("siren") - private String siren = null; + private Long siren = null; @FormParam("rna") private String rna = null; @@ -30,32 +31,38 @@ public class AffiliationRequestForm { @PartType(MediaType.APPLICATION_OCTET_STREAM) private byte[] logo = new byte[0]; - @FormParam("president-nom") - private String president_lname = null; - @FormParam("president-prenom") - private String president_fname = null; - @FormParam("president-mail") - private String president_email = null; - @FormParam("president-licence") - private String president_lincence = null; + @FormParam("m1_nom") + private String m1_lname = null; + @FormParam("m1_prenom") + private String m1_fname = null; + @FormParam("m1_mail") + private String m1_email = null; + @FormParam("m1_licence") + private String m1_lincence = null; + @FormParam("m1_role") + private RoleAsso m1_role = null; - @FormParam("tresorier-nom") - private String tresorier_lname = null; - @FormParam("tresorier-prenom") - private String tresorier_fname = null; - @FormParam("tresorier-mail") - private String tresorier_email = null; - @FormParam("tresorier-licence") - private String tresorier_lincence = null; + @FormParam("m2_nom") + private String m2_lname = null; + @FormParam("m2_prenom") + private String m2_fname = null; + @FormParam("m2_mail") + private String m2_email = null; + @FormParam("m2_licence") + private String m2_lincence = null; + @FormParam("m2_role") + private RoleAsso m2_role = null; - @FormParam("secretaire-nom") - private String secretaire_lname = null; - @FormParam("secretaire-prenom") - private String secretaire_fname = null; - @FormParam("secretaire-mail") - private String secretaire_email = null; - @FormParam("secretaire-licence") - private String secretaire_lincence = null; + @FormParam("m3_nom") + private String m3_lname = null; + @FormParam("m3_prenom") + private String m3_fname = null; + @FormParam("m3_mail") + private String m3_email = null; + @FormParam("m3_licence") + private String m3_lincence = null; + @FormParam("m3_role") + private RoleAsso m3_role = null; public AffiliationRequestModel toModel() { AffiliationRequestModel model = new AffiliationRequestModel(); @@ -64,23 +71,26 @@ public class AffiliationRequestForm { model.setRNA(this.getRna()); model.setAddress(this.getAdresse()); - model.setPresident_lname(this.getPresident_lname()); - model.setPresident_fname(this.getPresident_fname()); - model.setPresident_email(this.getPresident_email()); - model.setPresident_lincence((this.getPresident_lincence() == null || this.getPresident_lincence().isBlank()) - ? 0 : Integer.parseInt(this.getPresident_lincence())); + model.setM1_lname(this.getM1_lname()); + model.setM1_fname(this.getM1_fname()); + model.setM1_email(this.getM1_email()); + model.setM1_lincence((this.getM1_lincence() == null || this.getM1_lincence().isBlank()) + ? 0 : Integer.parseInt(this.getM1_lincence())); + model.setM1_role(this.getM1_role()); - model.setTresorier_lname(this.getTresorier_lname()); - model.setTresorier_fname(this.getTresorier_fname()); - model.setTresorier_email(this.getTresorier_email()); - model.setTresorier_lincence((this.getPresident_lincence() == null || this.getPresident_lincence().isBlank()) - ? 0 : Integer.parseInt(this.getTresorier_lincence())); + model.setM2_lname(this.getM2_lname()); + model.setM2_fname(this.getM2_fname()); + model.setM2_email(this.getM2_email()); + model.setM2_lincence((this.getM1_lincence() == null || this.getM1_lincence().isBlank()) + ? 0 : Integer.parseInt(this.getM2_lincence())); + model.setM2_role(this.getM2_role()); - model.setSecretaire_lname(this.getSecretaire_lname()); - model.setSecretaire_fname(this.getSecretaire_fname()); - model.setSecretaire_email(this.getSecretaire_email()); - model.setSecretaire_lincence((this.getPresident_lincence() == null || this.getPresident_lincence().isBlank()) - ? 0 : Integer.parseInt(this.getSecretaire_lincence())); + model.setM3_lname(this.getM3_lname()); + model.setM3_fname(this.getM3_fname()); + model.setM3_email(this.getM3_email()); + model.setM3_lincence((this.getM1_lincence() == null || this.getM1_lincence().isBlank()) + ? 0 : Integer.parseInt(this.getM3_lincence())); + model.setM3_role(this.getM3_role()); return model; } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java new file mode 100644 index 0000000..fcd9108 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java @@ -0,0 +1,23 @@ +package fr.titionfire.ffsaf.rest.from; + +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.core.MediaType; +import lombok.Getter; +import org.jboss.resteasy.reactive.PartType; + +@Getter +public class FullClubForm { + @FormParam("id") + private String id = null; + + @FormParam("name") + private String name = null; + + @FormParam("status") + @PartType(MediaType.APPLICATION_OCTET_STREAM) + private byte[] status = new byte[0]; + + @FormParam("logo") + @PartType(MediaType.APPLICATION_OCTET_STREAM) + private byte[] logo = new byte[0]; +} diff --git a/src/main/java/fr/titionfire/ffsaf/utils/RoleAsso.java b/src/main/java/fr/titionfire/ffsaf/utils/RoleAsso.java index 84991b1..c591150 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/RoleAsso.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/RoleAsso.java @@ -5,9 +5,13 @@ import io.quarkus.runtime.annotations.RegisterForReflection; @RegisterForReflection public enum RoleAsso { MEMBRE("Membre", 0), - PRESIDENT("Président", 3), - TRESORIER("Trésorier", 1), - SECRETAIRE("Secrétaire", 2); + PRESIDENT("Président", 7), + VPRESIDENT("Vise-Président", 6), + SECRETAIRE("Secrétaire", 5), + VSECRETAIRE("Vise-Secrétaire", 4), + TRESORIER("Trésorier", 3), + VTRESORIER("Vise-Trésorier", 2), + MEMBREBUREAU("Membre bureau", 1); public final String name; public final int level; diff --git a/src/main/java/fr/titionfire/ffsaf/utils/StringSimilarity.java b/src/main/java/fr/titionfire/ffsaf/utils/StringSimilarity.java new file mode 100644 index 0000000..def85ee --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/utils/StringSimilarity.java @@ -0,0 +1,65 @@ +package fr.titionfire.ffsaf.utils; + +public class StringSimilarity { + + public static int similarity(String s1, String s2) { + String longer = s1, shorter = s2; + if (s1.length() < s2.length()) { + longer = s2; + shorter = s1; + } + int longerLength = longer.length(); + if (longerLength == 0) { + return 1; + } + return editDistance(longer, shorter); + + } + + public static int editDistance(String s1, String s2) { + s1 = s1.toLowerCase(); + s2 = s2.toLowerCase(); + + int[] costs = new int[s2.length() + 1]; + for (int i = 0; i <= s1.length(); i++) { + int lastValue = i; + for (int j = 0; j <= s2.length(); j++) { + if (i == 0) + costs[j] = j; + else { + if (j > 0) { + int newValue = costs[j - 1]; + if (s1.charAt(i - 1) != s2.charAt(j - 1)) + newValue = Math.min(Math.min(newValue, lastValue), + costs[j]) + 1; + costs[j - 1] = lastValue; + lastValue = newValue; + } + } + } + if (i > 0) + costs[s2.length()] = lastValue; + } + return costs[s2.length()]; + } + + public static void printSimilarity(String s, String t) { + System.out.printf( + "%d is the similarity between \"%s\" and \"%s\"%n", similarity(s, t), s, t); + } + + /*public static void main(String[] args) { + printSimilarity("Xavier Login", "Xavier Lojin"); + printSimilarity("Xavier Login", "Xavier ogin"); + printSimilarity("Xavier Login", "avier Login"); + printSimilarity("Xavier Login", "xavier login"); + printSimilarity("Xavier Login", "Xaviér Login"); + printSimilarity("Xavier Gomme Login", "Xavier Login"); + printSimilarity("Xavier Login", "Xavier Gomme Login"); + printSimilarity("Xavier Login", "Xavier"); + printSimilarity("Xavier Login", "Login"); + printSimilarity("Jule", "Julles"); + printSimilarity("Xavier", "Xaviér"); + printSimilarity("Xavier", "xavvie"); + }*/ +} diff --git a/src/main/java/fr/titionfire/ffsaf/utils/Utils.java b/src/main/java/fr/titionfire/ffsaf/utils/Utils.java index 88c4925..e6de816 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/Utils.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/Utils.java @@ -1,8 +1,19 @@ package fr.titionfire.ffsaf.utils; +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import jodd.net.MimeTypes; +import net.sf.jmimemagic.Magic; +import net.sf.jmimemagic.MagicException; +import net.sf.jmimemagic.MagicMatchNotFoundException; +import net.sf.jmimemagic.MagicParseException; +import org.jboss.logging.Logger; import java.io.*; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URLConnection; import java.nio.file.Files; import java.util.Calendar; @@ -11,6 +22,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; public class Utils { + private static final org.jboss.logging.Logger LOGGER = Logger.getLogger(Utils.class); public static int getSaison() { return getSaison(new Date()); @@ -30,7 +42,12 @@ public class Utils { public static Future replacePhoto(long id, byte[] input, String media, String dir) { return CompletableFuture.supplyAsync(() -> { try (InputStream is = new BufferedInputStream(new ByteArrayInputStream(input))) { - String mimeType = URLConnection.guessContentTypeFromStream(is); + String mimeType; + try { + mimeType = Magic.getMagicMatch(input, false).getMimeType(); + } catch (MagicParseException | MagicMatchNotFoundException | MagicException e) { + mimeType = URLConnection.guessContentTypeFromStream(is); + } String[] detectedExtensions = MimeTypes.findExtensionsByMimeTypes(mimeType, false); if (detectedExtensions.length == 0) throw new IOException("Fail to detect file extension for MIME type " + mimeType); @@ -57,4 +74,39 @@ public class Utils { } }); } + + public static Uni getMediaFile(long id, String media, String dirname, + Uni uniBase) throws URISyntaxException { + Future> future = CompletableFuture.supplyAsync(() -> { + FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id)); + File[] files = new File(media, dirname).listFiles(filter); + if (files != null && files.length > 0) { + File file = files[0]; + try { + byte[] data = Files.readAllBytes(file.toPath()); + return new Pair<>(file, data); + } catch (IOException ignored) { + } + } + return null; + }); + + URI uri = new URI("https://mdbcdn.b-cdn.net/img/Photos/new-templates/bootstrap-chat/ava2.webp"); + + return uniBase.chain(__ -> Uni.createFrom().future(future) + .map(filePair -> { + if (filePair == null) + return Response.temporaryRedirect(uri).build(); + + String mimeType = URLConnection.guessContentTypeFromName(filePair.getKey().getName()); + + Response.ResponseBuilder resp = Response.ok(filePair.getValue()); + resp.type(MediaType.APPLICATION_OCTET_STREAM); + resp.header(HttpHeaders.CONTENT_LENGTH, filePair.getValue().length); + resp.header(HttpHeaders.CONTENT_TYPE, mimeType); + resp.header(HttpHeaders.CONTENT_DISPOSITION, "inline; "); + + return resp.build(); + })); + } } diff --git a/src/main/webapp/index.html b/src/main/webapp/index.html index 97bbfb1..95678f6 100644 --- a/src/main/webapp/index.html +++ b/src/main/webapp/index.html @@ -14,6 +14,10 @@ integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" /> + + diff --git a/src/main/webapp/package-lock.json b/src/main/webapp/package-lock.json index 37c5149..7e63a4c 100644 --- a/src/main/webapp/package-lock.json +++ b/src/main/webapp/package-lock.json @@ -15,8 +15,10 @@ "@fortawesome/react-fontawesome": "^0.2.0", "axios": "^1.6.5", "browser-image-compression": "^2.0.2", + "leaflet": "^1.9.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", "react-loader-spinner": "^6.1.6", "react-router-dom": "^6.21.2", "react-toastify": "^10.0.4" @@ -1023,6 +1025,16 @@ "node": ">= 8" } }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@remix-run/router": { "version": "1.14.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.14.2.tgz", @@ -3113,6 +3125,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3558,6 +3575,19 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/react-loader-spinner": { "version": "6.1.6", "resolved": "https://registry.npmjs.org/react-loader-spinner/-/react-loader-spinner-6.1.6.tgz", diff --git a/src/main/webapp/package.json b/src/main/webapp/package.json index 534a96f..39798b9 100644 --- a/src/main/webapp/package.json +++ b/src/main/webapp/package.json @@ -17,8 +17,10 @@ "@fortawesome/react-fontawesome": "^0.2.0", "axios": "^1.6.5", "browser-image-compression": "^2.0.2", + "leaflet": "^1.9.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", "react-loader-spinner": "^6.1.6", "react-router-dom": "^6.21.2", "react-toastify": "^10.0.4" diff --git a/src/main/webapp/src/components/MemberCustomFiels.jsx b/src/main/webapp/src/components/MemberCustomFiels.jsx index 6a00bce..fff6053 100644 --- a/src/main/webapp/src/components/MemberCustomFiels.jsx +++ b/src/main/webapp/src/components/MemberCustomFiels.jsx @@ -36,7 +36,7 @@ export function BirthDayField({inti_date, inti_category}) { } -export function OptionField({name, text, values, value, disabled=false}) { +export function OptionField({name, text, values, value, disabled = false}) { return
@@ -49,12 +49,20 @@ export function OptionField({name, text, values, value, disabled=false}) {
} -export function TextField({name, text, value, placeholder, type = "text"}) { +export function CountryList({name, text, value, values = undefined, disabled = false}) { + if (values === undefined){ + values = {NA: 'Sélectionner...', fr: 'FR', es: 'ES', be: 'BE'} + } + + return +} + +export function TextField({name, text, value, placeholder, type = "text", disabled = false}) { return
{text} + name={name} aria-describedby={name} defaultValue={value} disabled={disabled} required/>
} @@ -79,7 +87,7 @@ export function CheckField({name, text, value, row = false}) { } -export const Checkbox = ({ label, value, onChange }) => { +export const Checkbox = ({label, value, onChange}) => { const handleChange = () => { onChange(!value); }; diff --git a/src/main/webapp/src/components/Nav.jsx b/src/main/webapp/src/components/Nav.jsx index 3e5c6fb..382731e 100644 --- a/src/main/webapp/src/components/Nav.jsx +++ b/src/main/webapp/src/components/Nav.jsx @@ -72,7 +72,7 @@ function AdminMenu() {
  • Member
  • -
  • B
  • +
  • Club
} diff --git a/src/main/webapp/src/components/SearchBar.jsx b/src/main/webapp/src/components/SearchBar.jsx new file mode 100644 index 0000000..6140436 --- /dev/null +++ b/src/main/webapp/src/components/SearchBar.jsx @@ -0,0 +1,43 @@ +import {useEffect, useState} from "react"; + +const removeDiacritics = str => { + return str + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') +} + + +export function SearchBar({search}) { + const [searchInput, setSearchInput] = useState(""); + + const handelChange = (e) => { + setSearchInput(e.target.value); + } + + const handleKeyDown = (event) => { + if (event.key === 'Enter') { + searchMember(); + } + } + + const searchMember = () => { + search(removeDiacritics(searchInput)); + } + + useEffect(() => { + const delayDebounceFn = setTimeout(() => { + searchMember(); + }, 750) + return () => clearTimeout(delayDebounceFn) + }, [searchInput]) + + return
+
+ + +
+
+} \ No newline at end of file diff --git a/src/main/webapp/src/pages/DemandeAff.jsx b/src/main/webapp/src/pages/DemandeAff.jsx index b8f92b7..b07b9d4 100644 --- a/src/main/webapp/src/pages/DemandeAff.jsx +++ b/src/main/webapp/src/pages/DemandeAff.jsx @@ -30,15 +30,16 @@ export function DemandeAff() { const submit = (event) => { event.preventDefault() const formData = new FormData(event.target) + formData.append("m1_role", event.target.m1_role?.value) toast.promise( - apiAxios.post(`asso/affiliation`, formData, { headers: {'Accept': '*/*'}}), + apiAxios.post(`/affiliation`, formData, {headers: {'Accept': '*/*'}}), { pending: "Enregistrement de la demande d'affiliation en cours", success: "Demande d'affiliation enregistrée avec succès 🎉", error: "Échec de la demande d'affiliation 😕" } ).then(_ => { - // navigate("/affiliation/ok") + navigate("/affiliation/ok") }) } @@ -67,14 +68,14 @@ export function DemandeAff() {

L'association

-

Le président

- -

Le trésorier

- -

Le secrétaire

- +

Membre n°1

+ +

Membre n°2

+ +

Membre n°3

+ -
+

Après validation de votre demande, vous recevrez un login et mot de passe provisoire pour accéder à votre espace FFSAF

Notez que pour finaliser votre affiliation, il vous faudra : @@ -126,13 +127,13 @@ function AssoInfo() { Nom de l'association* + aria-describedby="basic-addon1" required defaultValue="Mesnie"/>
N° SIREN* setSiren(e.target.value)}/> + onChange={e => setSiren(e.target.value)} defaultValue={500213731}/> @@ -173,36 +174,65 @@ function AssoInfo() { } function MembreInfo({role}) { - return
-
-
- - + const [switchOn, setSwitchOn] = useState(false); + + return <> +
+ + +
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
-
-
- - + +
+ +
+ setSwitchOn(!switchOn)}/>
-
-
- - + {switchOn && +
+
+ + +
-
-
-
OU
-
- - -
-
-
+ } + + } export function DemandeAffOk() { diff --git a/src/main/webapp/src/pages/MemberList.jsx b/src/main/webapp/src/pages/MemberList.jsx index 8fbd771..98e8d95 100644 --- a/src/main/webapp/src/pages/MemberList.jsx +++ b/src/main/webapp/src/pages/MemberList.jsx @@ -9,6 +9,7 @@ import {Checkbox} from "../components/MemberCustomFiels.jsx"; import axios from "axios"; import {apiAxios} from "../utils/Tools.js"; import {toast} from "react-toastify"; +import {SearchBar} from "../components/SearchBar.jsx"; const removeDiacritics = str => { return str @@ -106,41 +107,6 @@ export function MemberList({source}) { } -function SearchBar({search}) { - const [searchInput, setSearchInput] = useState(""); - - const handelChange = (e) => { - setSearchInput(e.target.value); - } - - const handleKeyDown = (event) => { - if (event.key === 'Enter') { - searchMember(); - } - } - - const searchMember = () => { - search(removeDiacritics(searchInput)); - } - - useEffect(() => { - const delayDebounceFn = setTimeout(() => { - searchMember(); - }, 750) - return () => clearTimeout(delayDebounceFn) - }, [searchInput]) - - return
-
- - -
-
-} - function MakeCentralPanel({data, visibleMember, navigate, showLicenceState, page}) { const pages = [] for (let i = 1; i <= data.page_count; i++) { diff --git a/src/main/webapp/src/pages/admin/AdminRoot.jsx b/src/main/webapp/src/pages/admin/AdminRoot.jsx index e2860ed..063c17e 100644 --- a/src/main/webapp/src/pages/admin/AdminRoot.jsx +++ b/src/main/webapp/src/pages/admin/AdminRoot.jsx @@ -4,6 +4,10 @@ import {LoadingProvider} from "../../hooks/useLoading.jsx"; import {MemberList} from "../MemberList.jsx"; import {MemberPage} from "./member/MemberPage.jsx"; import {NewMemberPage} from "./member/NewMemberPage.jsx"; +import {ClubList} from "./club/ClubList.jsx"; +import {AffiliationReqPage} from "./affiliation/AffiliationReqPage.jsx"; +import {NewClubPage} from "./club/NewClubPage.jsx"; +import {ClubPage} from "./club/ClubPage.jsx"; export function AdminRoot() { return <> @@ -28,6 +32,22 @@ export function getAdminChildren() { path: 'member/new', element: }, + { + path: 'club', + element: + }, + { + path: 'club/:id', + element: + }, + { + path: 'affiliation/request', + element: + }, + { + path: 'club/new', + element: + }, { path: 'b', element:
Admin B
diff --git a/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx b/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx new file mode 100644 index 0000000..443e68e --- /dev/null +++ b/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx @@ -0,0 +1,16 @@ +import {useNavigate} from "react-router-dom"; + +export function AffiliationReqPage() { + const navigate = useNavigate(); + + return <> +

Page affiliation

+ +
+
+
+
+ +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx b/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx new file mode 100644 index 0000000..e392e9c --- /dev/null +++ b/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx @@ -0,0 +1,187 @@ +import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {useEffect, useReducer, useState} from "react"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faPen} from "@fortawesome/free-solid-svg-icons"; +import {AxiosError} from "../../../components/AxiosError.jsx"; +import {CheckField, TextField} from "../../../components/MemberCustomFiels.jsx"; +import {apiAxios, getSaison} from "../../../utils/Tools.js"; +import {Input} from "../../../components/Input.jsx"; +import {toast} from "react-toastify"; + +function affiliationReducer(affiliation, action) { + switch (action.type) { + case 'ADD': + return [ + ...affiliation, + action.payload + ] + case 'REMOVE': + return affiliation.filter(affiliation => affiliation.id !== action.payload) + case 'UPDATE_OR_ADD': + const index = affiliation.findIndex(affiliation => affiliation.id === action.payload.id) + if (index === -1) { + return [ + ...affiliation, + action.payload + ] + } else { + affiliation[index] = action.payload + return [...affiliation] + } + case 'SORT': + return affiliation.sort((a, b) => b.saison - a.saison) + default: + throw new Error() + } +} + +export function AffiliationCard({clubData}) { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/affiliation/${clubData.id}`, setLoading, 1) + + const [modalAffiliation, setModal] = useState({id: -1, club: clubData.id}) + const [affiliations, dispatch] = useReducer(affiliationReducer, []) + + useEffect(() => { + if (!data) return + for (const dataKey of data) { + dispatch({type: 'UPDATE_OR_ADD', payload: dataKey}) + } + dispatch({type: 'SORT'}) + }, [data]); + + return
+
+
+
Affiliation
+
+ +
+
+
+
+
    + {affiliations.map((affiliation, index) => { + return
    +
    {affiliation?.saison}-{affiliation?.saison + 1}
    + +
    + })} + {error && } +
+
+ + +
; +} + +function sendAffiliation(event, dispatch) { + event.preventDefault(); + + const formData = new FormData(event.target); + toast.promise( + apiAxios.post(`/affiliation/${formData.get('membre')}`, formData), // TODO + { + pending: "Enregistrement de l'affiliation en cours", + success: "Affiliation enregistrée avec succès 🎉", + error: "Échec de l'enregistrement de l'affiliation 😕" + } + ).then(data => { + dispatch({type: 'UPDATE_OR_ADD', payload: data.data}) + dispatch({type: 'SORT'}) + }) + +} + +function removeAffiliation(id, dispatch) { + toast.promise( + apiAxios.delete(`/affiliation/${id}`), + { + pending: "Suppression de l'affiliation en cours", + success: "Affiliation supprimée avec succès 🎉", + error: "Échec de la suppression de l'affiliation 😕" + } + ).then(_ => { + dispatch({type: 'REMOVE', payload: id}) + }) +} + +function ModalContent({affiliation, dispatch}) { + const [saison, setSaison] = useState(0) + const [validate, setValidate] = useState(false) + const [isNew, setNew] = useState(true) + const setSeason = (event) => { + setSaison(Number(event.target.value)) + } + const handleValidateChange = (event) => { + setValidate(event.target.value === 'true'); + } + + useEffect(() => { + if (affiliation.id !== -1) { + setNew(false) + setSaison(affiliation.saison) + setValidate(affiliation.validate) + } else { + setNew(true) + setSaison(getSaison()) + setValidate(false) + } + }, [affiliation]); + + return
sendAffiliation(e, dispatch)}> + + +
+

Edition de l'affiliation

+ +
+
+
+ {isNew + ? + : <>{saison} + } + - + {saison + 1} +
+ +
+
+ + + {isNew || } +
+
+} + +function RadioGroupeOnOff({value, onChange, name, text}) { + return
+ {text} + + + + +
; +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/club/ClubList.jsx b/src/main/webapp/src/pages/admin/club/ClubList.jsx new file mode 100644 index 0000000..80c0e9b --- /dev/null +++ b/src/main/webapp/src/pages/admin/club/ClubList.jsx @@ -0,0 +1,192 @@ +import {useLocation, useNavigate} from "react-router-dom"; +import {useEffect, useState} from "react"; +import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {toast} from "react-toastify"; +import {apiAxios} from "../../../utils/Tools.js"; +import {AxiosError} from "../../../components/AxiosError.jsx"; +import {Checkbox} from "../../../components/MemberCustomFiels.jsx"; +import {ThreeDots} from "react-loader-spinner"; +import {SearchBar} from "../../../components/SearchBar.jsx"; + +export function ClubList() { + const {hash} = useLocation(); + const navigate = useNavigate(); + let page = Number(hash.substring(1)); + page = (page > 0) ? page : 1; + + const [clubData, setClubData] = useState([]); + const [affiliationData, setAffiliationData] = useState([]); + const [showAffiliationState, setShowAffiliationState] = useState(false); + const [countryFilter, setCountryFilter] = useState(""); + const [lastSearch, setLastSearch] = useState(""); + + const setLoading = useLoadingSwitcher() + const {data, error, refresh} = useFetch(`/club/find?page=${page}`, setLoading, 1) + + + useEffect(() => { + refresh(`/club/find?page=${page}&search=${lastSearch}&country=${countryFilter}`); + }, [hash, countryFilter]); + + useEffect(() => { + if (!data) + return; + const data2 = []; + for (const e of data.result) { + data2.push({ + id: e.id, + name: e.name, + country: e.country, + shieldURL: e.shieldURL, + no_affiliation: e.no_affiliation, + affiliation: showAffiliationState ? affiliationData.find(licence => licence.club === e.id) : null + }) + } + setClubData(data2); + }, [data, affiliationData]); + + useEffect(() => { + if (!showAffiliationState) + return; + + toast.promise( + apiAxios.get(`/affiliation/current`), + { + pending: "Chargement des affiliation...", + success: "Affiliation chargées", + error: "Impossible de charger les affiliations" + }) + .then(data => { + setAffiliationData(data.data); + }); + }, [showAffiliationState]); + + const search = (search) => { + if (search === lastSearch) + return; + setLastSearch(search); + refresh(`/club/find?page=${page}&search=${search}&country=${countryFilter}`); + } + + return <> +

Club

+
+
+
+ + {data + ? + : error + ? + : + } +
+
+
+ + +
+
+
Filtre
+
+ +
+
+
+
+
+ +} + +function MakeCentralPanel({data, visibleclub, navigate, showAffiliationState, page}) { + const pages = [] + for (let i = 1; i <= data.page_count; i++) { + pages.push(
  • + navigate("#" + i)}>{i} +
  • ); + } + + return <> +
    + Ligne {((page - 1) * data.page_size) + 1} à { + (page * data.page_size > data.result_count) ? data.result_count : (page * data.page_size)} (page {page} sur {data.page_count}) +
    + {visibleclub.map(club => ())} +
    +
    +
    + +
    + +} + +function MakeRow({club, showAffiliationState, navigate}) { + const rowContent = <> +
    + {String(club.no_affiliation).padStart(5, '0')} +
    +
    {club.name}
    +
    +
    + {club.country} + + + if (showAffiliationState && club.affiliation != null) { + return
    navigate("" + club.id)}>{rowContent}
    + } else { + return
    navigate("" + club.id)}> + {rowContent} +
    + } +} + +let allCountry = [] + +function FiltreBar({showAffiliationState, setShowAffiliationState, data, countryFilter, setCountryFilter}) { + useEffect(() => { + if (!data) + return; + allCountry.push(...data.result.map((e) => e.club?.name)) + allCountry = allCountry.filter((value, index, self) => self.indexOf(value) === index).filter(value => value != null).sort() + }, [data]); + + return
    +
    + +
    +
    + +
    +
    +} + +function Def() { + return
    +
  • +
  • +
  • +
  • +
  • +
    +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/club/ClubPage.jsx b/src/main/webapp/src/pages/admin/club/ClubPage.jsx new file mode 100644 index 0000000..0269304 --- /dev/null +++ b/src/main/webapp/src/pages/admin/club/ClubPage.jsx @@ -0,0 +1,128 @@ +import {useNavigate, useParams} from "react-router-dom"; +import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {toast} from "react-toastify"; +import {apiAxios} from "../../../utils/Tools.js"; +import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx"; +import {AxiosError} from "../../../components/AxiosError.jsx"; +import {AffiliationCard} from "./AffiliationCard.jsx"; +import {CheckField, CountryList, TextField} from "../../../components/MemberCustomFiels.jsx"; + +import {MapContainer, Marker, Popup, TileLayer, useMap} from 'react-leaflet' + +const vite_url = import.meta.env.VITE_URL; + +export function ClubPage() { + const {id} = useParams() + const navigate = useNavigate(); + + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/club/${id}`, setLoading, 1) + + const handleRm = () => { + toast.promise( + apiAxios.delete(`/club/${id}`), + { + pending: "Suppression du club en cours...", + success: "Club supprimé avec succès 🎉", + error: "Échec de la suppression du club 😕" + } + ).then(_ => { + navigate("/admin/club") + }) + } + + return <> +

    Page membre

    + + {data + ?
    +
    +
    + +
    +
    + +
    + +
    + +
    +
    +
    + : error && + } + +} + +function InformationForm({data}) { + return
    +
    Licence n°{data.no_affiliation}
    +
    + + + + + + + avatar + +
    +
    + + +
    +
    Laissez vide pour ne rien changer.
    +
    + + + + + + + + +
    +
    ; +} + // https://annuaire-entreprises.data.gouv.fr/entreprise/la-mesnie-des-chevaliers-de-st-georges-et-de-st-michel-500213731 +const position = [51.505, -0.09] +function MainMap() { + function handleReturnCurrentPosition() { + console.log("I have clicked return button!!"); + //const newCurrentPositionId = uuidv4(); + //setReturnCurrentPosition(newCurrentPositionId); + //console.log(newCurrentPositionId); + } + + return ( + <> + + + + + A pretty CSS3 popup.
    Easily customizable. +
    +
    +
    + + + + ) +} + +function SearchBarMap() { + return <> + +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/club/NewClubPage.jsx b/src/main/webapp/src/pages/admin/club/NewClubPage.jsx new file mode 100644 index 0000000..20fe771 --- /dev/null +++ b/src/main/webapp/src/pages/admin/club/NewClubPage.jsx @@ -0,0 +1,16 @@ +import {useNavigate} from "react-router-dom"; + +export function NewClubPage() { + const navigate = useNavigate(); + + return <> +

    Page affiliation

    + +
    +
    +
    +
    + +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/member/InformationForm.jsx b/src/main/webapp/src/pages/admin/member/InformationForm.jsx index 21545b0..3c62e9c 100644 --- a/src/main/webapp/src/pages/admin/member/InformationForm.jsx +++ b/src/main/webapp/src/pages/admin/member/InformationForm.jsx @@ -2,7 +2,7 @@ import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; import {apiAxios} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; import imageCompression from "browser-image-compression"; -import {BirthDayField, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx"; +import {BirthDayField, CountryList, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx"; import {ClubSelect} from "../../../components/ClubSelect.jsx"; export function addPhoto(event, formData, send) { @@ -74,8 +74,7 @@ export function InformationForm({data}) { type="email"/> - +
    @@ -86,7 +85,11 @@ export function InformationForm({data}) { MEMBRE: 'Membre', PRESIDENT: 'Président', TRESORIER: 'Trésorier', - SECRETAIRE: 'Secrétaire' + SECRETAIRE: 'Secrétaire', + VPRESIDENT: 'Vise-Président', + VTRESORIER: 'Vise-Trésorier', + VSECRETAIRE: 'Vise-Secrétaire', + MEMBREBUREAU: 'Membre bureau' }}/> diff --git a/src/main/webapp/src/pages/club/member/InformationForm.jsx b/src/main/webapp/src/pages/club/member/InformationForm.jsx index 404c078..7d9f96f 100644 --- a/src/main/webapp/src/pages/club/member/InformationForm.jsx +++ b/src/main/webapp/src/pages/club/member/InformationForm.jsx @@ -3,7 +3,7 @@ import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; import {apiAxios} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; -import {BirthDayField, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx"; +import {BirthDayField, CountryList, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx"; import {addPhoto} from "../../admin/member/InformationForm.jsx"; export function InformationForm({data}) { @@ -52,8 +52,7 @@ export function InformationForm({data}) { type="email"/> - + From 6c4b01590d84d4fde19d8f1190c1c5f4bbe67753 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Fri, 12 Jul 2024 16:37:04 +0200 Subject: [PATCH 02/37] wip: addr field --- .../titionfire/ffsaf/rest/ClubEndpoints.java | 5 +- .../ffsaf/rest/data/SimpleClub.java | 2 + .../fr/titionfire/ffsaf/utils/Contact.java | 12 +- src/main/webapp/src/components/ListEditor.jsx | 83 ++++++++ .../src/pages/admin/club/AffiliationCard.jsx | 2 - .../webapp/src/pages/admin/club/ClubPage.jsx | 177 +++++++++++++++++- 6 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 src/main/webapp/src/components/ListEditor.jsx diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index 7b4c918..45a8199 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -5,6 +5,7 @@ import fr.titionfire.ffsaf.domain.service.ClubService; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; import fr.titionfire.ffsaf.rest.data.SimpleClub; import fr.titionfire.ffsaf.rest.from.FullClubForm; +import fr.titionfire.ffsaf.utils.Contact; import fr.titionfire.ffsaf.utils.GroupeUtils; import fr.titionfire.ffsaf.utils.PageResult; import fr.titionfire.ffsaf.utils.Utils; @@ -76,7 +77,9 @@ public class ClubEndpoints { @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) public Uni getById(@PathParam("id") long id) { - return clubService.getById(id).onItem().invoke(checkPerm).map(SimpleClub::fromModel); + return clubService.getById(id).onItem().invoke(checkPerm).map(SimpleClub::fromModel).invoke(m -> { + m.setContactMap(Contact.toSite()); + }); } @PUT diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java index 7941562..9e8d8ff 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java @@ -8,6 +8,7 @@ import lombok.Builder; import lombok.Data; import lombok.ToString; +import java.util.HashMap; import java.util.Map; @Data @@ -29,6 +30,7 @@ public class SimpleClub { private String SIRET; private String no_affiliation; private boolean international; + private HashMap contactMap = null; public static SimpleClub fromModel(ClubModel model) { if (model == null) diff --git a/src/main/java/fr/titionfire/ffsaf/utils/Contact.java b/src/main/java/fr/titionfire/ffsaf/utils/Contact.java index 884e6d6..ea49228 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/Contact.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/Contact.java @@ -2,6 +2,9 @@ package fr.titionfire.ffsaf.utils; import io.quarkus.runtime.annotations.RegisterForReflection; +import javax.naming.ldap.HasControls; +import java.util.HashMap; + @RegisterForReflection public enum Contact { COURRIEL("Courriel"), @@ -20,8 +23,11 @@ public enum Contact { this.name = name; } - @Override - public String toString() { - return name; + public static HashMap toSite() { + HashMap map = new HashMap<>(); + for (Contact contact : Contact.values()) { + map.put(contact.toString(), contact.name); + } + return map; } } diff --git a/src/main/webapp/src/components/ListEditor.jsx b/src/main/webapp/src/components/ListEditor.jsx new file mode 100644 index 0000000..52edec4 --- /dev/null +++ b/src/main/webapp/src/components/ListEditor.jsx @@ -0,0 +1,83 @@ +import {useEffect, useReducer, useState} from "react"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faPen, faTrashCan} from "@fortawesome/free-solid-svg-icons"; +import {AxiosError} from "./AxiosError.jsx"; + +function SimpleReducer(datas, action) { + switch (action.type) { + case 'ADD': + return [ + ...datas, + action.payload + ] + case 'REMOVE': + return datas.filter(data => data.id !== action.payload) + case 'UPDATE_OR_ADD': + const index = datas.findIndex(data => data.id === action.payload.id) + if (index === -1) { + return [ + ...datas, + action.payload + ] + } else { + datas[index] = action.payload + return [...datas] + } + default: + throw new Error() + } +} + +export function ListEditorTest() { + const [html, dispatch] = ListEditor(ListHTML) + + useEffect(() => { + dispatch({type: 'UPDATE_OR_ADD', payload: {id: 1, content: "data in"}}) + }, []); + + return html +} + +export function ListEditor(ListItem) { + const [modal, setModal] = useState({id: -1}) + const [state, dispatch] = useReducer(SimpleReducer, []) + + const sendAffiliation = (e) => { + + dispatch({type: 'UPDATE_OR_ADD', payload: e}) + } + + return [<> +
      + {state.map((d, index) => { + return
      + + + +
      + })} +
    + + +, dispatch] +} + +function ListHTML({ + data +}) { + return
    {data.content}
    +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx b/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx index e392e9c..a2b82c9 100644 --- a/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx +++ b/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx @@ -4,9 +4,7 @@ import {useEffect, useReducer, useState} from "react"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faPen} from "@fortawesome/free-solid-svg-icons"; import {AxiosError} from "../../../components/AxiosError.jsx"; -import {CheckField, TextField} from "../../../components/MemberCustomFiels.jsx"; import {apiAxios, getSaison} from "../../../utils/Tools.js"; -import {Input} from "../../../components/Input.jsx"; import {toast} from "react-toastify"; function affiliationReducer(affiliation, action) { diff --git a/src/main/webapp/src/pages/admin/club/ClubPage.jsx b/src/main/webapp/src/pages/admin/club/ClubPage.jsx index 0269304..53a79dd 100644 --- a/src/main/webapp/src/pages/admin/club/ClubPage.jsx +++ b/src/main/webapp/src/pages/admin/club/ClubPage.jsx @@ -9,9 +9,38 @@ import {AffiliationCard} from "./AffiliationCard.jsx"; import {CheckField, CountryList, TextField} from "../../../components/MemberCustomFiels.jsx"; import {MapContainer, Marker, Popup, TileLayer, useMap} from 'react-leaflet' +import {ListEditorTest} from "../../../components/ListEditor.jsx"; +import {useEffect, useReducer, useState} from "react"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faPen, faTrashCan} from "@fortawesome/free-solid-svg-icons"; const vite_url = import.meta.env.VITE_URL; +function SimpleReducer(datas, action) { + switch (action.type) { + case 'ADD': + return [ + ...datas, + action.payload + ] + case 'REMOVE': + return datas.filter(data => data.id !== action.payload) + case 'UPDATE_OR_ADD': + const index = datas.findIndex(data => data.id === action.payload.id) + if (index === -1) { + return [ + ...datas, + action.payload + ] + } else { + datas[index] = action.payload + return [...datas] + } + default: + throw new Error() + } +} + export function ClubPage() { const {id} = useParams() const navigate = useNavigate(); @@ -41,15 +70,19 @@ export function ClubPage() { ?
    - + + +
    -
    -
    @@ -83,18 +116,150 @@ function InformationForm({data}) {
    Laissez vide pour ne rien changer.
    - + + + +
    ; } - // https://annuaire-entreprises.data.gouv.fr/entreprise/la-mesnie-des-chevaliers-de-st-georges-et-de-st-michel-500213731 + +export function LocationEditor({data}) { + const [modal, setModal] = useState({id: -1}) + const [state, dispatch] = useReducer(SimpleReducer, []) + + useEffect(() => { + JSON.parse(data.training_location).forEach((d, index) => { + dispatch({type: 'UPDATE_OR_ADD', payload: {id: index, data: d}}) + }) + }, [data.training_location]); + + const sendAffiliation = (e) => { + + dispatch({type: 'UPDATE_OR_ADD', payload: e}) + } + + return <> +
      + {state.map((d, index) => { + return
      +
      {d.data.text}
      + + +
      + })} +
    + + +} + +function Autoc() { + const [location, setLocation] = useState("9 rue Gracchus") + const { + data, + error, + refresh + } = useFetch(`https://api-adresse.data.gouv.fr/search/?q=${encodeURI(location)}&type=housenumber&autocomplete=1`) + + useEffect(() => { + refresh(`https://api-adresse.data.gouv.fr/search/?q=${encodeURI(location)}&type=housenumber&autocomplete=1`) + }, [location]); + + return <> +
    + + setLocation(e.target.value)}/> + + {data && data.features.map((d, index) => { + return + })} + +
    + +} + +export function ContactEditor({ + data + }) { + const [state, dispatch] = useReducer(SimpleReducer, []) + + useEffect(() => { + for (const key in data.contact) { + dispatch({type: 'UPDATE_OR_ADD', payload: {id: key, data: data.contact[key]}}) + } + }, [data.contact]); + + return <> +
      + {state.map((d, index) => { + if (d.data === undefined) + return; + + return
      +
      + + { + dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: e.target.value}}) + }}/> + +
      +
      + })} +
    + +} + +// https://annuaire-entreprises.data.gouv.fr/entreprise/la-mesnie-des-chevaliers-de-st-georges-et-de-st-michel-500213731 const position = [51.505, -0.09] + function MainMap() { function handleReturnCurrentPosition() { console.log("I have clicked return button!!"); @@ -105,7 +270,7 @@ function MainMap() { return ( <> - + Date: Sat, 13 Jul 2024 18:32:01 +0200 Subject: [PATCH 03/37] wip: club add map --- src/main/java/fr/titionfire/BlackPage.java | 18 ++ src/main/webapp/package-lock.json | 20 ++ src/main/webapp/package.json | 1 + .../webapp/src/pages/admin/club/ClubPage.jsx | 222 ++++-------------- .../src/pages/admin/club/LocationEditor.jsx | 162 +++++++++++++ src/main/webapp/src/utils/SimpleReducer.jsx | 24 ++ 6 files changed, 267 insertions(+), 180 deletions(-) create mode 100644 src/main/java/fr/titionfire/BlackPage.java create mode 100644 src/main/webapp/src/pages/admin/club/LocationEditor.jsx create mode 100644 src/main/webapp/src/utils/SimpleReducer.jsx diff --git a/src/main/java/fr/titionfire/BlackPage.java b/src/main/java/fr/titionfire/BlackPage.java new file mode 100644 index 0000000..a77d835 --- /dev/null +++ b/src/main/java/fr/titionfire/BlackPage.java @@ -0,0 +1,18 @@ +package fr.titionfire; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@Path("/api") +public class BlackPage { + + @GET + @Produces(MediaType.TEXT_PLAIN) + public Response get() { + return Response.noContent().build(); + } + +} diff --git a/src/main/webapp/package-lock.json b/src/main/webapp/package-lock.json index 7e63a4c..78674e5 100644 --- a/src/main/webapp/package-lock.json +++ b/src/main/webapp/package-lock.json @@ -16,6 +16,7 @@ "axios": "^1.6.5", "browser-image-compression": "^2.0.2", "leaflet": "^1.9.4", + "proj4": "^2.11.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-leaflet": "^4.2.1", @@ -3184,6 +3185,11 @@ "yallist": "^3.0.2" } }, + "node_modules/mgrs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz", + "integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3503,6 +3509,15 @@ "node": ">= 0.8.0" } }, + "node_modules/proj4": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.11.0.tgz", + "integrity": "sha512-SasuTkAx8HnWQHfIyhkdUNJorSJqINHAN3EyMWYiQRVorftz9DHz650YraFgczwgtHOxqnfuDxSNv3C8MUnHeg==", + "dependencies": { + "mgrs": "1.0.0", + "wkt-parser": "^1.3.3" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4423,6 +4438,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wkt-parser": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.3.3.tgz", + "integrity": "sha512-ZnV3yH8/k58ZPACOXeiHaMuXIiaTk1t0hSUVisbO0t4RjA5wPpUytcxeyiN2h+LZRrmuHIh/1UlrR9e7DHDvTw==" + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/src/main/webapp/package.json b/src/main/webapp/package.json index 39798b9..32fbdf3 100644 --- a/src/main/webapp/package.json +++ b/src/main/webapp/package.json @@ -18,6 +18,7 @@ "axios": "^1.6.5", "browser-image-compression": "^2.0.2", "leaflet": "^1.9.4", + "proj4": "^2.11.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-leaflet": "^4.2.1", diff --git a/src/main/webapp/src/pages/admin/club/ClubPage.jsx b/src/main/webapp/src/pages/admin/club/ClubPage.jsx index 53a79dd..32c0a66 100644 --- a/src/main/webapp/src/pages/admin/club/ClubPage.jsx +++ b/src/main/webapp/src/pages/admin/club/ClubPage.jsx @@ -10,37 +10,15 @@ import {CheckField, CountryList, TextField} from "../../../components/MemberCust import {MapContainer, Marker, Popup, TileLayer, useMap} from 'react-leaflet' import {ListEditorTest} from "../../../components/ListEditor.jsx"; -import {useEffect, useReducer, useState} from "react"; +import {useEffect, useReducer, useRef, useState} from "react"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faPen, faTrashCan} from "@fortawesome/free-solid-svg-icons"; +import proj4 from "proj4"; +import {SimpleReducer} from "../../../utils/SimpleReducer.jsx"; +import {LocationEditor} from "./LocationEditor.jsx"; const vite_url = import.meta.env.VITE_URL; -function SimpleReducer(datas, action) { - switch (action.type) { - case 'ADD': - return [ - ...datas, - action.payload - ] - case 'REMOVE': - return datas.filter(data => data.id !== action.payload) - case 'UPDATE_OR_ADD': - const index = datas.findIndex(data => data.id === action.payload.id) - if (index === -1) { - return [ - ...datas, - action.payload - ] - } else { - datas[index] = action.payload - return [...datas] - } - default: - throw new Error() - } -} - export function ClubPage() { const {id} = useParams() const navigate = useNavigate(); @@ -69,12 +47,12 @@ export function ClubPage() { {data ?
    -
    +
    -
    +
    ; } -export function LocationEditor({data}) { - const [modal, setModal] = useState({id: -1}) - const [state, dispatch] = useReducer(SimpleReducer, []) - - useEffect(() => { - JSON.parse(data.training_location).forEach((d, index) => { - dispatch({type: 'UPDATE_OR_ADD', payload: {id: index, data: d}}) - }) - }, [data.training_location]); - - const sendAffiliation = (e) => { - - dispatch({type: 'UPDATE_OR_ADD', payload: e}) - } - - return <> -
      - {state.map((d, index) => { - return
      -
      {d.data.text}
      - - -
      - })} -
    - - -} - -function Autoc() { - const [location, setLocation] = useState("9 rue Gracchus") - const { - data, - error, - refresh - } = useFetch(`https://api-adresse.data.gouv.fr/search/?q=${encodeURI(location)}&type=housenumber&autocomplete=1`) - - useEffect(() => { - refresh(`https://api-adresse.data.gouv.fr/search/?q=${encodeURI(location)}&type=housenumber&autocomplete=1`) - }, [location]); - - return <> -
    - - setLocation(e.target.value)}/> - - {data && data.features.map((d, index) => { - return - })} - -
    - -} - -export function ContactEditor({ - data - }) { +export function ContactEditor({data}) { const [state, dispatch] = useReducer(SimpleReducer, []) useEffect(() => { @@ -221,73 +115,41 @@ export function ContactEditor({ } }, [data.contact]); - return <> -
      - {state.map((d, index) => { - if (d.data === undefined) - return; + return
      +
      + Contact +
        + {state.map((d, index) => { + if (d.data === undefined) + return; - return
        -
        - - { - dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: e.target.value}}) - }}/> - + return
        +
        + + { + dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: e.target.value}}) + }}/> + +
        -
        - })} -
      - -} - -// https://annuaire-entreprises.data.gouv.fr/entreprise/la-mesnie-des-chevaliers-de-st-georges-et-de-st-michel-500213731 -const position = [51.505, -0.09] - -function MainMap() { - function handleReturnCurrentPosition() { - console.log("I have clicked return button!!"); - //const newCurrentPositionId = uuidv4(); - //setReturnCurrentPosition(newCurrentPositionId); - //console.log(newCurrentPositionId); - } - - return ( - <> - - - - - A pretty CSS3 popup.
      Easily customizable. -
      -
      -
      - - - - ) -} - -function SearchBarMap() { - return <> - + })} +
    +
    +
    } \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/club/LocationEditor.jsx b/src/main/webapp/src/pages/admin/club/LocationEditor.jsx new file mode 100644 index 0000000..4ccb5af --- /dev/null +++ b/src/main/webapp/src/pages/admin/club/LocationEditor.jsx @@ -0,0 +1,162 @@ +import {useEffect, useReducer, useRef, useState} from "react"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faPen, faTrashCan} from "@fortawesome/free-solid-svg-icons"; +import proj4 from "proj4"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {MapContainer, Marker, TileLayer} from "react-leaflet"; +import {SimpleReducer} from "../../../utils/SimpleReducer.jsx"; + +export function LocationEditor({data}) { + const [modal, setModal] = useState({id: -1}) + const [state, dispatch] = useReducer(SimpleReducer, []) + + useEffect(() => { + if (data.training_location === null) + return + JSON.parse(data.training_location).forEach((d, index) => { + dispatch({type: 'UPDATE_OR_ADD', payload: {id: index, data: d}}) + }) + }, [data.training_location]); + + const sendAffiliation = (e) => { + e.preventDefault(); + + const formData = new FormData(e.target); + dispatch({ + type: 'UPDATE_OR_ADD', payload: { + id: Number(formData.get('id')), + data: { + text: formData.get('loc_text'), + lat: Number(formData.get('loc_lat')), + lng: Number(formData.get('loc_lng')) + } + } + }) + } + + return
    +
    + Lieux d'entrainement +
      + {state.map((d, index) => { + return
      +
      {d.data.text}
      + + +
      + })} +
    + +
    +
    +} + +proj4.defs("EPSG:9794", "+proj=lcc +lat_1=44 +lat_2=49 +lat_0=46.5 +lon_0=3 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs"); + +function convertLambert93ToLatLng(x, y) { + const lambertPoint = proj4.toPoint([x, y]); + const wgs84Point = proj4("EPSG:9794", "EPSG:4326", lambertPoint); + return {lat: wgs84Point.y, lng: wgs84Point.x}; +} + +function LocationEditorModalBody({modal}) { + const [location, setLocation] = useState("") + const [locationObj, setLocationObj] = useState({text: "", lng: undefined, lat: undefined}) + const [mapPosition, setMapPosition] = useState([46.652195, 2.430226]) + const {data, error, refresh} = useFetch(``) + const map = useRef(null) + + useEffect(() => { + if (modal.data !== undefined) { + setLocation(modal.data.text) + } + }, [modal]) + + useEffect(() => { + if (location.length < 3) + return + + const delayDebounceFn = setTimeout(() => { + refresh(`https://api-adresse.data.gouv.fr/search/?q=${encodeURI(location)}&type=housenumber&autocomplete=1`) + }, 500) + return () => clearTimeout(delayDebounceFn) + }, [location]); + + useEffect(() => { + if (data?.features?.length === 1) { + const {lat, lng} = convertLambert93ToLatLng(data.features[0].properties.x, data.features[0].properties.y) + setLocationObj({text: data.features[0].properties.label, lng: lng, lat: lat}) + } else { + setLocationObj({text: "", lng: undefined, lat: undefined}) + } + }, [data]); + + useEffect(() => { + if (locationObj.lat !== undefined) { + setMapPosition([locationObj.lat, locationObj.lng]) + map.current?.setView([locationObj.lat, locationObj.lng], 19) + } else { + map.current?.setView([46.652195, 2.430226], 5) + } + + const delayDebounceFn = setTimeout(() => { + map.current.invalidateSize(false); + }, 300) + return () => clearTimeout(delayDebounceFn) + }, [locationObj, modal]) + + + return <> + + + + +
    +
    + + setLocation(e.target.value)}/> + + {data?.features && data.features.map((d, index) => { + return + })} + +
    +
    + +
    + + + {locationObj.lat !== undefined && } + +
    + +} diff --git a/src/main/webapp/src/utils/SimpleReducer.jsx b/src/main/webapp/src/utils/SimpleReducer.jsx new file mode 100644 index 0000000..8b395c8 --- /dev/null +++ b/src/main/webapp/src/utils/SimpleReducer.jsx @@ -0,0 +1,24 @@ +export function SimpleReducer(datas, action) { + switch (action.type) { + case 'ADD': + return [ + ...datas, + action.payload + ] + case 'REMOVE': + return datas.filter(data => data.id !== action.payload) + case 'UPDATE_OR_ADD': + const index = datas.findIndex(data => data.id === action.payload.id) + if (index === -1) { + return [ + ...datas, + action.payload + ] + } else { + datas[index] = action.payload + return [...datas] + } + default: + throw new Error() + } +} \ No newline at end of file From e1a8c90f3e1f348c1e2a1defb26ec296f614fbf2 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Sat, 13 Jul 2024 22:42:04 +0200 Subject: [PATCH 04/37] wip: club end main --- .../src/components/MemberCustomFiels.jsx | 4 +- .../webapp/src/pages/admin/club/ClubPage.jsx | 184 ++++++++++++++---- .../src/pages/admin/club/LocationEditor.jsx | 64 +++--- 3 files changed, 192 insertions(+), 60 deletions(-) diff --git a/src/main/webapp/src/components/MemberCustomFiels.jsx b/src/main/webapp/src/components/MemberCustomFiels.jsx index fff6053..d03e69f 100644 --- a/src/main/webapp/src/components/MemberCustomFiels.jsx +++ b/src/main/webapp/src/components/MemberCustomFiels.jsx @@ -57,12 +57,12 @@ export function CountryList({name, text, value, values = undefined, disabled = f return } -export function TextField({name, text, value, placeholder, type = "text", disabled = false}) { +export function TextField({name, text, value, placeholder, type = "text", disabled = false, required = true}) { return
    {text} + name={name} aria-describedby={name} defaultValue={value} disabled={disabled} required={required}/>
    } diff --git a/src/main/webapp/src/pages/admin/club/ClubPage.jsx b/src/main/webapp/src/pages/admin/club/ClubPage.jsx index 32c0a66..a067e7c 100644 --- a/src/main/webapp/src/pages/admin/club/ClubPage.jsx +++ b/src/main/webapp/src/pages/admin/club/ClubPage.jsx @@ -8,14 +8,11 @@ import {AxiosError} from "../../../components/AxiosError.jsx"; import {AffiliationCard} from "./AffiliationCard.jsx"; import {CheckField, CountryList, TextField} from "../../../components/MemberCustomFiels.jsx"; -import {MapContainer, Marker, Popup, TileLayer, useMap} from 'react-leaflet' -import {ListEditorTest} from "../../../components/ListEditor.jsx"; import {useEffect, useReducer, useRef, useState} from "react"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faPen, faTrashCan} from "@fortawesome/free-solid-svg-icons"; -import proj4 from "proj4"; import {SimpleReducer} from "../../../utils/SimpleReducer.jsx"; -import {LocationEditor} from "./LocationEditor.jsx"; +import {LocationEditor, LocationEditorModal} from "./LocationEditor.jsx"; const vite_url = import.meta.env.VITE_URL; @@ -71,43 +68,82 @@ export function ClubPage() { } function InformationForm({data}) { - return
    -
    Licence n°{data.no_affiliation}
    -
    + const [switchOn, setSwitchOn] = useState(data.international); + const [modal, setModal] = useState({id: -1}) + const locationModalCallback = useRef(null) - - - - - - avatar + const handleSubmit = (event) => { + event.preventDefault(); -
    -
    - - + const formData = new FormData(event.target); + + toast.promise( + apiAxios.post(`/club/${data.id}`, formData), + { + pending: "Enregistrement du club en cours", + success: "Club enregistrée avec succès 🎉", + error: "Échec de l'enregistrement du club 😕" + } + ) + } + + return <> +
    +
    +
    Licence n°{data.no_affiliation}
    +
    + + + + + avatar + +
    +
    + + +
    +
    Laissez vide pour ne rien changer.
    +
    + +
    +
    + setSwitchOn(!switchOn)}/> +
    + +
    + {!switchOn && <> + + + + + + + + + + } +
    +
    +
    + +
    -
    Laissez vide pour ne rien changer.
    +
    - - - - - - - - -
    -
    ; + + } export function ContactEditor({data}) { const [state, dispatch] = useReducer(SimpleReducer, []) + const [out_data, setOutData] = useState({}) useEffect(() => { for (const key in data.contact) { @@ -115,9 +151,19 @@ export function ContactEditor({data}) { } }, [data.contact]); + useEffect(() => { + let out_data2 = {} + state.forEach(d => { + if (d.data !== undefined) + out_data2[d.id] = d.data + }) + setOutData(out_data2) + }, [state]); + return
    + + Contacts
    - Contact
      {state.map((d, index) => { if (d.data === undefined) @@ -152,4 +198,76 @@ export function ContactEditor({data}) {
    +} + +function timeNumberToSting(nbMin) { + return String(Math.floor(nbMin / 60)).padStart(2, '0') + ":" + String(nbMin % 60).padStart(2, '0') +} + +function timeStringToNumber(time) { + let times = time.split(':'); + return parseInt(times[0]) * 60 + parseInt(times[1]); +} + +export function HoraireEditor({data}) { + const [state, dispatch] = useReducer(SimpleReducer, []) + const [out_data, setOutData] = useState({}) + + useEffect(() => { + if (data.training_day_time === null) + return + JSON.parse(data.training_day_time).forEach((d, index) => { + dispatch({type: 'UPDATE_OR_ADD', payload: {id: index, data: d}}) + }) + }, [data.training_day_time]); + + useEffect(() => { + setOutData(state.map(d => { + return {day: d.data.day, time_start: d.data.time_start, time_end: d.data.time_end} + })) + }, [state]); + + return
    + + Horaires d'entrainements +
    +
      + {state.map((d, index) => { + return
      +
      + + de + { + d.data.time_start = timeStringToNumber(e.target.value) + dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: d.data}}) + }}/> + à + { + d.data.time_end = timeStringToNumber(e.target.value) + dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: d.data}}) + }}/> + +
      +
      + })} +
    +
    +
    } \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/club/LocationEditor.jsx b/src/main/webapp/src/pages/admin/club/LocationEditor.jsx index 4ccb5af..948649d 100644 --- a/src/main/webapp/src/pages/admin/club/LocationEditor.jsx +++ b/src/main/webapp/src/pages/admin/club/LocationEditor.jsx @@ -6,9 +6,9 @@ import {useFetch} from "../../../hooks/useFetch.js"; import {MapContainer, Marker, TileLayer} from "react-leaflet"; import {SimpleReducer} from "../../../utils/SimpleReducer.jsx"; -export function LocationEditor({data}) { - const [modal, setModal] = useState({id: -1}) +export function LocationEditor({data, setModal, sendData}) { const [state, dispatch] = useReducer(SimpleReducer, []) + const [out_data, setOutData] = useState({}) useEffect(() => { if (data.training_location === null) @@ -18,7 +18,7 @@ export function LocationEditor({data}) { }) }, [data.training_location]); - const sendAffiliation = (e) => { + sendData.current = (e) => { e.preventDefault(); const formData = new FormData(e.target); @@ -34,15 +34,25 @@ export function LocationEditor({data}) { }) } + useEffect(() => { + setOutData(state.map(d => { + return {text: d.data.text, lat: d.data.lat, lng: d.data.lng} + })) + }, [state]); + return
    + + Lieux d'entrainements
    - Lieux d'entrainement
      {state.map((d, index) => { return
      {d.data.text}
      })}
    - +} + +export function LocationEditorModal({modal, sendData}) { + return
    } From 6a21bd47357e36b7e4b8de8aaa77548b2ef72c0e Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Sun, 14 Jul 2024 13:24:02 +0200 Subject: [PATCH 05/37] feat: club end main --- .../ffsaf/data/model/ClubModel.java | 8 +- .../ffsaf/domain/entity/ClubEntity.java | 4 +- .../ffsaf/domain/service/ClubService.java | 46 ++++- .../ffsaf/net2/data/SimpleClubModel.java | 4 +- .../ffsaf/rest/data/SimpleClub.java | 4 +- .../ffsaf/rest/from/FullClubForm.java | 26 +++ .../java/fr/titionfire/ffsaf/utils/Utils.java | 2 +- .../src/components/Club/ContactEditor.jsx | 79 ++++++++ .../src/components/Club/HoraireEditor.jsx | 95 ++++++++++ .../Club}/LocationEditor.jsx | 172 ++++++++--------- .../webapp/src/pages/admin/club/ClubPage.jsx | 173 +++--------------- src/main/webapp/src/utils/SimpleReducer.jsx | 2 + 12 files changed, 365 insertions(+), 250 deletions(-) create mode 100644 src/main/webapp/src/components/Club/ContactEditor.jsx create mode 100644 src/main/webapp/src/components/Club/HoraireEditor.jsx rename src/main/webapp/src/{pages/admin/club => components/Club}/LocationEditor.jsx (53%) diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java index 791c31a..9f26806 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java @@ -28,8 +28,6 @@ public class ClubModel { String country; - String shieldURL; - //@Enumerated(EnumType.STRING) @ElementCollection @CollectionTable(name = "club_contact_mapping", @@ -37,15 +35,19 @@ public class ClubModel { @MapKeyColumn(name = "contact_type") Map contact; + @Lob + @Column(length=4096) String training_location; + @Lob + @Column(length=4096) String training_day_time; String contact_intern; String RNA; - String SIRET; + Long SIRET; String no_affiliation; diff --git a/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java b/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java index ab59f8b..bf4939a 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java @@ -18,13 +18,12 @@ public class ClubEntity { private String name; private String clubId; private String country; - private String shieldURL; private Map contact; private String training_location; private String training_day_time; private String contact_intern; private String RNA; - private String SIRET; + private Long SIRET; private String no_affiliation; private boolean international; @@ -38,7 +37,6 @@ public class ClubEntity { .name(model.getName()) .clubId(model.getClubId()) .country(model.getCountry()) - .shieldURL(model.getShieldURL()) .contact(model.getContact()) .training_location(model.getTraining_location()) .training_day_time(model.getTraining_day_time()) diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java index 6bb1081..1e3d4e4 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java @@ -1,9 +1,14 @@ package fr.titionfire.ffsaf.domain.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.data.repository.ClubRepository; +import fr.titionfire.ffsaf.net2.ServerCustom; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; +import fr.titionfire.ffsaf.net2.request.SReqClub; import fr.titionfire.ffsaf.rest.from.FullClubForm; +import fr.titionfire.ffsaf.utils.Contact; import fr.titionfire.ffsaf.utils.PageResult; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.PanacheQuery; @@ -19,8 +24,11 @@ import jakarta.ws.rs.BadRequestException; import org.hibernate.reactive.mutiny.Mutiny; import java.util.Collection; +import java.util.HashMap; import java.util.List; +import static fr.titionfire.ffsaf.net2.Client_Thread.MAPPER; + @WithSession @ApplicationScoped public class ClubService { @@ -28,13 +36,18 @@ public class ClubService { @Inject ClubRepository repository; + @Inject + ServerCustom serverCustom; + public SimpleClubModel findByIdOptionalClub(long id) throws Throwable { - return VertxContextSupport.subscribeAndAwait(() -> Panache.withTransaction(() -> repository.findById(id).map(SimpleClubModel::fromModel))); + return VertxContextSupport.subscribeAndAwait( + () -> Panache.withTransaction(() -> repository.findById(id).map(SimpleClubModel::fromModel))); } public Collection findAllClub() throws Throwable { return VertxContextSupport.subscribeAndAwait(() -> Panache.withTransaction( - () -> repository.findAll().list().map(clubModels -> clubModels.stream().map(SimpleClubModel::fromModel).toList()))); + () -> repository.findAll().list() + .map(clubModels -> clubModels.stream().map(SimpleClubModel::fromModel).toList()))); } public Uni> getAll() { @@ -88,15 +101,32 @@ public class ClubService { } public Uni update(long id, FullClubForm input) { - /*return repository.findById(id) - .onItem().transformToUni(m -> { + return repository.findById(id).call(m -> Mutiny.fetch(m.getContact())) + .onItem().transformToUni(Unchecked.function(m -> { + TypeReference> typeRef = new TypeReference<>() { + }; + m.setName(input.getName()); m.setCountry(input.getCountry()); - m.setNo_affiliation(input.getNo_affiliation()); - m.setShieldURL(input.getShieldURL()); + + if (!input.isInternational()) { + m.setTraining_location(input.getTraining_location()); + m.setTraining_day_time(input.getTraining_day_time()); + m.setContact_intern(input.getContact_intern()); + m.setRNA(input.getRna()); + m.setSIRET(input.getSiret()); + + try { + m.setContact(MAPPER.readValue(input.getContact(), typeRef)); + } catch (JsonProcessingException e) { + throw new BadRequestException(); + } + } return Panache.withTransaction(() -> repository.persist(m)); - });*/ - return Uni.createFrom().nullItem(); + })) + .invoke(membreModel -> SReqClub.sendIfNeed(serverCustom.clients, + SimpleClubModel.fromModel(membreModel))) + .map(__ -> "OK"); } public Uni add(FullClubForm input) { diff --git a/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java b/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java index 17d406c..815a464 100644 --- a/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java +++ b/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java @@ -23,7 +23,7 @@ public class SimpleClubModel { if (model == null) return null; - return new SimpleClubModel(model.getId(), model.getName(), model.getCountry(), model.getShieldURL(), - model.getNo_affiliation()); + return new SimpleClubModel(model.getId(), model.getName(), model.getCountry(), + "/api/club/" + model.getClubId() + "/logo", model.getNo_affiliation()); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java index 9e8d8ff..8972e07 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java @@ -21,13 +21,12 @@ public class SimpleClub { private String clubId; private String name; private String country; - private String shieldURL; private Map contact; private String training_location; private String training_day_time; private String contact_intern; private String RNA; - private String SIRET; + private Long SIRET; private String no_affiliation; private boolean international; private HashMap contactMap = null; @@ -41,7 +40,6 @@ public class SimpleClub { .clubId(model.getClubId()) .name(model.getName()) .country(model.getCountry()) - .shieldURL(model.getShieldURL()) .contact(model.getContact()) .training_location(model.getTraining_location()) .training_day_time(model.getTraining_day_time()) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java index fcd9108..2ad0d6f 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java @@ -3,8 +3,10 @@ package fr.titionfire.ffsaf.rest.from; import jakarta.ws.rs.FormParam; import jakarta.ws.rs.core.MediaType; import lombok.Getter; +import lombok.ToString; import org.jboss.resteasy.reactive.PartType; +@ToString @Getter public class FullClubForm { @FormParam("id") @@ -13,6 +15,30 @@ public class FullClubForm { @FormParam("name") private String name = null; + @FormParam("country") + private String country = null; + + @FormParam("contact") + private String contact = null; + + @FormParam("training_location") + private String training_location = null; + + @FormParam("training_day_time") + private String training_day_time = null; + + @FormParam("contact_intern") + private String contact_intern = null; + + @FormParam("rna") + private String rna = null; + + @FormParam("siret") + private Long siret = null; + + @FormParam("international") + private boolean international = false; + @FormParam("status") @PartType(MediaType.APPLICATION_OCTET_STREAM) private byte[] status = new byte[0]; diff --git a/src/main/java/fr/titionfire/ffsaf/utils/Utils.java b/src/main/java/fr/titionfire/ffsaf/utils/Utils.java index e6de816..7dda235 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/Utils.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/Utils.java @@ -54,7 +54,7 @@ public class Utils { File dirFile = new File(media, dir); if (!dirFile.exists()) - if (dirFile.mkdirs()) + if (!dirFile.mkdirs()) throw new IOException("Fail to create directory " + dir); FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id)); diff --git a/src/main/webapp/src/components/Club/ContactEditor.jsx b/src/main/webapp/src/components/Club/ContactEditor.jsx new file mode 100644 index 0000000..33582fe --- /dev/null +++ b/src/main/webapp/src/components/Club/ContactEditor.jsx @@ -0,0 +1,79 @@ +import {useEffect, useReducer, useState} from "react"; +import {SimpleReducer} from "../../utils/SimpleReducer.jsx"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faAdd, faTrashCan} from "@fortawesome/free-solid-svg-icons"; + +export function ContactEditor({data}) { + const [state, dispatch] = useReducer(SimpleReducer, []) + const [out_data, setOutData] = useState({}) + + useEffect(() => { + for (const key in data.contact) { + dispatch({type: 'UPDATE_OR_ADD', payload: {id: key, data: data.contact[key]}}) + } + }, [data.contact]); + + useEffect(() => { + let out_data2 = {} + state.forEach(d => { + if (d.data !== undefined) + out_data2[d.id] = d.data + }) + setOutData(out_data2) + }, [state]); + + return
    + + Contacts +
      + {state.map((d, index) => { + if (d.data === undefined) + return; + + return
      + + { + dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: e.target.value}}) + }}/> + +
      + })} +
      + +
      +
    +
    +} \ No newline at end of file diff --git a/src/main/webapp/src/components/Club/HoraireEditor.jsx b/src/main/webapp/src/components/Club/HoraireEditor.jsx new file mode 100644 index 0000000..3b6dd25 --- /dev/null +++ b/src/main/webapp/src/components/Club/HoraireEditor.jsx @@ -0,0 +1,95 @@ +import {useEffect, useReducer, useState} from "react"; +import {SimpleReducer} from "../../utils/SimpleReducer.jsx"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faAdd, faTrashCan} from "@fortawesome/free-solid-svg-icons"; + +function timeNumberToSting(nbMin) { + return String(Math.floor(nbMin / 60)).padStart(2, '0') + ":" + String(nbMin % 60).padStart(2, '0') +} + +function timeStringToNumber(time) { + let times = time.split(':'); + return parseInt(times[0]) * 60 + parseInt(times[1]); +} + +export function HoraireEditor({data}) { + const [state, dispatch] = useReducer(SimpleReducer, []) + const [out_data, setOutData] = useState({}) + + useEffect(() => { + if (data.training_day_time === null) + return + JSON.parse(data.training_day_time).forEach((d, index) => { + dispatch({type: 'UPDATE_OR_ADD', payload: {id: index, data: d}}) + }) + }, [data.training_day_time]); + + useEffect(() => { + setOutData(state.map(d => { + return {day: d.data.day, time_start: d.data.time_start, time_end: d.data.time_end} + })) + }, [state]); + + const sortHoraire = (a, b) => { + if (a.data.day === b.data.day) + return a.data.time_start - b.data.time_start; + return a.data.day - b.data.day; + } + + return
    + + Horaires d'entrainements +
      + {state.map((d, index) => { + return
      + + de + { + d.data.time_start = timeStringToNumber(e.target.value) + dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: d.data}}) + dispatch({type: 'SORT', payload: sortHoraire}) + }}/> + à + { + d.data.time_end = timeStringToNumber(e.target.value) + dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: d.data}}) + dispatch({type: 'SORT', payload: sortHoraire}) + }}/> + +
      + })} +
      + +
      +
    +
    +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/club/LocationEditor.jsx b/src/main/webapp/src/components/Club/LocationEditor.jsx similarity index 53% rename from src/main/webapp/src/pages/admin/club/LocationEditor.jsx rename to src/main/webapp/src/components/Club/LocationEditor.jsx index 948649d..e984cb4 100644 --- a/src/main/webapp/src/pages/admin/club/LocationEditor.jsx +++ b/src/main/webapp/src/components/Club/LocationEditor.jsx @@ -1,10 +1,10 @@ import {useEffect, useReducer, useRef, useState} from "react"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faPen, faTrashCan} from "@fortawesome/free-solid-svg-icons"; +import {faAdd, faPen, faTrashCan} from "@fortawesome/free-solid-svg-icons"; import proj4 from "proj4"; -import {useFetch} from "../../../hooks/useFetch.js"; +import {useFetch} from "../../hooks/useFetch.js"; import {MapContainer, Marker, TileLayer} from "react-leaflet"; -import {SimpleReducer} from "../../../utils/SimpleReducer.jsx"; +import {SimpleReducer} from "../../utils/SimpleReducer.jsx"; export function LocationEditor({data, setModal, sendData}) { const [state, dispatch] = useReducer(SimpleReducer, []) @@ -40,64 +40,46 @@ export function LocationEditor({data, setModal, sendData}) { })) }, [state]); - return
    + return
    Lieux d'entrainements -
    -
      - {state.map((d, index) => { - return
      -
      {d.data.text}
      - - -
      - })} -
    +
      + {state.map((d, index) => { + return
      + { + }}/> -
      + + +
    + })} +
    + +
    +
    } export function LocationEditorModal({modal, sendData}) { - return -} - -proj4.defs("EPSG:9794", "+proj=lcc +lat_1=44 +lat_2=49 +lat_0=46.5 +lon_0=3 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs"); - -function convertLambert93ToLatLng(x, y) { - const lambertPoint = proj4.toPoint([x, y]); - const wgs84Point = proj4("EPSG:9794", "EPSG:4326", lambertPoint); - return {lat: wgs84Point.y, lng: wgs84Point.x}; -} - -function LocationEditorModalBody({modal}) { const [location, setLocation] = useState("") const [locationObj, setLocationObj] = useState({text: "", lng: undefined, lat: undefined}) const [mapPosition, setMapPosition] = useState([46.652195, 2.430226]) @@ -107,6 +89,7 @@ function LocationEditorModalBody({modal}) { useEffect(() => { if (modal.data !== undefined) { setLocation(modal.data.text) + setLocationObj(modal.data) } }, [modal]) @@ -143,34 +126,59 @@ function LocationEditorModalBody({modal}) { return () => clearTimeout(delayDebounceFn) }, [locationObj, modal]) + return From 682894f32674dabcc349c48236e3cdd8e7243dc2 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Sun, 14 Jul 2024 23:04:22 +0200 Subject: [PATCH 07/37] wip: Affiliation request --- .../ffsaf/data/model/ClubModel.java | 2 +- .../ffsaf/domain/entity/ClubEntity.java | 2 +- .../domain/service/AffiliationService.java | 15 +- .../ffsaf/net2/data/SimpleClubModel.java | 2 +- .../ffsaf/rest/AffiliationEndpoints.java | 10 + .../titionfire/ffsaf/rest/CombEndpoints.java | 17 ++ .../ffsaf/rest/data/SimpleClub.java | 2 +- .../ffsaf/rest/data/SimpleReqAffiliation.java | 60 ++++++ .../rest/from/AffiliationRequestForm.java | 6 +- .../src/components/Club/LocationEditor.jsx | 2 +- .../src/components/MemberCustomFiels.jsx | 14 ++ src/main/webapp/src/hooks/useFetch.js | 3 +- src/main/webapp/src/pages/DemandeAff.jsx | 2 +- src/main/webapp/src/pages/admin/AdminRoot.jsx | 2 +- .../admin/affiliation/AffiliationReqPage.jsx | 183 +++++++++++++++++- .../src/pages/admin/club/AffiliationCard.jsx | 10 +- .../webapp/src/pages/admin/club/ClubPage.jsx | 5 +- .../pages/admin/member/InformationForm.jsx | 14 +- .../src/pages/admin/member/NewMemberPage.jsx | 10 +- .../src/pages/club/member/InformationForm.jsx | 14 +- 20 files changed, 322 insertions(+), 53 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliation.java diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java index 91b533f..6b38d76 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java @@ -49,7 +49,7 @@ public class ClubModel { Long SIRET; - String no_affiliation; + Long no_affiliation; boolean international; diff --git a/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java b/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java index bf4939a..cb840e9 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/entity/ClubEntity.java @@ -24,7 +24,7 @@ public class ClubEntity { private String contact_intern; private String RNA; private Long SIRET; - private String no_affiliation; + private Long no_affiliation; private boolean international; public static ClubEntity fromModel (ClubModel model) { diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index 0fa74ea..2b21572 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -7,6 +7,7 @@ import fr.titionfire.ffsaf.data.repository.AffiliationRequestRepository; import fr.titionfire.ffsaf.data.repository.ClubRepository; import fr.titionfire.ffsaf.data.repository.CombRepository; import fr.titionfire.ffsaf.rest.data.SimpleAffiliation; +import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliation; import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm; import fr.titionfire.ffsaf.utils.Utils; import io.quarkus.hibernate.reactive.panache.Panache; @@ -75,12 +76,24 @@ public class AffiliationService { .map(__ -> "Ok"); } + public Uni getRequest(long id) { + return repositoryRequest.findById(id).map(SimpleReqAffiliation::fromModel) + .call(out -> clubRepository.find("SIRET = ?1", out.getSiret()).firstResult().invoke(c -> { + if (c != null){ + out.setClub(c.getId()); + out.setClub_name(c.getName()); + out.setClub_no_aff(c.getNo_affiliation()); + } + }) + ); + } + public Uni> getCurrentSaisonAffiliation() { return repository.list("saison = ?1", Utils.getSaison()) .map(models -> models.stream().map(SimpleAffiliation::fromModel).toList()) .chain(aff -> repositoryRequest.list("saison = ?1", Utils.getSaison()) .chain(models -> Uni.join().all(models.stream().map(model -> - clubRepository.find("siret = ?1", model.getSiret()).firstResult() + clubRepository.find("SIRET = ?1", model.getSiret()).firstResult() .map(c -> new SimpleAffiliation(model.getId() * -1, c.getId(), model.getSaison(), false))) .toList()).andFailFast() diff --git a/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java b/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java index 815a464..748c9ec 100644 --- a/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java +++ b/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java @@ -17,7 +17,7 @@ public class SimpleClubModel { String name; String country; String shieldURL; - String no_affiliation; + Long no_affiliation; public static SimpleClubModel fromModel(ClubModel model) { if (model == null) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java index 8634197..2ac5007 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java @@ -2,6 +2,7 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.domain.service.AffiliationService; import fr.titionfire.ffsaf.rest.data.SimpleAffiliation; +import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliation; import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm; import fr.titionfire.ffsaf.utils.GroupeUtils; import io.quarkus.oidc.IdToken; @@ -35,7 +36,16 @@ public class AffiliationEndpoints { throw new ForbiddenException(); }); + @GET + @Path("/request/{id}") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni getAffRequest(@PathParam("id") long id) { + return service.getRequest(id); + } + @POST + @Path("/request") @Produces(MediaType.TEXT_PLAIN) @Consumes(MediaType.MULTIPART_FORM_DATA) public Uni saveAffRequest(AffiliationRequestForm form) { diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java index b7c60f7..0882952 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java @@ -26,6 +26,7 @@ import java.io.*; import java.net.URISyntaxException; import java.net.URLConnection; import java.nio.file.Files; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.function.Consumer; @@ -68,6 +69,14 @@ public class CombEndpoints { return membreService.searchAdmin(limit, page - 1, search, club); } + @GET + @Path("/find/similar") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni> getSimilar(@QueryParam("fname") String fname, @QueryParam("lname") String lname) { + return membreService.getSimilar(fname, lname); + } + @GET @Path("/find/club") @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) @@ -90,6 +99,14 @@ public class CombEndpoints { return membreService.getById(id).onItem().invoke(checkPerm).map(SimpleMembre::fromModel); } + @GET + @Path("/find/licence") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni getByLicence(@QueryParam("id") long id) { + return membreService.getByLicence(id).onItem().invoke(checkPerm).map(SimpleMembre::fromModel); + } + @PUT @Path("{id}") @RolesAllowed({"federation_admin"}) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java index 8972e07..44ba58f 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java @@ -27,7 +27,7 @@ public class SimpleClub { private String contact_intern; private String RNA; private Long SIRET; - private String no_affiliation; + private Long no_affiliation; private boolean international; private HashMap contactMap = null; diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliation.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliation.java new file mode 100644 index 0000000..9b0a594 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliation.java @@ -0,0 +1,60 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.AffiliationRequestModel; +import fr.titionfire.ffsaf.utils.RoleAsso; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@RegisterForReflection +public class SimpleReqAffiliation { + Long id; + Long club; + String club_name; + Long club_no_aff; + String name; + long siret; + String RNA; + String address; + List members; + int saison; + + public static SimpleReqAffiliation fromModel(AffiliationRequestModel model) { + if (model == null) + return null; + + return new SimpleReqAffiliation.SimpleReqAffiliationBuilder() + .id(model.getId()) + .name(model.getName()) + .siret(model.getSiret()) + .RNA(model.getRNA()) + .address(model.getAddress()) + .saison(model.getSaison()) + .members(List.of( + new AffiliationMember(model.getM1_lname(), model.getM1_fname(), model.getM1_email(), + model.getM1_lincence(), model.getM1_role()), + new AffiliationMember(model.getM2_lname(), model.getM2_fname(), model.getM2_email(), + model.getM2_lincence(), model.getM2_role()), + new AffiliationMember(model.getM3_lname(), model.getM3_fname(), model.getM3_email(), + model.getM3_lincence(), model.getM3_role()) + )) + .build(); + } + + @Data + @AllArgsConstructor + @RegisterForReflection + public static class AffiliationMember { + String lname; + String fname; + String email; + int licence; + RoleAsso role; + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java index d89b26b..4b61818 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java @@ -75,21 +75,21 @@ public class AffiliationRequestForm { model.setM1_fname(this.getM1_fname()); model.setM1_email(this.getM1_email()); model.setM1_lincence((this.getM1_lincence() == null || this.getM1_lincence().isBlank()) - ? 0 : Integer.parseInt(this.getM1_lincence())); + ? -1 : Integer.parseInt(this.getM1_lincence())); model.setM1_role(this.getM1_role()); model.setM2_lname(this.getM2_lname()); model.setM2_fname(this.getM2_fname()); model.setM2_email(this.getM2_email()); model.setM2_lincence((this.getM1_lincence() == null || this.getM1_lincence().isBlank()) - ? 0 : Integer.parseInt(this.getM2_lincence())); + ? -1 : Integer.parseInt(this.getM2_lincence())); model.setM2_role(this.getM2_role()); model.setM3_lname(this.getM3_lname()); model.setM3_fname(this.getM3_fname()); model.setM3_email(this.getM3_email()); model.setM3_lincence((this.getM1_lincence() == null || this.getM1_lincence().isBlank()) - ? 0 : Integer.parseInt(this.getM3_lincence())); + ? -1 : Integer.parseInt(this.getM3_lincence())); model.setM3_role(this.getM3_role()); return model; diff --git a/src/main/webapp/src/components/Club/LocationEditor.jsx b/src/main/webapp/src/components/Club/LocationEditor.jsx index e984cb4..086e82f 100644 --- a/src/main/webapp/src/components/Club/LocationEditor.jsx +++ b/src/main/webapp/src/components/Club/LocationEditor.jsx @@ -83,7 +83,7 @@ export function LocationEditorModal({modal, sendData}) { const [location, setLocation] = useState("") const [locationObj, setLocationObj] = useState({text: "", lng: undefined, lat: undefined}) const [mapPosition, setMapPosition] = useState([46.652195, 2.430226]) - const {data, error, refresh} = useFetch(``) + const {data, error, refresh} = useFetch(null) const map = useRef(null) useEffect(() => { diff --git a/src/main/webapp/src/components/MemberCustomFiels.jsx b/src/main/webapp/src/components/MemberCustomFiels.jsx index d03e69f..d7f0050 100644 --- a/src/main/webapp/src/components/MemberCustomFiels.jsx +++ b/src/main/webapp/src/components/MemberCustomFiels.jsx @@ -49,6 +49,20 @@ export function OptionField({name, text, values, value, disabled = false}) {
    } +export function RoleList({name, text, value, disabled = false}) { + return +} + export function CountryList({name, text, value, values = undefined, disabled = false}) { if (values === undefined){ values = {NA: 'Sélectionner...', fr: 'FR', es: 'ES', be: 'BE'} diff --git a/src/main/webapp/src/hooks/useFetch.js b/src/main/webapp/src/hooks/useFetch.js index 99379fc..947894b 100644 --- a/src/main/webapp/src/hooks/useFetch.js +++ b/src/main/webapp/src/hooks/useFetch.js @@ -24,7 +24,8 @@ export function useFetch(url, setLoading = null, loadingLevel = 1, config = {}) } useEffect(() => { - refresh(url) + if (url !== null) + refresh(url) }, []); return { diff --git a/src/main/webapp/src/pages/DemandeAff.jsx b/src/main/webapp/src/pages/DemandeAff.jsx index 8d436d7..e50c621 100644 --- a/src/main/webapp/src/pages/DemandeAff.jsx +++ b/src/main/webapp/src/pages/DemandeAff.jsx @@ -32,7 +32,7 @@ export function DemandeAff() { const formData = new FormData(event.target) formData.append("m1_role", event.target.m1_role?.value) toast.promise( - apiAxios.post(`/affiliation`, formData, {headers: {'Accept': '*/*'}}), + apiAxios.post(`/affiliation/request`, formData, {headers: {'Accept': '*/*'}}), { pending: "Enregistrement de la demande d'affiliation en cours", success: "Demande d'affiliation enregistrée avec succès 🎉", diff --git a/src/main/webapp/src/pages/admin/AdminRoot.jsx b/src/main/webapp/src/pages/admin/AdminRoot.jsx index 063c17e..83af37d 100644 --- a/src/main/webapp/src/pages/admin/AdminRoot.jsx +++ b/src/main/webapp/src/pages/admin/AdminRoot.jsx @@ -41,7 +41,7 @@ export function getAdminChildren() { element: }, { - path: 'affiliation/request', + path: 'affiliation/request/:id', element: }, { diff --git a/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx b/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx index 443e68e..2665aa5 100644 --- a/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx +++ b/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx @@ -1,16 +1,191 @@ -import {useNavigate} from "react-router-dom"; +import {useNavigate, useParams} from "react-router-dom"; +import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {AxiosError} from "../../../components/AxiosError.jsx"; +import {toast} from "react-toastify"; +import {apiAxios} from "../../../utils/Tools.js"; +import {RoleList, TextField} from "../../../components/MemberCustomFiels.jsx"; +import {useEffect, useRef, useState} from "react"; export function AffiliationReqPage() { + const {id} = useParams() const navigate = useNavigate(); + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/affiliation/request/${id}`, setLoading, 1) + return <> -

    Page affiliation

    +

    Demande d'affiliation

    -
    -
    + {data + ?
    + +
    + : error && + }
    +} + +function Content({data}) { + const handleSubmit = (event) => { + event.preventDefault(); + + const formData = new FormData(event.target); + + toast.promise( + apiAxios.put(`/club/${data.id}`, formData), + { + pending: "Enregistrement du club en cours", + success: "Club enregistrée avec succès 🎉", + error: "Échec de l'enregistrement du club 😕" + } + ) + } + + return <> +
    +
    + +
    Demande d'affiliation
    +
    + {data.club &&
    Ce club a déjà ete affilier (affiliation n°{data.club_no_aff})
    } +

    Saison {data.saison}-{data.saison + 1}

    + +
    +
    + Nom du club + +
    + {data.club &&
    Ancien nom: {data.club_name}
    } +
    + + + + + + {data.members.map((member, index) => { + return
    + +
    + })} + +
    +
    +
    + +} + +function MemberPart({index, member}) { + const [mode, setMode] = useState(member.licence >= 0 ? 0 : 2) + const [current, setCurrent] = useState(-1) + + useEffect(() => { + if (mode !== 1) + setCurrent(-1) + }, [mode]); + + return
    +
    +
    Membre n°{index + 1}
    +
    + + +
    +
    +
    +
    + setMode(0)}/> + +
    + +
    + + + +
    +
    +
    + +
    +
    +
    + setMode(1)}/> + +
    +
    + + + +
    +
    +
    + +
    +
    +
    + setMode(2)}/> + +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +} + +function MemberLicence({member, mode, index}) { + const setLoading = useLoadingSwitcher() + const [licence, setLicence] = useState(member.licence) + const {data, refresh} = useFetch(null, setLoading, 1) + const ref = useRef(-1) + + useEffect(() => { + if (licence === -1 || licence.length < 1 || licence === ref.current) + return + refresh(`/member/find/licence?id=${licence}`) + ref.current = licence + }, [licence]); + + const name = "m" + (index + 1) + "licence"; + return <> +
    +
    + Licence + = 0 ? String(licence) : ""} disabled={mode !== 0} required={mode === 0} + onChange={event => setLicence(event.target.value)}/> +
    +
    + {data && Nom: {data.lname} {data.fname}, Club: {data.club.name}} + +} + +function MemberSimilar({member, current, setCurrent, mode}) { + const setLoading = useLoadingSwitcher() + const {data} = useFetch(`/member/find/similar?fname=${encodeURI(member.fname)}&lname=${encodeURI(member.lname)}`, setLoading, 1) + + return
    + {data && data.map((m, index) => { + return + })} +
    } \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx b/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx index 84570c1..ef4cfd8 100644 --- a/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx +++ b/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx @@ -7,12 +7,13 @@ import {AxiosError} from "../../../components/AxiosError.jsx"; import {apiAxios, getSaison} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; import {SimpleReducer} from "../../../utils/SimpleReducer.jsx"; +import {useNavigate} from "react-router-dom"; export function AffiliationCard({clubData}) { const setLoading = useLoadingSwitcher() const {data, error} = useFetch(`/affiliation/${clubData.id}`, setLoading, 1) - const [modalAffiliation, setModal] = useState({id: -1, club: clubData.id}) + const [modalAffiliation, setModal] = useState({id: 0, club: clubData.id}) const [affiliations, dispatch] = useReducer(SimpleReducer, []) useEffect(() => { @@ -94,6 +95,7 @@ function removeAffiliation(id, dispatch) { } function ModalContent({affiliation, dispatch}) { + const navigate = useNavigate(); const [saison, setSaison] = useState(0) const setSeason = (event) => { setSaison(Number(event.target.value)) @@ -130,7 +132,9 @@ function ModalContent({affiliation, dispatch}) { {affiliation.validate ? Validée : <> En attente - // TODO + }
    @@ -138,7 +142,7 @@ function ModalContent({affiliation, dispatch}) { {affiliation.id === 0 && } {affiliation.id <= 0 || } + onClick={() => removeAffiliation(affiliation.id, dispatch)}>Supprimer}
    } \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/club/ClubPage.jsx b/src/main/webapp/src/pages/admin/club/ClubPage.jsx index 93b7a40..f6a8ac0 100644 --- a/src/main/webapp/src/pages/admin/club/ClubPage.jsx +++ b/src/main/webapp/src/pages/admin/club/ClubPage.jsx @@ -114,13 +114,14 @@ function InformationForm({data}) {
    setSwitchOn(!switchOn)}/> +
    -
    {!switchOn && <> - +
    diff --git a/src/main/webapp/src/pages/admin/member/InformationForm.jsx b/src/main/webapp/src/pages/admin/member/InformationForm.jsx index 3c62e9c..c6ea3fe 100644 --- a/src/main/webapp/src/pages/admin/member/InformationForm.jsx +++ b/src/main/webapp/src/pages/admin/member/InformationForm.jsx @@ -2,7 +2,7 @@ import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; import {apiAxios} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; import imageCompression from "browser-image-compression"; -import {BirthDayField, CountryList, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx"; +import {BirthDayField, CountryList, OptionField, RoleList, TextField} from "../../../components/MemberCustomFiels.jsx"; import {ClubSelect} from "../../../components/ClubSelect.jsx"; export function addPhoto(event, formData, send) { @@ -80,17 +80,7 @@ export function InformationForm({data}) {
    - +
    diff --git a/src/main/webapp/src/pages/admin/member/NewMemberPage.jsx b/src/main/webapp/src/pages/admin/member/NewMemberPage.jsx index ab4ca01..1106d99 100644 --- a/src/main/webapp/src/pages/admin/member/NewMemberPage.jsx +++ b/src/main/webapp/src/pages/admin/member/NewMemberPage.jsx @@ -2,7 +2,7 @@ import {useNavigate} from "react-router-dom"; import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; import {apiAxios} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; -import {BirthDayField, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx"; +import {BirthDayField, OptionField, RoleList, TextField} from "../../../components/MemberCustomFiels.jsx"; import {ClubSelect} from "../../../components/ClubSelect.jsx"; import {addPhoto} from "./InformationForm.jsx"; @@ -79,13 +79,7 @@ function Form() {
    - +
    diff --git a/src/main/webapp/src/pages/club/member/InformationForm.jsx b/src/main/webapp/src/pages/club/member/InformationForm.jsx index 7d9f96f..648adde 100644 --- a/src/main/webapp/src/pages/club/member/InformationForm.jsx +++ b/src/main/webapp/src/pages/club/member/InformationForm.jsx @@ -3,7 +3,7 @@ import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; import {apiAxios} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; -import {BirthDayField, CountryList, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx"; +import {BirthDayField, CountryList, OptionField, RoleList, TextField} from "../../../components/MemberCustomFiels.jsx"; import {addPhoto} from "../../admin/member/InformationForm.jsx"; export function InformationForm({data}) { @@ -55,17 +55,7 @@ export function InformationForm({data}) { - +
    From d03ec054d2ff83136442658cdecd70eddad56aba Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Mon, 15 Jul 2024 23:39:01 +0200 Subject: [PATCH 08/37] wip: affiliation --- .../ffsaf/data/model/ClubModel.java | 2 + .../ffsaf/data/model/MembreModel.java | 2 +- .../ffsaf/data/model/SequenceModel.java | 24 ++ .../data/repository/SequenceRepository.java | 21 ++ .../domain/service/AffiliationService.java | 191 +++++++++++++++- .../ffsaf/domain/service/KeycloakService.java | 6 +- .../ffsaf/domain/service/MembreService.java | 13 ++ .../ffsaf/rest/AffiliationEndpoints.java | 42 ++++ .../rest/from/AffiliationRequestSaveForm.java | 115 ++++++++++ .../titionfire/ffsaf/utils/SequenceType.java | 5 + .../java/fr/titionfire/ffsaf/utils/Utils.java | 50 ++++- src/main/webapp/src/pages/DemandeAff.jsx | 2 +- .../admin/affiliation/AffiliationReqPage.jsx | 207 ++++++++++++++++-- .../webapp/src/pages/admin/club/ClubPage.jsx | 3 +- 14 files changed, 643 insertions(+), 40 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/data/model/SequenceModel.java create mode 100644 src/main/java/fr/titionfire/ffsaf/data/repository/SequenceRepository.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestSaveForm.java create mode 100644 src/main/java/fr/titionfire/ffsaf/utils/SequenceType.java diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java index 6b38d76..d65b176 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java @@ -45,6 +45,8 @@ public class ClubModel { String contact_intern; + String address; + String RNA; Long SIRET; diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java index 334c3fc..a64b1a9 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java @@ -53,6 +53,6 @@ public class MembreModel { String url_photo; - @OneToMany(mappedBy = "membre", fetch = FetchType.LAZY) + @OneToMany(mappedBy = "membre", fetch = FetchType.LAZY, cascade = CascadeType.ALL) List licences; } diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/SequenceModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/SequenceModel.java new file mode 100644 index 0000000..f8fdad3 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/model/SequenceModel.java @@ -0,0 +1,24 @@ +package fr.titionfire.ffsaf.data.model; + +import fr.titionfire.ffsaf.utils.SequenceType; +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.*; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection + +@Entity +@Table(name = "sequence") +public class SequenceModel { + @Id + SequenceType type; + + long value; +} diff --git a/src/main/java/fr/titionfire/ffsaf/data/repository/SequenceRepository.java b/src/main/java/fr/titionfire/ffsaf/data/repository/SequenceRepository.java new file mode 100644 index 0000000..9cd235f --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/repository/SequenceRepository.java @@ -0,0 +1,21 @@ +package fr.titionfire.ffsaf.data.repository; + +import fr.titionfire.ffsaf.data.model.SequenceModel; +import fr.titionfire.ffsaf.utils.SequenceType; +import io.quarkus.hibernate.reactive.panache.PanacheRepositoryBase; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class SequenceRepository implements PanacheRepositoryBase { + + public Uni getNextValueInTransaction(SequenceType type) { + return this.findById(type).onItem().ifNull() + .switchTo(() -> this.persist(new SequenceModel(type, 1L))) + .chain(v -> { + v.setValue(v.getValue() + 1); + return this.persistAndFlush(v); + }) + .map(SequenceModel::getValue); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index 2b21572..7f06786 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -1,20 +1,19 @@ package fr.titionfire.ffsaf.domain.service; -import fr.titionfire.ffsaf.data.model.AffiliationModel; -import fr.titionfire.ffsaf.data.model.AffiliationRequestModel; -import fr.titionfire.ffsaf.data.repository.AffiliationRepository; -import fr.titionfire.ffsaf.data.repository.AffiliationRequestRepository; -import fr.titionfire.ffsaf.data.repository.ClubRepository; -import fr.titionfire.ffsaf.data.repository.CombRepository; +import fr.titionfire.ffsaf.data.model.*; +import fr.titionfire.ffsaf.data.repository.*; import fr.titionfire.ffsaf.rest.data.SimpleAffiliation; import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliation; import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm; +import fr.titionfire.ffsaf.rest.from.AffiliationRequestSaveForm; +import fr.titionfire.ffsaf.utils.SequenceType; import fr.titionfire.ffsaf.utils.Utils; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.common.WithSession; import io.smallrye.mutiny.Uni; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.ws.rs.NotFoundException; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.hibernate.reactive.mutiny.Mutiny; @@ -37,6 +36,15 @@ public class AffiliationService { @Inject AffiliationRepository repository; + @Inject + KeycloakService keycloakService; + + @Inject + SequenceRepository sequenceRepository; + + @Inject + LicenceRepository licenceRepository; + @ConfigProperty(name = "upload_dir") String media; @@ -46,21 +54,21 @@ public class AffiliationService { // noinspection ResultOfMethodCallIgnored return Uni.createFrom().item(affModel) - .call(model -> ((model.getM1_lincence() != 0) ? combRepository.find("licence", + .call(model -> ((model.getM1_lincence() != -1) ? combRepository.find("licence", model.getM1_lincence()).count().invoke(count -> { if (count == 0) { throw new IllegalArgumentException("Licence membre n°1 inconnue"); } }) : Uni.createFrom().nullItem()) ) - .call(model -> ((model.getM2_lincence() != 0) ? combRepository.find("licence", + .call(model -> ((model.getM2_lincence() != -1) ? combRepository.find("licence", model.getM2_lincence()).count().invoke(count -> { if (count == 0) { throw new IllegalArgumentException("Licence membre n°2 inconnue"); } }) : Uni.createFrom().nullItem()) ) - .call(model -> ((model.getM3_lincence() != 0) ? combRepository.find("licence", + .call(model -> ((model.getM3_lincence() != -1) ? combRepository.find("licence", model.getM3_lincence()).count().invoke(count -> { if (count == 0) { throw new IllegalArgumentException("Licence membre n°3 inconnue"); @@ -76,10 +84,165 @@ public class AffiliationService { .map(__ -> "Ok"); } + public Uni saveAdmin(AffiliationRequestSaveForm form) { + return repositoryRequest.findById(form.getId()) + .onItem().ifNull().failWith(new NotFoundException("Affiliation request not found")) + .map(model -> { + model.setName(form.getName()); + model.setSiret(form.getSiret()); + model.setRNA(form.getRna()); + model.setAddress(form.getAddress()); + + if (form.getM1_mode() == 2) { + model.setM1_lname(form.getM1_lname()); + model.setM1_fname(form.getM1_fname()); + } else { + model.setM1_lincence( + form.getM1_lincence() == null ? 0 : Integer.parseInt(form.getM1_lincence())); + } + model.setM1_role(form.getM1_role()); + if (form.getM1_email_mode() == 0) + model.setM1_email(form.getM1_email()); + + if (form.getM2_mode() == 2) { + model.setM2_lname(form.getM2_lname()); + model.setM2_fname(form.getM2_fname()); + } else { + model.setM2_lincence( + form.getM2_lincence() == null ? 0 : Integer.parseInt(form.getM2_lincence())); + } + model.setM2_role(form.getM2_role()); + if (form.getM2_email_mode() == 0) + model.setM2_email(form.getM2_email()); + + if (form.getM3_mode() == 2) { + model.setM3_lname(form.getM3_lname()); + model.setM3_fname(form.getM3_fname()); + } else { + model.setM3_lincence( + form.getM3_lincence() == null ? 0 : Integer.parseInt(form.getM3_lincence())); + } + model.setM3_role(form.getM3_role()); + if (form.getM3_email_mode() == 0) + model.setM3_email(form.getM3_email()); + + return model; + }) + .chain(model -> Panache.withTransaction(() -> repositoryRequest.persist(model))) + .onItem() + .invoke(model -> Uni.createFrom().future(Utils.replacePhoto(model.getId(), form.getLogo(), media, + "aff_request/logo"))) + .onItem() + .invoke(model -> Uni.createFrom().future(Utils.replacePhoto(model.getId(), form.getStatus(), media, + "aff_request/status"))) + .map(__ -> "Ok"); + } + + private Uni setMembre(AffiliationRequestSaveForm.Member member, ClubModel club, int saison) { + return Uni.createFrom().nullItem().chain(__ -> { + if (member.getMode() == 2) { + MembreModel membreModel = new MembreModel(); + membreModel.setFname(member.getFname()); + membreModel.setLname(member.getLname()); + membreModel.setClub(club); + membreModel.setRole(member.getRole()); + membreModel.setEmail(member.getEmail()); + return Panache.withTransaction(() -> + combRepository.persist(membreModel) + .chain(m -> sequenceRepository.getNextValueInTransaction(SequenceType.Licence) + .invoke(l -> m.setLicence(Math.toIntExact(l))) + .chain(() -> combRepository.persist(m)))); + } else { + return combRepository.find("licence", Integer.parseInt(member.getLicence())).firstResult() + .onItem().ifNull().switchTo(() -> { + MembreModel membreModel = new MembreModel(); + membreModel.setFname(member.getFname()); + membreModel.setLname(member.getLname()); + return Panache.withTransaction( + () -> sequenceRepository.getNextValueInTransaction(SequenceType.Licence) + .invoke(l -> membreModel.setLicence(Math.toIntExact(l))) + .chain(() -> combRepository.persist(membreModel))); + }) + .map(m -> { + m.setClub(club); + m.setRole(member.getRole()); + m.setEmail(member.getEmail()); + return m; + }).call(m -> Panache.withTransaction(() -> combRepository.persist(m))); + } + }) + .call(m -> (m.getUserId() == null) ? keycloakService.initCompte(m.getId()) : + keycloakService.setClubGroupMembre(m, club)) + .call(m -> Panache.withTransaction(() -> licenceRepository.persist( + new LicenceModel(null, m, saison, false, true)))); + } + + public Uni accept(AffiliationRequestSaveForm form) { + return repositoryRequest.findById(form.getId()) + .onItem().ifNull().failWith(new NotFoundException("Affiliation request not found")) + .chain(req -> + clubRepository.find("SIRET = ?1", form.getSiret()).firstResult() + .chain(model -> (model == null) ? acceptNew(form, req) : acceptOld(form, req, model)) + .call(club -> setMembre(form.new Member(1), club, req.getSaison()) + .call(__ -> setMembre(form.new Member(2), club, req.getSaison()) + .call(___ -> setMembre(form.new Member(3), club, req.getSaison())))) + .onItem() + .invoke(model -> Uni.createFrom() + .future(Utils.replacePhoto(form.getId(), form.getLogo(), media, + "aff_request/logo"))) + .onItem() + .invoke(model -> Uni.createFrom() + .future(Utils.replacePhoto(form.getId(), form.getStatus(), media, + "aff_request/status"))) + .call(model -> Utils.moveMedia(form.getId(), model.getId(), media, "aff_request/logo", + "ppClub")) + .call(model -> Utils.moveMedia(form.getId(), model.getId(), media, "aff_request/status", + "clubStatus")) + ) + .map(__ -> "Ok"); + } + + private Uni acceptNew(AffiliationRequestSaveForm form, AffiliationRequestModel model) { + return Uni.createFrom().nullItem() + .chain(() -> { + ClubModel club = new ClubModel(); + club.setName(form.getName()); + club.setCountry("fr"); + club.setSIRET(form.getSiret()); + club.setRNA(form.getRna()); + club.setAddress(form.getAddress()); + club.setAffiliations(List.of(new AffiliationModel(null, club, model.getSaison()))); + return Panache.withTransaction(() -> clubRepository.persist(club) + .chain(c -> sequenceRepository.getNextValueInTransaction(SequenceType.Affiliation) + .invoke(c::setNo_affiliation) + .chain(() -> clubRepository.persist(c)) + .chain(() -> repositoryRequest.delete(model)) + ) + .chain(() -> repository.persist(new AffiliationModel(null, club, model.getSaison()))) + .map(c -> club)); + }); + } + + private Uni acceptOld(AffiliationRequestSaveForm form, AffiliationRequestModel model, ClubModel club) { + return Uni.createFrom().nullItem() + .chain(() -> { + club.setName(form.getName()); + club.setCountry("fr"); + club.setSIRET(form.getSiret()); + club.setRNA(form.getRna()); + club.setAddress(form.getAddress()); + return Panache.withTransaction(() -> clubRepository.persist(club) + .chain(() -> repository.persist(new AffiliationModel(null, club, model.getSaison()))) + .chain(() -> repositoryRequest.delete(model))); + }) + .map(__ -> club); + } + public Uni getRequest(long id) { return repositoryRequest.findById(id).map(SimpleReqAffiliation::fromModel) + .onItem().ifNull().failWith(new NotFoundException("Affiliation request not found")) .call(out -> clubRepository.find("SIRET = ?1", out.getSiret()).firstResult().invoke(c -> { - if (c != null){ + if (c != null) { out.setClub(c.getId()); out.setClub_name(c.getName()); out.setClub_no_aff(c.getNo_affiliation()); @@ -102,7 +265,9 @@ public class AffiliationService { } public Uni> getAffiliation(long id) { - return clubRepository.findById(id).call(model -> Mutiny.fetch(model.getAffiliations())) + return clubRepository.findById(id) + .onItem().ifNull().failWith(new NotFoundException("Affiliation request not found")) + .call(model -> Mutiny.fetch(model.getAffiliations())) .chain(model -> repositoryRequest.list("siret = ?1", model.getSIRET()) .map(reqs -> reqs.stream().map(req -> new SimpleAffiliation(req.getId() * -1, model.getId(), req.getSaison(), false))) @@ -112,7 +277,9 @@ public class AffiliationService { } public Uni setAffiliation(long id, int saison) { - return clubRepository.findById(id).chain(club -> + return clubRepository.findById(id) + .onItem().ifNull().failWith(new NotFoundException("Affiliation request not found")) + .chain(club -> Panache.withTransaction(() -> repository.persist(new AffiliationModel(null, club, saison)))) .map(SimpleAffiliation::fromModel); } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java index f59c861..7a3c49d 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java @@ -192,9 +192,9 @@ public class KeycloakService { String finalLogin = login; return getUser(login).orElseThrow(() -> new KeycloakException("Fail to fetch user %s".formatted(finalLogin))); }) - .invoke(user -> keycloak.realm(realm).users().get(user.getId()) - .executeActionsEmail(List.of(RequiredAction.VERIFY_EMAIL.name(), - RequiredAction.UPDATE_PASSWORD.name()))) + //.invoke(user -> keycloak.realm(realm).users().get(user.getId()) // TODO enable for production + // .executeActionsEmail(List.of(RequiredAction.VERIFY_EMAIL.name(), + // RequiredAction.UPDATE_PASSWORD.name()))) .invoke(user -> membreModel.setUserId(user.getId())) .call(user -> membreService.setUserId(membreModel.getId(), user.getId())) .call(user -> setClubGroupMembre(membreModel, membreModel.getClub())); diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java index c545e09..138c737 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -27,6 +27,8 @@ import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.ForbiddenException; import org.eclipse.microprofile.jwt.JsonWebToken; +import java.util.List; + @WithSession @ApplicationScoped @@ -102,6 +104,10 @@ public class MembreService { return repository.findById(id); } + public Uni getByLicence(long licence) { + return repository.find("licence = ?1", licence).firstResult(); + } + public Uni update(long id, FullMemberForm membre) { return repository.findById(id) .chain(membreModel -> clubRepository.findById(membre.getClub()).map(club -> new Pair<>(membreModel, club))) @@ -235,4 +241,11 @@ public class MembreService { model.setGrade_arbitrage(input.getGrade_arbitrage()); return model; } + + public Uni> getSimilar(String fname, String lname) { + return repository.listAll().map(membreModels -> membreModels.stream() + .filter(m -> StringSimilarity.similarity(m.getFname(), fname) <= 3 && + StringSimilarity.similarity(m.getLname(), lname) <= 3) + .map(SimpleMembre::fromModel).toList()); + } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java index 2ac5007..185c189 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java @@ -4,7 +4,9 @@ import fr.titionfire.ffsaf.domain.service.AffiliationService; import fr.titionfire.ffsaf.rest.data.SimpleAffiliation; import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliation; import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm; +import fr.titionfire.ffsaf.rest.from.AffiliationRequestSaveForm; import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.Utils; import io.quarkus.oidc.IdToken; import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; @@ -13,8 +15,11 @@ import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.jwt.JsonWebToken; +import java.net.URISyntaxException; import java.util.List; import java.util.function.Consumer; @@ -31,6 +36,9 @@ public class AffiliationEndpoints { @Inject SecurityIdentity securityIdentity; + @ConfigProperty(name = "upload_dir") + String media; + Consumer checkPerm = Unchecked.consumer(id -> { if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(id, idToken)) throw new ForbiddenException(); @@ -44,6 +52,24 @@ public class AffiliationEndpoints { return service.getRequest(id); } + @PUT + @Path("/request/save") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Uni saveAdminAffRequest(AffiliationRequestSaveForm form) { + return service.saveAdmin(form); + } + + @PUT + @Path("/request/apply") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Uni acceptAffRequest(AffiliationRequestSaveForm form) { + return service.accept(form); + } + @POST @Path("/request") @Produces(MediaType.TEXT_PLAIN) @@ -52,6 +78,7 @@ public class AffiliationEndpoints { return service.save(form); } + @GET @Path("/current") @RolesAllowed({"federation_admin"}) @@ -83,4 +110,19 @@ public class AffiliationEndpoints { public Uni deleteLicence(@PathParam("id") long id) { return service.deleteAffiliation(id); } + + @GET + @Path("/request/{id}/logo") + @RolesAllowed({"federation_admin"}) + public Uni getLogo(@PathParam("id") long id) throws URISyntaxException { + return Utils.getMediaFile(id, media, "aff_request/logo", Uni.createFrom().nullItem()); + } + + @GET + @Path("/request/{id}/status") + @RolesAllowed({"federation_admin"}) + public Uni getStatus(@PathParam("id") long id) throws URISyntaxException { + return Utils.getMediaFile(id, media, "aff_request/status", "affiliation_request_" + id + ".pdf", + Uni.createFrom().nullItem()); + } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestSaveForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestSaveForm.java new file mode 100644 index 0000000..495b1d9 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestSaveForm.java @@ -0,0 +1,115 @@ +package fr.titionfire.ffsaf.rest.from; + +import fr.titionfire.ffsaf.utils.RoleAsso; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.core.MediaType; +import lombok.Getter; +import lombok.ToString; +import org.jboss.resteasy.reactive.PartType; + +@Getter +@ToString +public class AffiliationRequestSaveForm { + @FormParam("id") + private Long id = null; + @FormParam("name") + private String name = null; + @FormParam("siret") + private Long siret = null; + @FormParam("rna") + private String rna = null; + @FormParam("address") + private String address = null; + + @FormParam("status") + @PartType(MediaType.APPLICATION_OCTET_STREAM) + private byte[] status = new byte[0]; + @FormParam("logo") + @PartType(MediaType.APPLICATION_OCTET_STREAM) + private byte[] logo = new byte[0]; + + @FormParam("m1_mode") + private Integer m1_mode = null; + @FormParam("m1_role") + private RoleAsso m1_role = null; + @FormParam("m1_licence") + private String m1_lincence = null; + @FormParam("m1_lname") + private String m1_lname = null; + @FormParam("m1_fname") + private String m1_fname = null; + @FormParam("m1_email") + private String m1_email = null; + @FormParam("m1_email_mode") + private Integer m1_email_mode = null; + + @FormParam("m2_mode") + private Integer m2_mode = null; + @FormParam("m2_role") + private RoleAsso m2_role = null; + @FormParam("m2_licence") + private String m2_lincence = null; + @FormParam("m2_lname") + private String m2_lname = null; + @FormParam("m2_fname") + private String m2_fname = null; + @FormParam("m2_email") + private String m2_email = null; + @FormParam("m2_email_mode") + private Integer m2_email_mode = null; + + @FormParam("m3_mode") + private Integer m3_mode = null; + @FormParam("m3_role") + private RoleAsso m3_role = null; + @FormParam("m3_licence") + private String m3_lincence = null; + @FormParam("m3_lname") + private String m3_lname = null; + @FormParam("m3_fname") + private String m3_fname = null; + @FormParam("m3_email") + private String m3_email = null; + @FormParam("m3_email_mode") + private Integer m3_email_mode = null; + + + @Getter + public class Member { + private Integer mode; + private RoleAsso role; + private String licence; + private String lname; + private String fname; + private String email; + private Integer email_mode; + + public Member(int n){ + if (n == 1) { + mode = m1_mode; + role = m1_role; + licence = m1_lincence; + lname = m1_lname; + fname = m1_fname; + email = m1_email; + email_mode = m1_email_mode; + } else if (n == 2) { + mode = m2_mode; + role = m2_role; + licence = m2_lincence; + lname = m2_lname; + fname = m2_fname; + email = m2_email; + email_mode = m2_email_mode; + } else if (n == 3) { + mode = m3_mode; + role = m3_role; + licence = m3_lincence; + lname = m3_lname; + fname = m3_fname; + email = m3_email; + email_mode = m3_email_mode; + } + } + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/utils/SequenceType.java b/src/main/java/fr/titionfire/ffsaf/utils/SequenceType.java new file mode 100644 index 0000000..a38f496 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/utils/SequenceType.java @@ -0,0 +1,5 @@ +package fr.titionfire.ffsaf.utils; + +public enum SequenceType { + Licence, Affiliation +} diff --git a/src/main/java/fr/titionfire/ffsaf/utils/Utils.java b/src/main/java/fr/titionfire/ffsaf/utils/Utils.java index 7dda235..4910615 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/Utils.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/Utils.java @@ -39,8 +39,47 @@ public class Utils { } } + public static Uni moveMedia(long idSrc, long idDest, String media, String dirSrc, String dirDst) { + System.out.println("moveMedia: " + idSrc + " -> " + idDest + " " + media + " " + dirSrc + " " + dirDst); + return Uni.createFrom().nullItem().map(__ -> { + File dirFile = new File(media, dirSrc); + if (!dirFile.exists()) + return "Not found"; + + File dirDestFile = new File(media, dirDst); + if (!dirDestFile.exists()) + if (!dirDestFile.mkdirs()) + return "Fail to create directory " + dirDestFile; + + FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(idSrc)); + File[] files = dirFile.listFiles(filter); + if (files == null || files.length == 0) + return "Not found"; + + FilenameFilter filter2 = (directory, filename) -> filename.startsWith(String.valueOf(idDest)); + File[] files2 = dirDestFile.listFiles(filter2); + if (files2 != null) { + for (File file : files2) { + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + } + + for (File file : files) { + //noinspection ResultOfMethodCallIgnored + file.renameTo(new File(dirDestFile, + file.getName().replaceFirst(String.valueOf(idSrc), String.valueOf(idDest)))); + } + + return "Ok"; + }); + } + public static Future replacePhoto(long id, byte[] input, String media, String dir) { return CompletableFuture.supplyAsync(() -> { + if (input == null || input.length == 0) + return "OK"; + try (InputStream is = new BufferedInputStream(new ByteArrayInputStream(input))) { String mimeType; try { @@ -76,7 +115,12 @@ public class Utils { } public static Uni getMediaFile(long id, String media, String dirname, - Uni uniBase) throws URISyntaxException { + Uni uniBase) throws URISyntaxException { + return getMediaFile(id, media, dirname, null, uniBase); + } + + public static Uni getMediaFile(long id, String media, String dirname, String out_filename, + Uni uniBase) throws URISyntaxException { Future> future = CompletableFuture.supplyAsync(() -> { FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id)); File[] files = new File(media, dirname).listFiles(filter); @@ -104,8 +148,10 @@ public class Utils { resp.type(MediaType.APPLICATION_OCTET_STREAM); resp.header(HttpHeaders.CONTENT_LENGTH, filePair.getValue().length); resp.header(HttpHeaders.CONTENT_TYPE, mimeType); - resp.header(HttpHeaders.CONTENT_DISPOSITION, "inline; "); + resp.header(HttpHeaders.CONTENT_DISPOSITION, + "inline; " + ((out_filename == null) ? "" : "filename=\"" + out_filename + "\"")); + System.out.println("getMediaFile: " + mimeType); return resp.build(); })); } diff --git a/src/main/webapp/src/pages/DemandeAff.jsx b/src/main/webapp/src/pages/DemandeAff.jsx index e50c621..b981aca 100644 --- a/src/main/webapp/src/pages/DemandeAff.jsx +++ b/src/main/webapp/src/pages/DemandeAff.jsx @@ -137,7 +137,7 @@ function AssoInfo() {
    N° SIRET* - setSiret(e.target.value)} defaultValue={500213731}/>
    - + + avatar +
    +
    + + +
    + +
    +
    +
    + + + + + +
    +
    Laissez vide pour ne rien changer.
    +
    + {data.members.map((member, index) => { return
    @@ -75,6 +180,12 @@ function Content({data}) { })}
    +
    +
    + + +
    +
    @@ -83,17 +194,30 @@ function Content({data}) { function MemberPart({index, member}) { const [mode, setMode] = useState(member.licence >= 0 ? 0 : 2) const [current, setCurrent] = useState(-1) + const [email, setEmail] = useState("") + const [emailChoice, setEmailChoice] = useState("0") useEffect(() => { if (mode !== 1) setCurrent(-1) + if (mode === 2) { + setEmail("") + setEmailChoice("0") + } }, [mode]); + useEffect(() => { + if (mode !== 2 && email !== "") { + setEmailChoice("1") + } + }, [email]); + return
    Membre n°{index + 1}
    +
    - +
    @@ -106,7 +230,7 @@ function MemberPart({index, member}) {
    - +
    @@ -120,8 +244,10 @@ function MemberPart({index, member}) {
    + - +
    @@ -135,19 +261,39 @@ function MemberPart({index, member}) {
    - - - + +
    + +
    +
    + setEmailChoice(e.target.value)}/> +
    + Nouvel email + +
    +
    +
    + setEmailChoice(e.target.value)}/> +
    + Conserver l'ancien email + +
    } -function MemberLicence({member, mode, index}) { +function MemberLicence({member, mode, index, setEmail}) { const setLoading = useLoadingSwitcher() const [licence, setLicence] = useState(member.licence) const {data, refresh} = useFetch(null, setLoading, 1) @@ -160,12 +306,24 @@ function MemberLicence({member, mode, index}) { ref.current = licence }, [licence]); - const name = "m" + (index + 1) + "licence"; + + useEffect(() => { + if (data && mode === 0) { + if (data) + setEmail(data.email) + else + setEmail("") + } else if (mode === 0) + setEmail("") + }, [data, mode]); + + const name = "licence" + index; return <> +
    Licence - = 0 ? String(licence) : ""} disabled={mode !== 0} required={mode === 0} onChange={event => setLicence(event.target.value)}/>
    @@ -174,11 +332,22 @@ function MemberLicence({member, mode, index}) { } -function MemberSimilar({member, current, setCurrent, mode}) { +function MemberSimilar({member, current, setCurrent, mode, index, setEmail}) { const setLoading = useLoadingSwitcher() const {data} = useFetch(`/member/find/similar?fname=${encodeURI(member.fname)}&lname=${encodeURI(member.lname)}`, setLoading, 1) + useEffect(() => { + if (data && current >= 0 && mode === 1) { + if (data[current]) + setEmail(data[current].email) + else + setEmail("") + } else if (mode === 1) + setEmail("") + }, [current, data, mode]); + return
    + = 0) ? data[current].licence : ""} readOnly hidden/> {data && data.map((m, index) => { return
    From dae32e360745737384022cb075addaece194c3a9 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Tue, 16 Jul 2024 12:45:11 +0200 Subject: [PATCH 09/37] feat: affiliation request --- .../domain/service/AffiliationService.java | 4 ++ .../ffsaf/rest/AffiliationEndpoints.java | 8 ++++ .../admin/affiliation/AffiliationReqPage.jsx | 46 ++++++++++++++----- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index 7f06786..feb2adc 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -287,4 +287,8 @@ public class AffiliationService { public Uni deleteAffiliation(long id) { return Panache.withTransaction(() -> repository.deleteById(id)); } + + public Uni deleteReqAffiliation(long id) { + return Panache.withTransaction(() -> repositoryRequest.deleteById(id)); + } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java index 185c189..5e9b41c 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java @@ -52,6 +52,14 @@ public class AffiliationEndpoints { return service.getRequest(id); } + @DELETE + @Path("/request/{id}") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni getDelAffRequest(@PathParam("id") long id) { + return service.deleteReqAffiliation(id); + } + @PUT @Path("/request/save") @RolesAllowed({"federation_admin"}) diff --git a/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx b/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx index 06e2ddf..fd52ae2 100644 --- a/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx +++ b/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx @@ -8,6 +8,7 @@ import {RoleList, TextField} from "../../../components/MemberCustomFiels.jsx"; import {useEffect, useRef, useState} from "react"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faFilePdf} from "@fortawesome/free-solid-svg-icons"; +import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx"; const vite_url = import.meta.env.VITE_URL; @@ -16,7 +17,7 @@ export function AffiliationReqPage() { const navigate = useNavigate(); const setLoading = useLoadingSwitcher() - const {data, error} = useFetch(`/affiliation/request/${id}`, setLoading, 1) + const {data, refresh, error} = useFetch(`/affiliation/request/${id}`, setLoading, 1) return <>

    Demande d'affiliation

    @@ -26,7 +27,7 @@ export function AffiliationReqPage() {
    {data ?
    - +
    : error && } @@ -34,14 +35,30 @@ export function AffiliationReqPage() { } -function Content({data}) { +function Content({data, refresh}) { + const navigate = useNavigate(); + + const handleRm = (e) => { + toast.promise( + apiAxios.delete(`/affiliation/request/${data.id}`), + { + pending: "Suppression de la demande d'affiliation en cours", + success: "Demande d'affiliation supprimée avec succès 🎉", + error: "Échec de la suppression de la demande d'affiliation 😕" + } + ).then(_ => { + navigate("/admin/affiliation") + }) + } const handleSubmit = (event) => { event.preventDefault(); + + if (event.nativeEvent.submitter.value === "rm") { + return; + } + const formData = new FormData(); - - console.log(); - let err = 0; formData.append('id', data.id); @@ -53,7 +70,7 @@ function Content({data}) { if (event.target.logo.files[0]) formData.append('logo', event.target.logo.files[0]); if (event.target.status.files[0]) - formData.append('status', event.target.status.files[0]); + formData.append('status', event.target.status.files[0]); for (let i = 0; i < 3; i++) { const mode = event.target['mode' + i].value; @@ -113,7 +130,9 @@ function Content({data}) { success: "Demande d'affiliation enregistrée avec succès 🎉", error: "Échec de l'enregistrement de la demande d'affiliation 😕" } - ) + ).then(_ => { + refresh(`/affiliation/request/${data.id}`) + }) } else if (event.nativeEvent.submitter.value === "accept") { toast.promise( apiAxios.put(`/affiliation/request/apply`, formData), @@ -122,7 +141,9 @@ function Content({data}) { success: "Affiliation acceptée avec succès 🎉", error: "Échec de l'acceptation de l'affiliation 😕" } - ) + ).then(_ => { + navigate("/admin/affiliation") + }) } } @@ -182,8 +203,11 @@ function Content({data}) {
    - - + + + +
    From 2737e53de5af4a66388207f557bd96ffae73f193 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Tue, 16 Jul 2024 13:41:03 +0200 Subject: [PATCH 10/37] feat: affiliation request list --- .../domain/service/AffiliationService.java | 4 + .../ffsaf/rest/AffiliationEndpoints.java | 33 ++++-- .../rest/data/SimpleReqAffiliationResume.java | 30 +++++ src/main/webapp/src/pages/admin/AdminRoot.jsx | 5 + .../admin/affiliation/AffiliationReqList.jsx | 107 ++++++++++++++++++ .../admin/affiliation/AffiliationReqPage.jsx | 6 +- 6 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliationResume.java create mode 100644 src/main/webapp/src/pages/admin/affiliation/AffiliationReqList.jsx diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index feb2adc..b5ef5f8 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -48,6 +48,10 @@ public class AffiliationService { @ConfigProperty(name = "upload_dir") String media; + public Uni> getAllReq() { + return repositoryRequest.listAll(); + } + public Uni save(AffiliationRequestForm form) { AffiliationRequestModel affModel = form.toModel(); affModel.setSaison(Utils.getSaison()); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java index 5e9b41c..af85d10 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java @@ -3,6 +3,7 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.domain.service.AffiliationService; import fr.titionfire.ffsaf.rest.data.SimpleAffiliation; import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliation; +import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliationResume; import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm; import fr.titionfire.ffsaf.rest.from.AffiliationRequestSaveForm; import fr.titionfire.ffsaf.utils.GroupeUtils; @@ -44,6 +45,22 @@ public class AffiliationEndpoints { throw new ForbiddenException(); }); + @GET + @Path("/request") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni> getAllAffRequest() { + return service.getAllReq().map(o -> o.stream().map(SimpleReqAffiliationResume::fromModel).toList()); + } + + @POST + @Path("/request") + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Uni saveAffRequest(AffiliationRequestForm form) { + return service.save(form); + } + @GET @Path("/request/{id}") @RolesAllowed({"federation_admin"}) @@ -78,20 +95,12 @@ public class AffiliationEndpoints { return service.accept(form); } - @POST - @Path("/request") - @Produces(MediaType.TEXT_PLAIN) - @Consumes(MediaType.MULTIPART_FORM_DATA) - public Uni saveAffRequest(AffiliationRequestForm form) { - return service.save(form); - } - @GET @Path("/current") @RolesAllowed({"federation_admin"}) @Produces(MediaType.APPLICATION_JSON) - public Uni> getCurrentSaisonLicenceAdmin() { + public Uni> getCurrentSaisonAffiliationAdmin() { return service.getCurrentSaisonAffiliation(); } @@ -99,7 +108,7 @@ public class AffiliationEndpoints { @Path("{id}") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) - public Uni> getLicence(@PathParam("id") long id) { + public Uni> getAffiliation(@PathParam("id") long id) { return Uni.createFrom().item(id).invoke(checkPerm).chain(__ -> service.getAffiliation(id)); } @@ -107,7 +116,7 @@ public class AffiliationEndpoints { @Path("{id}") @RolesAllowed("federation_admin") @Produces(MediaType.APPLICATION_JSON) - public Uni setLicence(@PathParam("id") long id, @QueryParam("saison") int saison) { + public Uni setAffiliation(@PathParam("id") long id, @QueryParam("saison") int saison) { return service.setAffiliation(id, saison); } @@ -115,7 +124,7 @@ public class AffiliationEndpoints { @Path("{id}") @RolesAllowed("federation_admin") @Produces(MediaType.TEXT_PLAIN) - public Uni deleteLicence(@PathParam("id") long id) { + public Uni deleteAffiliation(@PathParam("id") long id) { return service.deleteAffiliation(id); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliationResume.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliationResume.java new file mode 100644 index 0000000..f3eb4ee --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliationResume.java @@ -0,0 +1,30 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.AffiliationRequestModel; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +@RegisterForReflection +public class SimpleReqAffiliationResume { + Long id; + String name; + long siret; + int saison; + + public static SimpleReqAffiliationResume fromModel(AffiliationRequestModel model) { + if (model == null) + return null; + + return new SimpleReqAffiliationResume.SimpleReqAffiliationResumeBuilder() + .id(model.getId()) + .name(model.getName()) + .siret(model.getSiret()) + .saison(model.getSaison()) + .build(); + } +} diff --git a/src/main/webapp/src/pages/admin/AdminRoot.jsx b/src/main/webapp/src/pages/admin/AdminRoot.jsx index 83af37d..3a4ddfa 100644 --- a/src/main/webapp/src/pages/admin/AdminRoot.jsx +++ b/src/main/webapp/src/pages/admin/AdminRoot.jsx @@ -8,6 +8,7 @@ import {ClubList} from "./club/ClubList.jsx"; import {AffiliationReqPage} from "./affiliation/AffiliationReqPage.jsx"; import {NewClubPage} from "./club/NewClubPage.jsx"; import {ClubPage} from "./club/ClubPage.jsx"; +import {AffiliationReqList} from "./affiliation/AffiliationReqList.jsx"; export function AdminRoot() { return <> @@ -44,6 +45,10 @@ export function getAdminChildren() { path: 'affiliation/request/:id', element: }, + { + path: 'affiliation/request', + element: + }, { path: 'club/new', element: diff --git a/src/main/webapp/src/pages/admin/affiliation/AffiliationReqList.jsx b/src/main/webapp/src/pages/admin/affiliation/AffiliationReqList.jsx new file mode 100644 index 0000000..54e9d65 --- /dev/null +++ b/src/main/webapp/src/pages/admin/affiliation/AffiliationReqList.jsx @@ -0,0 +1,107 @@ +import {AxiosError} from "../../../components/AxiosError.jsx"; +import {useNavigate} from "react-router-dom"; +import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {ThreeDots} from "react-loader-spinner"; +import {useEffect, useState} from "react"; +import {Checkbox} from "../../../components/MemberCustomFiels.jsx"; + +export function AffiliationReqList() { + const navigate = useNavigate(); + + const setLoading = useLoadingSwitcher() + const {data, refresh, error} = useFetch(`/affiliation/request`, setLoading, 1) + + const [saisonFilter, setSaisonFilter] = useState(null) + const visibleRequest = (data == null) ? [] : data.filter(e => !(saisonFilter && e.saison !== saisonFilter)).sort((a, b) => { + if (a.saison > b.saison) return 1 + if (a.saison < b.saison) return -1 + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1 + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1 + return 0 + }); + + return <> +

    Demande d'affiliation

    + + +
    +
    +
    + {data + ? + : error + ? + : + } +
    +
    +
    +
    Filtre
    +
    + +
    +
    +
    +
    +
    + +} + +function MakeCentralPanel({data, visibleRequest, navigate}) { + + return <> +
    + {visibleRequest.length} ligne(s) affichée(s) sur {data.length} +
    + {visibleRequest.map(req => ())} +
    +
    + +} + +function MakeRow({request, navigate}) { + return
    navigate("" + request.id)}> +
    +
    {request.name}
    +
    + {request.saison}-{request.saison + 1}
    {request.siret}
    +
    +} + +let allSaison = [] + +function FiltreBar({data, saisonFilter, setSaisonFilter}) { + useEffect(() => { + if (!data) + return; + allSaison.push(...data.map((e) => e.saison)) + allSaison = allSaison.filter((value, index, self) => self.indexOf(value) === index).filter(value => value != null).sort() + }, [data]); + + return
    +
    + +
    +
    +} + + +function Def() { + return
    +
  • +
  • +
  • +
  • +
  • +
    +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx b/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx index fd52ae2..2c8b4d5 100644 --- a/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx +++ b/src/main/webapp/src/pages/admin/affiliation/AffiliationReqPage.jsx @@ -21,7 +21,7 @@ export function AffiliationReqPage() { return <>

    Demande d'affiliation

    -
    @@ -47,7 +47,7 @@ function Content({data, refresh}) { error: "Échec de la suppression de la demande d'affiliation 😕" } ).then(_ => { - navigate("/admin/affiliation") + navigate("/admin/affiliation/request") }) } @@ -142,7 +142,7 @@ function Content({data, refresh}) { error: "Échec de l'acceptation de l'affiliation 😕" } ).then(_ => { - navigate("/admin/affiliation") + navigate("/admin/affiliation/request") }) } } From 6407bf44bcc423dd89ee5a1d8ceb37d8dcd99bbd Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Tue, 16 Jul 2024 14:24:52 +0200 Subject: [PATCH 11/37] fix: affiliation request page minor bug --- .../domain/service/AffiliationService.java | 13 +++- .../ffsaf/domain/service/MembreService.java | 5 ++ .../java/fr/titionfire/ffsaf/utils/Utils.java | 29 ++++++-- src/main/webapp/src/pages/DemandeAff.jsx | 70 ++++++++++++++----- 4 files changed, 89 insertions(+), 28 deletions(-) diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index b5ef5f8..31cf2bb 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -11,6 +11,7 @@ import fr.titionfire.ffsaf.utils.Utils; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.common.WithSession; import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.NotFoundException; @@ -57,7 +58,13 @@ public class AffiliationService { affModel.setSaison(Utils.getSaison()); // noinspection ResultOfMethodCallIgnored - return Uni.createFrom().item(affModel) + return repositoryRequest.count("siret = ?1 and saison = ?2", affModel.getSiret(), affModel.getSaison()) + .onItem().invoke(Unchecked.consumer(count -> { + if (count != 0) { + throw new IllegalArgumentException("Affiliation request already exists"); + } + })) + .map(o -> affModel) .call(model -> ((model.getM1_lincence() != -1) ? combRepository.find("licence", model.getM1_lincence()).count().invoke(count -> { if (count == 0) { @@ -293,6 +300,8 @@ public class AffiliationService { } public Uni deleteReqAffiliation(long id) { - return Panache.withTransaction(() -> repositoryRequest.deleteById(id)); + return Panache.withTransaction(() -> repositoryRequest.deleteById(id)) + .call(__ -> Utils.deleteMedia(id, media, "aff_request/logo")) + .call(__ -> Utils.deleteMedia(id, media, "aff_request/status")); } } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java index 138c737..decc099 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -25,6 +25,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.ForbiddenException; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.jwt.JsonWebToken; import java.util.List; @@ -46,6 +47,9 @@ public class MembreService { @Inject KeycloakService keycloakService; + @ConfigProperty(name = "upload_dir") + String media; + public SimpleCombModel find(int licence, String np) throws Throwable { return VertxContextSupport.subscribeAndAwait(() -> Panache.withTransaction(() -> repository.find("licence = ?1 AND (lname ILIKE ?2 OR fname ILIKE ?2)", @@ -217,6 +221,7 @@ public class MembreService { keycloakService.removeAccount(membreModel.getUserId()) : Uni.createFrom().nullItem()) .call(membreModel -> Panache.withTransaction(() -> repository.delete(membreModel))) .invoke(membreModel -> SReqComb.sendRm(serverCustom.clients, id)) + .call(__ -> Utils.deleteMedia(id, media, "ppMembre")) .map(__ -> "Ok"); } diff --git a/src/main/java/fr/titionfire/ffsaf/utils/Utils.java b/src/main/java/fr/titionfire/ffsaf/utils/Utils.java index 4910615..360e492 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/Utils.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/Utils.java @@ -40,7 +40,6 @@ public class Utils { } public static Uni moveMedia(long idSrc, long idDest, String media, String dirSrc, String dirDst) { - System.out.println("moveMedia: " + idSrc + " -> " + idDest + " " + media + " " + dirSrc + " " + dirDst); return Uni.createFrom().nullItem().map(__ -> { File dirFile = new File(media, dirSrc); if (!dirFile.exists()) @@ -51,12 +50,12 @@ public class Utils { if (!dirDestFile.mkdirs()) return "Fail to create directory " + dirDestFile; - FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(idSrc)); + FilenameFilter filter = (directory, filename) -> filename.startsWith(idSrc + "."); File[] files = dirFile.listFiles(filter); if (files == null || files.length == 0) return "Not found"; - FilenameFilter filter2 = (directory, filename) -> filename.startsWith(String.valueOf(idDest)); + FilenameFilter filter2 = (directory, filename) -> filename.startsWith(idDest + "."); File[] files2 = dirDestFile.listFiles(filter2); if (files2 != null) { for (File file : files2) { @@ -96,7 +95,7 @@ public class Utils { if (!dirFile.mkdirs()) throw new IOException("Fail to create directory " + dir); - FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id)); + FilenameFilter filter = (directory, filename) -> filename.startsWith(id +"."); File[] files = dirFile.listFiles(filter); if (files != null) { for (File file : files) { @@ -122,7 +121,7 @@ public class Utils { public static Uni getMediaFile(long id, String media, String dirname, String out_filename, Uni uniBase) throws URISyntaxException { Future> future = CompletableFuture.supplyAsync(() -> { - FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id)); + FilenameFilter filter = (directory, filename) -> filename.startsWith(id + "."); File[] files = new File(media, dirname).listFiles(filter); if (files != null && files.length > 0) { File file = files[0]; @@ -150,9 +149,25 @@ public class Utils { resp.header(HttpHeaders.CONTENT_TYPE, mimeType); resp.header(HttpHeaders.CONTENT_DISPOSITION, "inline; " + ((out_filename == null) ? "" : "filename=\"" + out_filename + "\"")); - - System.out.println("getMediaFile: " + mimeType); return resp.build(); })); } + + public static Uni deleteMedia(long id, String media, String dir) { + return Uni.createFrom().nullItem().map(__ -> { + File dirFile = new File(media, dir); + if (!dirFile.exists()) + return "OK"; + + FilenameFilter filter = (directory, filename) -> filename.startsWith(id + "."); + File[] files = dirFile.listFiles(filter); + if (files != null) { + for (File file : files) { + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + } + return "Ok"; + }); + } } diff --git a/src/main/webapp/src/pages/DemandeAff.jsx b/src/main/webapp/src/pages/DemandeAff.jsx index b981aca..2290d55 100644 --- a/src/main/webapp/src/pages/DemandeAff.jsx +++ b/src/main/webapp/src/pages/DemandeAff.jsx @@ -2,15 +2,30 @@ import {useState} from "react"; import {apiAxios} from "../utils/Tools.js"; import {toast} from "react-toastify"; import {useNavigate} from "react-router-dom"; +import {RoleList} from "../components/MemberCustomFiels.jsx"; + +const notUpperCase = ["de", "la", "le", "les", "des", "du", "d'", "l'", "sur"]; + +function formatAdresse(data) { + const words = data.split(" "); + + return words.map((word) => { + if (notUpperCase.includes(word.toLowerCase())) { + return word.toLowerCase(); + } + return word[0].toUpperCase() + word.substring(1).toLowerCase(); + }).join(" "); +} function reconstruireAdresse(infos) { let adresseReconstruite = ""; - adresseReconstruite += infos.numero_voie + ' ' + infos.type_voie + ' '; - adresseReconstruite += infos.libelle_voie + ', '; + adresseReconstruite += infos.numero_voie + ' ' + formatAdresse(infos.type_voie) + ' '; + + adresseReconstruite += formatAdresse(infos.libelle_voie) + ', '; adresseReconstruite += infos.code_postal + ' ' + infos.libelle_commune + ', '; if (infos.complement_adresse) { - adresseReconstruite += infos.complement_adresse + ', '; + adresseReconstruite += infos.complement_adresse.toLowerCase() + ', '; } if (infos.code_cedex && infos.libelle_cedex) { adresseReconstruite += 'Cedex ' + infos.code_cedex + ' - ' + infos.libelle_cedex; @@ -31,6 +46,19 @@ export function DemandeAff() { event.preventDefault() const formData = new FormData(event.target) formData.append("m1_role", event.target.m1_role?.value) + formData.append("rna", event.target.rna?.value) + + let error = false; + for (let i = 1; i <= 3; i++) { + if (event.target[`m${i}_role`]?.value === "0") { + toast.error(`Le rôle du membre ${i} est obligatoire`) + error = true; + } + } + if (error) { + return; + } + toast.promise( apiAxios.post(`/affiliation/request`, formData, {headers: {'Accept': '*/*'}}), { @@ -76,13 +104,11 @@ export function DemandeAff() {
    -

    Après validation de votre demande, vous recevrez un login et mot de passe provisoire pour +

    Après validation de votre demande, vous recevrez un identifiant et mot de passe provisoire pour accéder à votre espace FFSAF

    Notez que pour finaliser votre affiliation, il vous faudra :
      -
    • Disposer d’au moins trois membres licenciés, dont le président, le trésorier et le - secrétaire -
    • +
    • Disposer d’au moins trois membres licenciés, dont le président
    • S'être acquitté des cotisations prévues par les règlements fédéraux
    @@ -158,11 +184,16 @@ function AssoInfo() { disabled={!rnaEnable} name="rna" value={rna} onChange={e => setRna(e.target.value)}/>
    -
    - Adresse* - setAdresse(e.target.value)}/> +
    +
    + Adresse de contact* + setAdresse(e.target.value)}/> +
    +
    Vous pourrez par la suite, ajouter des adresses visibles publiquement pour vos lieux + d'entrainement +
    @@ -186,7 +217,7 @@ function MembreInfo({role}) { - + +
    - +
    - +
    @@ -231,7 +263,7 @@ function MembreInfo({role}) {
    + name={role + "_licence"} required/>
    @@ -244,7 +276,7 @@ export function DemandeAffOk() { return (

    Demande d'affiliation envoyée avec succès

    -

    Une fois votre demande validée, vous recevrez un login et mot de passe provisoire pour accéder à votre +

    Une fois votre demande validée, vous recevrez un identifiant et mot de passe provisoire pour accéder à votre espace FFSAF

    ); From f297ae557b42179ea30850e2c26a11c6ff7e25df Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Tue, 16 Jul 2024 22:08:02 +0200 Subject: [PATCH 12/37] feat: club remove --- .../ffsaf/data/model/ClubModel.java | 2 +- .../domain/service/AffiliationService.java | 25 +++++- .../ffsaf/domain/service/ClubService.java | 32 ++++++- .../ffsaf/domain/service/KeycloakService.java | 84 ++++++++++++++----- .../ffsaf/domain/service/MembreService.java | 2 +- .../titionfire/ffsaf/rest/ClubEndpoints.java | 11 ++- .../ffsaf/rest/data/SimpleClub.java | 2 + .../rest/from/AffiliationRequestForm.java | 6 +- .../ffsaf/rest/from/FullClubForm.java | 3 + src/main/webapp/src/pages/DemandeAff.jsx | 26 +++++- .../webapp/src/pages/admin/club/ClubPage.jsx | 10 ++- 11 files changed, 165 insertions(+), 38 deletions(-) diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java index d65b176..72a30de 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java @@ -55,6 +55,6 @@ public class ClubModel { boolean international; - @OneToMany(mappedBy = "club", fetch = FetchType.LAZY) + @OneToMany(mappedBy = "club", fetch = FetchType.LAZY, cascade = CascadeType.ALL) List affiliations; } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index 31cf2bb..b55c796 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -55,15 +55,29 @@ public class AffiliationService { public Uni save(AffiliationRequestForm form) { AffiliationRequestModel affModel = form.toModel(); - affModel.setSaison(Utils.getSaison()); + int currentSaison = Utils.getSaison(); // noinspection ResultOfMethodCallIgnored - return repositoryRequest.count("siret = ?1 and saison = ?2", affModel.getSiret(), affModel.getSaison()) + return Uni.createFrom().item(affModel) + .invoke(Unchecked.consumer(model -> { + if (model.getSaison() != currentSaison && model.getSaison() != currentSaison + 1) { + throw new IllegalArgumentException("Saison not valid"); + } + })) + .chain(() -> repositoryRequest.count("siret = ?1 and saison = ?2", affModel.getSiret(), + affModel.getSaison())) .onItem().invoke(Unchecked.consumer(count -> { if (count != 0) { throw new IllegalArgumentException("Affiliation request already exists"); } })) + .chain(() -> clubRepository.find("SIRET = ?1", affModel.getSiret()).firstResult().chain(club -> + repository.count("club = ?1 and saison = ?2", club, affModel.getSaison()))) + .onItem().invoke(Unchecked.consumer(count -> { + if (count != 0) { + throw new IllegalArgumentException("Affiliation already exists"); + } + })) .map(o -> affModel) .call(model -> ((model.getM1_lincence() != -1) ? combRepository.find("licence", model.getM1_lincence()).count().invoke(count -> { @@ -289,7 +303,12 @@ public class AffiliationService { public Uni setAffiliation(long id, int saison) { return clubRepository.findById(id) - .onItem().ifNull().failWith(new NotFoundException("Affiliation request not found")) + .onItem().ifNull().failWith(new NotFoundException("Club non trouver")) + .invoke(Unchecked.consumer(club -> { + if (club.getAffiliations().stream().anyMatch(affiliation -> affiliation.getSaison() == saison)) { + throw new IllegalArgumentException("Affiliation deja existante"); + } + })) .chain(club -> Panache.withTransaction(() -> repository.persist(new AffiliationModel(null, club, saison)))) .map(SimpleAffiliation::fromModel); diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java index 1e3d4e4..15d387a 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java @@ -4,12 +4,15 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.data.repository.ClubRepository; +import fr.titionfire.ffsaf.data.repository.CombRepository; import fr.titionfire.ffsaf.net2.ServerCustom; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; import fr.titionfire.ffsaf.net2.request.SReqClub; import fr.titionfire.ffsaf.rest.from.FullClubForm; import fr.titionfire.ffsaf.utils.Contact; import fr.titionfire.ffsaf.utils.PageResult; +import fr.titionfire.ffsaf.utils.RoleAsso; +import fr.titionfire.ffsaf.utils.Utils; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.PanacheQuery; import io.quarkus.hibernate.reactive.panache.common.WithSession; @@ -21,6 +24,7 @@ import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.BadRequestException; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.hibernate.reactive.mutiny.Mutiny; import java.util.Collection; @@ -39,6 +43,15 @@ public class ClubService { @Inject ServerCustom serverCustom; + @Inject + CombRepository combRepository; + + @Inject + KeycloakService keycloakService; + + @ConfigProperty(name = "upload_dir") + String media; + public SimpleClubModel findByIdOptionalClub(long id) throws Throwable { return VertxContextSupport.subscribeAndAwait( () -> Panache.withTransaction(() -> repository.findById(id).map(SimpleClubModel::fromModel))); @@ -115,6 +128,7 @@ public class ClubService { m.setContact_intern(input.getContact_intern()); m.setRNA(input.getRna()); m.setSIRET(input.getSiret()); + m.setAddress(input.getAddress()); try { m.setContact(MAPPER.readValue(input.getContact(), typeRef)); @@ -134,6 +148,22 @@ public class ClubService { } public Uni delete(long id) { - return Uni.createFrom().nullItem(); + return repository.findById(id) + .chain(club -> combRepository.list("club = ?1", club) + .map(combModels -> combModels.stream().peek(combModel -> { + combModel.setClub(null); + combModel.setRole(RoleAsso.MEMBRE); + }).toList()) + .call(list -> Uni.join().all(list.stream().filter(m -> m.getUserId() != null) + .map(m -> keycloakService.clearUser(m.getUserId())).toList()).andCollectFailures()) + .chain(list -> Panache.withTransaction(() -> combRepository.persist(list))) + .map(o -> club) + ) + .call(clubModel -> (clubModel.getClubId() == null) ? Uni.createFrom() + .voidItem() : keycloakService.removeClubGroup(clubModel.getClubId())) + .invoke(membreModel -> SReqClub.sendRmIfNeed(serverCustom.clients, id)) + .chain(clubModel -> Panache.withTransaction(() -> repository.delete(clubModel))) + .call(__ -> Utils.deleteMedia(id, media, "ppClub")) + .call(__ -> Utils.deleteMedia(id, media, "clubStatus")); } } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java index 7a3c49d..cb83f09 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java @@ -49,21 +49,26 @@ public class KeycloakService { return vertx.getOrCreateContext().executeBlocking(() -> { GroupRepresentation clubGroup = keycloak.realm(realm).groups().groups().stream().filter(g -> g.getName().equals("club")) - .findAny().orElseThrow(() -> new KeycloakException("Fail to fetch group %s".formatted("club"))); + .findAny() + .orElseThrow(() -> new KeycloakException("Fail to fetch group %s".formatted("club"))); GroupRepresentation groupRepresentation = new GroupRepresentation(); groupRepresentation.setName(club.getId() + "-" + club.getName()); try (Response response = keycloak.realm(realm).groups().group(clubGroup.getId()).subGroup(groupRepresentation)) { - if (!response.getStatusInfo().equals(Response.Status.CREATED) && !response.getStatusInfo().equals(Response.Status.CONFLICT)) - throw new KeycloakException("Fail to set group parent for club: %s (reason=%s)".formatted(club.getName(), - response.getStatusInfo().getReasonPhrase())); + if (!response.getStatusInfo().equals(Response.Status.CREATED) && !response.getStatusInfo() + .equals(Response.Status.CONFLICT)) + throw new KeycloakException( + "Fail to set group parent for club: %s (reason=%s)".formatted(club.getName(), + response.getStatusInfo().getReasonPhrase())); } return keycloak.realm(realm).groups().group(clubGroup.getId()).getSubGroups(0, 1000, true).stream() - .filter(g -> g.getName().startsWith(club.getId() + "-")).findAny().map(GroupRepresentation::getId) - .orElseThrow(() -> new KeycloakException("Fail to fetch group %s*".formatted(club.getId() + "-"))); + .filter(g -> g.getName().startsWith(club.getId() + "-")).findAny() + .map(GroupRepresentation::getId) + .orElseThrow( + () -> new KeycloakException("Fail to fetch group %s*".formatted(club.getId() + "-"))); } ).call(id -> clubService.setClubId(club.getId(), id)); } @@ -72,21 +77,24 @@ public class KeycloakService { public Uni getUserFromMember(MembreModel membreModel) { if (membreModel.getUserId() == null) { - return Uni.createFrom().failure(new NullPointerException("No keycloak user linked to the user id=" + membreModel.getId())); + return Uni.createFrom() + .failure(new NullPointerException("No keycloak user linked to the user id=" + membreModel.getId())); } return Uni.createFrom().item(membreModel::getUserId); } public Uni setClubGroupMembre(MembreModel membreModel, ClubModel club) { return getGroupFromClub(club).chain( - clubId -> getUserFromMember(membreModel).chain(userId -> vertx.getOrCreateContext().executeBlocking(() -> { - UserResource user = keycloak.realm(realm).users().get(userId); - user.groups().stream().filter(g -> g.getPath().startsWith("/club")).forEach(g -> user.leaveGroup(g.getId())); - user.joinGroup(clubId); - LOGGER.infof("Set club \"%s\" to user %s (%s)", club.getName(), userId, - user.toRepresentation().getUsername()); - return "OK"; - }))); + clubId -> getUserFromMember(membreModel).chain( + userId -> vertx.getOrCreateContext().executeBlocking(() -> { + UserResource user = keycloak.realm(realm).users().get(userId); + user.groups().stream().filter(g -> g.getPath().startsWith("/club")) + .forEach(g -> user.leaveGroup(g.getId())); + user.joinGroup(clubId); + LOGGER.infof("Set club \"%s\" to user %s (%s)", club.getName(), userId, + user.toRepresentation().getUsername()); + return "OK"; + }))); } public Uni setEmail(String userId, String email) { @@ -104,13 +112,14 @@ public class KeycloakService { public Uni setAutoRoleMembre(String id, RoleAsso role, GradeArbitrage gradeArbitrage) { List toRemove = new ArrayList<>(List.of("club_president", "club_tresorier", "club_secretaire", - "asseseur", "arbitre")); + "club_respo_intra", "asseseur", "arbitre")); List toAdd = new ArrayList<>(); switch (role) { - case PRESIDENT -> toAdd.add("club_president"); - case TRESORIER -> toAdd.add("club_tresorier"); - case SECRETAIRE -> toAdd.add("club_secretaire"); + case PRESIDENT, VPRESIDENT -> toAdd.add("club_president"); + case TRESORIER, VTRESORIER -> toAdd.add("club_tresorier"); + case SECRETAIRE, VSECRETAIRE -> toAdd.add("club_secretaire"); + case MEMBREBUREAU -> toAdd.add("club_respo_intra"); } switch (gradeArbitrage) { case ARBITRE -> toAdd.addAll(List.of("asseseur", "arbitre")); @@ -132,7 +141,8 @@ public class KeycloakService { public Uni> fetchRole(String id) { return vertx.getOrCreateContext().executeBlocking(() -> - keycloak.realm(realm).users().get(id).roles().realmLevel().listEffective().stream().map(RoleRepresentation::getName).toList()); + keycloak.realm(realm).users().get(id).roles().realmLevel().listEffective().stream() + .map(RoleRepresentation::getName).toList()); } public Uni updateRole(String id, List toAdd, List toRemove) { @@ -184,13 +194,15 @@ public class KeycloakService { RequiredAction.UPDATE_PASSWORD.name())); try (Response response = keycloak.realm(realm).users().create(user)) { - if (!response.getStatusInfo().equals(Response.Status.CREATED) && !response.getStatusInfo().equals(Response.Status.CONFLICT)) + if (!response.getStatusInfo().equals(Response.Status.CREATED) && !response.getStatusInfo() + .equals(Response.Status.CONFLICT)) throw new KeycloakException("Fail to creat user %s (reason=%s)".formatted(login, response.getStatusInfo().getReasonPhrase())); } String finalLogin = login; - return getUser(login).orElseThrow(() -> new KeycloakException("Fail to fetch user %s".formatted(finalLogin))); + return getUser(login).orElseThrow( + () -> new KeycloakException("Fail to fetch user %s".formatted(finalLogin))); }) //.invoke(user -> keycloak.realm(realm).users().get(user.getId()) // TODO enable for production // .executeActionsEmail(List.of(RequiredAction.VERIFY_EMAIL.name(), @@ -216,6 +228,30 @@ public class KeycloakService { }); } + public Uni removeClubGroup(String clubId) { + return vertx.getOrCreateContext().executeBlocking(() -> { + keycloak.realm(realm).groups().group(clubId).remove(); + return null; + }); + } + + public Uni clearUser(String userId) { + List toRemove = new ArrayList<>( + List.of("club_president", "club_tresorier", "club_secretaire", "club_respo_intra")); + + return vertx.getOrCreateContext().executeBlocking(() -> { + UserResource user = keycloak.realm(realm).users().get(userId); + + RoleScopeResource resource = user.roles().realmLevel(); + List roles = keycloak.realm(realm).roles().list(); + resource.remove(roles.stream().filter(r -> toRemove.contains(r.getName())).toList()); + + user.groups().stream().filter(g -> g.getPath().startsWith("/club")) + .forEach(g -> user.leaveGroup(g.getId())); + return "OK"; + }); + } + private Optional getUser(String username) { List users = keycloak.realm(realm).users().searchByUsername(username, true); @@ -226,7 +262,9 @@ public class KeycloakService { } private String makeLogin(MembreModel model) { - return Normalizer.normalize((model.getFname().toLowerCase() + "." + model.getLname().toLowerCase()).replace(' ', '_'), Normalizer.Form.NFD) + return Normalizer.normalize( + (model.getFname().toLowerCase() + "." + model.getLname().toLowerCase()).replace(' ', '_'), + Normalizer.Form.NFD) .replaceAll("\\p{M}", ""); } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java index decc099..dda7cc8 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -150,7 +150,7 @@ public class MembreService { RoleAsso source = RoleAsso.MEMBRE; if (securityIdentity.getRoles().contains("club_president")) source = RoleAsso.PRESIDENT; else if (securityIdentity.getRoles().contains("club_secretaire")) source = RoleAsso.SECRETAIRE; - else if (securityIdentity.getRoles().contains("club_respo_intra")) source = RoleAsso.SECRETAIRE; + else if (securityIdentity.getRoles().contains("club_respo_intra")) source = RoleAsso.MEMBREBUREAU; if (!membre.getRole().equals(membreModel.getRole()) && membre.getRole().level > source.level) throw new ForbiddenException(); })) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index 45a8199..7210cac 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -159,8 +159,15 @@ public class ClubEndpoints { @GET @Path("{id}/status") @RolesAllowed({"federation_admin", "club_president", "club_secretaire"}) - public Uni getStatus(@PathParam("id") long id) throws URISyntaxException { - return Utils.getMediaFile(id, media, "clubStatus", clubService.getById(id).onItem().invoke(checkPerm)); + public Uni getStatus(@PathParam("id") long id) { + return clubService.getById(id).onItem().invoke(checkPerm).chain(Unchecked.function(clubModel -> { + try { + return Utils.getMediaFile(clubModel.getId(), media, "clubStatus", + "statue-" + clubModel.getName() + ".pdf", Uni.createFrom().nullItem()); + } catch (URISyntaxException e) { + throw new InternalError(); + } + })); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java index 44ba58f..d81195c 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java @@ -25,6 +25,7 @@ public class SimpleClub { private String training_location; private String training_day_time; private String contact_intern; + private String address; private String RNA; private Long SIRET; private Long no_affiliation; @@ -48,6 +49,7 @@ public class SimpleClub { .SIRET(model.getSIRET()) .no_affiliation(model.getNo_affiliation()) .international(model.isInternational()) + .address(model.getAddress()) .build(); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java index 4b61818..7e83750 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java @@ -13,15 +13,14 @@ import org.jboss.resteasy.reactive.PartType; public class AffiliationRequestForm { @FormParam("name") private String name = null; - @FormParam("siret") private Long siret = null; - @FormParam("rna") private String rna = null; - @FormParam("adresse") private String adresse = null; + @FormParam("saison") + private int saison = -1; @FormParam("status") @PartType(MediaType.APPLICATION_OCTET_STREAM) @@ -70,6 +69,7 @@ public class AffiliationRequestForm { model.setSiret(this.getSiret()); model.setRNA(this.getRna()); model.setAddress(this.getAdresse()); + model.setSaison(this.getSaison()); model.setM1_lname(this.getM1_lname()); model.setM1_fname(this.getM1_fname()); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java index 2ad0d6f..a8a407a 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java @@ -30,6 +30,9 @@ public class FullClubForm { @FormParam("contact_intern") private String contact_intern = null; + @FormParam("address") + private String address = null; + @FormParam("rna") private String rna = null; diff --git a/src/main/webapp/src/pages/DemandeAff.jsx b/src/main/webapp/src/pages/DemandeAff.jsx index 2290d55..722c917 100644 --- a/src/main/webapp/src/pages/DemandeAff.jsx +++ b/src/main/webapp/src/pages/DemandeAff.jsx @@ -1,5 +1,5 @@ import {useState} from "react"; -import {apiAxios} from "../utils/Tools.js"; +import {apiAxios, getSaison} from "../utils/Tools.js"; import {toast} from "react-toastify"; import {useNavigate} from "react-router-dom"; import {RoleList} from "../components/MemberCustomFiels.jsx"; @@ -130,6 +130,7 @@ function AssoInfo() { const [rna, setRna] = useState("") const [rnaEnable, setRnaEnable] = useState(false) const [adresse, setAdresse] = useState("") + const [saison, setSaison] = useState(getSaison()) const fetchSiret = () => { if (siret.length < 14) { @@ -153,7 +154,26 @@ function AssoInfo() { }) } + const currentSaison = getSaison(); + return <> +
    +
    + setSaison(Number(e.target.value))}/> + {currentSaison + "-" + (currentSaison + 1)} +
    + OU +
    + setSaison(Number(e.target.value))}/> + {(currentSaison + 1) + "-" + (currentSaison + 2)} +
    +
    +
    Nom de l'association*
    - Adresse de contact* - Adresse administrative* + setAdresse(e.target.value)}/>
    diff --git a/src/main/webapp/src/pages/admin/club/ClubPage.jsx b/src/main/webapp/src/pages/admin/club/ClubPage.jsx index feb7ceb..f317ddf 100644 --- a/src/main/webapp/src/pages/admin/club/ClubPage.jsx +++ b/src/main/webapp/src/pages/admin/club/ClubPage.jsx @@ -12,6 +12,8 @@ import {useRef, useState} from "react"; import {LocationEditor, LocationEditorModal} from "../../../components/Club/LocationEditor.jsx"; import {ContactEditor} from "../../../components/Club/ContactEditor.jsx"; import {HoraireEditor} from "../../../components/Club/HoraireEditor.jsx"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faFilePdf} from "@fortawesome/free-solid-svg-icons"; const vite_url = import.meta.env.VITE_URL; @@ -122,11 +124,17 @@ function InformationForm({data}) { +
    - + + + +
    Laissez vide pour ne rien changer.
    From edcda185dbad925a2ba9235661fcd50458ce6e38 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Tue, 16 Jul 2024 23:16:46 +0200 Subject: [PATCH 13/37] feat: club filter --- .../domain/service/AffiliationService.java | 16 +++++----- .../ffsaf/domain/service/ClubService.java | 9 +++--- .../ffsaf/net2/data/SimpleClubModel.java | 3 +- .../titionfire/ffsaf/rest/ClubEndpoints.java | 9 +++--- .../ffsaf/rest/data/SimpleClubList.java | 29 +++++++++++++++++++ .../webapp/src/pages/admin/club/ClubList.jsx | 13 +++++---- 6 files changed, 54 insertions(+), 25 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClubList.java diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index b55c796..3ad1ea1 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -277,15 +277,13 @@ public class AffiliationService { } public Uni> getCurrentSaisonAffiliation() { - return repository.list("saison = ?1", Utils.getSaison()) - .map(models -> models.stream().map(SimpleAffiliation::fromModel).toList()) - .chain(aff -> repositoryRequest.list("saison = ?1", Utils.getSaison()) - .chain(models -> Uni.join().all(models.stream().map(model -> - clubRepository.find("SIRET = ?1", model.getSiret()).firstResult() - .map(c -> new SimpleAffiliation(model.getId() * -1, c.getId(), - model.getSaison(), false))) - .toList()).andFailFast() - ).map(aff2 -> Stream.concat(aff2.stream(), aff.stream()).toList()) + return repositoryRequest.list("saison = ?1 or saison = ?1 + 1", Utils.getSaison()) + .map(models -> models.stream() + .map(model -> new SimpleAffiliation(model.getId() * -1, model.getSiret(), model.getSaison(), + false)).toList()) + .chain(aff -> repository.list("saison = ?1", Utils.getSaison()) + .map(models -> models.stream().map(SimpleAffiliation::fromModel).toList()) + .map(aff2 -> Stream.concat(aff2.stream(), aff.stream()).toList()) ); } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java index 15d387a..17669fc 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java @@ -8,6 +8,7 @@ import fr.titionfire.ffsaf.data.repository.CombRepository; import fr.titionfire.ffsaf.net2.ServerCustom; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; import fr.titionfire.ffsaf.net2.request.SReqClub; +import fr.titionfire.ffsaf.rest.data.SimpleClubList; import fr.titionfire.ffsaf.rest.from.FullClubForm; import fr.titionfire.ffsaf.utils.Contact; import fr.titionfire.ffsaf.utils.PageResult; @@ -74,7 +75,7 @@ public class ClubService { }); } - public Uni> search(Integer limit, int page, String search, String country) { + public Uni> search(Integer limit, int page, String search, String country) { if (search == null) search = ""; search = search + "%"; @@ -90,8 +91,8 @@ public class ClubService { return getPageResult(query, limit, page); } - private Uni> getPageResult(PanacheQuery query, int limit, int page) { - return Uni.createFrom().item(new PageResult()) + private Uni> getPageResult(PanacheQuery query, int limit, int page) { + return Uni.createFrom().item(new PageResult()) .invoke(result -> result.setPage(page)) .invoke(result -> result.setPage_size(limit)) .call(result -> query.count().invoke(result::setResult_count)) @@ -101,7 +102,7 @@ public class ClubService { })) .invoke(result::setPage_count)) .call(result -> query.page(Page.of(page, limit)).list() - .map(membreModels -> membreModels.stream().map(SimpleClubModel::fromModel).toList()) + .map(membreModels -> membreModels.stream().map(SimpleClubList::fromModel).toList()) .invoke(result::setResult)); } diff --git a/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java b/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java index 748c9ec..5eb5781 100644 --- a/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java +++ b/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleClubModel.java @@ -17,13 +17,12 @@ public class SimpleClubModel { String name; String country; String shieldURL; - Long no_affiliation; public static SimpleClubModel fromModel(ClubModel model) { if (model == null) return null; return new SimpleClubModel(model.getId(), model.getName(), model.getCountry(), - "/api/club/" + model.getClubId() + "/logo", model.getNo_affiliation()); + "/api/club/" + model.getClubId() + "/logo"); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index 7210cac..7361198 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -4,6 +4,7 @@ import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.domain.service.ClubService; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; import fr.titionfire.ffsaf.rest.data.SimpleClub; +import fr.titionfire.ffsaf.rest.data.SimpleClubList; import fr.titionfire.ffsaf.rest.from.FullClubForm; import fr.titionfire.ffsaf.utils.Contact; import fr.titionfire.ffsaf.utils.GroupeUtils; @@ -60,10 +61,10 @@ public class ClubEndpoints { @Path("/find") @RolesAllowed({"federation_admin"}) @Produces(MediaType.APPLICATION_JSON) - public Uni> getFindAdmin(@QueryParam("limit") Integer limit, - @QueryParam("page") Integer page, - @QueryParam("search") String search, - @QueryParam("country") String country) { + public Uni> getFindAdmin(@QueryParam("limit") Integer limit, + @QueryParam("page") Integer page, + @QueryParam("search") String search, + @QueryParam("country") String country) { if (limit == null) limit = 50; if (page == null || page < 1) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClubList.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClubList.java new file mode 100644 index 0000000..09e1084 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClubList.java @@ -0,0 +1,29 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.ClubModel; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection +public class SimpleClubList { + Long id; + String name; + String country; + Long siret; + Long no_affiliation; + + public static SimpleClubList fromModel(ClubModel model) { + if (model == null) + return null; + + return new SimpleClubList(model.getId(), model.getName(), model.getCountry(), model.getSIRET(), + model.getNo_affiliation()); + } +} diff --git a/src/main/webapp/src/pages/admin/club/ClubList.jsx b/src/main/webapp/src/pages/admin/club/ClubList.jsx index 80c0e9b..7f1dc8d 100644 --- a/src/main/webapp/src/pages/admin/club/ClubList.jsx +++ b/src/main/webapp/src/pages/admin/club/ClubList.jsx @@ -38,9 +38,9 @@ export function ClubList() { id: e.id, name: e.name, country: e.country, - shieldURL: e.shieldURL, + siret: e.siret, no_affiliation: e.no_affiliation, - affiliation: showAffiliationState ? affiliationData.find(licence => licence.club === e.id) : null + affiliation: showAffiliationState ? affiliationData.find(aff => (aff.id >= 0) ? aff.club === e.id : aff.club === e.siret) : null }) } setClubData(data2); @@ -76,7 +76,7 @@ export function ClubList() {
    {data - ? : error ? @@ -91,7 +91,7 @@ export function ClubList() {
    Filtre
    -
    @@ -161,13 +161,14 @@ function FiltreBar({showAffiliationState, setShowAffiliationState, data, country useEffect(() => { if (!data) return; - allCountry.push(...data.result.map((e) => e.club?.name)) + allCountry.push(...data.result.map((e) => e.country)) allCountry = allCountry.filter((value, index, self) => self.indexOf(value) === index).filter(value => value != null).sort() }, [data]); return
    - +
    -
    Licence n°{data.no_affiliation}
    +
    Affiliation n°{data.no_affiliation}
    @@ -120,11 +120,12 @@ function InformationForm({data}) {
    {!switchOn && <> - + - +
    diff --git a/src/main/webapp/src/pages/admin/club/NewClubPage.jsx b/src/main/webapp/src/pages/admin/club/NewClubPage.jsx index 20fe771..74903a6 100644 --- a/src/main/webapp/src/pages/admin/club/NewClubPage.jsx +++ b/src/main/webapp/src/pages/admin/club/NewClubPage.jsx @@ -1,16 +1,111 @@ import {useNavigate} from "react-router-dom"; +import {LoadingProvider} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {toast} from "react-toastify"; +import {apiAxios} from "../../../utils/Tools.js"; +import {CountryList, TextField} from "../../../components/MemberCustomFiels.jsx"; + +import {useRef, useState} from "react"; +import {LocationEditor, LocationEditorModal} from "../../../components/Club/LocationEditor.jsx"; +import {ContactEditor} from "../../../components/Club/ContactEditor.jsx"; +import {HoraireEditor} from "../../../components/Club/HoraireEditor.jsx"; export function NewClubPage() { - const navigate = useNavigate(); + const navigate = useNavigate() return <> -

    Page affiliation

    -
    +
    + + + +
    -} \ No newline at end of file +} + +function InformationForm() { + const [switchOn, setSwitchOn] = useState(false); + const [modal, setModal] = useState({id: -1}) + const locationModalCallback = useRef(null) + const navigate = useNavigate() + + const {data} = useFetch(`/club/contact_type`) + + const handleSubmit = (event) => { + event.preventDefault(); + + const formData = new FormData(event.target); + + toast.promise( + apiAxios.put(`/club`, formData), + { + pending: "Création du club en cours", + success: "Club créé avec succès 🎉", + error: "Échec de la création du club 😕" + } + ).then(data => { + navigate(`/admin/club/${data.data}`); + }) + } + + return <> +
    +
    +
    Nouveau club
    +
    + + + + +
    +
    + + +
    +
    + +
    +
    + setSwitchOn(!switchOn)}/> + +
    +
    + {!switchOn && <> + + + + + +
    +
    + + +
    +
    + + + + + + } +
    +
    +
    + +
    +
    +
    +
    + + + +} diff --git a/src/main/webapp/src/pages/club/club/ClubPage.jsx b/src/main/webapp/src/pages/club/club/ClubPage.jsx new file mode 100644 index 0000000..d7196b4 --- /dev/null +++ b/src/main/webapp/src/pages/club/club/ClubPage.jsx @@ -0,0 +1,158 @@ +import {useNavigate, useParams} from "react-router-dom"; +import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {toast} from "react-toastify"; +import {apiAxios} from "../../../utils/Tools.js"; +import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx"; +import {AxiosError} from "../../../components/AxiosError.jsx"; +import {AffiliationCard} from "./AffiliationCard.jsx"; +import {CountryList, TextField} from "../../../components/MemberCustomFiels.jsx"; + +import {useRef, useState} from "react"; +import {LocationEditor, LocationEditorModal} from "../../../components/Club/LocationEditor.jsx"; +import {ContactEditor} from "../../../components/Club/ContactEditor.jsx"; +import {HoraireEditor} from "../../../components/Club/HoraireEditor.jsx"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faFilePdf} from "@fortawesome/free-solid-svg-icons"; + +const vite_url = import.meta.env.VITE_URL; + +export function ClubPage() { + const {id} = useParams() + const navigate = useNavigate(); + + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/club/${id}`, setLoading, 1) + + const handleRm = () => { + toast.promise( + apiAxios.delete(`/club/${id}`), + { + pending: "Suppression du club en cours...", + success: "Club supprimé avec succès 🎉", + error: "Échec de la suppression du club 😕" + } + ).then(_ => { + navigate("/admin/club") + }) + } + + return <> +

    Page club

    + + {data + ?
    +
    +
    + + + +
    +
    + +
    + +
    + +
    +
    +
    + : error && + } + +} + +function InformationForm({data}) { + const [switchOn, setSwitchOn] = useState(data.international); + const [modal, setModal] = useState({id: -1}) + const locationModalCallback = useRef(null) + + const handleSubmit = (event) => { + event.preventDefault(); + + const formData = new FormData(event.target); + + toast.promise( + apiAxios.put(`/club/${data.id}`, formData), + { + pending: "Enregistrement du club en cours", + success: "Club enregistrée avec succès 🎉", + error: "Échec de l'enregistrement du club 😕" + } + ) + } + + return <> +
    +
    + +
    Affiliation n°{data.no_affiliation}
    +
    + + + + + + avatar +
    +
    + + +
    + +
    + +
    +
    + setSwitchOn(!switchOn)}/> + +
    +
    + {!switchOn && <> + + + + + +
    +
    + + + + + +
    +
    Laissez vide pour ne rien changer.
    +
    + + + + + + } +
    +
    +
    + +
    +
    +
    +
    + + + +} From f84ad91dc8da11b5d1456acaeda16e440083eaa4 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 17 Jul 2024 11:14:26 +0200 Subject: [PATCH 15/37] feat: licence add number --- .../ffsaf/domain/service/LicenceService.java | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java index 6843358..155a103 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java @@ -4,7 +4,9 @@ import fr.titionfire.ffsaf.data.model.LicenceModel; import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.data.repository.CombRepository; import fr.titionfire.ffsaf.data.repository.LicenceRepository; +import fr.titionfire.ffsaf.data.repository.SequenceRepository; import fr.titionfire.ffsaf.rest.from.LicenceForm; +import fr.titionfire.ffsaf.utils.SequenceType; import fr.titionfire.ffsaf.utils.Utils; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.common.WithSession; @@ -29,8 +31,12 @@ public class LicenceService { @Inject CombRepository combRepository; + @Inject + SequenceRepository sequenceRepository; + public Uni> getLicence(long id, Consumer checkPerm) { - return combRepository.findById(id).invoke(checkPerm).chain(combRepository -> Mutiny.fetch(combRepository.getLicences())); + return combRepository.findById(id).invoke(checkPerm) + .chain(combRepository -> Mutiny.fetch(combRepository.getLicences())); } public Uni> getCurrentSaisonLicence(JsonWebToken idToken) { @@ -44,19 +50,33 @@ public class LicenceService { public Uni setLicence(long id, LicenceForm form) { if (form.getId() == -1) { - return combRepository.findById(id).chain(combRepository -> { + return combRepository.findById(id).chain(membreModel -> { LicenceModel model = new LicenceModel(); - model.setMembre(combRepository); + model.setMembre(membreModel); model.setSaison(form.getSaison()); model.setCertificate(form.isCertificate()); model.setValidate(form.isValidate()); - return Panache.withTransaction(() -> repository.persist(model)); + return Panache.withTransaction(() -> repository.persist(model) + .call(m -> (m.isValidate() && membreModel.getLicence() <= 0) ? + sequenceRepository.getNextValueInTransaction(SequenceType.Licence) + .invoke(i -> membreModel.setLicence(Math.toIntExact(i))) + .chain(() -> combRepository.persist(membreModel)) + : Uni.createFrom().nullItem() + )); }); } else { return repository.findById(form.getId()).chain(model -> { model.setCertificate(form.isCertificate()); model.setValidate(form.isValidate()); - return Panache.withTransaction(() -> repository.persist(model)); + return Panache.withTransaction(() -> repository.persist(model) + .call(m -> m.isValidate() ? Mutiny.fetch(m.getMembre()) + .call(membreModel -> (membreModel.getLicence() <= 0) ? + sequenceRepository.getNextValueInTransaction(SequenceType.Licence) + .invoke(i -> membreModel.setLicence(Math.toIntExact(i))) + .chain(() -> combRepository.persist(membreModel)) + : Uni.createFrom().nullItem()) + : Uni.createFrom().nullItem() + )); }); } } @@ -68,17 +88,18 @@ public class LicenceService { public Uni askLicence(long id, LicenceForm form, Consumer checkPerm) { return combRepository.findById(id).invoke(checkPerm).chain(membreModel -> { if (form.getId() == -1) { - return repository.find("saison = ?1 AND membre = ?2", Utils.getSaison(), membreModel).count().invoke(Unchecked.consumer(count -> { - if (count > 0) - throw new BadRequestException(); - })).chain(__ -> combRepository.findById(id).chain(combRepository -> { - LicenceModel model = new LicenceModel(); - model.setMembre(combRepository); - model.setSaison(Utils.getSaison()); - model.setCertificate(form.isCertificate()); - model.setValidate(false); - return Panache.withTransaction(() -> repository.persist(model)); - })); + return repository.find("saison = ?1 AND membre = ?2", Utils.getSaison(), membreModel).count() + .invoke(Unchecked.consumer(count -> { + if (count > 0) + throw new BadRequestException(); + })).chain(__ -> combRepository.findById(id).chain(combRepository -> { + LicenceModel model = new LicenceModel(); + model.setMembre(combRepository); + model.setSaison(Utils.getSaison()); + model.setCertificate(form.isCertificate()); + model.setValidate(false); + return Panache.withTransaction(() -> repository.persist(model)); + })); } else { return repository.findById(form.getId()).chain(model -> { model.setCertificate(form.isCertificate()); From 5ba4ee1f9011010cac841c1929a5773cc93c024d Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 17 Jul 2024 11:46:13 +0200 Subject: [PATCH 16/37] feat: membre required field light --- .../ffsaf/domain/service/MembreService.java | 32 +++++++++++++------ .../ffsaf/rest/from/FullMemberForm.java | 2 +- src/main/webapp/src/components/ClubSelect.jsx | 9 +++--- .../src/components/MemberCustomFiels.jsx | 7 ++-- .../pages/admin/member/InformationForm.jsx | 25 ++++++++++++--- .../src/pages/admin/member/NewMemberPage.jsx | 4 +-- 6 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java index dda7cc8..383cba7 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -57,7 +57,8 @@ public class MembreService { } public SimpleCombModel findByIdOptionalComb(long id) throws Throwable { - return VertxContextSupport.subscribeAndAwait(() -> Panache.withTransaction(() -> repository.findById(id).map(SimpleCombModel::fromModel))); + return VertxContextSupport.subscribeAndAwait( + () -> Panache.withTransaction(() -> repository.findById(id).map(SimpleCombModel::fromModel))); } public Uni> searchAdmin(int limit, int page, String search, String club) { @@ -84,7 +85,8 @@ public class MembreService { return repository.find("userId = ?1", subject).firstResult() .chain(membreModel -> { PanacheQuery query = repository.find("club = ?1 AND (lname LIKE ?2 OR fname LIKE ?2)", - Sort.ascending("fname", "lname"), membreModel.getClub(), finalSearch).page(Page.ofSize(limit)); + Sort.ascending("fname", "lname"), membreModel.getClub(), finalSearch) + .page(Page.ofSize(limit)); return getPageResult(query, limit, page); }); } @@ -114,7 +116,8 @@ public class MembreService { public Uni update(long id, FullMemberForm membre) { return repository.findById(id) - .chain(membreModel -> clubRepository.findById(membre.getClub()).map(club -> new Pair<>(membreModel, club))) + .chain(membreModel -> clubRepository.findById(membre.getClub()) + .map(club -> new Pair<>(membreModel, club))) .onItem().transformToUni(pair -> { MembreModel m = pair.getKey(); m.setFname(membre.getFname()); @@ -129,14 +132,19 @@ public class MembreService { m.setEmail(membre.getEmail()); return Panache.withTransaction(() -> repository.persist(m)); }) - .invoke(membreModel -> SReqComb.sendIfNeed(serverCustom.clients, SimpleCombModel.fromModel(membreModel))) + .invoke(membreModel -> SReqComb.sendIfNeed(serverCustom.clients, + SimpleCombModel.fromModel(membreModel))) .call(membreModel -> (membreModel.getUserId() != null) ? - keycloakService.setClubGroupMembre(membreModel, membreModel.getClub()) : Uni.createFrom().nullItem()) + ((membreModel.getClub() != null) ? + keycloakService.setClubGroupMembre(membreModel, membreModel.getClub()) : + keycloakService.clearUser(membreModel.getUserId())) + : Uni.createFrom().nullItem()) .call(membreModel -> (membreModel.getUserId() != null) ? keycloakService.setAutoRoleMembre(membreModel.getUserId(), membreModel.getRole(), membreModel.getGrade_arbitrage()) : Uni.createFrom().nullItem()) .call(membreModel -> (membreModel.getUserId() != null) ? - keycloakService.setEmail(membreModel.getUserId(), membreModel.getEmail()) : Uni.createFrom().nullItem()) + keycloakService.setEmail(membreModel.getUserId(), membreModel.getEmail()) : Uni.createFrom() + .nullItem()) .map(__ -> "OK"); } @@ -166,12 +174,14 @@ public class MembreService { target.setRole(membre.getRole()); return Panache.withTransaction(() -> repository.persist(target)); }) - .invoke(membreModel -> SReqComb.sendIfNeed(serverCustom.clients, SimpleCombModel.fromModel(membreModel))) + .invoke(membreModel -> SReqComb.sendIfNeed(serverCustom.clients, + SimpleCombModel.fromModel(membreModel))) .call(membreModel -> (membreModel.getUserId() != null) ? keycloakService.setAutoRoleMembre(membreModel.getUserId(), membreModel.getRole(), membreModel.getGrade_arbitrage()) : Uni.createFrom().nullItem()) .call(membreModel -> (membreModel.getUserId() != null) ? - keycloakService.setEmail(membreModel.getUserId(), membreModel.getEmail()) : Uni.createFrom().nullItem()) + keycloakService.setEmail(membreModel.getUserId(), membreModel.getEmail()) : Uni.createFrom() + .nullItem()) .map(__ -> "OK"); } @@ -181,7 +191,8 @@ public class MembreService { MembreModel model = getMembreModel(input, clubModel); return Panache.withTransaction(() -> repository.persist(model)); }) - .invoke(membreModel -> SReqComb.sendIfNeedAdd(serverCustom.clients, SimpleCombModel.fromModel(membreModel))) + .invoke(membreModel -> SReqComb.sendIfNeedAdd(serverCustom.clients, + SimpleCombModel.fromModel(membreModel))) .map(MembreModel::getId); } @@ -193,7 +204,8 @@ public class MembreService { model.setGrade_arbitrage(GradeArbitrage.NA); return Panache.withTransaction(() -> repository.persist(model)); }) - .invoke(membreModel -> SReqComb.sendIfNeedAdd(serverCustom.clients, SimpleCombModel.fromModel(membreModel))) + .invoke(membreModel -> SReqComb.sendIfNeedAdd(serverCustom.clients, + SimpleCombModel.fromModel(membreModel))) .map(MembreModel::getId); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/FullMemberForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/FullMemberForm.java index f164c9e..a94d3ab 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/FullMemberForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/FullMemberForm.java @@ -38,7 +38,7 @@ public class FullMemberForm { private String country; @FormParam("birth_date") - private Date birth_date; + private Date birth_date = null; @FormParam("email") private String email; diff --git a/src/main/webapp/src/components/ClubSelect.jsx b/src/main/webapp/src/components/ClubSelect.jsx index 9b7d906..fe23be9 100644 --- a/src/main/webapp/src/components/ClubSelect.jsx +++ b/src/main/webapp/src/components/ClubSelect.jsx @@ -2,15 +2,15 @@ import {LoadingProvider, useLoadingSwitcher} from "../hooks/useLoading.jsx"; import {useFetch} from "../hooks/useFetch.js"; import {AxiosError} from "./AxiosError.jsx"; -export function ClubSelect({defaultValue, name}) { +export function ClubSelect({defaultValue, name, na = false}) { return
    - +
    } -function ClubSelect_({defaultValue, name}) { +function ClubSelect_({defaultValue, name, na}) { const setLoading = useLoadingSwitcher() const {data, error} = useFetch(`/club/no_detail`, setLoading, 1) @@ -19,8 +19,9 @@ function ClubSelect_({defaultValue, name}) { ?
    diff --git a/src/main/webapp/src/components/MemberCustomFiels.jsx b/src/main/webapp/src/components/MemberCustomFiels.jsx index d7f0050..f0bd0e6 100644 --- a/src/main/webapp/src/components/MemberCustomFiels.jsx +++ b/src/main/webapp/src/components/MemberCustomFiels.jsx @@ -1,7 +1,7 @@ import {useEffect, useState} from "react"; import {getCategoryFormBirthDate} from "../utils/Tools.js"; -export function BirthDayField({inti_date, inti_category}) { +export function BirthDayField({inti_date, inti_category, required = true}) { const [date, setDate] = useState(inti_date) const [category, setCategory] = useState(inti_category) const [canUpdate, setCanUpdate] = useState(false) @@ -15,19 +15,18 @@ export function BirthDayField({inti_date, inti_category}) { setCategory(getCategoryFormBirthDate(new Date(date), new Date('2023-09-01'))) } - return <>
    Date de naissance setDate(e.target.value)}/>
    Catégorie {canUpdate && } diff --git a/src/main/webapp/src/pages/admin/member/InformationForm.jsx b/src/main/webapp/src/pages/admin/member/InformationForm.jsx index c6ea3fe..e8fcacb 100644 --- a/src/main/webapp/src/pages/admin/member/InformationForm.jsx +++ b/src/main/webapp/src/pages/admin/member/InformationForm.jsx @@ -32,15 +32,30 @@ export function InformationForm({data}) { event.preventDefault(); setLoading(1) + let error = false; + if (event.target.country?.value === "NA") { + toast.error('Veuillez sélectionner un pays valide 😕'); + error = true; + } + if (event.target.club?.value === "Sélectionner...") { + toast.error('Veuillez sélectionner un club valide 😕'); + error = true; + } + + if (error) { + setLoading(0) + return; + } + const formData = new FormData(); formData.append("id", data.id); formData.append("lname", event.target.lname?.value.toUpperCase()); formData.append("fname", event.target.fname?.value); - formData.append("categorie", event.target.category?.value); + formData.append("categorie", (event.target.category?.value.length === 0) ? null : event.target.category?.value); formData.append("club", event.target.club?.value); formData.append("genre", event.target.genre?.value); formData.append("country", event.target.country?.value); - formData.append("birth_date", new Date(event.target.birth_date?.value).toUTCString()); + formData.append("birth_date", (event.target.birth_date?.value.length === 0) ? null : new Date(event.target.birth_date?.value).toUTCString()); formData.append("email", event.target.email?.value); formData.append("role", event.target.role?.value); formData.append("grade_arbitrage", event.target.grade_arbitrage?.value); @@ -71,14 +86,14 @@ export function InformationForm({data}) { + type="email" required={false}/> + inti_category={data.categorie} required={false}/>
    - +
    + type="email" required={false}/>
    - +
    Date: Wed, 17 Jul 2024 12:21:37 +0200 Subject: [PATCH 17/37] fix: club set role --- .../ffsaf/domain/service/MembreService.java | 2 +- src/main/webapp/src/components/Nav.jsx | 2 +- src/main/webapp/src/pages/MemberList.jsx | 19 ++++++++----------- .../src/pages/club/member/InformationForm.jsx | 2 +- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java index 383cba7..6dd6afe 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -159,7 +159,7 @@ public class MembreService { if (securityIdentity.getRoles().contains("club_president")) source = RoleAsso.PRESIDENT; else if (securityIdentity.getRoles().contains("club_secretaire")) source = RoleAsso.SECRETAIRE; else if (securityIdentity.getRoles().contains("club_respo_intra")) source = RoleAsso.MEMBREBUREAU; - if (!membre.getRole().equals(membreModel.getRole()) && membre.getRole().level > source.level) + if (!membre.getRole().equals(membreModel.getRole()) && membre.getRole().level >= source.level) throw new ForbiddenException(); })) .onItem().transformToUni(target -> { diff --git a/src/main/webapp/src/components/Nav.jsx b/src/main/webapp/src/components/Nav.jsx index 382731e..371ec21 100644 --- a/src/main/webapp/src/components/Nav.jsx +++ b/src/main/webapp/src/components/Nav.jsx @@ -46,7 +46,7 @@ function ClubMenu() { const {is_authenticated, userinfo} = useAuth() if (!is_authenticated || !(userinfo?.roles?.includes("club_president") - || userinfo?.roles?.includes("club_secretaire") || userinfo?.roles?.includes("club_tresorier"))) + || userinfo?.roles?.includes("club_secretaire") || userinfo?.roles?.includes("club_respo_intra"))) return <> return
  • diff --git a/src/main/webapp/src/pages/MemberList.jsx b/src/main/webapp/src/pages/MemberList.jsx index 98e8d95..204b470 100644 --- a/src/main/webapp/src/pages/MemberList.jsx +++ b/src/main/webapp/src/pages/MemberList.jsx @@ -11,12 +11,6 @@ import {apiAxios} from "../utils/Tools.js"; import {toast} from "react-toastify"; import {SearchBar} from "../components/SearchBar.jsx"; -const removeDiacritics = str => { - return str - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') -} - export function MemberList({source}) { const {hash} = useLocation(); const navigate = useNavigate(); @@ -47,6 +41,7 @@ export function MemberList({source}) { fname: e.fname, lname: e.lname, club: e.club, + categorie: e.categorie, licence_number: e.licence, licence: showLicenceState ? licenceData.find(licence => licence.membre === e.id) : null }) @@ -84,7 +79,7 @@ export function MemberList({source}) { {data ? + page={page} source={source}/> : error ? : @@ -107,7 +102,7 @@ export function MemberList({source}) { } -function MakeCentralPanel({data, visibleMember, navigate, showLicenceState, page}) { +function MakeCentralPanel({data, visibleMember, navigate, showLicenceState, page, source}) { const pages = [] for (let i = 1; i <= data.page_count; i++) { pages.push(
  • @@ -120,7 +115,7 @@ function MakeCentralPanel({data, visibleMember, navigate, showLicenceState, page Ligne {((page - 1) * data.page_size) + 1} à { (page * data.page_size > data.result_count) ? data.result_count : (page * data.page_size)} (page {page} sur {data.page_count})
    - {visibleMember.map(member => ())} + {visibleMember.map(member => ())}
  • @@ -137,7 +132,7 @@ function MakeCentralPanel({data, visibleMember, navigate, showLicenceState, page } -function MakeRow({member, showLicenceState, navigate}) { +function MakeRow({member, showLicenceState, navigate, source}) { const rowContent = <>
    {String(member.licence_number).padStart(5, '0')} @@ -145,7 +140,9 @@ function MakeRow({member, showLicenceState, navigate}) {
    {member.fname} {member.lname}
    - {member.club?.name || "Sans club"} + {source === "club" ? + {member.categorie} + : {member.club?.name || "Sans club"}} if (showLicenceState && member.licence != null) { diff --git a/src/main/webapp/src/pages/club/member/InformationForm.jsx b/src/main/webapp/src/pages/club/member/InformationForm.jsx index 648adde..8c72615 100644 --- a/src/main/webapp/src/pages/club/member/InformationForm.jsx +++ b/src/main/webapp/src/pages/club/member/InformationForm.jsx @@ -55,7 +55,7 @@ export function InformationForm({data}) { - +
    From c7a2133eed510c94e450d617c58ca8c8910f62c1 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 17 Jul 2024 23:11:59 +0200 Subject: [PATCH 18/37] feat: club page wip: edit af request --- .../ffsaf/domain/service/ClubService.java | 76 ++++++++- .../titionfire/ffsaf/rest/ClubEndpoints.java | 36 +++- .../titionfire/ffsaf/rest/CombEndpoints.java | 26 --- .../ffsaf/rest/data/RenewAffData.java | 45 +++++ .../ffsaf/rest/from/AffiliationForm.java | 4 - .../ffsaf/rest/from/PartClubForm.java | 27 +++ src/main/webapp/src/components/Nav.jsx | 2 +- src/main/webapp/src/pages/DemandeAff.jsx | 158 +++++++++++++----- .../webapp/src/pages/admin/club/ClubPage.jsx | 2 +- src/main/webapp/src/pages/club/ClubRoot.jsx | 5 +- .../src/pages/club/club/AffiliationCard.jsx | 95 +++++++++++ .../webapp/src/pages/club/club/ClubPage.jsx | 158 ------------------ .../webapp/src/pages/club/club/MyClubPage.jsx | 117 +++++++++++++ 13 files changed, 507 insertions(+), 244 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/RenewAffData.java delete mode 100644 src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationForm.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/from/PartClubForm.java create mode 100644 src/main/webapp/src/pages/club/club/AffiliationCard.jsx delete mode 100644 src/main/webapp/src/pages/club/club/ClubPage.jsx create mode 100644 src/main/webapp/src/pages/club/club/MyClubPage.jsx diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java index ef72137..3553079 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java @@ -2,18 +2,19 @@ package fr.titionfire.ffsaf.domain.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import fr.titionfire.ffsaf.data.model.AffiliationModel; import fr.titionfire.ffsaf.data.model.ClubModel; +import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.data.repository.ClubRepository; import fr.titionfire.ffsaf.data.repository.CombRepository; import fr.titionfire.ffsaf.net2.ServerCustom; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; import fr.titionfire.ffsaf.net2.request.SReqClub; +import fr.titionfire.ffsaf.rest.data.RenewAffData; import fr.titionfire.ffsaf.rest.data.SimpleClubList; import fr.titionfire.ffsaf.rest.from.FullClubForm; -import fr.titionfire.ffsaf.utils.Contact; -import fr.titionfire.ffsaf.utils.PageResult; -import fr.titionfire.ffsaf.utils.RoleAsso; -import fr.titionfire.ffsaf.utils.Utils; +import fr.titionfire.ffsaf.rest.from.PartClubForm; +import fr.titionfire.ffsaf.utils.*; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.PanacheQuery; import io.quarkus.hibernate.reactive.panache.common.WithSession; @@ -25,10 +26,14 @@ import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotFoundException; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; import org.hibernate.reactive.mutiny.Mutiny; import java.util.Collection; +import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -114,6 +119,44 @@ public class ClubService { return repository.find("clubId", clubId).firstResult(); } + public Uni getOfUser(JsonWebToken idToken) { + return combRepository.find("userId = ?1", idToken.getSubject()).firstResult().invoke(Unchecked.consumer(m -> { + if (m == null || m.getClub() == null) + throw new NotFoundException("Club not found"); + })) + .map(MembreModel::getClub) + .call(club -> Mutiny.fetch(club.getContact())); + } + + public Uni updateOfUser(JsonWebToken idToken, PartClubForm form) { + TypeReference> typeRef = new TypeReference<>() { + }; + + return combRepository.find("userId = ?1", idToken.getSubject()).firstResult().invoke(Unchecked.consumer(m -> { + if (m == null || m.getClub() == null) + throw new NotFoundException("Club not found"); + if (!GroupeUtils.isInClubGroup(m.getClub().getId(), idToken)) + throw new ForbiddenException(); + })) + .map(MembreModel::getClub) + .call(club -> Mutiny.fetch(club.getContact())) + .chain(Unchecked.function(club -> { + club.setContact_intern(form.getContact_intern()); + club.setAddress(form.getAddress()); + + try { + club.setContact(MAPPER.readValue(form.getContact(), typeRef)); + } catch (JsonProcessingException e) { + throw new BadRequestException(); + } + + club.setTraining_location(form.getTraining_location()); + club.setTraining_day_time(form.getTraining_day_time()); + return Panache.withTransaction(() -> repository.persist(club)); + })) + .map(__ -> "OK"); + } + public Uni update(long id, FullClubForm input) { return repository.findById(id).call(m -> Mutiny.fetch(m.getContact())) .onItem().transformToUni(Unchecked.function(m -> { @@ -201,4 +244,29 @@ public class ClubService { .call(__ -> Utils.deleteMedia(id, media, "ppClub")) .call(__ -> Utils.deleteMedia(id, media, "clubStatus")); } + + public Uni getRenewData(long id) { + RenewAffData data = new RenewAffData(); + + return repository.findById(id) + .call(clubModel -> Mutiny.fetch(clubModel.getAffiliations())) + .invoke(clubModel -> { + data.setName(clubModel.getName()); + data.setSiret(clubModel.getSIRET()); + data.setRna(clubModel.getRNA()); + data.setAddress(clubModel.getAddress()); + data.setSaison( + clubModel.getAffiliations().stream().max(Comparator.comparing(AffiliationModel::getSaison)) + .map(AffiliationModel::getSaison).map(i -> Math.min(i + 1, Utils.getSaison() + 1)) + .orElse(Utils.getSaison())); + }) + .chain(club -> combRepository.list("club = ?1", club)) + .invoke(combs -> data.setMembers(combs.stream() + .filter(o -> o.getRole() != null && o.getRole().level >= RoleAsso.MEMBREBUREAU.level) + .sorted((o1, o2) -> o2.getRole().level - o1.getRole().level) + .limit(3) + .map(RenewAffData.RenewMember::new) + .toList())) + .map(o -> data); + } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index abb255f..0472054 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -3,9 +3,11 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.domain.service.ClubService; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; +import fr.titionfire.ffsaf.rest.data.RenewAffData; import fr.titionfire.ffsaf.rest.data.SimpleClub; import fr.titionfire.ffsaf.rest.data.SimpleClubList; import fr.titionfire.ffsaf.rest.from.FullClubForm; +import fr.titionfire.ffsaf.rest.from.PartClubForm; import fr.titionfire.ffsaf.utils.Contact; import fr.titionfire.ffsaf.utils.GroupeUtils; import fr.titionfire.ffsaf.utils.PageResult; @@ -44,11 +46,15 @@ public class ClubEndpoints { @ConfigProperty(name = "upload_dir") String media; - Consumer checkPerm = Unchecked.consumer(membreModel -> { - if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getId(), + Consumer checkPerm = Unchecked.consumer(clubModel -> { + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(clubModel.getId(), idToken)) throw new ForbiddenException(); }); + Consumer checkPerm2 = Unchecked.consumer(id -> { + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(id, idToken)) + throw new ForbiddenException(); + }); @GET @Path("/no_detail") @@ -125,7 +131,6 @@ public class ClubEndpoints { @Produces(MediaType.TEXT_PLAIN) @Consumes(MediaType.MULTIPART_FORM_DATA) public Uni addAdminClub(FullClubForm input) { - System.out.println(input); return clubService.add(input) .invoke(Unchecked.consumer(id -> { if (id == null) throw new InternalError("Fail to create club data"); @@ -152,6 +157,31 @@ public class ClubEndpoints { return clubService.delete(id); } + @GET + @Path("/me") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni getOfUser() { + return clubService.getOfUser(idToken).map(SimpleClub::fromModel) + .invoke(m -> m.setContactMap(Contact.toSite())); + } + + @PUT + @Path("/me") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Uni setClubOfUser(PartClubForm form) { + return clubService.updateOfUser(idToken, form); + } + + @GET + @Path("/renew/{id}") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni getOfUser(@PathParam("id") long id) { + return Uni.createFrom().item(id).invoke(checkPerm2).chain(__ -> clubService.getRenewData(id)); + } @GET @Path("{clubId}/logo") diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java index 0882952..95654ec 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java @@ -198,32 +198,6 @@ public class CombEndpoints { return membreService.delete(id, idToken); } - private Future replacePhoto(long id, byte[] input) { - return CompletableFuture.supplyAsync(() -> { - try (InputStream is = new BufferedInputStream(new ByteArrayInputStream(input))) { - String mimeType = URLConnection.guessContentTypeFromStream(is); - String[] detectedExtensions = MimeTypes.findExtensionsByMimeTypes(mimeType, false); - if (detectedExtensions.length == 0) - throw new IOException("Fail to detect file extension for MIME type " + mimeType); - - FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id)); - File[] files = new File(media, "ppMembre").listFiles(filter); - if (files != null) { - for (File file : files) { - //noinspection ResultOfMethodCallIgnored - file.delete(); - } - } - - String extension = "." + detectedExtensions[0]; - Files.write(new File(media, "ppMembre/" + id + extension).toPath(), input); - return "OK"; - } catch (IOException e) { - return e.getMessage(); - } - }); - } - @GET @Path("{id}/photo") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/RenewAffData.java b/src/main/java/fr/titionfire/ffsaf/rest/data/RenewAffData.java new file mode 100644 index 0000000..02ce7cc --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/RenewAffData.java @@ -0,0 +1,45 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.MembreModel; +import fr.titionfire.ffsaf.utils.RoleAsso; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection +public class RenewAffData { + String name; + Long siret; + String rna; + String address; + int saison; + List members; + + + @Data + @AllArgsConstructor + @RegisterForReflection + public static class RenewMember { + String lname; + String fname; + String email; + int licence; + RoleAsso role; + + public RenewMember(MembreModel o) { + this.lname = o.getLname(); + this.fname = o.getFname(); + this.email = o.getEmail(); + this.licence = o.getLicence(); + this.role = o.getRole(); + } + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationForm.java deleted file mode 100644 index cfda16d..0000000 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationForm.java +++ /dev/null @@ -1,4 +0,0 @@ -package fr.titionfire.ffsaf.rest.from; - -public class AffiliationForm { -} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/PartClubForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/PartClubForm.java new file mode 100644 index 0000000..c4c68db --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/PartClubForm.java @@ -0,0 +1,27 @@ +package fr.titionfire.ffsaf.rest.from; + +import jakarta.ws.rs.FormParam; +import lombok.Getter; +import lombok.ToString; + +@ToString +@Getter +public class PartClubForm { + @FormParam("id") + private String id = null; + + @FormParam("contact") + private String contact = null; + + @FormParam("training_location") + private String training_location = null; + + @FormParam("training_day_time") + private String training_day_time = null; + + @FormParam("contact_intern") + private String contact_intern = null; + + @FormParam("address") + private String address = null; +} diff --git a/src/main/webapp/src/components/Nav.jsx b/src/main/webapp/src/components/Nav.jsx index 371ec21..2b3a4f4 100644 --- a/src/main/webapp/src/components/Nav.jsx +++ b/src/main/webapp/src/components/Nav.jsx @@ -54,8 +54,8 @@ function ClubMenu() { Club
      +
    • Mon club
    • Member
    • -
    • B
    } diff --git a/src/main/webapp/src/pages/DemandeAff.jsx b/src/main/webapp/src/pages/DemandeAff.jsx index 722c917..66a4e9e 100644 --- a/src/main/webapp/src/pages/DemandeAff.jsx +++ b/src/main/webapp/src/pages/DemandeAff.jsx @@ -1,8 +1,9 @@ -import {useState} from "react"; +import {useEffect, useState} from "react"; import {apiAxios, getSaison} from "../utils/Tools.js"; import {toast} from "react-toastify"; -import {useNavigate} from "react-router-dom"; +import {useLocation, useNavigate} from "react-router-dom"; import {RoleList} from "../components/MemberCustomFiels.jsx"; +import {useAuth} from "../hooks/useAuth.jsx"; const notUpperCase = ["de", "la", "le", "les", "des", "du", "d'", "l'", "sur"]; @@ -40,13 +41,38 @@ function reconstruireAdresse(infos) { export function DemandeAff() { + const {hash} = useLocation(); const navigate = useNavigate(); + const [initData, setInitData] = useState(null) + const [needFile, setNeedFile] = useState(true) + + useEffect(() => { + if (hash.startsWith("#d")) { + const data = JSON.parse(decodeURI(hash.substring(2))); + setInitData(data) + setNeedFile(false) + } else if (hash.startsWith("#e")) { + apiAxios.get(`/affiliation/request/${hash.substring(2)}`).then(data => { + for (let i = 0; i < data.data.members.length; i++) { + if (data.data.members[i].licence === -1) + data.data.members[i].licence = "" + } + setInitData(data.data) + setNeedFile(false) + }).catch(_ => { + setInitData({}) + }) + } else { + setInitData({}) + } + }, []); const submit = (event) => { event.preventDefault() const formData = new FormData(event.target) formData.append("m1_role", event.target.m1_role?.value) formData.append("rna", event.target.rna?.value) + formData.append("siret", event.target.siret?.value) let error = false; for (let i = 1; i <= 3; i++) { @@ -59,16 +85,44 @@ export function DemandeAff() { return; } - toast.promise( - apiAxios.post(`/affiliation/request`, formData, {headers: {'Accept': '*/*'}}), - { - pending: "Enregistrement de la demande d'affiliation en cours", - success: "Demande d'affiliation enregistrée avec succès 🎉", - error: "Échec de la demande d'affiliation 😕" - } - ).then(_ => { - navigate("/affiliation/ok") - }) + if (event.nativeEvent.submitter.value === "undo") { + toast.promise( + apiAxios.delete(`/affiliation/request/${initData.id}`), + { + pending: "Annulation de la demande d'affiliation en cours", + success: "Demande d'affiliation annulée avec succès 🎉", + error: "Échec de l'annulation de la demande d'affiliation 😕" + } + ).then(_ => { + navigate("/club/me") + }) + }else if (event.nativeEvent.submitter.value === "edit") { + formData.append("id", initData.id) + + toast.promise( + apiAxios.put(`/affiliation/request/edit`, formData), + { + pending: "Enregistrement des modifications en cours", + success: "Modifications enregistrées avec succès 🎉", + error: "Échec de l'enregistrement des modifications 😕" + } + ).then(_ => { + navigate("/club/me") + }) + }else { + formData.append("id", -1) + + toast.promise( + apiAxios.post(`/affiliation/request`, formData, {headers: {'Accept': '*/*'}}), + { + pending: "Enregistrement de la demande d'affiliation en cours", + success: "Demande d'affiliation enregistrée avec succès 🎉", + error: "Échec de la demande d'affiliation 😕" + } + ).then(_ => { + navigate("/affiliation/ok") + }) + } } return
    @@ -91,46 +145,59 @@ export function DemandeAff() { -
    + {initData &&

    L'association

    - +

    Membre n°1

    - +

    Membre n°2

    - +

    Membre n°3

    - +
    -

    Après validation de votre demande, vous recevrez un identifiant et mot de passe provisoire pour - accéder à votre espace FFSAF

    + {!initData.id &&

    Après validation de votre demande, vous recevrez un identifiant et mot de passe provisoire pour + accéder à votre espace FFSAF

    } Notez que pour finaliser votre affiliation, il vous faudra :
    • Disposer d’au moins trois membres licenciés, dont le président
    • S'être acquitté des cotisations prévues par les règlements fédéraux
    -
    -
    - + + {!initData.id ? +
    +
    + +
    +
    : +
    +
    + +
    +
    + +
    -
    + }
    -
    +
    }
    } -function AssoInfo() { +function AssoInfo({initData, needFile}) { const [denomination, setDenomination] = useState("") - const [siret, setSiret] = useState("") - const [rna, setRna] = useState("") + const [siret, setSiret] = useState(initData.siret ? String(initData.siret) : "") + const [rna, setRna] = useState(initData.rna ? initData.rna : "") const [rnaEnable, setRnaEnable] = useState(false) - const [adresse, setAdresse] = useState("") - const [saison, setSaison] = useState(getSaison()) + const [adresse, setAdresse] = useState(initData.address ? initData.address : "") + const [saison, setSaison] = useState(initData.saison ? initData.saison : getSaison()) const fetchSiret = () => { if (siret.length < 14) { @@ -150,7 +217,8 @@ function AssoInfo() { setDenomination(data2.denomination) setRnaEnable(data2.identifiant_association === null) setRna(data2.identifiant_association ? data2.identifiant_association : "") - setAdresse(reconstruireAdresse(data2.etablissement_siege)) + if (!initData.saison || adresse === "") + setAdresse(reconstruireAdresse(data2.etablissement_siege)) }) } @@ -178,13 +246,13 @@ function AssoInfo() { Nom de l'association* + aria-describedby="basic-addon1" required defaultValue={initData.name ? initData.name : ""}/>
    N° SIRET* - setSiret(e.target.value)} defaultValue={500213731}/> + setSiret(e.target.value)}/> @@ -218,25 +286,25 @@ function AssoInfo() {
    - +
    + required={needFile}/>
    ; } -function MembreInfo({role}) { - const [switchOn, setSwitchOn] = useState(false); +function MembreInfo({role, initData}) { + const [switchOn, setSwitchOn] = useState(!!initData.licence); return <>
    - @@ -251,22 +319,22 @@ function MembreInfo({role}) {
    - +
    + name={role + "_prenom"} defaultValue={initData.fname ? initData.fname : ""} required/>
    + name={role + "_mail"} defaultValue={initData.email ? initData.email : ""} required/>
    @@ -283,7 +351,7 @@ function MembreInfo({role}) {
    + name={role + "_licence"} defaultValue={initData.licence ? Number(initData.licence) : ""} required/>
    diff --git a/src/main/webapp/src/pages/admin/club/ClubPage.jsx b/src/main/webapp/src/pages/admin/club/ClubPage.jsx index 2f751d3..0257dec 100644 --- a/src/main/webapp/src/pages/admin/club/ClubPage.jsx +++ b/src/main/webapp/src/pages/admin/club/ClubPage.jsx @@ -129,7 +129,7 @@ function InformationForm({data}) {
    - + +
    +
    +
    +
    +
      + {data && data.sort((a, b) => b.saison - a.saison).map((affiliation, index) => { + return
      +
      {affiliation?.saison}-{affiliation?.saison + 1}
      + +
      + })} + {error && } +
    +
    + + +
    ; +} + +function ModalContent({affiliation}) { + const navigate = useNavigate(); + + return <> + {affiliation &&
    +
    +

    Etat de l'affiliation

    + +
    +
    +
    + {affiliation.saison || 0} + - + {(affiliation.saison || 0) + 1} +
    +
    + État de la demande + {affiliation.validate ? Validée : + <> + En attente + + } +
    +
    +
    + +
    +
    + } + +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/club/club/ClubPage.jsx b/src/main/webapp/src/pages/club/club/ClubPage.jsx deleted file mode 100644 index d7196b4..0000000 --- a/src/main/webapp/src/pages/club/club/ClubPage.jsx +++ /dev/null @@ -1,158 +0,0 @@ -import {useNavigate, useParams} from "react-router-dom"; -import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; -import {useFetch} from "../../../hooks/useFetch.js"; -import {toast} from "react-toastify"; -import {apiAxios} from "../../../utils/Tools.js"; -import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx"; -import {AxiosError} from "../../../components/AxiosError.jsx"; -import {AffiliationCard} from "./AffiliationCard.jsx"; -import {CountryList, TextField} from "../../../components/MemberCustomFiels.jsx"; - -import {useRef, useState} from "react"; -import {LocationEditor, LocationEditorModal} from "../../../components/Club/LocationEditor.jsx"; -import {ContactEditor} from "../../../components/Club/ContactEditor.jsx"; -import {HoraireEditor} from "../../../components/Club/HoraireEditor.jsx"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faFilePdf} from "@fortawesome/free-solid-svg-icons"; - -const vite_url = import.meta.env.VITE_URL; - -export function ClubPage() { - const {id} = useParams() - const navigate = useNavigate(); - - const setLoading = useLoadingSwitcher() - const {data, error} = useFetch(`/club/${id}`, setLoading, 1) - - const handleRm = () => { - toast.promise( - apiAxios.delete(`/club/${id}`), - { - pending: "Suppression du club en cours...", - success: "Club supprimé avec succès 🎉", - error: "Échec de la suppression du club 😕" - } - ).then(_ => { - navigate("/admin/club") - }) - } - - return <> -

    Page club

    - - {data - ?
    -
    -
    - - - -
    -
    - -
    - -
    - -
    -
    -
    - : error && - } - -} - -function InformationForm({data}) { - const [switchOn, setSwitchOn] = useState(data.international); - const [modal, setModal] = useState({id: -1}) - const locationModalCallback = useRef(null) - - const handleSubmit = (event) => { - event.preventDefault(); - - const formData = new FormData(event.target); - - toast.promise( - apiAxios.put(`/club/${data.id}`, formData), - { - pending: "Enregistrement du club en cours", - success: "Club enregistrée avec succès 🎉", - error: "Échec de l'enregistrement du club 😕" - } - ) - } - - return <> -
    - -
    - - - -} diff --git a/src/main/webapp/src/pages/club/club/MyClubPage.jsx b/src/main/webapp/src/pages/club/club/MyClubPage.jsx new file mode 100644 index 0000000..2842adb --- /dev/null +++ b/src/main/webapp/src/pages/club/club/MyClubPage.jsx @@ -0,0 +1,117 @@ +import {useNavigate, useParams} from "react-router-dom"; +import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {toast} from "react-toastify"; +import {apiAxios} from "../../../utils/Tools.js"; +import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx"; +import {AxiosError} from "../../../components/AxiosError.jsx"; +import {AffiliationCard} from "./AffiliationCard.jsx"; +import {CountryList, TextField} from "../../../components/MemberCustomFiels.jsx"; + +import {useRef, useState} from "react"; +import {LocationEditor, LocationEditorModal} from "../../../components/Club/LocationEditor.jsx"; +import {ContactEditor} from "../../../components/Club/ContactEditor.jsx"; +import {HoraireEditor} from "../../../components/Club/HoraireEditor.jsx"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faFilePdf} from "@fortawesome/free-solid-svg-icons"; + +const vite_url = import.meta.env.VITE_URL; + +export function MyClubPage() { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/club/me`, setLoading, 1) + + return <> +

    Mon club

    + {data + ?
    +
    +
    + + + +
    +
    + +
    +
    +
    + : error && + } + +} + +function InformationForm({data}) { + const [modal, setModal] = useState({id: -1}) + const locationModalCallback = useRef(null) + + const handleSubmit = (event) => { + event.preventDefault(); + + const formData = new FormData(event.target); + + toast.promise( + apiAxios.put(`/club/me`, formData), + { + pending: "Enregistrement des modifications en cours", + success: "Modifications enregistrées avec succès 🎉", + error: "Échec de l'enregistrement des modifications 😕" + } + ) + } + + return <> +
    +
    + +
    Affiliation n°{data.no_affiliation}
    +
    + + + + {!data.international && <> + + + } + +
    +
    + avatar +
    + +
    Pour modifier les informations ci-dessus, merci de contacter la FFSAF par mail.
    +
    + + {!data.international && <> + + + + + + + + } +
    +
    +
    + +
    +
    +
    +
    + + + +} From b93a08da71db38f721c10754f8891d0563c35b44 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 17 Jul 2024 23:11:59 +0200 Subject: [PATCH 19/37] feat: club page wip: edit af request --- .../domain/service/AffiliationService.java | 53 ++++-- .../ffsaf/domain/service/ClubService.java | 76 ++++++++- .../ffsaf/rest/AffiliationEndpoints.java | 22 ++- .../titionfire/ffsaf/rest/ClubEndpoints.java | 36 +++- .../titionfire/ffsaf/rest/CombEndpoints.java | 26 --- .../ffsaf/rest/data/RenewAffData.java | 45 +++++ .../ffsaf/rest/from/AffiliationForm.java | 4 - .../rest/from/AffiliationRequestForm.java | 3 + .../ffsaf/rest/from/PartClubForm.java | 27 +++ src/main/webapp/src/components/Nav.jsx | 2 +- src/main/webapp/src/pages/DemandeAff.jsx | 158 +++++++++++++----- .../webapp/src/pages/admin/club/ClubPage.jsx | 2 +- src/main/webapp/src/pages/club/ClubRoot.jsx | 5 +- .../src/pages/club/club/AffiliationCard.jsx | 95 +++++++++++ .../webapp/src/pages/club/club/ClubPage.jsx | 158 ------------------ .../webapp/src/pages/club/club/MyClubPage.jsx | 117 +++++++++++++ 16 files changed, 572 insertions(+), 257 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/RenewAffData.java delete mode 100644 src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationForm.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/from/PartClubForm.java create mode 100644 src/main/webapp/src/pages/club/club/AffiliationCard.jsx delete mode 100644 src/main/webapp/src/pages/club/club/ClubPage.jsx create mode 100644 src/main/webapp/src/pages/club/club/MyClubPage.jsx diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index 61c72b9..c380c44 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -53,11 +53,10 @@ public class AffiliationService { return repositoryRequest.listAll(); } - public Uni save(AffiliationRequestForm form) { + public Uni pre_save(AffiliationRequestForm form, boolean unique) { AffiliationRequestModel affModel = form.toModel(); int currentSaison = Utils.getSaison(); - // noinspection ResultOfMethodCallIgnored return Uni.createFrom().item(affModel) .invoke(Unchecked.consumer(model -> { if (model.getSaison() != currentSaison && model.getSaison() != currentSaison + 1) { @@ -67,7 +66,7 @@ public class AffiliationService { .chain(() -> repositoryRequest.count("siret = ?1 and saison = ?2", affModel.getSiret(), affModel.getSaison())) .onItem().invoke(Unchecked.consumer(count -> { - if (count != 0) { + if (count != 0 && unique) { throw new IllegalArgumentException("Affiliation request already exists"); } })) @@ -80,26 +79,60 @@ public class AffiliationService { })) .map(o -> affModel) .call(model -> ((model.getM1_lincence() != -1) ? combRepository.find("licence", - model.getM1_lincence()).count().invoke(count -> { + model.getM1_lincence()).count().invoke(Unchecked.consumer(count -> { if (count == 0) { throw new IllegalArgumentException("Licence membre n°1 inconnue"); } - }) : Uni.createFrom().nullItem()) + })) : Uni.createFrom().nullItem()) ) .call(model -> ((model.getM2_lincence() != -1) ? combRepository.find("licence", - model.getM2_lincence()).count().invoke(count -> { + model.getM2_lincence()).count().invoke(Unchecked.consumer(count -> { if (count == 0) { throw new IllegalArgumentException("Licence membre n°2 inconnue"); } - }) : Uni.createFrom().nullItem()) + })) : Uni.createFrom().nullItem()) ) .call(model -> ((model.getM3_lincence() != -1) ? combRepository.find("licence", - model.getM3_lincence()).count().invoke(count -> { + model.getM3_lincence()).count().invoke(Unchecked.consumer(count -> { if (count == 0) { throw new IllegalArgumentException("Licence membre n°3 inconnue"); } - }) : Uni.createFrom().nullItem()) - ).chain(model -> Panache.withTransaction(() -> repositoryRequest.persist(model))) + })) : Uni.createFrom().nullItem()) + ); + } + + public Uni saveEdit(AffiliationRequestForm form) { + return pre_save(form, false) + .chain(model -> repositoryRequest.findById(form.getId()) + .onItem().ifNull().failWith(new NotFoundException("Affiliation request not found")) + .chain(origine -> { + origine.setName(model.getName()); + origine.setRNA(model.getRNA()); + origine.setAddress(model.getAddress()); + origine.setM1_lname(model.getM1_lname()); + origine.setM1_fname(model.getM1_fname()); + origine.setM1_lincence(model.getM1_lincence()); + origine.setM1_role(model.getM1_role()); + origine.setM1_email(model.getM1_email()); + origine.setM2_lname(model.getM2_lname()); + origine.setM2_fname(model.getM2_fname()); + origine.setM2_lincence(model.getM2_lincence()); + origine.setM2_role(model.getM2_role()); + origine.setM2_email(model.getM2_email()); + origine.setM3_lname(model.getM3_lname()); + origine.setM3_fname(model.getM3_fname()); + origine.setM3_lincence(model.getM3_lincence()); + origine.setM3_role(model.getM3_role()); + origine.setM3_email(model.getM3_email()); + + return Panache.withTransaction(() -> repositoryRequest.persist(origine)); + })); + } + + public Uni save(AffiliationRequestForm form) { + // noinspection ResultOfMethodCallIgnored + return pre_save(form, true) + .chain(model -> Panache.withTransaction(() -> repositoryRequest.persist(model))) .onItem() .invoke(model -> Uni.createFrom().future(Utils.replacePhoto(model.getId(), form.getLogo(), media, "aff_request/logo"))) diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java index ef72137..3553079 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java @@ -2,18 +2,19 @@ package fr.titionfire.ffsaf.domain.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import fr.titionfire.ffsaf.data.model.AffiliationModel; import fr.titionfire.ffsaf.data.model.ClubModel; +import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.data.repository.ClubRepository; import fr.titionfire.ffsaf.data.repository.CombRepository; import fr.titionfire.ffsaf.net2.ServerCustom; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; import fr.titionfire.ffsaf.net2.request.SReqClub; +import fr.titionfire.ffsaf.rest.data.RenewAffData; import fr.titionfire.ffsaf.rest.data.SimpleClubList; import fr.titionfire.ffsaf.rest.from.FullClubForm; -import fr.titionfire.ffsaf.utils.Contact; -import fr.titionfire.ffsaf.utils.PageResult; -import fr.titionfire.ffsaf.utils.RoleAsso; -import fr.titionfire.ffsaf.utils.Utils; +import fr.titionfire.ffsaf.rest.from.PartClubForm; +import fr.titionfire.ffsaf.utils.*; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.PanacheQuery; import io.quarkus.hibernate.reactive.panache.common.WithSession; @@ -25,10 +26,14 @@ import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotFoundException; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; import org.hibernate.reactive.mutiny.Mutiny; import java.util.Collection; +import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -114,6 +119,44 @@ public class ClubService { return repository.find("clubId", clubId).firstResult(); } + public Uni getOfUser(JsonWebToken idToken) { + return combRepository.find("userId = ?1", idToken.getSubject()).firstResult().invoke(Unchecked.consumer(m -> { + if (m == null || m.getClub() == null) + throw new NotFoundException("Club not found"); + })) + .map(MembreModel::getClub) + .call(club -> Mutiny.fetch(club.getContact())); + } + + public Uni updateOfUser(JsonWebToken idToken, PartClubForm form) { + TypeReference> typeRef = new TypeReference<>() { + }; + + return combRepository.find("userId = ?1", idToken.getSubject()).firstResult().invoke(Unchecked.consumer(m -> { + if (m == null || m.getClub() == null) + throw new NotFoundException("Club not found"); + if (!GroupeUtils.isInClubGroup(m.getClub().getId(), idToken)) + throw new ForbiddenException(); + })) + .map(MembreModel::getClub) + .call(club -> Mutiny.fetch(club.getContact())) + .chain(Unchecked.function(club -> { + club.setContact_intern(form.getContact_intern()); + club.setAddress(form.getAddress()); + + try { + club.setContact(MAPPER.readValue(form.getContact(), typeRef)); + } catch (JsonProcessingException e) { + throw new BadRequestException(); + } + + club.setTraining_location(form.getTraining_location()); + club.setTraining_day_time(form.getTraining_day_time()); + return Panache.withTransaction(() -> repository.persist(club)); + })) + .map(__ -> "OK"); + } + public Uni update(long id, FullClubForm input) { return repository.findById(id).call(m -> Mutiny.fetch(m.getContact())) .onItem().transformToUni(Unchecked.function(m -> { @@ -201,4 +244,29 @@ public class ClubService { .call(__ -> Utils.deleteMedia(id, media, "ppClub")) .call(__ -> Utils.deleteMedia(id, media, "clubStatus")); } + + public Uni getRenewData(long id) { + RenewAffData data = new RenewAffData(); + + return repository.findById(id) + .call(clubModel -> Mutiny.fetch(clubModel.getAffiliations())) + .invoke(clubModel -> { + data.setName(clubModel.getName()); + data.setSiret(clubModel.getSIRET()); + data.setRna(clubModel.getRNA()); + data.setAddress(clubModel.getAddress()); + data.setSaison( + clubModel.getAffiliations().stream().max(Comparator.comparing(AffiliationModel::getSaison)) + .map(AffiliationModel::getSaison).map(i -> Math.min(i + 1, Utils.getSaison() + 1)) + .orElse(Utils.getSaison())); + }) + .chain(club -> combRepository.list("club = ?1", club)) + .invoke(combs -> data.setMembers(combs.stream() + .filter(o -> o.getRole() != null && o.getRole().level >= RoleAsso.MEMBREBUREAU.level) + .sorted((o1, o2) -> o2.getRole().level - o1.getRole().level) + .limit(3) + .map(RenewAffData.RenewMember::new) + .toList())) + .map(o -> data); + } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java index af85d10..a9fc32f 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java @@ -63,10 +63,13 @@ public class AffiliationEndpoints { @GET @Path("/request/{id}") - @RolesAllowed({"federation_admin"}) + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) public Uni getAffRequest(@PathParam("id") long id) { - return service.getRequest(id); + return service.getRequest(id).invoke(Unchecked.consumer(o -> { + if (o.getClub() == null && !securityIdentity.getRoles().contains("federation_admin")) + throw new ForbiddenException(); + })).invoke(o -> checkPerm.accept(o.getClub())); } @DELETE @@ -74,7 +77,11 @@ public class AffiliationEndpoints { @RolesAllowed({"federation_admin"}) @Produces(MediaType.APPLICATION_JSON) public Uni getDelAffRequest(@PathParam("id") long id) { - return service.deleteReqAffiliation(id); + return service.getRequest(id).invoke(Unchecked.consumer(o -> { + if (o.getClub() == null && !securityIdentity.getRoles().contains("federation_admin")) + throw new ForbiddenException(); + })).invoke(o -> checkPerm.accept(o.getClub())) + .chain(o -> service.deleteReqAffiliation(id)); } @PUT @@ -86,6 +93,15 @@ public class AffiliationEndpoints { return service.saveAdmin(form); } + @PUT + @Path("/request/edit") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Uni saveEditAffRequest(AffiliationRequestForm form) { + return service.saveEdit(form); + } + @PUT @Path("/request/apply") @RolesAllowed({"federation_admin"}) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index abb255f..0472054 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -3,9 +3,11 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.domain.service.ClubService; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; +import fr.titionfire.ffsaf.rest.data.RenewAffData; import fr.titionfire.ffsaf.rest.data.SimpleClub; import fr.titionfire.ffsaf.rest.data.SimpleClubList; import fr.titionfire.ffsaf.rest.from.FullClubForm; +import fr.titionfire.ffsaf.rest.from.PartClubForm; import fr.titionfire.ffsaf.utils.Contact; import fr.titionfire.ffsaf.utils.GroupeUtils; import fr.titionfire.ffsaf.utils.PageResult; @@ -44,11 +46,15 @@ public class ClubEndpoints { @ConfigProperty(name = "upload_dir") String media; - Consumer checkPerm = Unchecked.consumer(membreModel -> { - if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getId(), + Consumer checkPerm = Unchecked.consumer(clubModel -> { + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(clubModel.getId(), idToken)) throw new ForbiddenException(); }); + Consumer checkPerm2 = Unchecked.consumer(id -> { + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(id, idToken)) + throw new ForbiddenException(); + }); @GET @Path("/no_detail") @@ -125,7 +131,6 @@ public class ClubEndpoints { @Produces(MediaType.TEXT_PLAIN) @Consumes(MediaType.MULTIPART_FORM_DATA) public Uni addAdminClub(FullClubForm input) { - System.out.println(input); return clubService.add(input) .invoke(Unchecked.consumer(id -> { if (id == null) throw new InternalError("Fail to create club data"); @@ -152,6 +157,31 @@ public class ClubEndpoints { return clubService.delete(id); } + @GET + @Path("/me") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni getOfUser() { + return clubService.getOfUser(idToken).map(SimpleClub::fromModel) + .invoke(m -> m.setContactMap(Contact.toSite())); + } + + @PUT + @Path("/me") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Uni setClubOfUser(PartClubForm form) { + return clubService.updateOfUser(idToken, form); + } + + @GET + @Path("/renew/{id}") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni getOfUser(@PathParam("id") long id) { + return Uni.createFrom().item(id).invoke(checkPerm2).chain(__ -> clubService.getRenewData(id)); + } @GET @Path("{clubId}/logo") diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java index 0882952..95654ec 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java @@ -198,32 +198,6 @@ public class CombEndpoints { return membreService.delete(id, idToken); } - private Future replacePhoto(long id, byte[] input) { - return CompletableFuture.supplyAsync(() -> { - try (InputStream is = new BufferedInputStream(new ByteArrayInputStream(input))) { - String mimeType = URLConnection.guessContentTypeFromStream(is); - String[] detectedExtensions = MimeTypes.findExtensionsByMimeTypes(mimeType, false); - if (detectedExtensions.length == 0) - throw new IOException("Fail to detect file extension for MIME type " + mimeType); - - FilenameFilter filter = (directory, filename) -> filename.startsWith(String.valueOf(id)); - File[] files = new File(media, "ppMembre").listFiles(filter); - if (files != null) { - for (File file : files) { - //noinspection ResultOfMethodCallIgnored - file.delete(); - } - } - - String extension = "." + detectedExtensions[0]; - Files.write(new File(media, "ppMembre/" + id + extension).toPath(), input); - return "OK"; - } catch (IOException e) { - return e.getMessage(); - } - }); - } - @GET @Path("{id}/photo") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/RenewAffData.java b/src/main/java/fr/titionfire/ffsaf/rest/data/RenewAffData.java new file mode 100644 index 0000000..02ce7cc --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/RenewAffData.java @@ -0,0 +1,45 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.MembreModel; +import fr.titionfire.ffsaf.utils.RoleAsso; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection +public class RenewAffData { + String name; + Long siret; + String rna; + String address; + int saison; + List members; + + + @Data + @AllArgsConstructor + @RegisterForReflection + public static class RenewMember { + String lname; + String fname; + String email; + int licence; + RoleAsso role; + + public RenewMember(MembreModel o) { + this.lname = o.getLname(); + this.fname = o.getFname(); + this.email = o.getEmail(); + this.licence = o.getLicence(); + this.role = o.getRole(); + } + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationForm.java deleted file mode 100644 index cfda16d..0000000 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationForm.java +++ /dev/null @@ -1,4 +0,0 @@ -package fr.titionfire.ffsaf.rest.from; - -public class AffiliationForm { -} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java index 7e83750..38086de 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java @@ -11,6 +11,9 @@ import org.jboss.resteasy.reactive.PartType; @Getter @ToString public class AffiliationRequestForm { + @FormParam("id") + private Long id = null; + @FormParam("name") private String name = null; @FormParam("siret") diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/PartClubForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/PartClubForm.java new file mode 100644 index 0000000..c4c68db --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/PartClubForm.java @@ -0,0 +1,27 @@ +package fr.titionfire.ffsaf.rest.from; + +import jakarta.ws.rs.FormParam; +import lombok.Getter; +import lombok.ToString; + +@ToString +@Getter +public class PartClubForm { + @FormParam("id") + private String id = null; + + @FormParam("contact") + private String contact = null; + + @FormParam("training_location") + private String training_location = null; + + @FormParam("training_day_time") + private String training_day_time = null; + + @FormParam("contact_intern") + private String contact_intern = null; + + @FormParam("address") + private String address = null; +} diff --git a/src/main/webapp/src/components/Nav.jsx b/src/main/webapp/src/components/Nav.jsx index 371ec21..2b3a4f4 100644 --- a/src/main/webapp/src/components/Nav.jsx +++ b/src/main/webapp/src/components/Nav.jsx @@ -54,8 +54,8 @@ function ClubMenu() { Club
      +
    • Mon club
    • Member
    • -
    • B
    } diff --git a/src/main/webapp/src/pages/DemandeAff.jsx b/src/main/webapp/src/pages/DemandeAff.jsx index 722c917..66a4e9e 100644 --- a/src/main/webapp/src/pages/DemandeAff.jsx +++ b/src/main/webapp/src/pages/DemandeAff.jsx @@ -1,8 +1,9 @@ -import {useState} from "react"; +import {useEffect, useState} from "react"; import {apiAxios, getSaison} from "../utils/Tools.js"; import {toast} from "react-toastify"; -import {useNavigate} from "react-router-dom"; +import {useLocation, useNavigate} from "react-router-dom"; import {RoleList} from "../components/MemberCustomFiels.jsx"; +import {useAuth} from "../hooks/useAuth.jsx"; const notUpperCase = ["de", "la", "le", "les", "des", "du", "d'", "l'", "sur"]; @@ -40,13 +41,38 @@ function reconstruireAdresse(infos) { export function DemandeAff() { + const {hash} = useLocation(); const navigate = useNavigate(); + const [initData, setInitData] = useState(null) + const [needFile, setNeedFile] = useState(true) + + useEffect(() => { + if (hash.startsWith("#d")) { + const data = JSON.parse(decodeURI(hash.substring(2))); + setInitData(data) + setNeedFile(false) + } else if (hash.startsWith("#e")) { + apiAxios.get(`/affiliation/request/${hash.substring(2)}`).then(data => { + for (let i = 0; i < data.data.members.length; i++) { + if (data.data.members[i].licence === -1) + data.data.members[i].licence = "" + } + setInitData(data.data) + setNeedFile(false) + }).catch(_ => { + setInitData({}) + }) + } else { + setInitData({}) + } + }, []); const submit = (event) => { event.preventDefault() const formData = new FormData(event.target) formData.append("m1_role", event.target.m1_role?.value) formData.append("rna", event.target.rna?.value) + formData.append("siret", event.target.siret?.value) let error = false; for (let i = 1; i <= 3; i++) { @@ -59,16 +85,44 @@ export function DemandeAff() { return; } - toast.promise( - apiAxios.post(`/affiliation/request`, formData, {headers: {'Accept': '*/*'}}), - { - pending: "Enregistrement de la demande d'affiliation en cours", - success: "Demande d'affiliation enregistrée avec succès 🎉", - error: "Échec de la demande d'affiliation 😕" - } - ).then(_ => { - navigate("/affiliation/ok") - }) + if (event.nativeEvent.submitter.value === "undo") { + toast.promise( + apiAxios.delete(`/affiliation/request/${initData.id}`), + { + pending: "Annulation de la demande d'affiliation en cours", + success: "Demande d'affiliation annulée avec succès 🎉", + error: "Échec de l'annulation de la demande d'affiliation 😕" + } + ).then(_ => { + navigate("/club/me") + }) + }else if (event.nativeEvent.submitter.value === "edit") { + formData.append("id", initData.id) + + toast.promise( + apiAxios.put(`/affiliation/request/edit`, formData), + { + pending: "Enregistrement des modifications en cours", + success: "Modifications enregistrées avec succès 🎉", + error: "Échec de l'enregistrement des modifications 😕" + } + ).then(_ => { + navigate("/club/me") + }) + }else { + formData.append("id", -1) + + toast.promise( + apiAxios.post(`/affiliation/request`, formData, {headers: {'Accept': '*/*'}}), + { + pending: "Enregistrement de la demande d'affiliation en cours", + success: "Demande d'affiliation enregistrée avec succès 🎉", + error: "Échec de la demande d'affiliation 😕" + } + ).then(_ => { + navigate("/affiliation/ok") + }) + } } return
    @@ -91,46 +145,59 @@ export function DemandeAff() { -
    + {initData &&

    L'association

    - +

    Membre n°1

    - +

    Membre n°2

    - +

    Membre n°3

    - +
    -

    Après validation de votre demande, vous recevrez un identifiant et mot de passe provisoire pour - accéder à votre espace FFSAF

    + {!initData.id &&

    Après validation de votre demande, vous recevrez un identifiant et mot de passe provisoire pour + accéder à votre espace FFSAF

    } Notez que pour finaliser votre affiliation, il vous faudra :
    • Disposer d’au moins trois membres licenciés, dont le président
    • S'être acquitté des cotisations prévues par les règlements fédéraux
    -
    -
    - + + {!initData.id ? +
    +
    + +
    +
    : +
    +
    + +
    +
    + +
    -
    + }
    -
    +
    }
    } -function AssoInfo() { +function AssoInfo({initData, needFile}) { const [denomination, setDenomination] = useState("") - const [siret, setSiret] = useState("") - const [rna, setRna] = useState("") + const [siret, setSiret] = useState(initData.siret ? String(initData.siret) : "") + const [rna, setRna] = useState(initData.rna ? initData.rna : "") const [rnaEnable, setRnaEnable] = useState(false) - const [adresse, setAdresse] = useState("") - const [saison, setSaison] = useState(getSaison()) + const [adresse, setAdresse] = useState(initData.address ? initData.address : "") + const [saison, setSaison] = useState(initData.saison ? initData.saison : getSaison()) const fetchSiret = () => { if (siret.length < 14) { @@ -150,7 +217,8 @@ function AssoInfo() { setDenomination(data2.denomination) setRnaEnable(data2.identifiant_association === null) setRna(data2.identifiant_association ? data2.identifiant_association : "") - setAdresse(reconstruireAdresse(data2.etablissement_siege)) + if (!initData.saison || adresse === "") + setAdresse(reconstruireAdresse(data2.etablissement_siege)) }) } @@ -178,13 +246,13 @@ function AssoInfo() { Nom de l'association* + aria-describedby="basic-addon1" required defaultValue={initData.name ? initData.name : ""}/>
    N° SIRET* - setSiret(e.target.value)} defaultValue={500213731}/> + setSiret(e.target.value)}/> @@ -218,25 +286,25 @@ function AssoInfo() {
    - +
    + required={needFile}/>
    ; } -function MembreInfo({role}) { - const [switchOn, setSwitchOn] = useState(false); +function MembreInfo({role, initData}) { + const [switchOn, setSwitchOn] = useState(!!initData.licence); return <>
    - @@ -251,22 +319,22 @@ function MembreInfo({role}) {
    - +
    + name={role + "_prenom"} defaultValue={initData.fname ? initData.fname : ""} required/>
    + name={role + "_mail"} defaultValue={initData.email ? initData.email : ""} required/>
    @@ -283,7 +351,7 @@ function MembreInfo({role}) {
    + name={role + "_licence"} defaultValue={initData.licence ? Number(initData.licence) : ""} required/>
    diff --git a/src/main/webapp/src/pages/admin/club/ClubPage.jsx b/src/main/webapp/src/pages/admin/club/ClubPage.jsx index 2f751d3..0257dec 100644 --- a/src/main/webapp/src/pages/admin/club/ClubPage.jsx +++ b/src/main/webapp/src/pages/admin/club/ClubPage.jsx @@ -129,7 +129,7 @@ function InformationForm({data}) {
    - + +
    +
    +
    +
    +
      + {data && data.sort((a, b) => b.saison - a.saison).map((affiliation, index) => { + return
      +
      {affiliation?.saison}-{affiliation?.saison + 1}
      + +
      + })} + {error && } +
    +
    + + +
    ; +} + +function ModalContent({affiliation}) { + const navigate = useNavigate(); + + return <> + {affiliation &&
    +
    +

    Etat de l'affiliation

    + +
    +
    +
    + {affiliation.saison || 0} + - + {(affiliation.saison || 0) + 1} +
    +
    + État de la demande + {affiliation.validate ? Validée : + <> + En attente + + } +
    +
    +
    + +
    +
    + } + +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/club/club/ClubPage.jsx b/src/main/webapp/src/pages/club/club/ClubPage.jsx deleted file mode 100644 index d7196b4..0000000 --- a/src/main/webapp/src/pages/club/club/ClubPage.jsx +++ /dev/null @@ -1,158 +0,0 @@ -import {useNavigate, useParams} from "react-router-dom"; -import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; -import {useFetch} from "../../../hooks/useFetch.js"; -import {toast} from "react-toastify"; -import {apiAxios} from "../../../utils/Tools.js"; -import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx"; -import {AxiosError} from "../../../components/AxiosError.jsx"; -import {AffiliationCard} from "./AffiliationCard.jsx"; -import {CountryList, TextField} from "../../../components/MemberCustomFiels.jsx"; - -import {useRef, useState} from "react"; -import {LocationEditor, LocationEditorModal} from "../../../components/Club/LocationEditor.jsx"; -import {ContactEditor} from "../../../components/Club/ContactEditor.jsx"; -import {HoraireEditor} from "../../../components/Club/HoraireEditor.jsx"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faFilePdf} from "@fortawesome/free-solid-svg-icons"; - -const vite_url = import.meta.env.VITE_URL; - -export function ClubPage() { - const {id} = useParams() - const navigate = useNavigate(); - - const setLoading = useLoadingSwitcher() - const {data, error} = useFetch(`/club/${id}`, setLoading, 1) - - const handleRm = () => { - toast.promise( - apiAxios.delete(`/club/${id}`), - { - pending: "Suppression du club en cours...", - success: "Club supprimé avec succès 🎉", - error: "Échec de la suppression du club 😕" - } - ).then(_ => { - navigate("/admin/club") - }) - } - - return <> -

    Page club

    - - {data - ?
    -
    -
    - - - -
    -
    - -
    - -
    - -
    -
    -
    - : error && - } - -} - -function InformationForm({data}) { - const [switchOn, setSwitchOn] = useState(data.international); - const [modal, setModal] = useState({id: -1}) - const locationModalCallback = useRef(null) - - const handleSubmit = (event) => { - event.preventDefault(); - - const formData = new FormData(event.target); - - toast.promise( - apiAxios.put(`/club/${data.id}`, formData), - { - pending: "Enregistrement du club en cours", - success: "Club enregistrée avec succès 🎉", - error: "Échec de l'enregistrement du club 😕" - } - ) - } - - return <> -
    - -
    - - - -} diff --git a/src/main/webapp/src/pages/club/club/MyClubPage.jsx b/src/main/webapp/src/pages/club/club/MyClubPage.jsx new file mode 100644 index 0000000..2842adb --- /dev/null +++ b/src/main/webapp/src/pages/club/club/MyClubPage.jsx @@ -0,0 +1,117 @@ +import {useNavigate, useParams} from "react-router-dom"; +import {LoadingProvider, useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; +import {useFetch} from "../../../hooks/useFetch.js"; +import {toast} from "react-toastify"; +import {apiAxios} from "../../../utils/Tools.js"; +import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx"; +import {AxiosError} from "../../../components/AxiosError.jsx"; +import {AffiliationCard} from "./AffiliationCard.jsx"; +import {CountryList, TextField} from "../../../components/MemberCustomFiels.jsx"; + +import {useRef, useState} from "react"; +import {LocationEditor, LocationEditorModal} from "../../../components/Club/LocationEditor.jsx"; +import {ContactEditor} from "../../../components/Club/ContactEditor.jsx"; +import {HoraireEditor} from "../../../components/Club/HoraireEditor.jsx"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faFilePdf} from "@fortawesome/free-solid-svg-icons"; + +const vite_url = import.meta.env.VITE_URL; + +export function MyClubPage() { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/club/me`, setLoading, 1) + + return <> +

    Mon club

    + {data + ?
    +
    +
    + + + +
    +
    + +
    +
    +
    + : error && + } + +} + +function InformationForm({data}) { + const [modal, setModal] = useState({id: -1}) + const locationModalCallback = useRef(null) + + const handleSubmit = (event) => { + event.preventDefault(); + + const formData = new FormData(event.target); + + toast.promise( + apiAxios.put(`/club/me`, formData), + { + pending: "Enregistrement des modifications en cours", + success: "Modifications enregistrées avec succès 🎉", + error: "Échec de l'enregistrement des modifications 😕" + } + ) + } + + return <> +
    +
    + +
    Affiliation n°{data.no_affiliation}
    +
    + + + + {!data.international && <> + + + } + +
    +
    + avatar +
    + +
    Pour modifier les informations ci-dessus, merci de contacter la FFSAF par mail.
    +
    + + {!data.international && <> + + + + + + + + } +
    +
    +
    + +
    +
    +
    +
    + + + +} From 53d59a5b569c098aebd2473d9c28fc7b5505b32b Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Thu, 18 Jul 2024 11:46:54 +0200 Subject: [PATCH 20/37] feat: club set role --- .../ffsaf/domain/service/ClubService.java | 17 +++- .../titionfire/ffsaf/rest/ClubEndpoints.java | 15 ++- .../ffsaf/rest/data/DeskMember.java | 29 ++++++ src/main/webapp/src/pages/DemandeAff.jsx | 28 +++--- .../src/pages/admin/club/AffiliationCard.jsx | 2 +- .../webapp/src/pages/admin/club/ClubPage.jsx | 24 +++++ .../src/pages/club/club/AffiliationCard.jsx | 96 ++++++++++++++++--- .../webapp/src/pages/club/club/MyClubPage.jsx | 7 +- 8 files changed, 188 insertions(+), 30 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/DeskMember.java diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java index 3553079..c624945 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java @@ -10,6 +10,7 @@ import fr.titionfire.ffsaf.data.repository.CombRepository; import fr.titionfire.ffsaf.net2.ServerCustom; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; import fr.titionfire.ffsaf.net2.request.SReqClub; +import fr.titionfire.ffsaf.rest.data.DeskMember; import fr.titionfire.ffsaf.rest.data.RenewAffData; import fr.titionfire.ffsaf.rest.data.SimpleClubList; import fr.titionfire.ffsaf.rest.from.FullClubForm; @@ -36,6 +37,7 @@ import java.util.Collection; import java.util.Comparator; import java.util.HashMap; import java.util.List; +import java.util.function.Consumer; import static fr.titionfire.ffsaf.net2.Client_Thread.MAPPER; @@ -128,6 +130,16 @@ public class ClubService { .call(club -> Mutiny.fetch(club.getContact())); } + public Uni> getClubDesk(Consumer consumer, long id) { + return repository.findById(id).invoke(consumer) + .chain(club -> combRepository.list("club = ?1", club)) + .map(combs -> combs.stream() + .filter(o -> o.getRole() != null && o.getRole().level >= RoleAsso.MEMBREBUREAU.level) + .sorted((o1, o2) -> o2.getRole().level - o1.getRole().level) + .map(DeskMember::fromModel) + .toList()); + } + public Uni updateOfUser(JsonWebToken idToken, PartClubForm form) { TypeReference> typeRef = new TypeReference<>() { }; @@ -245,7 +257,7 @@ public class ClubService { .call(__ -> Utils.deleteMedia(id, media, "clubStatus")); } - public Uni getRenewData(long id) { + public Uni getRenewData(long id, List mIds) { RenewAffData data = new RenewAffData(); return repository.findById(id) @@ -260,11 +272,10 @@ public class ClubService { .map(AffiliationModel::getSaison).map(i -> Math.min(i + 1, Utils.getSaison() + 1)) .orElse(Utils.getSaison())); }) - .chain(club -> combRepository.list("club = ?1", club)) + .chain(club -> combRepository.list("id IN ?1", mIds)) .invoke(combs -> data.setMembers(combs.stream() .filter(o -> o.getRole() != null && o.getRole().level >= RoleAsso.MEMBREBUREAU.level) .sorted((o1, o2) -> o2.getRole().level - o1.getRole().level) - .limit(3) .map(RenewAffData.RenewMember::new) .toList())) .map(o -> data); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index 0472054..d7e6555 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -3,6 +3,7 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.domain.service.ClubService; import fr.titionfire.ffsaf.net2.data.SimpleClubModel; +import fr.titionfire.ffsaf.rest.data.DeskMember; import fr.titionfire.ffsaf.rest.data.RenewAffData; import fr.titionfire.ffsaf.rest.data.SimpleClub; import fr.titionfire.ffsaf.rest.data.SimpleClubList; @@ -179,8 +180,18 @@ public class ClubEndpoints { @Path("/renew/{id}") @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) - public Uni getOfUser(@PathParam("id") long id) { - return Uni.createFrom().item(id).invoke(checkPerm2).chain(__ -> clubService.getRenewData(id)); + public Uni getOfUser(@PathParam("id") long id, @QueryParam("m1") long m1_id, + @QueryParam("m2") long m2_id, @QueryParam("m3") long m3_id) { + return Uni.createFrom().item(id).invoke(checkPerm2) + .chain(__ -> clubService.getRenewData(id, List.of(m1_id, m2_id, m3_id))); + } + + @GET + @Path("/desk/{id}") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + public Uni> getClubDesk(@PathParam("id") long id) { + return clubService.getClubDesk(checkPerm, id); } @GET diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/DeskMember.java b/src/main/java/fr/titionfire/ffsaf/rest/data/DeskMember.java new file mode 100644 index 0000000..c653916 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/DeskMember.java @@ -0,0 +1,29 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.MembreModel; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@RegisterForReflection +public class DeskMember { + private Long id; + private String lname; + private String fname; + private String role; + + public static DeskMember fromModel(MembreModel membreModel) { + if (membreModel == null) + return null; + + DeskMember deskMember = new DeskMember(); + deskMember.setId(membreModel.getId()); + deskMember.setLname(membreModel.getLname()); + deskMember.setFname(membreModel.getFname()); + deskMember.setRole(membreModel.getRole().toString()); + + return deskMember; + } +} diff --git a/src/main/webapp/src/pages/DemandeAff.jsx b/src/main/webapp/src/pages/DemandeAff.jsx index 66a4e9e..a8ec7e8 100644 --- a/src/main/webapp/src/pages/DemandeAff.jsx +++ b/src/main/webapp/src/pages/DemandeAff.jsx @@ -2,8 +2,6 @@ import {useEffect, useState} from "react"; import {apiAxios, getSaison} from "../utils/Tools.js"; import {toast} from "react-toastify"; import {useLocation, useNavigate} from "react-router-dom"; -import {RoleList} from "../components/MemberCustomFiels.jsx"; -import {useAuth} from "../hooks/useAuth.jsx"; const notUpperCase = ["de", "la", "le", "les", "des", "du", "d'", "l'", "sur"]; @@ -96,7 +94,7 @@ export function DemandeAff() { ).then(_ => { navigate("/club/me") }) - }else if (event.nativeEvent.submitter.value === "edit") { + } else if (event.nativeEvent.submitter.value === "edit") { formData.append("id", initData.id) toast.promise( @@ -109,7 +107,7 @@ export function DemandeAff() { ).then(_ => { navigate("/club/me") }) - }else { + } else { formData.append("id", -1) toast.promise( @@ -284,15 +282,23 @@ function AssoInfo({initData, needFile}) {
    -
    - - +
    +
    + + +
    + {!needFile &&
    Laissez vide pour ne rien changer. (Si un blason a déjà été envoyé lors de cette + demande, il sera utilisé, sinon nous utiliserons celui de la précédant affiliation)
    }
    -
    - - +
    +
    + + +
    + {!needFile &&
    Laissez vide pour ne rien changer. (Si un statu a déjà été envoyé lors de cette + demande, il sera utilisé, sinon nous utiliserons celui de la précédant affiliation)
    }
    ; } diff --git a/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx b/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx index ef4cfd8..d6d2186 100644 --- a/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx +++ b/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx @@ -24,7 +24,7 @@ export function AffiliationCard({clubData}) { dispatch({type: 'SORT', payload: (a, b) => b.saison - a.saison}) }, [data]); - return
    + return
    Affiliation
    diff --git a/src/main/webapp/src/pages/admin/club/ClubPage.jsx b/src/main/webapp/src/pages/admin/club/ClubPage.jsx index 0257dec..f92021e 100644 --- a/src/main/webapp/src/pages/admin/club/ClubPage.jsx +++ b/src/main/webapp/src/pages/admin/club/ClubPage.jsx @@ -52,6 +52,7 @@ export function ClubPage() {
    +
    @@ -157,3 +159,25 @@ function InformationForm({data}) { } + + +export function BureauCard({clubData}) { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/club/desk/${clubData.id}`, setLoading, 1) + + return <> +
    +
    Bureau
    +
    +
      + {data && data.map((d, index) => { + return
      +
      {d.role}
      {d.lname} {d.fname}
      +
      + })} +
    +
    +
    + {error && } + +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/club/club/AffiliationCard.jsx b/src/main/webapp/src/pages/club/club/AffiliationCard.jsx index a91526a..aa483fb 100644 --- a/src/main/webapp/src/pages/club/club/AffiliationCard.jsx +++ b/src/main/webapp/src/pages/club/club/AffiliationCard.jsx @@ -10,25 +10,16 @@ import {SimpleReducer} from "../../../utils/SimpleReducer.jsx"; import {useNavigate} from "react-router-dom"; export function AffiliationCard({clubData}) { - const navigate = useNavigate(); const setLoading = useLoadingSwitcher() const {data, error} = useFetch(`/affiliation/${clubData.id}`, setLoading, 1) const [modalAffiliation, setModal] = useState({id: 0, club: clubData.id}) - const sendAffiliationRequest = () => { - let createData = {} - apiAxios.get(`/club/renew/${clubData.id}`).then(data => { - navigate('/affiliation#d' + encodeURI(JSON.stringify(data.data))) - }) - } - - return
    + return
    Affiliation
    - +
    @@ -92,4 +83,87 @@ function ModalContent({affiliation}) {
    } +} + +export function BureauCard({clubData}) { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/club/desk/${clubData.id}`, setLoading, 1) + + return <> +
    +
    Bureau
    +
    +
      + {data && data.map((d, index) => { + return
      +
      {d.role}
      {d.lname} {d.fname}
      +
      + })} +
    +
    +
    + + + + {error && } + +} + +function ModalContent2({clubData, data}) { + const navigate = useNavigate(); + + const sendAffiliationRequest = (event) => { + event.preventDefault() + + let list = [] + for (let i = 0; i < event.target.length; i++) { + if (event.target[i].type === "checkbox") { + if (event.target[i].checked) { + list.push(event.target[i].name) + } + } + } + + if (list.length !== 3) { + toast.error("Il faut sélectionner 3 membres pour renouveler l'affiliation") + return + } + apiAxios.get(`/club/renew/${clubData.id}?m1=${list[0]}&m2=${list[1]}&m3=${list[2]}`).then(data => { + navigate('/affiliation#d' + encodeURI(JSON.stringify(data.data))) + }) + } + + return
    +
    +

    Renouvellement de l'affiliation

    + +
    +
    +

    Veuillez sélectionner 3 membres du bureau pour remplir la pré-demande. (Si un membre non-bureau va le devenir l'an prochain, + vous pourrez toujours remplacer un des membres sélectionné à la prochaine étape)

    + {data && data.map((d, index) => { + return
    +
    + +
    + {d.role} + {d.lname} {d.fname} +
    + } + )} +
    +
    + + +
    +
    } \ No newline at end of file diff --git a/src/main/webapp/src/pages/club/club/MyClubPage.jsx b/src/main/webapp/src/pages/club/club/MyClubPage.jsx index 2842adb..462f4ee 100644 --- a/src/main/webapp/src/pages/club/club/MyClubPage.jsx +++ b/src/main/webapp/src/pages/club/club/MyClubPage.jsx @@ -5,7 +5,7 @@ import {toast} from "react-toastify"; import {apiAxios} from "../../../utils/Tools.js"; import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx"; import {AxiosError} from "../../../components/AxiosError.jsx"; -import {AffiliationCard} from "./AffiliationCard.jsx"; +import {AffiliationCard, BureauCard} from "./AffiliationCard.jsx"; import {CountryList, TextField} from "../../../components/MemberCustomFiels.jsx"; import {useRef, useState} from "react"; @@ -32,7 +32,10 @@ export function MyClubPage() {
    - + + + +
    From 8ba3f452156f0c7545b297c5285147320d086353 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Thu, 18 Jul 2024 18:05:38 +0200 Subject: [PATCH 21/37] feat: add flags --- src/main/webapp/public/flags/flags_ad.png | Bin 0 -> 235 bytes src/main/webapp/public/flags/flags_ae.png | Bin 0 -> 122 bytes src/main/webapp/public/flags/flags_af.png | Bin 0 -> 296 bytes src/main/webapp/public/flags/flags_ag.png | Bin 0 -> 281 bytes src/main/webapp/public/flags/flags_ai.png | Bin 0 -> 245 bytes src/main/webapp/public/flags/flags_al.png | Bin 0 -> 183 bytes src/main/webapp/public/flags/flags_am.png | Bin 0 -> 110 bytes src/main/webapp/public/flags/flags_ao.png | Bin 0 -> 201 bytes src/main/webapp/public/flags/flags_aq.png | Bin 0 -> 186 bytes src/main/webapp/public/flags/flags_ar.png | Bin 0 -> 141 bytes src/main/webapp/public/flags/flags_as.png | Bin 0 -> 237 bytes src/main/webapp/public/flags/flags_at.png | Bin 0 -> 102 bytes src/main/webapp/public/flags/flags_au.png | Bin 0 -> 211 bytes src/main/webapp/public/flags/flags_aw.png | Bin 0 -> 139 bytes src/main/webapp/public/flags/flags_ax.png | Bin 0 -> 163 bytes src/main/webapp/public/flags/flags_az.png | Bin 0 -> 150 bytes src/main/webapp/public/flags/flags_ba.png | Bin 0 -> 175 bytes src/main/webapp/public/flags/flags_bb.png | Bin 0 -> 149 bytes src/main/webapp/public/flags/flags_bd.png | Bin 0 -> 129 bytes src/main/webapp/public/flags/flags_be.png | Bin 0 -> 105 bytes src/main/webapp/public/flags/flags_bf.png | Bin 0 -> 140 bytes src/main/webapp/public/flags/flags_bg.png | Bin 0 -> 97 bytes src/main/webapp/public/flags/flags_bh.png | Bin 0 -> 153 bytes src/main/webapp/public/flags/flags_bi.png | Bin 0 -> 264 bytes src/main/webapp/public/flags/flags_bj.png | Bin 0 -> 107 bytes src/main/webapp/public/flags/flags_bl.png | Bin 0 -> 353 bytes src/main/webapp/public/flags/flags_bm.png | Bin 0 -> 286 bytes src/main/webapp/public/flags/flags_bn.png | Bin 0 -> 350 bytes src/main/webapp/public/flags/flags_bo.png | Bin 0 -> 145 bytes src/main/webapp/public/flags/flags_bq.png | Bin 0 -> 321 bytes src/main/webapp/public/flags/flags_br.png | Bin 0 -> 260 bytes src/main/webapp/public/flags/flags_bs.png | Bin 0 -> 147 bytes src/main/webapp/public/flags/flags_bt.png | Bin 0 -> 316 bytes src/main/webapp/public/flags/flags_bv.png | Bin 0 -> 150 bytes src/main/webapp/public/flags/flags_bw.png | Bin 0 -> 114 bytes src/main/webapp/public/flags/flags_by.png | Bin 0 -> 145 bytes src/main/webapp/public/flags/flags_bz.png | Bin 0 -> 253 bytes src/main/webapp/public/flags/flags_ca.png | Bin 0 -> 171 bytes src/main/webapp/public/flags/flags_cc.png | Bin 0 -> 211 bytes src/main/webapp/public/flags/flags_cd.png | Bin 0 -> 233 bytes src/main/webapp/public/flags/flags_cf.png | Bin 0 -> 206 bytes src/main/webapp/public/flags/flags_cg.png | Bin 0 -> 164 bytes src/main/webapp/public/flags/flags_ch.png | Bin 0 -> 129 bytes src/main/webapp/public/flags/flags_ci.png | Bin 0 -> 114 bytes src/main/webapp/public/flags/flags_ck.png | Bin 0 -> 230 bytes src/main/webapp/public/flags/flags_cl.png | Bin 0 -> 144 bytes src/main/webapp/public/flags/flags_cm.png | Bin 0 -> 135 bytes src/main/webapp/public/flags/flags_cn.png | Bin 0 -> 144 bytes src/main/webapp/public/flags/flags_co.png | Bin 0 -> 112 bytes src/main/webapp/public/flags/flags_cr.png | Bin 0 -> 142 bytes src/main/webapp/public/flags/flags_cu.png | Bin 0 -> 163 bytes src/main/webapp/public/flags/flags_cv.png | Bin 0 -> 146 bytes src/main/webapp/public/flags/flags_cw.png | Bin 0 -> 154 bytes src/main/webapp/public/flags/flags_cx.png | Bin 0 -> 240 bytes src/main/webapp/public/flags/flags_cy.png | Bin 0 -> 196 bytes src/main/webapp/public/flags/flags_cz.png | Bin 0 -> 196 bytes src/main/webapp/public/flags/flags_de.png | Bin 0 -> 97 bytes src/main/webapp/public/flags/flags_dj.png | Bin 0 -> 234 bytes src/main/webapp/public/flags/flags_dk.png | Bin 0 -> 127 bytes src/main/webapp/public/flags/flags_dm.png | Bin 0 -> 200 bytes src/main/webapp/public/flags/flags_do.png | Bin 0 -> 164 bytes src/main/webapp/public/flags/flags_dz.png | Bin 0 -> 160 bytes src/main/webapp/public/flags/flags_ec.png | Bin 0 -> 237 bytes src/main/webapp/public/flags/flags_ee.png | Bin 0 -> 117 bytes src/main/webapp/public/flags/flags_eg.png | Bin 0 -> 160 bytes src/main/webapp/public/flags/flags_eh.png | Bin 0 -> 230 bytes src/main/webapp/public/flags/flags_er.png | Bin 0 -> 204 bytes src/main/webapp/public/flags/flags_es.png | Bin 0 -> 190 bytes src/main/webapp/public/flags/flags_et.png | Bin 0 -> 215 bytes src/main/webapp/public/flags/flags_fi.png | Bin 0 -> 121 bytes src/main/webapp/public/flags/flags_fj.png | Bin 0 -> 257 bytes src/main/webapp/public/flags/flags_fk.png | Bin 0 -> 285 bytes src/main/webapp/public/flags/flags_fm.png | Bin 0 -> 156 bytes src/main/webapp/public/flags/flags_fo.png | Bin 0 -> 147 bytes src/main/webapp/public/flags/flags_fr.png | Bin 0 -> 105 bytes src/main/webapp/public/flags/flags_ga.png | Bin 0 -> 98 bytes src/main/webapp/public/flags/flags_gb.png | Bin 0 -> 196 bytes src/main/webapp/public/flags/flags_gd.png | Bin 0 -> 228 bytes src/main/webapp/public/flags/flags_ge.png | Bin 0 -> 179 bytes src/main/webapp/public/flags/flags_gf.png | Bin 0 -> 232 bytes src/main/webapp/public/flags/flags_gg.png | Bin 0 -> 154 bytes src/main/webapp/public/flags/flags_gh.png | Bin 0 -> 155 bytes src/main/webapp/public/flags/flags_gi.png | Bin 0 -> 223 bytes src/main/webapp/public/flags/flags_gl.png | Bin 0 -> 190 bytes src/main/webapp/public/flags/flags_gm.png | Bin 0 -> 119 bytes src/main/webapp/public/flags/flags_gn.png | Bin 0 -> 113 bytes src/main/webapp/public/flags/flags_gp.png | Bin 0 -> 299 bytes src/main/webapp/public/flags/flags_gq.png | Bin 0 -> 244 bytes src/main/webapp/public/flags/flags_gr.png | Bin 0 -> 158 bytes src/main/webapp/public/flags/flags_gs.png | Bin 0 -> 327 bytes src/main/webapp/public/flags/flags_gt.png | Bin 0 -> 156 bytes src/main/webapp/public/flags/flags_gu.png | Bin 0 -> 165 bytes src/main/webapp/public/flags/flags_gw.png | Bin 0 -> 141 bytes src/main/webapp/public/flags/flags_gy.png | Bin 0 -> 259 bytes src/main/webapp/public/flags/flags_hk.png | Bin 0 -> 187 bytes src/main/webapp/public/flags/flags_hm.png | Bin 0 -> 222 bytes src/main/webapp/public/flags/flags_hn.png | Bin 0 -> 138 bytes src/main/webapp/public/flags/flags_hr.png | Bin 0 -> 193 bytes src/main/webapp/public/flags/flags_ht.png | Bin 0 -> 139 bytes src/main/webapp/public/flags/flags_hu.png | Bin 0 -> 110 bytes src/main/webapp/public/flags/flags_id.png | Bin 0 -> 98 bytes src/main/webapp/public/flags/flags_ie.png | Bin 0 -> 105 bytes src/main/webapp/public/flags/flags_il.png | Bin 0 -> 174 bytes src/main/webapp/public/flags/flags_im.png | Bin 0 -> 178 bytes src/main/webapp/public/flags/flags_in.png | Bin 0 -> 148 bytes src/main/webapp/public/flags/flags_io.png | Bin 0 -> 391 bytes src/main/webapp/public/flags/flags_iq.png | Bin 0 -> 152 bytes src/main/webapp/public/flags/flags_ir.png | Bin 0 -> 177 bytes src/main/webapp/public/flags/flags_is.png | Bin 0 -> 138 bytes src/main/webapp/public/flags/flags_it.png | Bin 0 -> 105 bytes src/main/webapp/public/flags/flags_je.png | Bin 0 -> 223 bytes src/main/webapp/public/flags/flags_jm.png | Bin 0 -> 159 bytes src/main/webapp/public/flags/flags_jo.png | Bin 0 -> 173 bytes src/main/webapp/public/flags/flags_jp.png | Bin 0 -> 138 bytes src/main/webapp/public/flags/flags_ke.png | Bin 0 -> 205 bytes src/main/webapp/public/flags/flags_kg.png | Bin 0 -> 154 bytes src/main/webapp/public/flags/flags_kh.png | Bin 0 -> 204 bytes src/main/webapp/public/flags/flags_ki.png | Bin 0 -> 311 bytes src/main/webapp/public/flags/flags_km.png | Bin 0 -> 234 bytes src/main/webapp/public/flags/flags_kn.png | Bin 0 -> 341 bytes src/main/webapp/public/flags/flags_kp.png | Bin 0 -> 158 bytes src/main/webapp/public/flags/flags_kr.png | Bin 0 -> 315 bytes src/main/webapp/public/flags/flags_kw.png | Bin 0 -> 160 bytes src/main/webapp/public/flags/flags_ky.png | Bin 0 -> 272 bytes src/main/webapp/public/flags/flags_kz.png | Bin 0 -> 166 bytes src/main/webapp/public/flags/flags_la.png | Bin 0 -> 137 bytes src/main/webapp/public/flags/flags_lb.png | Bin 0 -> 199 bytes src/main/webapp/public/flags/flags_lc.png | Bin 0 -> 188 bytes src/main/webapp/public/flags/flags_li.png | Bin 0 -> 146 bytes src/main/webapp/public/flags/flags_lk.png | Bin 0 -> 277 bytes src/main/webapp/public/flags/flags_lr.png | Bin 0 -> 152 bytes src/main/webapp/public/flags/flags_ls.png | Bin 0 -> 149 bytes src/main/webapp/public/flags/flags_lt.png | Bin 0 -> 98 bytes src/main/webapp/public/flags/flags_lu.png | Bin 0 -> 98 bytes src/main/webapp/public/flags/flags_lv.png | Bin 0 -> 92 bytes src/main/webapp/public/flags/flags_ly.png | Bin 0 -> 134 bytes src/main/webapp/public/flags/flags_ma.png | Bin 0 -> 115 bytes src/main/webapp/public/flags/flags_mc.png | Bin 0 -> 92 bytes src/main/webapp/public/flags/flags_md.png | Bin 0 -> 188 bytes src/main/webapp/public/flags/flags_me.png | Bin 0 -> 188 bytes src/main/webapp/public/flags/flags_mf.png | Bin 0 -> 105 bytes src/main/webapp/public/flags/flags_mg.png | Bin 0 -> 118 bytes src/main/webapp/public/flags/flags_mh.png | Bin 0 -> 321 bytes src/main/webapp/public/flags/flags_mk.png | Bin 0 -> 184 bytes src/main/webapp/public/flags/flags_ml.png | Bin 0 -> 114 bytes src/main/webapp/public/flags/flags_mm.png | Bin 0 -> 219 bytes src/main/webapp/public/flags/flags_mn.png | Bin 0 -> 165 bytes src/main/webapp/public/flags/flags_mo.png | Bin 0 -> 163 bytes src/main/webapp/public/flags/flags_mp.png | Bin 0 -> 341 bytes src/main/webapp/public/flags/flags_mq.png | Bin 0 -> 219 bytes src/main/webapp/public/flags/flags_mr.png | Bin 0 -> 175 bytes src/main/webapp/public/flags/flags_ms.png | Bin 0 -> 246 bytes src/main/webapp/public/flags/flags_mt.png | Bin 0 -> 140 bytes src/main/webapp/public/flags/flags_mu.png | Bin 0 -> 126 bytes src/main/webapp/public/flags/flags_mv.png | Bin 0 -> 153 bytes src/main/webapp/public/flags/flags_mw.png | Bin 0 -> 155 bytes src/main/webapp/public/flags/flags_mx.png | Bin 0 -> 189 bytes src/main/webapp/public/flags/flags_my.png | Bin 0 -> 189 bytes src/main/webapp/public/flags/flags_mz.png | Bin 0 -> 257 bytes src/main/webapp/public/flags/flags_na.png | Bin 0 -> 310 bytes src/main/webapp/public/flags/flags_nc.png | Bin 0 -> 249 bytes src/main/webapp/public/flags/flags_ne.png | Bin 0 -> 139 bytes src/main/webapp/public/flags/flags_nf.png | Bin 0 -> 175 bytes src/main/webapp/public/flags/flags_ng.png | Bin 0 -> 109 bytes src/main/webapp/public/flags/flags_ni.png | Bin 0 -> 123 bytes src/main/webapp/public/flags/flags_nl.png | Bin 0 -> 117 bytes src/main/webapp/public/flags/flags_no.png | Bin 0 -> 150 bytes src/main/webapp/public/flags/flags_np.png | Bin 0 -> 371 bytes src/main/webapp/public/flags/flags_nr.png | Bin 0 -> 129 bytes src/main/webapp/public/flags/flags_nu.png | Bin 0 -> 194 bytes src/main/webapp/public/flags/flags_nz.png | Bin 0 -> 207 bytes src/main/webapp/public/flags/flags_om.png | Bin 0 -> 146 bytes src/main/webapp/public/flags/flags_pa.png | Bin 0 -> 158 bytes src/main/webapp/public/flags/flags_pe.png | Bin 0 -> 95 bytes src/main/webapp/public/flags/flags_pf.png | Bin 0 -> 218 bytes src/main/webapp/public/flags/flags_pg.png | Bin 0 -> 265 bytes src/main/webapp/public/flags/flags_ph.png | Bin 0 -> 227 bytes src/main/webapp/public/flags/flags_pk.png | Bin 0 -> 183 bytes src/main/webapp/public/flags/flags_pl.png | Bin 0 -> 98 bytes src/main/webapp/public/flags/flags_pm.png | Bin 0 -> 727 bytes src/main/webapp/public/flags/flags_pn.png | Bin 0 -> 309 bytes src/main/webapp/public/flags/flags_pr.png | Bin 0 -> 219 bytes src/main/webapp/public/flags/flags_ps.png | Bin 0 -> 189 bytes src/main/webapp/public/flags/flags_pt.png | Bin 0 -> 226 bytes src/main/webapp/public/flags/flags_pw.png | Bin 0 -> 165 bytes src/main/webapp/public/flags/flags_py.png | Bin 0 -> 149 bytes src/main/webapp/public/flags/flags_qa.png | Bin 0 -> 120 bytes src/main/webapp/public/flags/flags_re.png | Bin 0 -> 244 bytes src/main/webapp/public/flags/flags_ro.png | Bin 0 -> 105 bytes src/main/webapp/public/flags/flags_rs.png | Bin 0 -> 291 bytes src/main/webapp/public/flags/flags_ru.png | Bin 0 -> 112 bytes src/main/webapp/public/flags/flags_rw.png | Bin 0 -> 157 bytes src/main/webapp/public/flags/flags_sa.png | Bin 0 -> 152 bytes src/main/webapp/public/flags/flags_sb.png | Bin 0 -> 184 bytes src/main/webapp/public/flags/flags_sc.png | Bin 0 -> 241 bytes src/main/webapp/public/flags/flags_sd.png | Bin 0 -> 192 bytes src/main/webapp/public/flags/flags_se.png | Bin 0 -> 124 bytes src/main/webapp/public/flags/flags_sg.png | Bin 0 -> 150 bytes src/main/webapp/public/flags/flags_sh.png | Bin 0 -> 236 bytes src/main/webapp/public/flags/flags_si.png | Bin 0 -> 143 bytes src/main/webapp/public/flags/flags_sj.png | Bin 0 -> 150 bytes src/main/webapp/public/flags/flags_sk.png | Bin 0 -> 207 bytes src/main/webapp/public/flags/flags_sl.png | Bin 0 -> 117 bytes src/main/webapp/public/flags/flags_sm.png | Bin 0 -> 291 bytes src/main/webapp/public/flags/flags_sn.png | Bin 0 -> 135 bytes src/main/webapp/public/flags/flags_so.png | Bin 0 -> 140 bytes src/main/webapp/public/flags/flags_sr.png | Bin 0 -> 152 bytes src/main/webapp/public/flags/flags_ss.png | Bin 0 -> 201 bytes src/main/webapp/public/flags/flags_st.png | Bin 0 -> 150 bytes src/main/webapp/public/flags/flags_sv.png | Bin 0 -> 144 bytes src/main/webapp/public/flags/flags_sx.png | Bin 0 -> 286 bytes src/main/webapp/public/flags/flags_sy.png | Bin 0 -> 152 bytes src/main/webapp/public/flags/flags_sz.png | Bin 0 -> 318 bytes src/main/webapp/public/flags/flags_tc.png | Bin 0 -> 216 bytes src/main/webapp/public/flags/flags_td.png | Bin 0 -> 105 bytes src/main/webapp/public/flags/flags_tf.png | Bin 0 -> 197 bytes src/main/webapp/public/flags/flags_tg.png | Bin 0 -> 188 bytes src/main/webapp/public/flags/flags_th.png | Bin 0 -> 125 bytes src/main/webapp/public/flags/flags_tj.png | Bin 0 -> 150 bytes src/main/webapp/public/flags/flags_tk.png | Bin 0 -> 220 bytes src/main/webapp/public/flags/flags_tl.png | Bin 0 -> 188 bytes src/main/webapp/public/flags/flags_tm.png | Bin 0 -> 283 bytes src/main/webapp/public/flags/flags_tn.png | Bin 0 -> 154 bytes src/main/webapp/public/flags/flags_to.png | Bin 0 -> 133 bytes src/main/webapp/public/flags/flags_tr.png | Bin 0 -> 154 bytes src/main/webapp/public/flags/flags_tt.png | Bin 0 -> 218 bytes src/main/webapp/public/flags/flags_tv.png | Bin 0 -> 231 bytes src/main/webapp/public/flags/flags_tw.png | Bin 0 -> 149 bytes src/main/webapp/public/flags/flags_tz.png | Bin 0 -> 276 bytes src/main/webapp/public/flags/flags_ua.png | Bin 0 -> 98 bytes src/main/webapp/public/flags/flags_ug.png | Bin 0 -> 211 bytes src/main/webapp/public/flags/flags_um.png | Bin 0 -> 221 bytes src/main/webapp/public/flags/flags_us.png | Bin 0 -> 221 bytes src/main/webapp/public/flags/flags_uy.png | Bin 0 -> 217 bytes src/main/webapp/public/flags/flags_uz.png | Bin 0 -> 144 bytes src/main/webapp/public/flags/flags_va.png | Bin 0 -> 273 bytes src/main/webapp/public/flags/flags_vc.png | Bin 0 -> 155 bytes src/main/webapp/public/flags/flags_ve.png | Bin 0 -> 166 bytes src/main/webapp/public/flags/flags_vg.png | Bin 0 -> 282 bytes src/main/webapp/public/flags/flags_vi.png | Bin 0 -> 443 bytes src/main/webapp/public/flags/flags_vn.png | Bin 0 -> 141 bytes src/main/webapp/public/flags/flags_vu.png | Bin 0 -> 220 bytes src/main/webapp/public/flags/flags_wf.png | Bin 0 -> 169 bytes src/main/webapp/public/flags/flags_ws.png | Bin 0 -> 127 bytes src/main/webapp/public/flags/flags_ye.png | Bin 0 -> 117 bytes src/main/webapp/public/flags/flags_yt.png | Bin 0 -> 327 bytes src/main/webapp/public/flags/flags_za.png | Bin 0 -> 291 bytes src/main/webapp/public/flags/flags_zm.png | Bin 0 -> 170 bytes src/main/webapp/public/flags/flags_zw.png | Bin 0 -> 210 bytes 249 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/main/webapp/public/flags/flags_ad.png create mode 100644 src/main/webapp/public/flags/flags_ae.png create mode 100644 src/main/webapp/public/flags/flags_af.png create mode 100644 src/main/webapp/public/flags/flags_ag.png create mode 100644 src/main/webapp/public/flags/flags_ai.png create mode 100644 src/main/webapp/public/flags/flags_al.png create mode 100644 src/main/webapp/public/flags/flags_am.png create mode 100644 src/main/webapp/public/flags/flags_ao.png create mode 100644 src/main/webapp/public/flags/flags_aq.png create mode 100644 src/main/webapp/public/flags/flags_ar.png create mode 100644 src/main/webapp/public/flags/flags_as.png create mode 100644 src/main/webapp/public/flags/flags_at.png create mode 100644 src/main/webapp/public/flags/flags_au.png create mode 100644 src/main/webapp/public/flags/flags_aw.png create mode 100644 src/main/webapp/public/flags/flags_ax.png create mode 100644 src/main/webapp/public/flags/flags_az.png create mode 100644 src/main/webapp/public/flags/flags_ba.png create mode 100644 src/main/webapp/public/flags/flags_bb.png create mode 100644 src/main/webapp/public/flags/flags_bd.png create mode 100644 src/main/webapp/public/flags/flags_be.png create mode 100644 src/main/webapp/public/flags/flags_bf.png create mode 100644 src/main/webapp/public/flags/flags_bg.png create mode 100644 src/main/webapp/public/flags/flags_bh.png create mode 100644 src/main/webapp/public/flags/flags_bi.png create mode 100644 src/main/webapp/public/flags/flags_bj.png create mode 100644 src/main/webapp/public/flags/flags_bl.png create mode 100644 src/main/webapp/public/flags/flags_bm.png create mode 100644 src/main/webapp/public/flags/flags_bn.png create mode 100644 src/main/webapp/public/flags/flags_bo.png create mode 100644 src/main/webapp/public/flags/flags_bq.png create mode 100644 src/main/webapp/public/flags/flags_br.png create mode 100644 src/main/webapp/public/flags/flags_bs.png create mode 100644 src/main/webapp/public/flags/flags_bt.png create mode 100644 src/main/webapp/public/flags/flags_bv.png create mode 100644 src/main/webapp/public/flags/flags_bw.png create mode 100644 src/main/webapp/public/flags/flags_by.png create mode 100644 src/main/webapp/public/flags/flags_bz.png create mode 100644 src/main/webapp/public/flags/flags_ca.png create mode 100644 src/main/webapp/public/flags/flags_cc.png create mode 100644 src/main/webapp/public/flags/flags_cd.png create mode 100644 src/main/webapp/public/flags/flags_cf.png create mode 100644 src/main/webapp/public/flags/flags_cg.png create mode 100644 src/main/webapp/public/flags/flags_ch.png create mode 100644 src/main/webapp/public/flags/flags_ci.png create mode 100644 src/main/webapp/public/flags/flags_ck.png create mode 100644 src/main/webapp/public/flags/flags_cl.png create mode 100644 src/main/webapp/public/flags/flags_cm.png create mode 100644 src/main/webapp/public/flags/flags_cn.png create mode 100644 src/main/webapp/public/flags/flags_co.png create mode 100644 src/main/webapp/public/flags/flags_cr.png create mode 100644 src/main/webapp/public/flags/flags_cu.png create mode 100644 src/main/webapp/public/flags/flags_cv.png create mode 100644 src/main/webapp/public/flags/flags_cw.png create mode 100644 src/main/webapp/public/flags/flags_cx.png create mode 100644 src/main/webapp/public/flags/flags_cy.png create mode 100644 src/main/webapp/public/flags/flags_cz.png create mode 100644 src/main/webapp/public/flags/flags_de.png create mode 100644 src/main/webapp/public/flags/flags_dj.png create mode 100644 src/main/webapp/public/flags/flags_dk.png create mode 100644 src/main/webapp/public/flags/flags_dm.png create mode 100644 src/main/webapp/public/flags/flags_do.png create mode 100644 src/main/webapp/public/flags/flags_dz.png create mode 100644 src/main/webapp/public/flags/flags_ec.png create mode 100644 src/main/webapp/public/flags/flags_ee.png create mode 100644 src/main/webapp/public/flags/flags_eg.png create mode 100644 src/main/webapp/public/flags/flags_eh.png create mode 100644 src/main/webapp/public/flags/flags_er.png create mode 100644 src/main/webapp/public/flags/flags_es.png create mode 100644 src/main/webapp/public/flags/flags_et.png create mode 100644 src/main/webapp/public/flags/flags_fi.png create mode 100644 src/main/webapp/public/flags/flags_fj.png create mode 100644 src/main/webapp/public/flags/flags_fk.png create mode 100644 src/main/webapp/public/flags/flags_fm.png create mode 100644 src/main/webapp/public/flags/flags_fo.png create mode 100644 src/main/webapp/public/flags/flags_fr.png create mode 100644 src/main/webapp/public/flags/flags_ga.png create mode 100644 src/main/webapp/public/flags/flags_gb.png create mode 100644 src/main/webapp/public/flags/flags_gd.png create mode 100644 src/main/webapp/public/flags/flags_ge.png create mode 100644 src/main/webapp/public/flags/flags_gf.png create mode 100644 src/main/webapp/public/flags/flags_gg.png create mode 100644 src/main/webapp/public/flags/flags_gh.png create mode 100644 src/main/webapp/public/flags/flags_gi.png create mode 100644 src/main/webapp/public/flags/flags_gl.png create mode 100644 src/main/webapp/public/flags/flags_gm.png create mode 100644 src/main/webapp/public/flags/flags_gn.png create mode 100644 src/main/webapp/public/flags/flags_gp.png create mode 100644 src/main/webapp/public/flags/flags_gq.png create mode 100644 src/main/webapp/public/flags/flags_gr.png create mode 100644 src/main/webapp/public/flags/flags_gs.png create mode 100644 src/main/webapp/public/flags/flags_gt.png create mode 100644 src/main/webapp/public/flags/flags_gu.png create mode 100644 src/main/webapp/public/flags/flags_gw.png create mode 100644 src/main/webapp/public/flags/flags_gy.png create mode 100644 src/main/webapp/public/flags/flags_hk.png create mode 100644 src/main/webapp/public/flags/flags_hm.png create mode 100644 src/main/webapp/public/flags/flags_hn.png create mode 100644 src/main/webapp/public/flags/flags_hr.png create mode 100644 src/main/webapp/public/flags/flags_ht.png create mode 100644 src/main/webapp/public/flags/flags_hu.png create mode 100644 src/main/webapp/public/flags/flags_id.png create mode 100644 src/main/webapp/public/flags/flags_ie.png create mode 100644 src/main/webapp/public/flags/flags_il.png create mode 100644 src/main/webapp/public/flags/flags_im.png create mode 100644 src/main/webapp/public/flags/flags_in.png create mode 100644 src/main/webapp/public/flags/flags_io.png create mode 100644 src/main/webapp/public/flags/flags_iq.png create mode 100644 src/main/webapp/public/flags/flags_ir.png create mode 100644 src/main/webapp/public/flags/flags_is.png create mode 100644 src/main/webapp/public/flags/flags_it.png create mode 100644 src/main/webapp/public/flags/flags_je.png create mode 100644 src/main/webapp/public/flags/flags_jm.png create mode 100644 src/main/webapp/public/flags/flags_jo.png create mode 100644 src/main/webapp/public/flags/flags_jp.png create mode 100644 src/main/webapp/public/flags/flags_ke.png create mode 100644 src/main/webapp/public/flags/flags_kg.png create mode 100644 src/main/webapp/public/flags/flags_kh.png create mode 100644 src/main/webapp/public/flags/flags_ki.png create mode 100644 src/main/webapp/public/flags/flags_km.png create mode 100644 src/main/webapp/public/flags/flags_kn.png create mode 100644 src/main/webapp/public/flags/flags_kp.png create mode 100644 src/main/webapp/public/flags/flags_kr.png create mode 100644 src/main/webapp/public/flags/flags_kw.png create mode 100644 src/main/webapp/public/flags/flags_ky.png create mode 100644 src/main/webapp/public/flags/flags_kz.png create mode 100644 src/main/webapp/public/flags/flags_la.png create mode 100644 src/main/webapp/public/flags/flags_lb.png create mode 100644 src/main/webapp/public/flags/flags_lc.png create mode 100644 src/main/webapp/public/flags/flags_li.png create mode 100644 src/main/webapp/public/flags/flags_lk.png create mode 100644 src/main/webapp/public/flags/flags_lr.png create mode 100644 src/main/webapp/public/flags/flags_ls.png create mode 100644 src/main/webapp/public/flags/flags_lt.png create mode 100644 src/main/webapp/public/flags/flags_lu.png create mode 100644 src/main/webapp/public/flags/flags_lv.png create mode 100644 src/main/webapp/public/flags/flags_ly.png create mode 100644 src/main/webapp/public/flags/flags_ma.png create mode 100644 src/main/webapp/public/flags/flags_mc.png create mode 100644 src/main/webapp/public/flags/flags_md.png create mode 100644 src/main/webapp/public/flags/flags_me.png create mode 100644 src/main/webapp/public/flags/flags_mf.png create mode 100644 src/main/webapp/public/flags/flags_mg.png create mode 100644 src/main/webapp/public/flags/flags_mh.png create mode 100644 src/main/webapp/public/flags/flags_mk.png create mode 100644 src/main/webapp/public/flags/flags_ml.png create mode 100644 src/main/webapp/public/flags/flags_mm.png create mode 100644 src/main/webapp/public/flags/flags_mn.png create mode 100644 src/main/webapp/public/flags/flags_mo.png create mode 100644 src/main/webapp/public/flags/flags_mp.png create mode 100644 src/main/webapp/public/flags/flags_mq.png create mode 100644 src/main/webapp/public/flags/flags_mr.png create mode 100644 src/main/webapp/public/flags/flags_ms.png create mode 100644 src/main/webapp/public/flags/flags_mt.png create mode 100644 src/main/webapp/public/flags/flags_mu.png create mode 100644 src/main/webapp/public/flags/flags_mv.png create mode 100644 src/main/webapp/public/flags/flags_mw.png create mode 100644 src/main/webapp/public/flags/flags_mx.png create mode 100644 src/main/webapp/public/flags/flags_my.png create mode 100644 src/main/webapp/public/flags/flags_mz.png create mode 100644 src/main/webapp/public/flags/flags_na.png create mode 100644 src/main/webapp/public/flags/flags_nc.png create mode 100644 src/main/webapp/public/flags/flags_ne.png create mode 100644 src/main/webapp/public/flags/flags_nf.png create mode 100644 src/main/webapp/public/flags/flags_ng.png create mode 100644 src/main/webapp/public/flags/flags_ni.png create mode 100644 src/main/webapp/public/flags/flags_nl.png create mode 100644 src/main/webapp/public/flags/flags_no.png create mode 100644 src/main/webapp/public/flags/flags_np.png create mode 100644 src/main/webapp/public/flags/flags_nr.png create mode 100644 src/main/webapp/public/flags/flags_nu.png create mode 100644 src/main/webapp/public/flags/flags_nz.png create mode 100644 src/main/webapp/public/flags/flags_om.png create mode 100644 src/main/webapp/public/flags/flags_pa.png create mode 100644 src/main/webapp/public/flags/flags_pe.png create mode 100644 src/main/webapp/public/flags/flags_pf.png create mode 100644 src/main/webapp/public/flags/flags_pg.png create mode 100644 src/main/webapp/public/flags/flags_ph.png create mode 100644 src/main/webapp/public/flags/flags_pk.png create mode 100644 src/main/webapp/public/flags/flags_pl.png create mode 100644 src/main/webapp/public/flags/flags_pm.png create mode 100644 src/main/webapp/public/flags/flags_pn.png create mode 100644 src/main/webapp/public/flags/flags_pr.png create mode 100644 src/main/webapp/public/flags/flags_ps.png create mode 100644 src/main/webapp/public/flags/flags_pt.png create mode 100644 src/main/webapp/public/flags/flags_pw.png create mode 100644 src/main/webapp/public/flags/flags_py.png create mode 100644 src/main/webapp/public/flags/flags_qa.png create mode 100644 src/main/webapp/public/flags/flags_re.png create mode 100644 src/main/webapp/public/flags/flags_ro.png create mode 100644 src/main/webapp/public/flags/flags_rs.png create mode 100644 src/main/webapp/public/flags/flags_ru.png create mode 100644 src/main/webapp/public/flags/flags_rw.png create mode 100644 src/main/webapp/public/flags/flags_sa.png create mode 100644 src/main/webapp/public/flags/flags_sb.png create mode 100644 src/main/webapp/public/flags/flags_sc.png create mode 100644 src/main/webapp/public/flags/flags_sd.png create mode 100644 src/main/webapp/public/flags/flags_se.png create mode 100644 src/main/webapp/public/flags/flags_sg.png create mode 100644 src/main/webapp/public/flags/flags_sh.png create mode 100644 src/main/webapp/public/flags/flags_si.png create mode 100644 src/main/webapp/public/flags/flags_sj.png create mode 100644 src/main/webapp/public/flags/flags_sk.png create mode 100644 src/main/webapp/public/flags/flags_sl.png create mode 100644 src/main/webapp/public/flags/flags_sm.png create mode 100644 src/main/webapp/public/flags/flags_sn.png create mode 100644 src/main/webapp/public/flags/flags_so.png create mode 100644 src/main/webapp/public/flags/flags_sr.png create mode 100644 src/main/webapp/public/flags/flags_ss.png create mode 100644 src/main/webapp/public/flags/flags_st.png create mode 100644 src/main/webapp/public/flags/flags_sv.png create mode 100644 src/main/webapp/public/flags/flags_sx.png create mode 100644 src/main/webapp/public/flags/flags_sy.png create mode 100644 src/main/webapp/public/flags/flags_sz.png create mode 100644 src/main/webapp/public/flags/flags_tc.png create mode 100644 src/main/webapp/public/flags/flags_td.png create mode 100644 src/main/webapp/public/flags/flags_tf.png create mode 100644 src/main/webapp/public/flags/flags_tg.png create mode 100644 src/main/webapp/public/flags/flags_th.png create mode 100644 src/main/webapp/public/flags/flags_tj.png create mode 100644 src/main/webapp/public/flags/flags_tk.png create mode 100644 src/main/webapp/public/flags/flags_tl.png create mode 100644 src/main/webapp/public/flags/flags_tm.png create mode 100644 src/main/webapp/public/flags/flags_tn.png create mode 100644 src/main/webapp/public/flags/flags_to.png create mode 100644 src/main/webapp/public/flags/flags_tr.png create mode 100644 src/main/webapp/public/flags/flags_tt.png create mode 100644 src/main/webapp/public/flags/flags_tv.png create mode 100644 src/main/webapp/public/flags/flags_tw.png create mode 100644 src/main/webapp/public/flags/flags_tz.png create mode 100644 src/main/webapp/public/flags/flags_ua.png create mode 100644 src/main/webapp/public/flags/flags_ug.png create mode 100644 src/main/webapp/public/flags/flags_um.png create mode 100644 src/main/webapp/public/flags/flags_us.png create mode 100644 src/main/webapp/public/flags/flags_uy.png create mode 100644 src/main/webapp/public/flags/flags_uz.png create mode 100644 src/main/webapp/public/flags/flags_va.png create mode 100644 src/main/webapp/public/flags/flags_vc.png create mode 100644 src/main/webapp/public/flags/flags_ve.png create mode 100644 src/main/webapp/public/flags/flags_vg.png create mode 100644 src/main/webapp/public/flags/flags_vi.png create mode 100644 src/main/webapp/public/flags/flags_vn.png create mode 100644 src/main/webapp/public/flags/flags_vu.png create mode 100644 src/main/webapp/public/flags/flags_wf.png create mode 100644 src/main/webapp/public/flags/flags_ws.png create mode 100644 src/main/webapp/public/flags/flags_ye.png create mode 100644 src/main/webapp/public/flags/flags_yt.png create mode 100644 src/main/webapp/public/flags/flags_za.png create mode 100644 src/main/webapp/public/flags/flags_zm.png create mode 100644 src/main/webapp/public/flags/flags_zw.png diff --git a/src/main/webapp/public/flags/flags_ad.png b/src/main/webapp/public/flags/flags_ad.png new file mode 100644 index 0000000000000000000000000000000000000000..b750895ff0f7fb642723f7d41ffbb17eea1b1fff GIT binary patch literal 235 zcmVNV002ovPDHLkV1hNZWGnyx literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ae.png b/src/main/webapp/public/flags/flags_ae.png new file mode 100644 index 0000000000000000000000000000000000000000..b5d25f66fec31e8542df5cb57cf7783e40436e12 GIT binary patch literal 122 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3HF^gw{O+QjVT3jv*Gk$$!ot_%NT5*Ffy` zVs1g+87HoJ>N87jtgJkHH1>+=gopOa1U5Fjic34FwxjB6)+XK~uTtGM^-6P{WMdGY W%lFjohkXvvFa}RoKbLh*2~7aGR4NVt literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_af.png b/src/main/webapp/public/flags/flags_af.png new file mode 100644 index 0000000000000000000000000000000000000000..f38b17a252c45971a702c6bd0e204c69abb2698d GIT binary patch literal 296 zcmV+@0oVSCP)oc#6Bl0byI>iMI=@^tH(uKN#|M+X(ZdBE)e0000pW<~}zds*7_Hj#HlsEqQ@bQN& zyQA0z9#^z1(DpM4oM52A67SFP;e_y{O75yOPJ2_BAQ~an7IW z$hbPCNW~W(o9(V-o#4Bup%kvFq+Ax3H!-4y_j|^H{(G#pd{N7zWUlrW`}W$qMv7US cGA*K}B=UeE$FZ(3KlI4>iqbte98OwCZs4 z%#_lypFf}d2Z9Hi!+q+*ed@k^_+HpXzTA!mi<55hULO;U9Oiu4$#8#;vBK;oebpZq8%+O-ITZeTEF;FQP+H1# rBsRX>c0v2HTxkcJ5K;DzPJ3=JcJi^lOWd>=Ay1r%M87;?yR|C>o79?m-k(Ab`5jHAOH6mXdr{9tDnm{r-UW|pSLs` literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_am.png b/src/main/webapp/public/flags/flags_am.png new file mode 100644 index 0000000000000000000000000000000000000000..36a2cc2996f747735f7120784c5dde650db32a78 GIT binary patch literal 110 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3HF^gw{O+Ql_3Rjv*Gk$$L&Je3{?a)zCSs z?cxy!H~ytgtEy(EwlARzA$44$rj JF6*2UngG>-B!B<_ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ao.png b/src/main/webapp/public/flags/flags_ao.png new file mode 100644 index 0000000000000000000000000000000000000000..301a92d1a0e612c93cb9aa552986d71c72b7a7f4 GIT binary patch literal 201 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}sSqAuoxTdj)KS`1l;AIGPVK^_U z)0EDjAjNQoQy(aApvI6dAiA!L;f|ZaRb91%Rh$Kp3^y#4UzUoz@8iq%`LqP6$lKG! zF+}2W?HNn40}33jfdP&MKAZj@UDl{{@$qWA?F^sPERuI!ViUaFgSWi3&hktc(gw z7QQILc*em~2UIjPRxl(P`QKwp@nhijXW7J3d+>~dK(Nf292bK~2OmX)$+ryy13rkH kdEn-_L$%d!#WiMz^QX9U47Yk-1{%xY>FVdQ&MBb@0Au+{ssI20 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ar.png b/src/main/webapp/public/flags/flags_ar.png new file mode 100644 index 0000000000000000000000000000000000000000..795042c092cc03eff6ddf80248d98c7be45528ce GIT binary patch literal 141 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nasfUet|e>k|NsC0;^QyhKfHXl zJ^k{%$G^URzkL76^@+SUCUS2&c%v7nQp?lDF@)oKa)JV*24iEEKu3xI3zKlV0B2g1 o+2NULc?oJBQ-cH}bU7IqPF`j!R8QS{2B?d{)78&qol`;+0HfA2Q2+n{ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_as.png b/src/main/webapp/public/flags/flags_as.png new file mode 100644 index 0000000000000000000000000000000000000000..464e36b0263614cdc26ad0bfe263dd9147b5ceb4 GIT binary patch literal 237 zcmV8AkKc)$s4@mVi=-dvw&!zpSRC+0@g+w2P>j zb*+pyoOd3&t%z+_G2h?cSuPfXYhB;Gyp>s1*#H0lS4l)cR0!8)&RGtCAPfZ1(Yl~e zaNq8KP^+Q=oS!Cd(zK|PsH`cFLU}kKf)*=p`-2{?5D_~BAkj=DLsf$LYi2f!_Um6U nSkoambQk?N*#M)B-}6$O86yP{2eY1j00000NkvXXu0mjfIo4rN literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_at.png b/src/main/webapp/public/flags/flags_at.png new file mode 100644 index 0000000000000000000000000000000000000000..0848b2a68ccd78e9b76c20e0676f32d8dab1b763 GIT binary patch literal 102 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&sGmyMux9lvC;tcQ!as9bt-&;+~|NsB1|J?Ww yC@A6S;uyklJ(+`*k%;OXk;vd$@?2>=(eES~@X literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ax.png b/src/main/webapp/public/flags/flags_ax.png new file mode 100644 index 0000000000000000000000000000000000000000..5ce6bdab5ae3af0b3d085e436ed6cf183d6a6037 GIT binary patch literal 163 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^niUB?$t_;CT(_0fS37CAI&6nAh zcvDyV|9OT7-pV(2Nq(Hg`{FSFKmA~Fpkk(yAiv=M|MqWs9|+{>d%8G=a9mGLNJz-w zPi|oR<0B|O{b{0}!-EqBH*ef9xgo)!BRS;(L!^kOE@v16L*hAM+4TkcP6D+uc)I$z JtaD0e0swRXHGBX7 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_az.png b/src/main/webapp/public/flags/flags_az.png new file mode 100644 index 0000000000000000000000000000000000000000..d45b6eb5d9a47f66fbe16425933403983b9bf85e GIT binary patch literal 150 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*DXjpX5LbqsPweNI&WkKsmD{|< z&wNK-}Oij_%4k|{MSvicn9aNeXWD?Su82-K#Nt(L)m>f_GgQu&X%Q~loCIFZ;F|z;w literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ba.png b/src/main/webapp/public/flags/flags_ba.png new file mode 100644 index 0000000000000000000000000000000000000000..dc479956d4ee14bcf45f46b2005dc78e49dbdc04 GIT binary patch literal 175 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*DXjpX5Z8aF85oqOI{4Ta`1F<4B@z*EFjWrlmG-m zA}InQcfxqmz5EOt3|WikK0MR+n9X2W!iyw}V5_?f$3*75SFM8^>bP0l+XkK+&d%_ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_be.png b/src/main/webapp/public/flags/flags_be.png new file mode 100644 index 0000000000000000000000000000000000000000..f4270f2ed20355bc3ee3d01d2d689c413f9512cc GIT binary patch literal 105 zcmeAS@N?(olHy`uVBq!ia0vp^B0wz2!VDz!vnKWeDgFST5ZAYwmO$pO2Tq?-t%JSA zR{T2R1>}o*x;Tb#Tu&Ad5h)1@3QA*WQar}Okou8tZYYcUMxX))Pgg&ebxsLQ0KkD7 Am;e9( literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_bf.png b/src/main/webapp/public/flags/flags_bf.png new file mode 100644 index 0000000000000000000000000000000000000000..1dffc199d0db34e52600d81a38deecbb5227eea0 GIT binary patch literal 140 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^niUB?$t_<@$-)rkuBwLlISbZ?n z{(V9CbF|9RPMsfukKpkg&o7sn8e>&XHw%xsHhDD@Sjxk=B+T(V?I mh!mg76iJ7MVn*hl3=Dg&vro!-zup0;gTd3)&t;ucLK6UsFDrBa literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_bg.png b/src/main/webapp/public/flags/flags_bg.png new file mode 100644 index 0000000000000000000000000000000000000000..8cd991c5cccf92a7b3a195a7befa7a37d4f6623b GIT binary patch literal 97 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W;!3HFgc;@~FQW~Bvjv*Gk$$$R;|6k9h#iW)c vWoE*Yk}_kOxm4Q}#$(x8PKy&gBpDc9{^C#y-g!X^sE5JR)z4*}Q$iB}VC@>` literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_bh.png b/src/main/webapp/public/flags/flags_bh.png new file mode 100644 index 0000000000000000000000000000000000000000..dcc9a0c0ea251f6355905f50c6f24095ea9c15d3 GIT binary patch literal 153 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W;!VDxQC^eh_QYryHA+F~H)&Bqge?O_<+ofw4 z<&FM)`1p3&x({1+KdxxKZsYfI+WhObekTvf_yg4&c)B=-a9mFo5D^JWY+!J7bX2wo zKEewejuf<9LM2e0j`$c|zL^uK)l5a!Eu%R0!8?&)W`yFc5^{AK1ba zdH~8neg6mbLZjBOmtQiOSqQhGx-Ebo>H07A9ivBTjb<9=WtoSm!CDDP?jR)2lN30d zUMTqtr9k%52eksgstWC4cDD+Bra<|PyPcQ-Qe=M@T}-+hPu%|Aj64CYkq2mYNaBtF O0000LZ#NW zKbAmoDNh&25RU7~9K5kxvqYH{KmGsz-(eCX$A?J_6TXU1u@+hcGM2&9)z4*}Q$iB} DnG+u% literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_bl.png b/src/main/webapp/public/flags/flags_bl.png new file mode 100644 index 0000000000000000000000000000000000000000..fdc0baa398b5144cae85ec9bc7f617fedcaa163f GIT binary patch literal 353 zcmV-n0iOPeP)l^P%PVE-s0kupVQK-!kVtn$=BVn zzSX3(!9Pxmoo31`M%dfL#=D5q&$HODoZPU4r?!BQnsBn6j*r8=*Hc>JeSkk#j&Fgg zQBQ%+n5u}ExWSpNyvhYc0001CNkl6a_yZMHEo%z3csNsH{wg8)rFr z1=uCRAP5mo;6)&uQkrGk3g5_B>sY$vX%9+Mj06Bz%dGe#E!*9uNZ2E*ermW^=UqLv kzAOi0irjx9QAFSmHy}a;EG$69<^TWy07*qoM6N<$g1{qyHvj+t literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_bn.png b/src/main/webapp/public/flags/flags_bn.png new file mode 100644 index 0000000000000000000000000000000000000000..bcd4bb9b6507a6c142cef8f571e20ae38b6b6b82 GIT binary patch literal 350 zcmV-k0iphhP)0z>tuT`s;0AU|{_5nR{erc^eUZFc894V(+~# zurwQ*Mo%yU2gg(zr-q64+gf2XIQie!&#bRcGYH?`-}mAr{`=uTJ~I9FwtszUnv4#x zu&_Ni1^<;h9RL6TbV)=(R0thy!G{ilFcbyQ3$%T&YzXena0~AL|1_-{6B5pWLI)x- zpN`N*UqbE*`~|bgnBKJO9)wg43^szr_UQYm)}j<0&2D*ylh07*qoM6N<$f;NJk0ssI2 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_bo.png b/src/main/webapp/public/flags/flags_bo.png new file mode 100644 index 0000000000000000000000000000000000000000..1c613a33356c15ad5316851589e3d4fed8f17451 GIT binary patch literal 145 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W?!3HGtkJx+wQW2gmjv*Gk$$M7m{+{31II+>{ zfjzVERG+5igZT>F{cp|L+Ui5NoR|IQ>9LnH{FA<9Qsw{u{H+E{>Xx{?4f=KdgvZ=~ uKmY&#ub)<0&LSzXLPERQo@ZsA0z>R-QPsn?`>z4*VDNPHb6Mw<&;$S`3^cU> literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_bq.png b/src/main/webapp/public/flags/flags_bq.png new file mode 100644 index 0000000000000000000000000000000000000000..7dfbb590ec764074a9f004e32fe691c2d7fd08e4 GIT binary patch literal 321 zcmV-H0lxl;P)z-{0Tl@P3rTA48S>{r&akf~%{mV`F2;$jJKP zLhq20v9Yn;9wWH8xSy`lP;9KPyxEVT%3yl5|NZa3$>A_kozK_kaEH73+a3S<;H0d< z{`k?5kdXZDmG;EOpP!%8)6>DcpZCSbg@u{z0001ZNkl zd+)mc{V9XY@!)roL( zctl}kD{TQx`R%h?Q@6FTyDfh(%}Y_{!_6a7dpRYH5Nc1xxJekJS}_g_{bzgwvMC7h TyM=E400000NkvXXu0mjf>#(Hd literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_br.png b/src/main/webapp/public/flags/flags_br.png new file mode 100644 index 0000000000000000000000000000000000000000..39cf3e7e0b30cd0d9e1a9cb022385f0788421571 GIT binary patch literal 260 zcmV+f0sH=mP)uLQr5)5Ch1&0001MNkl@zDZ~Vbw>~W=>Do5UuNZqWC8fHdo`VoD^fl9RqNqdaj5?01bDj(V^6p|S zB8ts;7t9#Xwm_P#HDS*%*KZNl?Rf96Q(O4PI02|g%hSa%gyVWLhhZBd+d&bQ;0+Q7r_4CO sz}VE(6f}E^1H;tLB?~0F6FIaQwh9RvhfQHO0_tM$boFyt=akR{0Q5yC2><{9 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_bt.png b/src/main/webapp/public/flags/flags_bt.png new file mode 100644 index 0000000000000000000000000000000000000000..6aaf2ebb0e43abb5e88f2322d8dfe0dbb94568e0 GIT binary patch literal 316 zcmV-C0mJ@@P) zyrci!O#kbf|5+gaj%WXS8vjlb|70%z{5QaR1U%|I~v2Z$AI<-v795 z|LBPSzN-Jgm;al3|MbfL;9dXcrEm6>rT_o{c1c7*R2b7`%tsP~Fc3sh6k8*a3<8{S zPWL|`3tVPg`n!IM&a$N3rnW7>mK<(MjuzlJyh6B$<{wq~JnqJEl<&5u&i5Quj6`HK zuwWan+W4n-98W7>qY+)T;BGHUa|Up%f4C_f4m96=F}DEVDll9ZNSXjTsRu4|#67J5 O0000Eal|aXmR9A%VXvxq<1?%%-ME y2m6{74s7uBTv<6$h{HhQFykhTNl~A2m>Fy?3kVin`TG>8k-^i|&t;ucLK6UHEjAti literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_bw.png b/src/main/webapp/public/flags/flags_bw.png new file mode 100644 index 0000000000000000000000000000000000000000..08934e7aa2f39e61c468297a4371e3f0335538fd GIT binary patch literal 114 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n`~f~8uJv1PO4xph2gL#bW~#!<~0r){9n0Yy_%g@O1Ta JS?83{1OTO^9i#vN literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_by.png b/src/main/webapp/public/flags/flags_by.png new file mode 100644 index 0000000000000000000000000000000000000000..e2eed0cf5e94d172462ecc8a3fbbaa6ed2fa4ca4 GIT binary patch literal 145 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*Da8Pv5Lbp8gDwlzbK(k5YMLIE zRz2^Vbjv%)GuP%$WWwuti&h4?>ql)e0xH(_ba4#fxSnj_7u3YW7R+*aDd#y8#|}5M r#N=5zA}vg8)0x;L3|tL*ANnv%EaUxAVfQKksExtX)z4*}Q$iB}PunZ9 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_bz.png b/src/main/webapp/public/flags/flags_bz.png new file mode 100644 index 0000000000000000000000000000000000000000..5166529a578d92733ec31fe222368f054a618440 GIT binary patch literal 253 zcmVl-Lt5^h04;w81F5X00013NklZxhmOH5Lbo#}E!#)`P8#4F(Jh3MFsTj{jfIb%BAwqqc`>g3yP(Yr_~C zexIyOJb#*Pt<%nP$_8gsI2dN61#mH3F*O64z`)QU$iQ%b|0*kE_nQ+xpMyj^UHx3v IIVCg!04|0_egFUf literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_cd.png b/src/main/webapp/public/flags/flags_cd.png new file mode 100644 index 0000000000000000000000000000000000000000..db2e24bf7ef675e1040f41af47b38a71f926a791 GIT binary patch literal 233 zcmV0000&P)t-s&JZE1 zvRogE;`i1V+D9GJC?A-#aR7h+K9$7~h45{qnb)&2$*w)(pd-*6Amei!;FKfca~yoC zi`mOGy1-Fgpr{Ln|I7dY0Afi*K~xyiUCT)lgdh+F(T}J}VbAFOuj&H30xOpp@kFd_ zvw&k*Q+i7R+T`!|StN;?b57<`TAqS6w?V$Xs|oh9o6Gr8Kww}lD}aK;=OF*{Ci@Js j&mj8@@|t(qXOIA#`G5!yXdfJV00000NkvXXu0mjfoVQ_@ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_cf.png b/src/main/webapp/public/flags/flags_cf.png new file mode 100644 index 0000000000000000000000000000000000000000..0c73cc18b167ad6f24e97c1630c521c179f541b9 GIT binary patch literal 206 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}sSqAuoxW+D#`~Uxcx0K(*0HrCX zr!W{a{XfTW;LHP!=^B>=OlH{XAN5Oom{1{MU;5Br$sr=Kq26(oqt5+Z0<*6LZwDF@ z;OXKRB5}F)gr!h}fdI2(F^5FW=l{niy%B2Y?sMasv`OWx!D*J2v0tmNO}qa*Ps!!I z!AzEu)ivwXoWAh2TR!|EH-DXT{joH$h&}($GadcSrX0Zf=EO8LO`zEfp00i_>zopr E06KF|^#A|> literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_cg.png b/src/main/webapp/public/flags/flags_cg.png new file mode 100644 index 0000000000000000000000000000000000000000..5c87941ae86af0ad7efda85c68c414b75a33a784 GIT binary patch literal 164 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n(g8jpt_)M1@2JTCzULLc(e*{9 z-s_v5KQFl(EOvS1uKx4F}S`&-3qR);63Jhuoc>T}>`VkyEBHu}+;L)8LclXkc*Qq{`8lkmnDW Z84?z-mUT_4>H+Fv@O1TaS?83{1OSzSDuVz3 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ci.png b/src/main/webapp/public/flags/flags_ci.png new file mode 100644 index 0000000000000000000000000000000000000000..c9f1db6c7b6be5acd55b35f01f57020889469148 GIT binary patch literal 114 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nLIFM@uHWk!80IDX|NsBz%FvK~ zQ$qGl^^po%1eBHbba4#fxSpJlkYErL6eKDllG0!h%GSkM#LV!pmn)}ciRw3?G6qjq KKbLh*2~7ZdjUayj literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ck.png b/src/main/webapp/public/flags/flags_ck.png new file mode 100644 index 0000000000000000000000000000000000000000..b339940d8d3af9d19260b3c998fd6faf2c1fb7df GIT binary patch literal 230 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDW?FR5Lf%~zJjLJ3O3bK7a!E~ zXbwo5A#Yv9sF-;|Ku*ZAa@rQ3b;g(pN>y=*ghr+S_7u~d>fI73H4&eO-ML;UX<6#;#* e!}p~-_{D4Au%2|(NIwd+iow&>&t;ucLK6T1lv-5) literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_cl.png b/src/main/webapp/public/flags/flags_cl.png new file mode 100644 index 0000000000000000000000000000000000000000..7015e884ff0c597f88105162c8cc4cf8c125d69e GIT binary patch literal 144 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^niUB?$u2;3?7%Z3l|NsBh)P9Go zJy}M{0{RvfQq#|T^vI=t|tqKh?E2|F|#=uvRqy*aBj{N qj`V4YE`qn5oSkR)9&Bh#VqkcFmnUB=EvXWyi^0>?&t;ucLK6UqeJvsY literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_cm.png b/src/main/webapp/public/flags/flags_cm.png new file mode 100644 index 0000000000000000000000000000000000000000..1d089f2cd9abba6e1189e1177faefb8af9a874da GIT binary patch literal 135 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n(g8jpt_)Rie=drh6IA=KP|_vL z`by3*~ua&-cWe z_jM8HizM#Pv)KOMWB9&-rRu<~M4(DtPZ!4!j_b(@2?+uT4NPfm4jSEo!Hp*wPOS?_ qXwmd2N+@pF_%N&SKtrP!1B3Q?rpIcfdnJJy89ZJ6T-G@yGywp2oGwKG literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_co.png b/src/main/webapp/public/flags/flags_co.png new file mode 100644 index 0000000000000000000000000000000000000000..d03450a262143a590ae564b822eccdaf31f3b57e GIT binary patch literal 112 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&g8<1QX;1>s^%spKkLo9le{}czlv1b-#6gK6R zHl1MAu(YyUHk3CdWy1`c9g}&xKW<+hVmjgDoCUWYd^5Vr%fg`ioy&i*`Tkm<5e%NL KelF{r5}E)iAR<)& literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_cr.png b/src/main/webapp/public/flags/flags_cr.png new file mode 100644 index 0000000000000000000000000000000000000000..84d4abae1bd56ded1eb59f71811a97b7963d122b GIT binary patch literal 142 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W;!VDxQC^eh_Qi=gSA+8MC_5c6>KPRYmQQr9a zyfqJ}^xVzQb*{{NkX62N$D#u#wy3s$-wsr);pyTS!f`!0;Q$xQLx!0vm!v#e!@}l} q_MFSYcrVX66W%tXjk6m>85pMdaek{^!JGxu!{F)a=d#Wzp$PzdmN41? literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_cu.png b/src/main/webapp/public/flags/flags_cu.png new file mode 100644 index 0000000000000000000000000000000000000000..2285564887d00157a8e6ed8c0ef3e2d143966923 GIT binary patch literal 163 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*Dct~{5ZC|z|1)UypBK@dD(NPr z*7SDyhD(a(yc!)>4PBpiO}$|s^!doiQrWN)+3*kB_U38y#sD=Mdb&7TJni)J@ L{an^LB{Ts5n?^Mt literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_cv.png b/src/main/webapp/public/flags/flags_cv.png new file mode 100644 index 0000000000000000000000000000000000000000..b27e125eba66021d87171c2e99dfc2c40f779f3c GIT binary patch literal 146 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W;!VDxQC^eh_QqloFA+F~Y)EO)$|Ns9#HZNYl zvCAr=`1k#^|1TyloM9Ea+X|%L$kW9!gyVX$KnpXQ(hMabC4*%K0!=f$yj&BOEIHD^ sGG)r70~{J$A+KNXTvpRk?8?()sJ7;E-!}1C22eMHr>mdKI;Vst0M61a+yDRo literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_cw.png b/src/main/webapp/public/flags/flags_cw.png new file mode 100644 index 0000000000000000000000000000000000000000..459d4db1720988fdf28b80401a1966556c7b451b GIT binary patch literal 154 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n>H$6>t~W0zGHBQD+Nm#U)46kp zo^9;h+}5qXfB)4BoOa{x>-JfP>L%|C&R_9s&Fc9;Eqb0Vjv*Y^lLcCs*)EKxagJsfSGt#kvIs{~q7tLJsz$)Ug<=O;81kC9CAz2eZw2aP@O1TaS?83{1ORb^ BGWh@i literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_cx.png b/src/main/webapp/public/flags/flags_cx.png new file mode 100644 index 0000000000000000000000000000000000000000..b70ce5f4cb7729d181e44a6baaaea01353f3831d GIT binary patch literal 240 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDZc=p5LcNlC#^n*xod1fXWF|> zwHMIav}(OIgW_7zf*5w|iPpgfDmt(9udp$&pEkK8UcqL2K-w{ji4HNd?H=v3EL><; zx5ReBgwSK#t(GryUC@#2EBGTDXmYHli(?4K_1LqXdmdKI;Vst0AfsAU;qFB literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_cy.png b/src/main/webapp/public/flags/flags_cy.png new file mode 100644 index 0000000000000000000000000000000000000000..984a03ef454653029f38ce9f462a2c76e2646d85 GIT binary patch literal 196 zcmV;#06YJQP) z-M{(j*Xioz$<4vi*30zu^3`|%>brma_V3@CKe4x@*@PGG%%APUkKK_lot)UV>9 zPohf1^8f$$E0000t|NsBGh}7$@UPsFmS;z9%ojpl$&3>lw9gFt&sM&w`{QZ=eLWcCiYM>!* zo-U3d5|?vNJrr_K5MX%dYT@+iulzNh)?K&f-`CMQr}cuJ`JgY`s+r5HmWk-3#CzS& to?DX|<(_%*|Ms4*WsB{X_9|;m<`!XL(}@wh$`3S=!PC{xWt~$(698O9NxuL9 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_de.png b/src/main/webapp/public/flags/flags_de.png new file mode 100644 index 0000000000000000000000000000000000000000..f57ee83d9073ddffa8e31c6758ee8497627e91d4 GIT binary patch literal 97 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W;!3HFgc;@~FQW~Bvjv*Gk$tej5Kh8V29&qtJ v+}75pQ@%3i!GrHcS9w$Z)aRMV25~T~;bu7~>wGZ{sE5JR)z4*}Q$iB}v@IPK literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_dj.png b/src/main/webapp/public/flags/flags_dj.png new file mode 100644 index 0000000000000000000000000000000000000000..366eb1e05fbcb9e8afc2f10614a3d6a958342932 GIT binary patch literal 234 zcmV&m9!u*c%|^7;8Hwm^Kl>T0s*60Ivfu!-n;eea*4{qXSG?D>cHi2wiq07*qoM6N<$f~mdKI;Vst0Bpl2tN;K2 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_dm.png b/src/main/webapp/public/flags/flags_dm.png new file mode 100644 index 0000000000000000000000000000000000000000..89c01b8a12866dd989f8dfd37e2d9439f9f40a90 GIT binary patch literal 200 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDa!z#5Z5WKu0dtS>$FTZ2U;;? z+k13m1vRGGPsp=xHJLhf>P}Uo!`en$B9fMyN6s_X-VmR$H8NQx#vv^rU96hZ8)%4^ zr;B3<$MxPmYoP-Q9IQW60vvl!{^ieRvK#EAihnv|f?cr6^#q xb$Uf)q1M*r_Z26|1in^LJRUH$UVm;O)8<4L1OAG~`an||JYD@<);T3K0RY&cLw^7O literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_do.png b/src/main/webapp/public/flags/flags_do.png new file mode 100644 index 0000000000000000000000000000000000000000..8384003c207a2e458e7d87b423642110cb4dcdd7 GIT binary patch literal 164 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n1_3@HuHPHV5VmjLzZd%8G=a9mFg zU}_RDPgY1!Xw~Fkx&B(h;Q%W~-8sgI33=D~Cde>*gm!6ruxt7-gw;spFlH%50JSoB My85}Sb4q9e0MGP0X#fBK literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_dz.png b/src/main/webapp/public/flags/flags_dz.png new file mode 100644 index 0000000000000000000000000000000000000000..1e75b36c56d4c44a0b43c1b4ed4aea51dd6def9a GIT binary patch literal 160 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nx&b~RuK)l4XGk*sa`=p?uknla znU@4izF)fG?`-sO%l@OH#%0FF(&0uEHI1(8`b^L=ZuZje0czCqba4#fxSlM)!pLTD z;Ov^>Cc~v_#wrdoqj^me7AgTe~ HDWM4fBa<|V literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ec.png b/src/main/webapp/public/flags/flags_ec.png new file mode 100644 index 0000000000000000000000000000000000000000..3abdceb6d273d80b0e537426fcea0aa38efa90b6 GIT binary patch literal 237 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}sMFseTxY{^2d-`{_rpqsBHE*02 z@cja(Q26x!cNyNws4)92aw@89pA%oOaq)r`Hn{=Gx($>2%e1}y%1$2Dm@%WZEF^Z> z%#!`{g=QX@K08N$*}j#tD{MAwGPrkCw0fEG3pJAhpe=r$E{-7*my;8YGf8-erKBdM zi5&>gJglPDvM^{?yZbazwG&K?dlpT4bZAciAG113LFb3hp9>u=4IIt#I5qRkW+?C& keBv}ZWGNHD@Ue-(_!;|~*k=z~fhIF}y85}Sb4q9e0AL_jx&QzG literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ee.png b/src/main/webapp/public/flags/flags_ee.png new file mode 100644 index 0000000000000000000000000000000000000000..ed175b5fd4b0eea812196309abce56fbe4173a82 GIT binary patch literal 117 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nLIFM@t_(%z{{R0E=`Q@3FD_3sWy#C{kefLk)UO2Mo=hrtcZ>>#Ve#I52$k5ZpF@)oKazGO^Ta%B# zgE_m{PftBKbK%Z}w9f8s@9%sH56>m=u(Ql$NS!3c{9J?<*#m2Qe zGJNf^ExWt|&fB`4w{bmmdjFx7t3I?C-2xin>*?Yc!f`$Kge6mh0uRdp2G1CQ_tF3V z`*Uv$m{GK1ZBa{_j>?%BkA%%iN$WK@85zpHzH)jyS)Ko@)E)EbQ)fOtsn2xArT#_R dn-%A~^HQ#|oVff$xd~`6gQu&X%Q~loCICQ1TwwqJ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_er.png b/src/main/webapp/public/flags/flags_er.png new file mode 100644 index 0000000000000000000000000000000000000000..01377ae5771faf844b16a6852e6192ad0cbf48e0 GIT binary patch literal 204 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDYF2d5Z6~M<};j)!pcvalQey+ zWUMnoJ1JM+vGcCbTJ6vN`VC?8R-2Yw5%Kw8Zum7#??;pN?^zn(3Uy!2db}TKLZGLM zV+hCf*wfxZ4F(*{0at!FF1_$OKCEC()F#ExMi)$UK2LL+Xw$+OUeYXGJW=aE|9USr zkt(5PdF9qU6>`5N-k-|J*if}-@dm7#9Pnd2Cv&=lDzIZLra0X9TKbLh*2~7ZW C?@OEj literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_es.png b/src/main/webapp/public/flags/flags_es.png new file mode 100644 index 0000000000000000000000000000000000000000..d0b393bf7d7b147288b52b92e93810b53adf06d3 GIT binary patch literal 190 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}s*#!86xE|w{yEE6|d=bz4wTwS^ zF&tj+aC3&+$7$^Uk1%|k%D%74^GcV}{@jGq6=pl*B=^LbR62!Rn9aK@M(fI!?LQAH z-v^pw>*?YcB5^r6VF6FUrep>`Ha>v_!IWnXObL!@YHE36C5~=(dWmjgCXQx`jB;ir mj$v_nbzv!QBXXDXFfh1iu(&-|UlI&7h{4m<&t;ucLK6U3+CMn} literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_et.png b/src/main/webapp/public/flags/flags_et.png new file mode 100644 index 0000000000000000000000000000000000000000..0493946395bab28db753abe78c7c57b3aa6eb0a5 GIT binary patch literal 215 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDW?FR5Z7BmQtUPE2eum}Ru`{2 zBqrjqTE>6rpSzqNX7aOl8Uz>Bxn^}Nlegb5p>w%UxmPuaD?fhfCQZk*9!1}UIZe5_ zP1&K3y##@#1$eqRhHzZ3J>ezopr0BdPXm;e9( literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_fi.png b/src/main/webapp/public/flags/flags_fi.png new file mode 100644 index 0000000000000000000000000000000000000000..c79484ebeb961d994597cc5c77734556e924f55f GIT binary patch literal 121 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W;!VDxQC^eh_QbGYfA+8Lj4KcOr{{R0k?bX}2 z^tf@%tk<8D=K^JwJY5_^IIbrL1O+7;1TitPHTgI?2C}(13M3rhn;5(jfc%E>A zs#i>Y%iQYIjAO8NWsbF)^VQm>sgU8p!oRet&V-D;ici*KOa3l_cmMzZUr9tkR0tiF z!ABN@FbqJ!Z(Fhyn@)PW|1n{eJlHWq$P!bEbIAP3SgLBNlK{42b@~Cne%jUnnQBq3 zWfbtsFnk@zVU8(9$pQTKdN$6Mzx!UDEj!;Ijj>~0XB@Er)q?|*p;=4w00000NkvXX Hu0mjfOhF` zY=qLGveS2q!LYiMiJh;Y$EB89k&K>^h-RtOl9ixkqQ7dAi$PhB zKUa>>!_tf|%&!0d0A@)eK>B_c1c-CC&aLefK|Pbu`j2UNpY666>BxA|4raUjRY)5S4_<9c#}NDC7qn?Udlfpa23 zEyjXeOsuR&7n~7tbS&2}4h#qo3D(`j>G8}7XyY4p4xRfKrgi~!GkCiCxvXTTM%y z$R*S4GoOF>S-SeR=fn$pOhX!7N&|ygDu9Z0JzX3_IIbrrBqZ>cB{wiVn%UGObg-{U v;lP3=OH3*!3UL@n9A@04F)8X(4l~1(UHtrFmy9m}H8Oa*`njxgN@xNAqy{rF literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_fr.png b/src/main/webapp/public/flags/flags_fr.png new file mode 100644 index 0000000000000000000000000000000000000000..1cba62e2c6a408855ea044bc361ca2045c33ac08 GIT binary patch literal 105 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n`~f~8u5UFh8I-5~|NlQEd&lR3 z>hvFXl7Uj9o-U3d9M_WtL_|!2f`Sqmnj8?2| B9I5~S literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ga.png b/src/main/webapp/public/flags/flags_ga.png new file mode 100644 index 0000000000000000000000000000000000000000..9dfacfbd263809c5f18b77474b84f68a7b041fe5 GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W~#0(^7eq%odq&Ne7LR=Z-u2lB@`SttTjl1_Bzj^TF?VpdIZ{B$xP;)v9s4LXd#W95AdU8RWmd1%C z3K}!soteOw!aVckxoF9TBSC7c3f>bM8y`NKqtfb literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_gd.png b/src/main/webapp/public/flags/flags_gd.png new file mode 100644 index 0000000000000000000000000000000000000000..edc237436358759ee4ad0515576fe962581478df GIT binary patch literal 228 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W;!3-orFXf&DQZ@lTA+9T;^!JpiuT9b|F0lD? zQB0vFFVdQ&MBb@01Tp6>i_@% literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ge.png b/src/main/webapp/public/flags/flags_ge.png new file mode 100644 index 0000000000000000000000000000000000000000..fc702ed39e5f1d09f4636915935672df1ebcc554 GIT binary patch literal 179 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n>H$6>uK$66f#JWs{r~;@|2sJR zfBN))e*S-X`TzR*{{;m8A3pp)EbM=Mee9gBB|t6io-U3d9M_W*7?_+B*$fhV84PbS zHTZn9R7gu>oN#ih=#ic{hCt@f)sG%Mx}?FV5+lL#u)&d&LrT>_Wm8m&@Psq2n|RlW ZF+|kxmoLvV(ghmH;OXk;vd$@?2>@stKFR<9 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_gf.png b/src/main/webapp/public/flags/flags_gf.png new file mode 100644 index 0000000000000000000000000000000000000000..0646981291960d7228fa6b567e1066a28e246518 GIT binary patch literal 232 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}sIR*HHxQg}ZT$hx*dy)6-S-$VL zIbL4n)|{&O`wmCZI+?)5DtnI$Pu?l9Zj{c{F4vIE*v;;_lQMn;G?Ky7)z4*}Q$iB}#L-*% literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_gg.png b/src/main/webapp/public/flags/flags_gg.png new file mode 100644 index 0000000000000000000000000000000000000000..e81ad6745f3f57f0f6aa8d9aea65f9e3478d7022 GIT binary patch literal 154 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n(g8jpuK)l4|9SY-3qjqt-U%Pe z6@T6pdu^`qK1=D1P4MM2It4&wR-P`7Asp9}6BwA3Q`sa8yxWX@=X^AnI_1igkU3K( zO_|c*b2UJLgUR^eq9%t6j~JCcN~Fc{q&wXKS}DaHbMMIl4xlLvp00i_>zopr0JH}* AiU0rr literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_gh.png b/src/main/webapp/public/flags/flags_gh.png new file mode 100644 index 0000000000000000000000000000000000000000..f60438e546ef591d61b8bc09ffe7c0e5f707c783 GIT binary patch literal 155 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nDgizrt}E6nznv(}kZpfXQ0>n} zu^IE_-y9aX*DjeC%?VU8w~3dZmvM=S+F~Hb$kW9!gyVX$K^rq$laHf=!vs!-f|(Lq|J+(x2h`2r>FVdQ&MBb@00=-V As{jB1 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_gi.png b/src/main/webapp/public/flags/flags_gi.png new file mode 100644 index 0000000000000000000000000000000000000000..f5c613ce494cfb7b893f119d0c2fd447902632e6 GIT binary patch literal 223 zcmV<503iQ~P)P)t-s|NsBW zB?j`cw#-ynt}{8$VP>>GMf9_`+5imv{QR~~ROGU>iAOBXXl^+G0GuT&%>@vPEiq;Q z0>=Od-5CkTVFD;30PwoK@43AAnbV2@002)(L_t&t9aX_u4!|G?1;D2Q#cDUZ-v6Xc zjC?SIA$RZ!i*SMW(YDbGC-Wh4aE7y-`+hF)=C!qT#xQj;QLU8X)IrS?)Id!LYM|!6 Z)cjAr1SYAWQ=0$)002ovPDHLkV1i%#TZRAt literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_gl.png b/src/main/webapp/public/flags/flags_gl.png new file mode 100644 index 0000000000000000000000000000000000000000..5d72262f3f9b201f36ed556992422a1da4594c5a GIT binary patch literal 190 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n1_3@HuK)l4zrbVsymiKlwwYHH zobUQ&eK~yQX<5(fsms1yym8Yq^3VHEkJ9VkF5dL>!P5_$_L~O2`VZ9W>FMGa!f`!0 z;Q*tBw{pUn?xv>IiaK`gj*fvGlRX7E4(OyRt11MTnVF>th%j+Bq_Vi085n$Up3%fE nA+gM1W>D_SrT{e_1w+PZ%elU8SHEEoG?c;9)z4*}Q$iB}xNt|A literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_gm.png b/src/main/webapp/public/flags/flags_gm.png new file mode 100644 index 0000000000000000000000000000000000000000..b7e639ffa604065efe4b72abeda60599bcee890e GIT binary patch literal 119 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nLIFM@u2Pzl_Fml1BhzD5u5nIK z?fr)B`H~aOfU*jnE{-7_*OLnxnc14o1Xv_EFnJX;=xA}U^BgO1n83-9V<7%=qS9`V Ou?(KBelF{r5}E*KMIQG6 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_gn.png b/src/main/webapp/public/flags/flags_gn.png new file mode 100644 index 0000000000000000000000000000000000000000..5ec8902949503e8803057fefd7f81741d36d17f6 GIT binary patch literal 113 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nLIFM@t_)KW&IzjhxhQrwKp|v> zd&qM4_sLsM0A-~-T^vI=t|tqKh?oQg1*I}DaRjF>n4w_K!0>q;S58wKgF8?agQu&X J%Q~loCIH8-9Z&!O literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_gp.png b/src/main/webapp/public/flags/flags_gp.png new file mode 100644 index 0000000000000000000000000000000000000000..519b7cae4d4721edc19227d1d919bdc82799398f GIT binary patch literal 299 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}sB?tI~xB_YW;0y*`1_ogU|JZl| zJ_ZHr3I>&qKX*A9c=QA$wdeJ)&YNzbAj4>?$GC0=8-o=?UynmwqsQ4T?BUf+`KA5> z`t^1BEC%lR3=s^Rehl_uObQ{4dzZ1r2Q$ZKM!Y%CaawQIQ=m1?o-U3d5|=~Ih6^25 z;9&{4ID2JZc31KLV7CW7&fk(7%`Y){Y3gkhQQWF}oM}sTL%gLY5e?|m6QZ$vJYtzLG2q4P1nE9>N2uXmPT uTU@d2AiM9D%L2zQR{jzBm$rBRJ9#Z{w*5XiJ{myhFnGH9xvXGlr;~^Nn-Kq$4y*`=%_O`PCbI!lq^T^wY+vD59-Jcw3xH6L`CW}suKV3#y zl@ouB)FmUhFgj|kxrvy*AB5EB!M~i=vb^KU_s-0S;P{aM003A?L_t&-({0L04uBvK zMA3o((gM!2-v6A^i6kogRpq77Re3ON0m+n*#uCz^W5KzgpBSkIAkIS|#kleC5+CHJ ul)}GpwcNGaRK!9312t2QW>z`&xv3kZ!UjuIIl-*}0000G`r9_kW6-sTxXt}_9S+qJiVDQ-B2s0$FQO{c}L)oizD!8T9D# Z#|PQY1<1( literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_gt.png b/src/main/webapp/public/flags/flags_gt.png new file mode 100644 index 0000000000000000000000000000000000000000..b2664f7972fce6cb1907ea263a53424f72bff481 GIT binary patch literal 156 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nx&b~RuB|&Ccuv3Y|Ns9NZ=QVl z{%P0AHH+8xA3M4B(5ZPJUp?=gS+r*V%*S^(zPh{W!;>{SUEjUX3y-nFetP4y=^^V|E6-jET5-Q*;)?!`bxsqv+im_;2-556;uyklJ-I-n zg-MW&kxe6k#ds#~1E0I@1!>pKER6SvoICTAF+I!gP#V*fn3i0Im+#rXJe+0=atMQ` LtDnm{r-UW|=YTj# literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_gw.png b/src/main/webapp/public/flags/flags_gw.png new file mode 100644 index 0000000000000000000000000000000000000000..27077208d3d16f64365452d1d0f484d590f885e8 GIT binary patch literal 141 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*Da8Pv5LbqIo_{WiofA~+vNyaN zpsS*s`zg&Cat--)P7sp-Z=sX`IW=@_#7lS{#-Yoer4&I$Lmf% zUiou}r{rkFcta#l3S$V_6|)8nJ0Ge-l+ev~A{!?R|Qlg)`HnVLUAJPrrAOgWS| z%$QZqFqm=-mE_zM{&?W{?S3j3^ HP6anX$TGkbqO zdGsPT_V33J@27M;i}ilwtoLQps{jB0KMAybU?%_f)5o9JF9X$l-Lo~b|63-|1P4zS z#}En0wFgfMHaKvw9B6zhac%V(`^f!^>`&*_Y<%=~Muo-&KOx_!bArouXd6ElD4D%V k$L0A(ZxyTaPGWV1jI#w8rFuEaKxQ#`y85}Sb4q9e0QlTdE&u=k literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_hm.png b/src/main/webapp/public/flags/flags_hm.png new file mode 100644 index 0000000000000000000000000000000000000000..d372e40f72d42a162296b5b7b53f55ee0645578f GIT binary patch literal 222 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDc1m>5LX6HRi9Gj^BydvWCeCA>S WrWHk3UR49lXYh3Ob6Mw<&;$UvXICEp literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_hn.png b/src/main/webapp/public/flags/flags_hn.png new file mode 100644 index 0000000000000000000000000000000000000000..5fac3cc2139a637cbea40cf4a6bdde83107580a2 GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*Dd_;85Lbrc^Z)<&XcUEK{aTxiWQ1SA$6Nf&~jUWF#n0 nU^1NSDs$GsN7X_hLyW=BiglCJ^qs4Kx)?lN{an^LB{Ts5Of)mI literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_hr.png b/src/main/webapp/public/flags/flags_hr.png new file mode 100644 index 0000000000000000000000000000000000000000..372b89e53830f9e4b1ab2a7df5774e293940c276 GIT binary patch literal 193 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDa!z#5La>WX-3ME_oa8AcMjaV z`Ofdz^Zr*?|6jH0KLf-6|NkF4`u$H!TO3gEf60>j(-s62E%|@oKw@@jM{S3c-1UDz zL!3Qb978y+Pd#BR)Sw{1a*^>)=)V_#`MozeCrtdXXx*{RjBZ-0f74(5{mb+`H)?Hf tZ${gzg!MCBKh2sh7@F8T=R4nVwY&(HBHQ&6CxON>c)I$ztaD0e0sv)1QKtX^ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ht.png b/src/main/webapp/public/flags/flags_ht.png new file mode 100644 index 0000000000000000000000000000000000000000..648053642009053be4c78f187cf17cf7efa69ac0 GIT binary patch literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W;!VDxQC^eh_QYryHA+8Jx^DhaQEbC5u_VC8W zV~6Yd&fh(Ip=$CjUIxbAi8;;FkF~U?eiS?N4X9qp)5S4_<9c#}f?$h&tFiB#gHsd) mQdTs27z9@f&EaHZnaQBLpRGK^$~X(Cn!(f6&t;ucLK6Uk@ht-Y literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_hu.png b/src/main/webapp/public/flags/flags_hu.png new file mode 100644 index 0000000000000000000000000000000000000000..2b7d26d72f12c53a7de2c5057ea917de08c9a548 GIT binary patch literal 110 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3HF^gw{O+Ql_3Rjv*Gk$u_G~zt3;%YUrHR zcJYYAv2yEgfmcuW{Qv)7{!ZeFq8En(S4o|CG3UMYj=esm%#4o|d4InsacTw{z~JfX K=d#Wzp$Pz-$0_Up literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_id.png b/src/main/webapp/public/flags/flags_id.png new file mode 100644 index 0000000000000000000000000000000000000000..03fdc567a294206cc574d7d2a1835b250d009a24 GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&sGmyMux9lvC;tcQ!as6Lk|DS>3|NsAvY~HIu tsytmBLpZJ{bFeb9{ZV0A!^HSUg<;Qb*~hinf~SG<44$rjF6*2UngDgA8mRyP literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ie.png b/src/main/webapp/public/flags/flags_ie.png new file mode 100644 index 0000000000000000000000000000000000000000..68b870401e122f0155590396030d290bb23ab52c GIT binary patch literal 105 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3HF^gw{O+QU;zbjv*Gk$s)7oewok6{UOvO zamM30oxWX-iO#ajhtD=gES<68wSs()lttEq1Oowvjjn7rv^iB)0QE9>y85}Sb4q9e E0IS9yf&c&j literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_il.png b/src/main/webapp/public/flags/flags_il.png new file mode 100644 index 0000000000000000000000000000000000000000..617bcf6cada7859cd2ccc1dc5476b9e39d7acc58 GIT binary patch literal 174 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W~!VDzEwtY$iQn~>?A+G=b|4;9@!eFt(KJ#Qw z&$Tb#{#>~A>G8|&PT8l{9eA;3-;2h%w;i)i)y=%+oO8-{qADj)qm8GFV+hCf zM~j4jCg!%ZWQ`4s+DZBin(4lU+zm!M_iXoR=(Ky#(ETfJX2VLcG^R;AJ+>+CWMpJ8 X4iM1T%=_N}Xbyv?tDnm{r-UW|@-#vf literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_im.png b/src/main/webapp/public/flags/flags_im.png new file mode 100644 index 0000000000000000000000000000000000000000..5fe29266e7e9c48f4342aef10786ba964e2b3f46 GIT binary patch literal 178 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDYF2d5ZCh}+7DK&xS(KmqpALO zMcI|QysJJjXM{8_>N#D>FTQB1e>F1W-kfP)o}GQYf90EpkIu=PUUjnl8g1tRG{M}{ z#W95AdUAq-?1pARqX!9zPo6$VN^=qwO-WCEn&N!@?6LDF)Eo3p96EfiFH_K5-R!pI chuwS((V+~_rZH(30*zqsboFyt=akR{0GOUd@c;k- literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_in.png b/src/main/webapp/public/flags/flags_in.png new file mode 100644 index 0000000000000000000000000000000000000000..e234bfda5d2928546d402f9e926f4ba6fc6256de GIT binary patch literal 148 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^niUB?$t{bke`G0rMe;}A?EY!h1 zbLNHR%damzx$6D-ofQ>_3JdpYSEMNe73+DrIEHXsPc~>{W^3|sRG5&#GLgs2K|qR2 u>EM|YOAFGXBxZ0jyF`Y=qLG zveS2qRBM=af2PpH)33SEQ-pU$i&HIsGB#~uWSDmkE^)l7r@W?)YJ8`{f|!ALo>xqM z!-JV+gpjh#xin3PF?CUgpR0_Xv&+VdLQHr*R#Mg2*D*I=$v85}J}%VP*UGhP(8JP2 z+gO_b005&&L_t&t9X-KUR>Lq91;C5+s^so-NH0!-6jDgM|F!YQ3=Xw7ZQJaJI>r(f zT`j_Q-PrftsIDNJ9=|34KK?;vF`J*h6>uH9zW?lYPIOt$XJ@m7`mk@>w()uhQcQ1) zWfcOpWlq~Fr>csv#QW;72qP{NF^ClO`hDIF3UMz)pe|66&Bk2;W3mY+%bAGtdd+4D l%CagrghE5e`uJgl)gQ^(3hWhB-?0Dy002ovPDHLkV1jLHwd()? literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_iq.png b/src/main/webapp/public/flags/flags_iq.png new file mode 100644 index 0000000000000000000000000000000000000000..47903a49317b41b16082ce1a000c2b4d592c019a GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n>H$6>uK)l4f46?yIYBj`!0Oej z_uoC&yl=t$v)j%;yY>G2=lk!Tt-pH6e{tpC>^~oYDs((u978y+CkHe!vo-lRS|l(u zww=&p|nbYEC@hysd&~Q+KkHd(utVT>gYwl(}pgsmqS3j3^P6h;OXk;vd$@?2>_&pKB52s literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_is.png b/src/main/webapp/public/flags/flags_is.png new file mode 100644 index 0000000000000000000000000000000000000000..4ebc8453f27816dc493bd92be0b0543629e3b114 GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W?!VDzuzEV64q@)9ULR_DQrZuiS#}qW@j-2V+ zzL^KEJb&=&^8-uY{gi}o_|IZev>r@UJ6O#ZC9m!lg3S0z06Ff4`rDVR) z2ZNwe;{kIBAn#{HXjTwR7%q6pim`kh62kfU9%ufk*tV Zd;wp22H5WxYcK!+002ovPDHLkV1l40Ur7J} literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_jm.png b/src/main/webapp/public/flags/flags_jm.png new file mode 100644 index 0000000000000000000000000000000000000000..a380e175413e552dae1a59bf294e33be171bc050 GIT binary patch literal 159 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*Dd_;85LbnTM*l7{C`d6d%(eoG z&2MIyw?}kd6T|$yqS`CpZUqY2d%8G=a9meCW+=#@z{7mNM<&~)fMZ3gmII?yKtqYg zuglHWYXleH+cDu^yi%#}-|Kfg=e>IJ)@lof?7aS2{}?>|L`ri$=WGNT#o+1c=d#Wz Gp$Py=*E8q< literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_jo.png b/src/main/webapp/public/flags/flags_jo.png new file mode 100644 index 0000000000000000000000000000000000000000..cc5422b544b1587ef99da7189af9c31d17117288 GIT binary patch literal 173 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*DT4r?5Z6_!R-F@6W2mxSeR==? z|Nnu)*UUXq6a(kDc0&NDDJWi~Zc$2v4P8b}`$5wS_(rch)}P)A{R@*=H&M)#!S2)|!$Jp^;ipJ%@fB=?LAtE9oO-8JZ k48bCvx|29Po+U9cB)G7=e)sKPJy0Wqr>mdKI;Vst0A>d*&j0`b literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ke.png b/src/main/webapp/public/flags/flags_ke.png new file mode 100644 index 0000000000000000000000000000000000000000..88cd07fd966efc149c36341acfe7ed873c5d48f7 GIT binary patch literal 205 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}sSqAuoxIXOZIT;kN-Nhx4fnhfT zgJh}X83zZ3GzJL?37`@!PEM&(DYayQgoFf-QX3;9qniZZ-I(UJY5_^IIbrr9N?5;P0(>$?8+CQ7Ffte@j2l=J}gGkCiCxvXXD{yb zD0swgc-J?TS#RM32Gxi6?(a`+-4|W|^y$;L*KgfAc=RrV%Ja&e%gfe(I(^aZ zuG2Cy&nGiIX=l1%!Eq}^Fu8c%{FO(3Twqwe`Sht%yC%=yn_jW#f(h^46wb>IJO-}y zMzdmPf!t9N2SUV10Lpp%SSb;-`D;(4sx;= zh^Xqy(G>9C_DSRW5wRQEmv(s`&CiMcC_CBh^{>Bil5^$U@7`pp(EIrOX8Y?#3$MbY zLsOL|iHL*oU?AM zuAHy)Y`?;}9ZK_7X@pMI_nc(VB6;2oXn2UHi(?4K_1v@JLJbN$t{2&bIz+2)|946) zDQPOOKffd)^0dl>JH{L`(M?OF7o9wr5S^;KKskiZwtP_oSD8Hr?adB~Pv@*}YJv;ya0ES6KK~xwSO~D5egdh+E zz)^u+%sF1p@&DgD4oiz}kSp1A0tPw0!w8F3$>yaLj107B-x1^lh$;uoyj=)<}{(^8y+E nu3;bW3fB1a*#SdzeZa0CFVzXQDQZ!T00000NkvXXu0mjfCm)1! literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_kp.png b/src/main/webapp/public/flags/flags_kp.png new file mode 100644 index 0000000000000000000000000000000000000000..2aa96f0494a9c6d4634db150bc399383675f8b67 GIT binary patch literal 158 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*DXjpX5ZAXd>P-HN;+l7SO3Zw( zYyNB3zOVJIKW5JR_u=CQdyg-L6`z8m-%6?dee|Ruuq+&?OW)JQF@)oKvOo(no2o?u zLlY;D!Gqa}49QaRo(+c6i`Wm$c=xt|)y5##qfK!Q0|SpRXELX9#5SN-22WQ%mvv4F FO#tl#H7WoA literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_kr.png b/src/main/webapp/public/flags/flags_kr.png new file mode 100644 index 0000000000000000000000000000000000000000..4a737a2bb9ea3f2f0cf598dfb365f428cbba39d4 GIT binary patch literal 315 zcmV-B0mS}^P)T3G|>(9<@-keY&DorA)@o64lyW!h4kJJFaN_@b@oH!?lsn~ zu4tTdA1s6l5hpi#AOWriY49v<#qZYhRfO_A&q!a$`ao-U3d9M_Xqlr%E3wJCF`{T8v9 zoH$X4g<)n-lR(3%m>eEP9-f@F3k41w*kCGh>`cQ(C&r!`Vu^m+n|y%!89ZJ6T-G@y GGywp?P%Z!f literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ky.png b/src/main/webapp/public/flags/flags_ky.png new file mode 100644 index 0000000000000000000000000000000000000000..33c479920a6fef6a793db0de065faf612e499173 GIT binary patch literal 272 zcmV+r0q_2aP)G7 z!_(7ui%nm61te-4H+a&avd9oF#%+T@SdQwZL5iS-KUIIAteust%-7FxqMaDuACgPFju!;f)zIZt_^v9GgTe~DWM4f0Ujv$ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_lb.png b/src/main/webapp/public/flags/flags_lb.png new file mode 100644 index 0000000000000000000000000000000000000000..09a452f2a4ef291c7fb5e8c60c683a0f3c479c3c GIT binary patch literal 199 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}sSqAuoxc)kJ!hTPU{wPU8m&xD*A3f#)ts|C1cEl?0vR~MF^wSYle yTU}XymV&gox-z$Pi2WU>v^qUGi*_S# literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_lc.png b/src/main/webapp/public/flags/flags_lc.png new file mode 100644 index 0000000000000000000000000000000000000000..bdec5c8ed9ca55a6724636238fe303a83e1ebfdf GIT binary patch literal 188 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDYF2d5ZAOb|L0wPFCZW=dG`GL z^Zys@KOY|-|L3Au&&~hc=Z?htb1d1lC(KVP|ICLsheht~R&G18qw(U0z{C5d0!?uA zba4#fkgYu!&DWs7!*YO?E4s4!|7ov3lO|a-*RQfT+R?m3Wy75Bd|@Xn4_xpHJG!o9 nlX%X%gQ*<*Ztai+m9LwqSXAf{T`&{pDG$5m1-1r;B3<$Mxg{2PU2H2WPIV sWluPBbh2>58MUQMY&;4a>?uZ!3)gW*<}+%~11e|mboFyt=akR{0N&Oo2><{9 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_lk.png b/src/main/webapp/public/flags/flags_lk.png new file mode 100644 index 0000000000000000000000000000000000000000..8e8da850b9e7c89f775ea6c6437686969e1e5093 GIT binary patch literal 277 zcmV+w0qXvVP)uubO4_)HrS6R*qI&wz9_O@Cctkc{IMdqW+0|W zER-NMr&1uzdn)p=CYn7fo-;J+pDWCJE99Of@2e)`rzOdO8^LxV$bltZosq%-003=C zL_t&t*KLkT4umia1RD~w*^`ZR1_t>5D+vcgkZ8$$C|T-)()#b5D(`$1bXDbbo%It4 z0LA(h7i=(EFB@|}n?Pek#Eu3?VYd##13zHrKW7$7q2*n9yF5Qz3Nyl7Btuc@^r3iB b=}rCs-AxAwDD-B600000NkvXXu0mjf^W&o168@c>vRKB`kpS1Asp9}3mTc(-uS32U}1UXsvvYG#J9=V tbk>72Yf=l+o`e`&mf(|MdB|Wb%y8?jDDSiLUco@E44$rjF6*2UngGJ1Er0+3 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_lt.png b/src/main/webapp/public/flags/flags_lt.png new file mode 100644 index 0000000000000000000000000000000000000000..13c6ea41b0547028629189a5cb2303f5cf072514 GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W;#0(_mlS(sy6lZ`>h$};u%RzPBzdMCnm;9Uz uQswF57{YNqIf9jmXNHQN02k8?6^2|^;kPR+CcXp8GkCiCxvXh%3Xwdv7%@|NsC0_1&Ud uAXT0&jv*Y^lQ~!!*?fvbIT92L85nX|h2IMNoVyJu&*16m=d#Wzp$Pz0j2Yqp literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_lv.png b/src/main/webapp/public/flags/flags_lv.png new file mode 100644 index 0000000000000000000000000000000000000000..bccb85eb5d4fc5af25cb0f70cce46baa1609c7f5 GIT binary patch literal 92 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3HF^gw{O+Qp%n#jv*Gk$#c>Ze$H>~YUrHR q#>>kr`Tzfa`yGcT6q-!aWMh~Vz#+BRQ27r~IfJLGpUXO@geCwZzZ&BJ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ly.png b/src/main/webapp/public/flags/flags_ly.png new file mode 100644 index 0000000000000000000000000000000000000000..298393fe99c95a5d2862c72f291a0773a79fd3d0 GIT binary patch literal 134 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*Dd_;85LX~AARr*Go88$mUSr4$y{>A!47D`B1Yq$OvXT=DJ8l(UK literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_md.png b/src/main/webapp/public/flags/flags_md.png new file mode 100644 index 0000000000000000000000000000000000000000..3ca84e6a53038e1b0cfdc1217a3b266852065605 GIT binary patch literal 188 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDVqSF5LX7bb!)o$@`9Blq}W1A zO3p50Yl%}m!>RxO62n~|`3-I|S2wcno6YK)P*NPq7#{Cd<0Sq52t$p9l9`s^_S`Rm zKy$1;T^vI=t|uojFpKdSJV;?@X4}(rr)SL?iKa6#F+MMv?i^xr%4w3&`dMihQuJZt lM#H3A=MH-hvFXl7Uj9o-U3d9M_WtL_|!2f`Sqmnj8?2| B9I5~S literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_mg.png b/src/main/webapp/public/flags/flags_mg.png new file mode 100644 index 0000000000000000000000000000000000000000..46f0a575edb6cf7f9ae29b6b3d1429af4845449d GIT binary patch literal 118 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nq5(c3u77Ne80xJ4|NmbXYqt8z zzJL37UpcvFnL@;6paL0B7sn8e>&XE@L0kz944-D6JeI(aWX09QIMav0#h$0{<1(2r PpgIOmS3j3^P6sg&a9?`~E~%Y-(;y}+JgDZ-X$FkPp=w6CeBt(bC9dWot~cD0Coebadu zznpxEUN>4*1Lyz%00?waPE-B=|NsC0|NUR8G=%^F0Cq`4K~xCWRm|HKf-nq3(NG{J zEm%PAsDR-A|DtI@h4Z-AtN|cWbjtJ~7#=C<5glgvpWcXVCzvwt+gpRUYGweHa+z;b zELF`MM6wLC=q`3G+O8Cd3Eb!G$PU_z&G^ti*)VxMkK*w%oyFk?(9#H3 TK920w00000NkvXXu0mjfq;`@E literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_mk.png b/src/main/webapp/public/flags/flags_mk.png new file mode 100644 index 0000000000000000000000000000000000000000..ce71c21c3d5ba2ab9719db94385d41fb6bac7401 GIT binary patch literal 184 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*DXjpX5Z8ArrJhvD-Sks_GgtbY zg2vBlVi$GPZbvG9J|OYqtk}mb63^RZue&SVPnADf_Ol$Q%hS`vF@)oKasY>4;m4f9 zLgUn=g20TTLW9DK2}Owog@HGoX-qo%gehs4$o##@EK4L7&0y_TQD4O^rQ@3BBr;?7 hjs;oH5^Wop7{ruioQ}_CjQ|?T;OXk;vd$@?2>=>@K;QrX literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ml.png b/src/main/webapp/public/flags/flags_ml.png new file mode 100644 index 0000000000000000000000000000000000000000..6c0dcf0651a1282c2020f69699c86eec24266aaa GIT binary patch literal 114 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nLIFM@t|D8l&IzjhxhQrwKp}CT zZsK0u{)bGnfU?q_E{-7_*OL?wiAuZjcYWv-N1u-Gi(guA@NKF$No&&FyW;w- z!fpP{_aExnPMj_@KXO9zqB&ePO37iZr4DBU!>(To5;Akse)ZzU`G)z+Di~$%ux(&< SJ-Y^I1%s!npUXO@geCxV7+|OX literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_mn.png b/src/main/webapp/public/flags/flags_mn.png new file mode 100644 index 0000000000000000000000000000000000000000..2b00e7bb9c2b77a7827781948fd95bc0ea4b1922 GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*DXjpX5Z7A@Mht12Cz{7jGK+ca zuks>YZjyQIT|=E8+d1Dgi@mLte3d8jWdYwqXN`**sTY8{%sgEjLpZJ{CnO|zh=_H$6>uKcA*m77+`)n?08#|BOI z-*V-gZd>X9|NkGpdy}|ix=m;3#Qocro<71Vf1n$v#oW`yF@)oKa>4=58N3gC#Kk2} zcLgRG`q=H|S*G;W#b08|%BYKSEU8*~Q^cBH&eq^G@9|{ZK9|8fgoA6r%-s1vBN#kg L{an^LB{Ts5lN&eV literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_mp.png b/src/main/webapp/public/flags/flags_mp.png new file mode 100644 index 0000000000000000000000000000000000000000..ee9d621311f60f12d4dc93746d4c3639ed38c88f GIT binary patch literal 341 zcmV-b0jmCqP)$Y*{q;b2xvahbMiKMoeth$%2o{El>in-Y8?eLkw+=7avxSUvf zg^&?4QaJzs0B%V{K~xAG1;GUpfX z0FqXjJbw9M6adithWK`k<5eF3n|64ouV-jS09b70X&Owr02nOuPIW2rvH*a_e4S0RU?CdJW(Y8Bzvb9L`%X00000NkvXXu0mjfBBh;5 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_mq.png b/src/main/webapp/public/flags/flags_mq.png new file mode 100644 index 0000000000000000000000000000000000000000..ca303e897430d55b9e8363df29085b751f296527 GIT binary patch literal 219 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nS^+*Gt`im>S9I$8|Np;Df(88&Rx6nDxz@Ji3^X@YB#ojdiE5kE63BtF@)oKvVaJmm`IR_ ziLpqEz?TGOqgH95Mjyv(ZVOg87);QN-Yj@xPo2aGlNCHHS_$*DPB1X}a3y$g@=b7M zoom_H)@kCyB3UMu(t5>BA-IJ*Wyawwh0Du&0zCqgDz!XJ4hm?z64T*eSaV5KH$6>u4lFE7?O++>)0yf=?G-# zZ&=E2U7=puCp}@N*!rac|Bo=--_91$sQgrSXADq_v!{z=2*>s01P2CHiv)odW`oOn zSx$+FiSUZ}uoxfZY2|HI^qJ`3=oq--mY@5?mjTO;yo&8L5YFK{%-cMxJ4Kp_f#D%1 VUzNc%t}>u;44$rjF6*2UngH2zGmZcN literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ms.png b/src/main/webapp/public/flags/flags_ms.png new file mode 100644 index 0000000000000000000000000000000000000000..31b268b05abb7b6f9960719f30a340e1039c0c52 GIT binary patch literal 246 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#Hsn`IY5LX7p%nOGvUoP*yv~Ux{ zg53u<966HHaza4QA-vCK@q7Y9P#}E$LsRzyZ8Vm#& z4wgPSF=NjE|2{E|Px>UAg!eqpl9A8sy>x4Z-;Wf1jZYmOR;Af%eY6#}t<#;zt$t5= u9plE|S)Mvq&BB+QO4jl8b&mgVoVi_0jjl literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_mt.png b/src/main/webapp/public/flags/flags_mt.png new file mode 100644 index 0000000000000000000000000000000000000000..b83f796bdc8d504d1aa0f4b28dc94ab127a5a6d6 GIT binary patch literal 140 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nDgizruK)l4KQE$vXxr9bPoAG& zv;O&^quVyDd9Y>2&qJr)U%kF*?b9h45*yJ)78&qol`;+0LYp&DgXcg literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_mu.png b/src/main/webapp/public/flags/flags_mu.png new file mode 100644 index 0000000000000000000000000000000000000000..df6294b36cc13f3ebfeb3022427d30affa957da1 GIT binary patch literal 126 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nq5(c3uKQ1jNGas@R-5@LCA`wG zWLO&b|0=^D?E_js1uC8{jv*Y^lM5P|+1~ghTOKQy;{an^LB{Ts5bhaXO literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_mv.png b/src/main/webapp/public/flags/flags_mv.png new file mode 100644 index 0000000000000000000000000000000000000000..3af54a4190b308b502f113e998cc5e5083906698 GIT binary patch literal 153 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n>H$6>u9pN%80xG{k}TXJ%^B(} zGu_RF+T9JN#h-tED`|b3!@O+6Y4P>@=6qv_p8(XN>*?Yc!f`!0L4hM;>H!6Y6DeXM yBJ<}dWiTYBadaJ+kuBh(68Q0^z*Y}60R{%&dX5l7UICCJ7(8A5T-G@yGywq2t19OJ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_mw.png b/src/main/webapp/public/flags/flags_mw.png new file mode 100644 index 0000000000000000000000000000000000000000..9f2daed48a89cf2cea88a2108e629a4a9e27f4fd GIT binary patch literal 155 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nDgizruIB{RX4z{4c^%x6#`8>_ zn7Lxv_<0$bBpI1YI7RiD*kEal|aXmSJscF?yg-MJZ-ob|tEj)AK zP3}U0Wo~NBhqtIHT$bRI;9zpJNGNDzZt`K!5Eq+z&-ZE!P&b38tDnm{r-UW|6gnr| literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_mx.png b/src/main/webapp/public/flags/flags_mx.png new file mode 100644 index 0000000000000000000000000000000000000000..0004186959ff97436c4b84ae5dc1ab6264b27d19 GIT binary patch literal 189 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W|!3-oluE!PvDf^M z*4dqpl5~MaS$euShHzX@PGDdbD>HcTgn^mOh~?%C2}72fKPoFOvhJKQvxQ~n1(wK} pKU-LLT->_ZsB1&eBNY`#2G$mq#j}(CJ_j1Y;OXk;vd$@?2>>zIPyGM@ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_my.png b/src/main/webapp/public/flags/flags_my.png new file mode 100644 index 0000000000000000000000000000000000000000..b1c71f567e6444d3f2c5df1ccad59646a4f0eb0e GIT binary patch literal 189 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDYF2d5ZBi;X1ty^?_vLBdj|LS zn>H~pq`h6V=s{}gv%0$5US1Zu{s(4C3h>4&NJTcJ882v1OYpUSzn|^aR>4z16P!F< z978x{a}ORDY%maDIe3&uNZtG2$)K8P{kvFQK6t9Vn0&1vr-|t?=MJBM6?<7ygpYJY nHKthQ7{6V1fA4~&#Sd9|7O|Kb?oTlVn#SPi>gTe~DWM4fmU%@f literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_mz.png b/src/main/webapp/public/flags/flags_mz.png new file mode 100644 index 0000000000000000000000000000000000000000..641125194fbfabefca14820e01dde823f3cc038e GIT binary patch literal 257 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}sB?tI~xL)EnxoxfaFiP=?tU+Ua zVPQpiG@Iz3hYSpb89*gJFEjIIrF0p(cBQ8sm$g15WOQ9y_sdSsRZ6xRaj|bE3H$qm zoIg^yvrpsA3Z1%|*mK8b6q_4vFLW<`3sIEF|_rXF+^ zYfxZtILNP&SbqJl|I1?4M?8^P^$Rj{IW(%JA52{Ojai^eslRqXe=YM{n~=vfzFb?X z1J|7{XeyJNFjc@+?q|h`BQ*@Gj2Q*Caw-acE@n>o#r`tz6oWC)5(ZCKKbLh*2~7a4 Caa+Iu literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_na.png b/src/main/webapp/public/flags/flags_na.png new file mode 100644 index 0000000000000000000000000000000000000000..99f7e3cdad7b8d27d1bd2d252357d6dc023c31fd GIT binary patch literal 310 zcmV-60m=S}P)Z zyl{=U!nDcSJXzMU!ho8*?%nvjwDhSTA_rIZwwh1to zFH)LS9Z!HHITvhG7I=)}IP%*z1;`RfpJ8}Sw)3GG&pvFmnIyfPw95g=7iJ!%zH0&6 zeQhJoHjmFP14!3P3s_wq07bd7F(v<-nF*k{PPQN3?#;~X3+7-5N^4LdZU6uP07*qo IM6N<$f^WBkd;kCd literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_nc.png b/src/main/webapp/public/flags/flags_nc.png new file mode 100644 index 0000000000000000000000000000000000000000..34a619e5daa44169836aab952d3d7ba4d5de1d00 GIT binary patch literal 249 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#Hse}NZ5Lbq&&X12WKeIPJu$*h2 zP3*5{3`$--45n-6$5_5~G`-y-{_+~bl|u|g84RE9Gsw*_SFY3Cwx5fy(_Fh$v1YC0 zmUe~7?F@&e%57aQ>*2&u+wN3b%&?`z%q+Cn+KgdZ7sCs#mJ*;{A)YRdAsp9RPq+&; zC=CBow_Mkw#Q^j(CXz!FE3D%NH$t@XQGFX xK+v6_?7Nk-WISaV8KQQ64{dDUrTOtWOP@NcMcu@SszCD@JYD@<);T3K0RXb-T08&% literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ne.png b/src/main/webapp/public/flags/flags_ne.png new file mode 100644 index 0000000000000000000000000000000000000000..bdce81cd73618bf801cf82e0bf370ba0edce5235 GIT binary patch literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^B0wz2!VDz!vnKWeDTx4|5ZAcVMGu15UUf(CZqWY! z|NrMb^MAj*|EeQcT>Fa{P>r6ai(?4K^<;q-X11UiUIhmpFtI#bsvvZvL|DY9(I}Rc m)!D!`F>7Ljq0EFsEDUY0r6&YBEdB%3$>8bg=d#Wzp$Pyy)GZ(Y literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_nf.png b/src/main/webapp/public/flags/flags_nf.png new file mode 100644 index 0000000000000000000000000000000000000000..e1f1b2fa8d34242cbba7db56d3670edc4a8ed1b4 GIT binary patch literal 175 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*DT4r?5ZC|z|1(sYy!!I){IeVH zzkk|)_l$g}Z~o?<_|^6NjrR7l()1@q-hcPBe)mlOMMeEbmTFA9z6Yq+%G1R$gyVX$ zfQU#)LP7#l5RZadH{+a>aSZ8g@omQc&o&q@Hcs%Fx+b%IfzQ)a>z)Lz2xft2Rt+;4 Y*39C~Z8YD*2{eVl)78&qol`;+0LE-V*Z=?k literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ng.png b/src/main/webapp/public/flags/flags_ng.png new file mode 100644 index 0000000000000000000000000000000000000000..488068e78113c5d6e0e36b98bb65f2694bf711fa GIT binary patch literal 109 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*DgFST5Lbrwz>sxaA?v$B*LVN_ z|NqOZ&&fb3NlzEY5RU7~2?+@Xg@uJl4GapGC%XAe5M_`*#QAjQty#4|MGT&Pdb&7YU~o~U@@M``_N~jgT>`Qjx#EZ3=EGI WnM-d!mR1KUX7F_Nb6Mw<&;$SyVJIK~ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_nl.png b/src/main/webapp/public/flags/flags_nl.png new file mode 100644 index 0000000000000000000000000000000000000000..564a9154a552f14743fdf7d79d2799745c538120 GIT binary patch literal 117 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nLIFM@u1_{^-*)JxqFeVm8IAw{ z|8G5bW8cy_w}G;9o-U3d9M_W#+L+mzd{iDVv0Kb&Yzj~~wSa}4WhTR-8)Ev!EAAQr PRWf+G`njxgN@xNAm3SoD literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_no.png b/src/main/webapp/public/flags/flags_no.png new file mode 100644 index 0000000000000000000000000000000000000000..bfbd46d5d0b29b6c71a1863d188685948aba3ddc GIT binary patch literal 150 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W~!VDzEwtY$iQYryHA+GPWb-z~E^e@=MppkLm z`s3KL`5){Ye$1U`65RIt(BaaqwG$Wa4YHlz4^*$~>Eal|aXmR9A%VXvxq<1?%%-ME y2m6{74s7uBTv<6$h{HhQFykhTNl~A2m>Fy?3kVin`TG>8k-^i|&t;ucLK6UHEjAti literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_np.png b/src/main/webapp/public/flags/flags_np.png new file mode 100644 index 0000000000000000000000000000000000000000..1c3205df622f02997b92dac3afe99c3ce767b645 GIT binary patch literal 371 zcmV-(0gV2MP)t<7H!pp^&E)c@w*B$*YW>w1x{8%{$te@}I_`O@0=#m}I%*U#DO?uwrI)7!%sMR1S8 zmmXJPCv1BoVdq_WFkPp6uBU|n006T|L_t&-(`C@v5`!QN1<(L$6vTaP?Y{p17dEI> zl)Pl-jOJd2V}{OvJ`^s75TucdP&R(zAVA-qT?Fvb%tg32MY19To_B_yZM2}^60wdM z1$uL7di_)(4syKFPo)W&L{+o-^xKr4f=X|hmwqI3dhm_bx%?{y{;s^pdAw1=c&L=z zC}C}OZeO7C%Iq7nl%AAkcXk?mJ3m$GD(ns>oqxChyV>&VY~ACipu^p0AAdEB4M}f= RUq1i<002ovPDHLkV1iQEu#*4) literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_nr.png b/src/main/webapp/public/flags/flags_nr.png new file mode 100644 index 0000000000000000000000000000000000000000..e915a4886732bae7c0689a39e70d9cd1ee867b8b GIT binary patch literal 129 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*Dd_;85ZCOSFb3`V51+mZShOp+ z_B$lZFDXqpaO(br8_%E3-c}7%rs(P77{YNqS)hfPt;t7Z0$VZ%*LLYcSGu%V4{hC< bFpGg9UWx7caV1V?pkfA3S3j3^P6`mXj*k%OXG0!%=#3Id!enDbB4PPYqS#ZL@k`v;P0hAak8@zsZ}c4M39^JYD@<);T3K0RSbkO>Y1I literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_nz.png b/src/main/webapp/public/flags/flags_nz.png new file mode 100644 index 0000000000000000000000000000000000000000..7d9e16151b61c37c9288df2c6bbe4ca250351781 GIT binary patch literal 207 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDfvQoDk4+i0DfSZVdNt6woU=)HI{9VqQVhs>97QtJ3>@yz1nwt2z_f4ifove_)UqE9SJYD@<);T3K F0RWJ#POty~ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_om.png b/src/main/webapp/public/flags/flags_om.png new file mode 100644 index 0000000000000000000000000000000000000000..9c658d41d01b476254165439d661c81e33e9f00b GIT binary patch literal 146 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*Da8Pv5LbovBnfG-JUp2No2@d;w_3C5az}VV^bwI^Bo-U3d9M_WtvW3ms7}zeZELavIF-KER t?-3ItPg6m|DUqNi28G}TMy?G;j3LI{o|aoqJpk%s@O1TaS?83{1OR3SE}Z}X literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_pa.png b/src/main/webapp/public/flags/flags_pa.png new file mode 100644 index 0000000000000000000000000000000000000000..0c45461b3d81081eea0924078e1bec6999e9c140 GIT binary patch literal 158 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nDgizruI$R;|NsBLB_!26arN8I z-e0%xi0UU^y!K$*zH_;CbM7f>K8s9vdGr1xpn6kJ7sn8e>&XHvjH&_&4ryKIby~WY zDTL&gc-iyyn}4S)tPc)I$ztaD0e F0swDKG*tiq literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_pe.png b/src/main/webapp/public/flags/flags_pe.png new file mode 100644 index 0000000000000000000000000000000000000000..7a462ccec411237334b5974ec6e2e28dcb6bd240 GIT binary patch literal 95 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&sGmyMux9lvC;tcQ!alI*^{4_r2|Ns9cYd3&Y q3wpXZhHzX@PDqG0ZES4xQei085^!8@E|&$AWbkzLb6Mw<&;$S~wHX%x literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_pf.png b/src/main/webapp/public/flags/flags_pf.png new file mode 100644 index 0000000000000000000000000000000000000000..d2ccc545723ff15da40e45668171440a58ca3885 GIT binary patch literal 218 zcmV<0044v4P)E~y18!~vn;imJ zxFmVFplo)Spcpd~lY|JGpa4Iw5HlZ|Ae%6wAR`-^APc9sC@0)ZIACQ(uqpEq0McOx UB9&*XssI2007*qoM6N<$f}3Ml`% zD5lQL&dbOIQlp|QARyq!#&^d{;eMpNfq}twQ>AxPr5YL<6ciK+3JN?tJU%Xv{JvN0 zb*J=!0|y>Fc<{%%P8X=N($mE;MB;L4zo$@(0S_}XOUmS4U4#GiFJ<27u z%+UVok^J0G|NQXOE>X)F;+3#P1$Ph%W@ACS<&gze++5YjhL~!gd0000=NklISE#1{IUxf@S~!002ovPDHLkV1jaPZQlR@ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_pk.png b/src/main/webapp/public/flags/flags_pk.png new file mode 100644 index 0000000000000000000000000000000000000000..442d03c561ed156ba0a1ff58c4d856247c8653b4 GIT binary patch literal 183 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n1_3@Hu8fW{|Ns9_=qMEM(Nc)8 za3~1PomlhW`6JyV=YtoIfB5oIF3fD|`UUF`Z*N%8cmDR3n6k}bK)p_$E{-7_*OLWA zL=qa9N|v$CIa$(hkl|EGdcxWUBU4F479^2?mDVN1Vq6Pxhq)O=IwM^>bP0l+XkKSp`1G literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_pl.png b/src/main/webapp/public/flags/flags_pl.png new file mode 100644 index 0000000000000000000000000000000000000000..f220bfea71a9d8e00d5e0c843a1a3cc6d86c146b GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&g8<1QX;1>s^G(BA$Lo9le|NQ^|zn)EtNi9pt vEK8x3;q>lzKSN9>eC+Euo3Tmq9UBvaUjj!|;w#^|Kurvuu6{1-oD!Mojo=SwN!*Ox)!otvd zdyH;f0FuZ6k;pKm(*Mr@K&;jvp3l$E&lrogEp=^Zgg(!x0RR905SPmwn8)AW-8rVv zGJ$yCwE*2r06j+^3X;bcnafG7(~E~49F3xdm{B>Eim{LY@4^62m3w-cN}-b$VsR}} zXfczMC9<+Lh?h2Wf-Y-wCBMJF)FuGvcmTc+7Sb93rdmqlf`QLOLMV{AM2CNLp>zJ*731E3SdUF+lfgo;~RS%1=vzQ5Fj84zD6ianW4Tq{$TphKO0K%*lUwApb zzA*3a@7FT`?2G`&9}e6>03cc^@}vOJI1KKh0L%aY-&p{$zOC=Uz=5HK;_Ky6KppeI zz`>JS8Su|CMt^fc46m(KfQ{U9a%iqbr$=JHi)5+7v(9qn?+;5Rb!2kdN z^+`lQR2Ugm!P9~RK@b4IJ|<`N%&e6~Y&$vIwr%s<^L$nC`}0+Iy6_*U{~7PqUc|+) zxv?G#&rc&F)V@DnYki2Da>{g4k|a+e!l(0Ay>CI>Q}X$PbRrTbV+cco13jH>NY0Ng z7|XFWlZ_)7s-PO}cycOd7>l#CG#f+^3PM3}Nx|&drDA2bl1np)AQe`ogqq*b5 z?UjHvfe8KG?q+ufVp6e`okS!WK!Q*y**DisqA12fnbqaF(CEmp2+#JTeb?&scyynC zLHGE5y5@!AQ|X}ygiuN~ZHnVKo)9R%lqE% zYEHy#gwmn11SVn@Iawb@OUMu|Kv<5$gPGHJi_pZ=G+RAga6V9LJttZ@9cp>Ou*89Q zo>xqMaDuA9uftAUPdr&hNqZrim^Cj%Su9^y5G-RnK2$GNOmUSRR9bUYbTmd*XDeV> zV3>P9ikgg_JjKwDW^QV7oKJj!KS5cK(8JQ+`&d{2003!8L_t&t9d*H14gw(nK*2{9 zaC@=q+AH@zpZcT0#2I|=kdoHde7Q!&5LE0OFJ{f#=mQG&+f}B)by0;dR*gfKR_yL` z4ZUxXl1J^qp0A=5q>AczI^?AC>T+97Ntw+sGyGa)cETS|wgUyDK`3&_00000NkvXX Hu0mjf$g_Nj literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_pr.png b/src/main/webapp/public/flags/flags_pr.png new file mode 100644 index 0000000000000000000000000000000000000000..aa7bef7fca4d5773f4c96ff6460a2a020ad822ee GIT binary patch literal 219 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}sIR*HHxPG#?=MR6o9^-|NnOk3>UcMtZlYL8cn;;%3qznBYx7i{g;17d9ItV`R9XI|1LX* zUUmv|-LLT&Xj+J;i(`ny<TrP@j5f5~%`SXAME@OcYI_kda%GohiOt=LiPXv literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_pt.png b/src/main/webapp/public/flags/flags_pt.png new file mode 100644 index 0000000000000000000000000000000000000000..1be1ceea800f9eb3b5c12c4d64983a63f1a3ad34 GIT binary patch literal 226 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}sg$MYAxc+Bg_%xrvvV&piVTK>! z+J9siK1^d^NMrcs&hRBp$*qs!-jk1iZEe2?JDlCLbMXO&4~H0jY-RYgMswxP+h6J! zYSu7pK6wA;BL?qYhE+!y7H`)4Ua0@KNdNeuW${2O+&oUt3x0F+eh%%f1uVwh8zj`F*;jc5X4=_FI)+Srb0%od9w(+FH~hBs ZGekXQ30YS?r5|V_gQu&X%Q~loCIBnmQzHNX literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_pw.png b/src/main/webapp/public/flags/flags_pw.png new file mode 100644 index 0000000000000000000000000000000000000000..2efb74444967bf8dc2c8444fd423befc95a843e8 GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n>H$6>uK)itFwFd)uz$Y7()$jZ z_6y8^zu;#4hoAfpKFc?psXy?{-hR`8{ZAeGXSE&%YO(ZmaSY+Oo-EM9%oeh&W1Hd> z&8mil9H&H*6A}zs3{)F@TseXnPR!|0>{=35!gz{LxIwt@C literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_py.png b/src/main/webapp/public/flags/flags_py.png new file mode 100644 index 0000000000000000000000000000000000000000..3ebdc4930c0999933ad0e0cfa4299e507d481da4 GIT binary patch literal 149 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W|!VDyP)i)FXDa8Pv5ZC|z|6kRXW3X89xG*!c z>gfCT?+;!-^spe~+>MJNm4|1Xm~Y&fa15we-_yl0gyVX$KnpY58=qta1AVrMJPHRc x9$;V&Tv?!&%`~Hkv1w+ogG#fIOhP&n!^U5{W_fQa4gs|?c)I$ztaD0e0stmLGiv|< literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_qa.png b/src/main/webapp/public/flags/flags_qa.png new file mode 100644 index 0000000000000000000000000000000000000000..9b0e95a986e679872e5a1b8fab5f3ce73542af17 GIT binary patch literal 120 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W+!VDz2Hy^qOq=W)|LR@>LZU6uO|KsE5W4-hC z*H79}&~};Y#4(_(qNj^v2*>qg0TB_dga!sjM@M#x;1djrT~j;PFnCH|F<@9E#$0O} SnzRV0n8DN4&t;ucLK6TGk|dV^ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_re.png b/src/main/webapp/public/flags/flags_re.png new file mode 100644 index 0000000000000000000000000000000000000000..98b2ca358784271545d2ffd940c605e2c7edabd2 GIT binary patch literal 244 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}sIR*HHxEiPZw~v3n|Ga;i&;H1! z^Z)-d^sMav&%iKqbA4LR{-?YOK9%?98&ufmzptM^zu0a4CpLjiY6&+U8Eiflzidx_ za?BwiJApkw({eps977}|V-K3S94wyt)am*E_+Cj4&No6r%coc@b=cVbT!5Xg zZX;`e^vjT!i$eVlUvm(>Wmb@Vaj~4d^#3mQ_#G`9migT*H!02Ls0p2TYVzqzsmJ?Q s7OiaSK3#OWuX*OvL^A`wPe#4+oTpf--iNlz04-+lboFyt=akR{0E@X|EC2ui literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ro.png b/src/main/webapp/public/flags/flags_ro.png new file mode 100644 index 0000000000000000000000000000000000000000..8ea5627e8bfdfaa418376a7829ef25d78310f5e0 GIT binary patch literal 105 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n`~f~8uIB{R7_{sEToemQioP45 zu$R@!zow~%mey@F(OXdT*wFm)^4^kLKtXD&M^w^^JK&ywb#{~I$jZiLIm2U8 z&Rj5vJyP4e!q$n6$!l+aNW-82003i2L_t&-({0FA4uCKa0MTvH#frQ8{ZEJmLJRL_ zk^%V#T#>BrY(}$4#n}fzExo3`n=IcSD*XY6mNJ{nz;o&9sfIYa#o?y5D~ZSKSbKni ph`H&|B*L3=PRxiJqBwc~a|82T2lAqO{T2WK002ovPDHLkV1lKkjr{-s literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ru.png b/src/main/webapp/public/flags/flags_ru.png new file mode 100644 index 0000000000000000000000000000000000000000..6821d59ba7b47c991256dd1cb06938967f15934f GIT binary patch literal 112 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n`~f~8u5KokAytPNESFu?mizzz ze-gg{NUe;gi(?4K_2hy^X0|3DM~j4jCT1BQl>{DkmYEC!55?_uIwLuN>KHs-{an^L HB{Ts5WQQ9S literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_rw.png b/src/main/webapp/public/flags/flags_rw.png new file mode 100644 index 0000000000000000000000000000000000000000..84ea77af260e815c87082798ca5ee02455733632 GIT binary patch literal 157 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nS^+*Gt~ERJ6%uTZY~yEGc<}V2bTbK>3O<1hHzX@HfUpJ^I3NE zOM=AaPa<*j%utw_)3?2z}L$K*dI$E{-7_*OLuFV) z13u@(O3!Pu3a}nq*0_x6M~{JsNQlxQH8uvFMQl?n+S303bu)On`njxgN@xNAGc7R| literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_sb.png b/src/main/webapp/public/flags/flags_sb.png new file mode 100644 index 0000000000000000000000000000000000000000..0caccaaf129f67cb1628fac78f661fcd7b983db4 GIT binary patch literal 184 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*DU|@95Z8U{6?UvOX9(P-7;PL} zs%?=wRX+95y%*mEqW3L7^xVDSj9&gRBYO@#pn9f~Aiv=E@8=)5`w7T%^mK6y;kce0 z5PT_&i7}M1?aPucDh8)#IqD1T>7n)78&qol`;+0MOGve*gdg literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_sc.png b/src/main/webapp/public/flags/flags_sc.png new file mode 100644 index 0000000000000000000000000000000000000000..2ccd568b0d202a2d4645b332f6534f19bcd6b694 GIT binary patch literal 241 zcmV14NQ98Zs_f2Ku9kj{wwn}zLWuwX0A5K%K~xCWRm;&5Kp+r>;ia-AA{BZ6 z7gf75Id|uq`9aeh)XgK(fGkwnSHup}RV;e^q3cq_Ndd=SI;lZQp!iKmB%$N9iL?TA rs40sT=dmruK#IOdyFAGG?k?{EUa|&g0Db3i00000NkvXXu0mjf9i3?! literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_sd.png b/src/main/webapp/public/flags/flags_sd.png new file mode 100644 index 0000000000000000000000000000000000000000..80517e63bab457aef0ea1b8a10c1ed7a37919b67 GIT binary patch literal 192 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDfCd)f?j@ z9tQ(Wu<>+p4B@z5dg3%wgMf(Z#l`JqD_#En-#+V67Vq@F4bqSHu%DdF#`%7`8B4?b oZMW)w?J;2q58*eTJ$ds|rjk1>4g8Wpu^^{-y85}Sb4q9e0E&@Af&c&j literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_se.png b/src/main/webapp/public/flags/flags_se.png new file mode 100644 index 0000000000000000000000000000000000000000..f937cb003c379aba3581fa33518701ad16f95517 GIT binary patch literal 124 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n`~f~8uKPD@?%S-zkhT2Z8HV@A zxSn2|`2Z-T;pyTS!f`!0ASfs)Er^McEoeqhQ`5P=CWQkDJnTFU58fPLV0$u?n}OMu VFCf8${|-&XcT1_=#~QzaPzGX|=0bZc_!JaOTAsn((54sCAC2|sI6SpxxvYNZ+ ktoxnHVqf7O9F6gMrIJj$>pi!30F7txboFyt=akR{0K{ZpcmMzZ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_si.png b/src/main/webapp/public/flags/flags_si.png new file mode 100644 index 0000000000000000000000000000000000000000..ebfa53e43a0796be4adbb59122d6f67874e9ba12 GIT binary patch literal 143 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*DY*cj5ZC|z|A$O^#}M;0%y!0q z28JCMf28E>7f8K!{=u*4vU7*UnvVlj>Ug?1hHzX@PH)kHOQ`&t;ucLK6ThDJ`P_ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_sj.png b/src/main/webapp/public/flags/flags_sj.png new file mode 100644 index 0000000000000000000000000000000000000000..bfbd46d5d0b29b6c71a1863d188685948aba3ddc GIT binary patch literal 150 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W~!VDzEwtY$iQYryHA+GPWb-z~E^e@=MppkLm z`s3KL`5){Ye$1U`65RIt(BaaqwG$Wa4YHlz4^*$~>Eal|aXmR9A%VXvxq<1?%%-ME y2m6{74s7uBTv<6$h{HhQFykhTNl~A2m>Fy?3kVin`TG>8k-^i|&t;ucLK6UHEjAti literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_sk.png b/src/main/webapp/public/flags/flags_sk.png new file mode 100644 index 0000000000000000000000000000000000000000..01e2c89c78c74bdcbe9d6caaff01cb4fe8580e83 GIT binary patch literal 207 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}s*#!86xQ5zRe6Vo%TG#S8K8M?H z(a)7@KZQoebRK*sqx%2<|9H=a_gY2=8m8CBbR>jy&(sUfvn%+VmH*f?^j3D|J&_X| zf#&#mx;TbNT%LNuU8q5Uhv5KA&;+HU|Nn>DOf0l`At&C)`(vukUhdl=q5WrGlrkzw zJ(|N4HSfy{nYi_5wr-u3>-SN`N?9V&Yw3~%hZ{fUnG1@u$0kZ%eFij{!PC{xWt~$( F69BvfQHuZo literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_sl.png b/src/main/webapp/public/flags/flags_sl.png new file mode 100644 index 0000000000000000000000000000000000000000..a7d36d7a1e6ee813d71beb02165e2b12fdc013d6 GIT binary patch literal 117 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nLIFM@uB$G*+45|!+*T`wqGSL6 z|KIX_-!;Z9>OfgJPZ!4!j_b(*P0VafXB-?g1X2qcnPq%b5_s5IW-=VvDW>lf!srQ9 O$>8bg=d#Wzp$Py>NFm(- literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_sm.png b/src/main/webapp/public/flags/flags_sm.png new file mode 100644 index 0000000000000000000000000000000000000000..482dfcf39623e1a22b67ff9ca2be94268f5387d1 GIT binary patch literal 291 zcmV+;0o?wHP)0001cP)t-s|NsBs z*~eX>!MoAhhLM6?wBmES;a;}nuiEi^VH&KRaiWSgZgoL}ovVm< zM)dUbHC|myj-*tj&`@n&o1dZY@9#x=ggRGCk$zXHkyvhue{P11erYdcSuadoM0AgW zi*z7Ddd>uS3AAym9V!FTL)@te(Qq pO+Y{}^`OvA3Bk}b17v*n9ZzaF2VFJ?_YeR8002ovPDHLkV1iyZf2IHc literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_sn.png b/src/main/webapp/public/flags/flags_sn.png new file mode 100644 index 0000000000000000000000000000000000000000..3ecd16631695fc2588593dfc4c4e604d5525b149 GIT binary patch literal 135 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n(g8jpt_-dAp(`E!zIS>ot^6iY z=j{tehXr<<&f70NWuJduYBx}sx~Gd{2*>s0goFfvpr9Z_GqW^>u9Z?O>0(`nXY$Gv gq(x|bY)dp?R9eNaHKW355>N|+r>mdKI;Vst0H~oTTL1t6 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_so.png b/src/main/webapp/public/flags/flags_so.png new file mode 100644 index 0000000000000000000000000000000000000000..70f94ec1cecbc02d17e660c29da043188f25bf03 GIT binary patch literal 140 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^niUB?$u8y5|r|o^?GvWTffB)B> z`4BPv;n@dYw_pA=@9?{~pZ~o1{QGcKeJW6~nx~6n2*>s01O+w&%>y%Kcn!popB6FB osM#bL)4D-1W^FbHb0-4>^CspJ<)wyKfI1jFUHx3vIVCg!0L%n5egFUf literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_sr.png b/src/main/webapp/public/flags/flags_sr.png new file mode 100644 index 0000000000000000000000000000000000000000..26c2681c177c761403276f599bc6c88365fbc50f GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nasfUeu0Ni?YTwkeg-h4G&VHAi zR@=tz*N0>eS!$e(RlVJ-6cp>94peFC>Eal|aXs0fjhRg)ZGnPFhmiuKszN{^)5L^j yZo)k)jdU+_s=b=V6dv%1FHMP2rFdf$4})2N(C)qS8KQs&FnGH9xvX=qmXH*CrvXq&>!fIlpcPvxrtX)};R)|&Z*br?ZDwn+;80Ium2&hmZSXT| z_;u;uRgh_(E{-7_*K<#M@--+3FdSr)lqo#e`@eqbzopr0Dq!KKmY&$ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_st.png b/src/main/webapp/public/flags/flags_st.png new file mode 100644 index 0000000000000000000000000000000000000000..a029334bc0b779c2d8522f20123570fc6087175e GIT binary patch literal 150 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*DY*cj5Lc5;s+R;z{-0wITC3gX zt^YV&;lUOL6D*^47=>Je8ZNY@NF3r~NOa-f6LZXEE>Jszr>mdKI;Vst0L2w4tpET3 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_sv.png b/src/main/webapp/public/flags/flags_sv.png new file mode 100644 index 0000000000000000000000000000000000000000..17965f43c8038492ed7835ea9f914c4f58e466d9 GIT binary patch literal 144 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W|!VDyP)i)FXDfIxK5LX8G)efZx{{R1PU$Xzh zhl`J1pPsa1-T$BWTlTGAcx3*D+tao`-v0m3d%JS!1fUibPZ!4!j_b(@3M@Sj8yq<~ typkMLX6#HdoAYuSSDF;pAqK}}MjacD?Sb2sm4NCQJYD@<);T3K0RVTYHDdq( literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_sx.png b/src/main/webapp/public/flags/flags_sx.png new file mode 100644 index 0000000000000000000000000000000000000000..bdeff0c9a872c9370fb4a46873d3b92efcde95fd GIT binary patch literal 286 zcmV+(0pb3MP)& zNlxc*D!nps&*MsH))Q@Gw%Hw~(oTmBT<^ zrErJ4?TU@X#-^~n+UtCO^tQOsrl_I9*7wwBk-XaZ)71RsTu!F5|M=3`jxy`nW9Fkg z=f06{vD}!k!S1qS+N6{htU62p003S|L_t&-({0R05`#bxLs5|hgUP`JW0Sf630dNy z`O8-&;pKWF&^8{Bte=o?SIp;ezg>XMcUwO!H2fW{>N_&=uV5~gvO1>tkLD0eWdOV07*qoM6N<$g4M-~(f|Me literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_sy.png b/src/main/webapp/public/flags/flags_sy.png new file mode 100644 index 0000000000000000000000000000000000000000..fe0384257978460b3c6d488934007b29cb294eca GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^niUB?$uB%q9dcS_#|NsAi0_OzP z`j4zE-!c9Cvs?DFa_+x-#!zL;^@=$UC}-s9;uyklJ-MKfnXSpk(IO!rD2OX4D2O%4 yVcD%N&snja3CpThO$v$-oY2YA^RPidnBn*m$>$#gWn+Q589ZJ6T-G@yGywo8gfou- literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_sz.png b/src/main/webapp/public/flags/flags_sz.png new file mode 100644 index 0000000000000000000000000000000000000000..2cd6beba6dd3920af75d7d21aa99a961d54ed29a GIT binary patch literal 318 zcmV-E0m1%>P)yST0nGpD@(0rA(CG_wHq7Hg;CtKwWLlf=)|6wNGPZn7`rhE-^a4Dva+#MFa7=f z&TK<7H$l5&6EZt$Dl|*OR|Y>ze{(=U)h>yd0001WNklkSY}Bd^MpU{4o3e(Ot6h%pL#%m|pkc_I@@Z$Fz1HAYeA8 zp|ZB^MDi{zM}UC3=0k67dlK2KcrgbEfBdPcCOZbe0ENPA_a+piiVBK~FA(nuUw?9c QxBvhE07*qoM6N<$g6#W!FaQ7m literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_tc.png b/src/main/webapp/public/flags/flags_tc.png new file mode 100644 index 0000000000000000000000000000000000000000..2d3a90174142dd6ab4386183c837076047456cc1 GIT binary patch literal 216 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDUSf35LX6`j4O*bTs(HEy2@+o zJd0zk(~svioD)nopQSGQgNT1Cg9&4(Ln=H|DqI=Jc3w-w&sw#OZ6;oMQq z9T8X?9#C_wyzTd?pLReqy**tVLpWrY9&{IKP!M1^sCa79+5i7n&RKG5_1ztM73ZIF zO%7T5d2NaCvZt&qjgGUzEVDydH{8yX+-3byPLprL>%^Z%nv(mRdK&M``LAU16t>^0 Q1vHky)78&qol`;+0Pwe15C8xG literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_td.png b/src/main/webapp/public/flags/flags_td.png new file mode 100644 index 0000000000000000000000000000000000000000..c59f4e9dcc3e2c7f5608efaf71bdbbec84845430 GIT binary patch literal 105 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n`~f~8uE%%`7}Qe!oo2Y{tq>aP zXD4mro3-2PE65%p1&&jYW;0f$%?+&D2Gs82>Eal| zaXt5}rBH(bk8|Knz9+)he$St#)GPVN>#T>+SUYb(@lR%V)~D<~0{ n^Y8xjm%pxfuU!zQaajDuQ`X7N`_AbP0l+XkK9#~6H literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_th.png b/src/main/webapp/public/flags/flags_th.png new file mode 100644 index 0000000000000000000000000000000000000000..76836d876b80b68a7a463f584f78ab1d595f91a2 GIT binary patch literal 125 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nLIFM@u6a34&n{fo)$&>@Y53*q zkLTyFiAUDR0cF)ZT^vI=t|uF`F|(_ z!0_+Wp?^Q_|NC-LyG~iJUit6q)oMHMngSIYc)B=-a9mF=Xk=znS!O6>(BP0I!OYV# x{ZK^C%T!K-vMWIbsvInHjU7~)g=7>A8IL*gXDX=v*aOtd;OXk;vd$@?2>?>eFSq~z literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_tk.png b/src/main/webapp/public/flags/flags_tk.png new file mode 100644 index 0000000000000000000000000000000000000000..2eb355f916adfc8a75580db9295a179a4dbbe716 GIT binary patch literal 220 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDfmdKI;Vst0ME5ZdH?_b literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_tl.png b/src/main/webapp/public/flags/flags_tl.png new file mode 100644 index 0000000000000000000000000000000000000000..248c109989986bfeacb347d628f939f13b778f9c GIT binary patch literal 188 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3-puyZf#HDa!z#5LX}_W6gWbL+(VQjJ_&6 zKQDWIef=F3`KP`L-{&jOuMxMgvH5>o?d#XC?{_K3d-0uLD5s~VCoeDm{-#1OP_vz< zi(?4K_1trwdzopr08uGKVE_OC literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_tm.png b/src/main/webapp/public/flags/flags_tm.png new file mode 100644 index 0000000000000000000000000000000000000000..747f9b2e1c1f73789f583b4d2d89506b46f9be90 GIT binary patch literal 283 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}sRRs8ixH7cZMn@ana@N~rZ!j^< zaJsj_>_iKN$)N(>o{fP9Einc~K8B@{25W-!``ryH9E>8}Osf)|0#;Qv9GaJ~scl|F z_%%fWtv{l^m!1E?yCl9sha%@>(;CME-XEz3w*S zqn3t9J8Pv9mRYy9J$S)6H$6>uFn~Se;zpUQb_r&gWs3_ z>3{A&{Qv*|TYKM+1y#S#U;3C|`S1OQZ}XSUIPPu;)S~C<;uyklJvl*v$v}5XP=k@= zU0c=#KEZhxQ#39w31B~Fa<#2nX=Q5GlmLZeY7A34Sm&>N#q%1dlfl!~&t;ucLK6Ut CX**Q_ literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_to.png b/src/main/webapp/public/flags/flags_to.png new file mode 100644 index 0000000000000000000000000000000000000000..e933b3565683515d81bbdb33e2a974c7fa08a0a7 GIT binary patch literal 133 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*DY*cj5ZC|z{~u&vxEdV%{r>&a zdU{{4T)9_S`F8W>UoT(&{qp5**(OJzN+nMh#}JO|$q5O%2>~K3jBIUY=@!9(1x#Xr gEm4jZ3ay+BTIn43xve<3fvOoiUHx3vIVCg!07#Q7`~Uy| literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_tr.png b/src/main/webapp/public/flags/flags_tr.png new file mode 100644 index 0000000000000000000000000000000000000000..69a32d46ce2f18654ba46dc525f285768f21da52 GIT binary patch literal 154 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^n>H$6>u8+CIU%LgoP0D^>TJwGD zj{pDvKao^@Vd3;9EdH5>$&W)v|2%m3ZS!{9l;%317ClcF#}JO|$q5QPGyG>BoUxo$ zVxi791va)`CWFa_6B-YeGR-*IDc~B^ox=oF|HhB)q{fm*RY08#p00i_>zopr0F{?F AfdBvi literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_tt.png b/src/main/webapp/public/flags/flags_tt.png new file mode 100644 index 0000000000000000000000000000000000000000..e9c92f99ff986f3ce1a38f08ad506c304429ec17 GIT binary patch literal 218 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W;!3-orFXf&DQceLrA+EQiOa%l4;^X7b9zLqC zum7T;asK@IK*@)$(RVZ)`T6-juH5|M*y*QoQYE_WD&?Y(L}{p68ySbfulAI95GN+-jZh)y8|i8HNrY?A!M+i%i*P R(gHM|!PC{xWt~$(69BwQReJyc literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_tv.png b/src/main/webapp/public/flags/flags_tv.png new file mode 100644 index 0000000000000000000000000000000000000000..9a462572f64c1c5b1f311896e8776437d4fac59c GIT binary patch literal 231 zcmV z#8*syz^}u9xn$9yvbO})F#rGnT}ebiR0thi!3Pq;Fc1X6krkFxa>W0CD#=A5y77mV zTFdS?E&|I->-}um_)vn|7cX)Su-um3+nJ50FoysfelN8a+dP-ZK!9Qrzor?I_@l5g hTBEziiP0p)+XJu>0~@RQALsx8002ovPDHLkV1is`VKD#z literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_tw.png b/src/main/webapp/public/flags/flags_tw.png new file mode 100644 index 0000000000000000000000000000000000000000..82451285c92a56ecfd0cbd18d8aba1c851e9a468 GIT binary patch literal 149 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nDgizruK$1xhN*Q7-U0%%XU)2j zl(e^@;Z%6|&P$ixIykKV|NnnIgV+7{8OlIaI-V|$Asp9}1y~r_3>5OE@}V( literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_tz.png b/src/main/webapp/public/flags/flags_tz.png new file mode 100644 index 0000000000000000000000000000000000000000..0a6184f69b057be00f5c102064af829491523fe5 GIT binary patch literal 276 zcmV+v0qg#WP) z8Uhfd&@8q$-ohD;bqu|n5C8xG9<@3T4FPXl2~V<}w!}?NK?bkKA+3)OPeKOIwHRZ* zEk85`RlG3Kz!;9bYud0;9Cd{c3FDC>bsKYwAG=IS<#K|6hXbX#!$us}} z0BK1?K~xyiWy)C=Krj#m(MQncd7kZmljtOlP@ne{e4}xii3o41n~=yvF?!Zbfs5Z( z6`e+r>MdTNuyk_X5+?#AiZly&%VHY}KiiG#?($}Nt*s1OQSlb`1D^id6>rCTlQ>VE aK{x@LR|l23#QL@X0000#jKQ9apEqoH8Z1y(ULF@B(AgY$;n1PR>X7#nBj5M= z{{R1fp^8s3P>-jli(`m{WbVO}j0Y4rSPq0Pez11uFMU(~AFYxT7*iZ9>$o;CUhVvN z=Wr)iSnaQKT!DLyf2?rO`e8i5$vY{wTJ_=tUW1K{IX@USs(5Z` literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_um.png b/src/main/webapp/public/flags/flags_um.png new file mode 100644 index 0000000000000000000000000000000000000000..09078c5aa2dbf14b657a9cb32f21f2d21e99ca2a GIT binary patch literal 221 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W|!3-oluE!PvDYF2d5ZC=4;R*4b2fQLfgX=Gp zHNMz;=t^_%la1RSt=V)WIPvj@ZI9M&ITD(D&?ow8YrltkxubP%phu}lW6~3#30x&X ze!>6#gTcRlf7@6#gTcRlf7@10@H`NP#JS=!_ zF4x;TOTNCn*R}Zit~#~^pqW0NE{-7*lDP-ng&GWaST^_;noW)S_dmF=;($;FgX&Th znMlUvmArmMHI)T7zby{WO5j>+De21i`sJ^q(o&ue*?o>ONIiJm?{3!6#=cgOZFyYS R_g0{}44$rjF6*2UngEFfT$ca< literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_uz.png b/src/main/webapp/public/flags/flags_uz.png new file mode 100644 index 0000000000000000000000000000000000000000..2b14e5b516fcc109c57a73139d55db104f877bdf GIT binary patch literal 144 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*Da8Pv5Z60vR^D2*S#GP<|Ns9P zW^PqpzCUos>6m@zt=1pmpTCn~#@4L49qWLKwLD!MLpZJ{f6)2rQO?NBwq0{wX27!S smzOv+f(7^_dLA;&>|~kpfWcar;lMT_kvA^f@jzV+p00i_>zopr04PB*rvLx| literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_va.png b/src/main/webapp/public/flags/flags_va.png new file mode 100644 index 0000000000000000000000000000000000000000..34a4cf1dd459bb630855812387aa8cdcb7e89583 GIT binary patch literal 273 zcmV+s0q*{ZP)xN#0001NP)t-s|NsB> z@5uZ4>-_xu`|q6o`riKE-~ZqM_4W1s{O;!E-sIVp|I*U*?634{UFp2M>gwwF@WAZe zfB5v-;NH;d*HQB0fBND&^5Syz^5pX8mg=-{)5F8i(9rw3mHDGP_S=f(r-$~*Y4fj8 z%DxV^0001BNklR~anp+OSY~ z%1X71GgyS@KH?A#qej%44$rjF6*2UngGD+ BF4zD7 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ve.png b/src/main/webapp/public/flags/flags_ve.png new file mode 100644 index 0000000000000000000000000000000000000000..163bb3178b92a956750047b649a7018afe36e58b GIT binary patch literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nx&b~Rt_&)*^0qy$@`-M8iEeT8 zgR_=}M2CdN1jiMxi7#1`UcF(${9_a6A3HCi{r?Pu%;%}rK#eAzE{-7_*OP0GGcfz` zu*~Rr$rNy{Uf`fm@gc@j@uvZW@a`IkX(F`eTLq#-AAGs8S*x?SkHG2R6 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_vg.png b/src/main/webapp/public/flags/flags_vg.png new file mode 100644 index 0000000000000000000000000000000000000000..dac7003de65f066ce2bccd1f5b8a0494fa100e09 GIT binary patch literal 282 zcmV+#0pGA zcZ<@YvOaZa!LY={Y=qFm(*Pi6$Pg|-SdOQ|nkaE5JaQ#VekiuDV~etiSYmR*f|!AL zo>xqMaDuACgPDDTh*OJCJyL$yxnwPQLSvCtJ#}htc7cJFTvw4~VQP3?d|SMsS74lZ zKUa>@qOw4Alvw}(0Aoo+K~xAGb-_sz!XN-e!3P1pWFDP0n&|zn=*|>Y+0Eo4-ZONL zP3&zj#)k35X7>fkuFp(B9K+j~VEYIv&zDMon+Cimx(-y#c_C6No2C+}<9*E!Eveh7 gu9rPO^k~H&9{}kDT=vaCsQ>@~07*qoM6N<$f*BKaL;wH) literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_vi.png b/src/main/webapp/public/flags/flags_vi.png new file mode 100644 index 0000000000000000000000000000000000000000..2e65977e4b48be90413088aab1756d0749554054 GIT binary patch literal 443 zcmV;s0Yv_ZP)byDm^WoyXR{s9}`}_O%?$qYFJrpEmceT~&&VuId=<3E~_3Fp;<-Fgu zKH|1D<-SqP;Oy|&mwm9#^zPiU%(ix?d*j1v`1ttW(5B_prR~RDv6@@ctGCjsw$QU+ zvzAA-RdKi0=Qx$dr_JJZve65Cxn-!#GLpq;on@`Qfb&FSb$c%il0E%b; z005^+L_t&-8Fj(c62dSP1;Eob$xT89THM{;-T(ia$neAru#4Z~2}v;K3I@qU8~_ia zWLm)vDg>2IMj?m6g~M3s*leClj5&hCkgz>RS7Sz1JDyDhP|sRycC7KZT`vuNNRe~_ zbO%;G$IGpHtyRu$*Oqhv$PVT5esHSSoR{Re!~1XTJ&kA z@W-`W|Nk?5JIVg{BcqP^nGZl^x}GkMAsp9}6BO7sSR9zShS6YJrE9g}l#^?wr7#Hd ndAF_(Eo|B-!79Ppv5|wprh>&WvMzNoP$PqTJY5_^IIgFjwH9hH;9(BDDag^D-|&0>)Fw%Vl+)LBu4QI4ZA|SrR{49ei}P85 zT5<1r%l~nHdb8Z6cn@b@`^Weimpk5PC7sn8e>&YB* zHI6Phz`({bGu@6WJ~$y`J%drXePM8Nf}vSi!wG{89jb?BJ}U_9h!SF8;56sW&Qj(+ Q0MyUm>FVdQ&MBb@0G10np8x;= literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ws.png b/src/main/webapp/public/flags/flags_ws.png new file mode 100644 index 0000000000000000000000000000000000000000..7892b7ff77d1431e237d6ce33cecdeaa1e9b9902 GIT binary patch literal 127 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!VDzcs*|q*DWL$L5LX86`g4M620_yWEZXH= z`W+JHPsv&51C-V9ba4#fxSpJl5RjB0z{1GJ7RJUZF}d?7!?6^DM(qTSCPUVT4I*j` YhP|x+ozul-fO;4_UHx3vIVCg!0O&CvFaQ7m literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_ye.png b/src/main/webapp/public/flags/flags_ye.png new file mode 100644 index 0000000000000000000000000000000000000000..9f3c0f17830c03b51713f3708f545380872d4a46 GIT binary patch literal 117 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&kGmwm~3(f^nLIFM@uJ6}xTeWJ{IYBj`!2kdM zSFT*ynkE|w16 N@O1TaS?83{1OVA@AzJ_d literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_yt.png b/src/main/webapp/public/flags/flags_yt.png new file mode 100644 index 0000000000000000000000000000000000000000..b40a523d0a0be21f8a3836ca7af8dc9584df2ca4 GIT binary patch literal 327 zcmV-N0l5B&P)U3Lz`(Dsua}pX)YR0wySviT((kaP=bMf6%EL`f zsm(eGo}QlQ=;-H(dCSYo_t(sCu3Q_t#DBs%L*z7H-WAGX;Hu*lHji-Nw^?{lL$-A`+4rUE`0$9%0kV@ zWf|W(%m@y?QEdGTL%K3$d^qK3yL6h;&LYAkM{1&NiyFcmE}mkZQPz-E9^8U}5it|M ZvoG;M2Vivs^wt0X002ovPDHLkV1lLapqu~z literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_za.png b/src/main/webapp/public/flags/flags_za.png new file mode 100644 index 0000000000000000000000000000000000000000..5b929be457a4268048eaff505fb8ae445020fc3a GIT binary patch literal 291 zcmV+;0o?wHP)3jeqq>x6ynkc#!ezR=U@ zu(sNOmooX<*zyszrT_o{X-PyuR2b7;k4FxGFbu%LZrGH)H~)Vkv{E6(N6X3p#ID~` zPXk&+49rEBP4rkRacB(n;FmI9i3BKe8CRF1072}Bj~)$$lrYW0zR=QDkeR!!__k;4 p1)5XdX>&$o`_F_FzaLz4>;@V$37xpK3h4j<002ovPDHLkV1lOkdXE4A literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/flags/flags_zm.png b/src/main/webapp/public/flags/flags_zm.png new file mode 100644 index 0000000000000000000000000000000000000000..3274c5a6135b5de8da35489196eca7019179a4f8 GIT binary patch literal 170 zcmeAS@N?(olHy`uVBq!ia0vp^A|N&gGmxCuw=V}s83*`;xJq_0l-Du%H8Lc&FhuJE zMH$Q`7~a=1+*1%Rtzt?{WNxWqm{!dY@5#8hh~ZEvgK{&&^7_r*Ky^l*E{-7*my;6| zI3y}}NJ{RUdBJ67#gCse3`CPTI136tekw9FUd&NcRIp*=rVX3%V*O;9`5~ ztvL*jeAN!kVpw~uunlOYtEY=&2*>r@bDn$+20YGz>_1X90@B{q2X6~G86CAL$Mo%k z4wI9O#rJ;CI? Date: Thu, 18 Jul 2024 21:40:36 +0200 Subject: [PATCH 22/37] feat: mePage --- src/main/java/fr/titionfire/SomePage.java | 13 +- .../ffsaf/domain/service/MembreService.java | 13 ++ .../titionfire/ffsaf/rest/CombEndpoints.java | 15 ++- .../ffsaf/rest/CountriesEndpoints.java | 32 +++++ .../fr/titionfire/ffsaf/rest/data/MeData.java | 45 +++++++ .../fr/titionfire/ffsaf/utils/Categorie.java | 16 +++ .../java/fr/titionfire/ffsaf/utils/Genre.java | 15 ++- .../ffsaf/utils/GradeArbitrage.java | 6 +- .../fr/titionfire/ffsaf/utils/RoleAsso.java | 6 +- src/main/webapp/src/App.jsx | 7 +- .../src/components/MemberCustomFiels.jsx | 26 +++- src/main/webapp/src/components/Nav.jsx | 26 ++-- src/main/webapp/src/hooks/useCountries.jsx | 24 ++++ src/main/webapp/src/pages/MePage.jsx | 115 ++++++++++++++++++ .../src/pages/admin/club/NewClubPage.jsx | 2 +- .../src/pages/admin/member/NewMemberPage.jsx | 5 +- .../src/pages/club/member/NewMemberPage.jsx | 5 +- 17 files changed, 333 insertions(+), 38 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/CountriesEndpoints.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/MeData.java create mode 100644 src/main/webapp/src/hooks/useCountries.jsx create mode 100644 src/main/webapp/src/pages/MePage.jsx diff --git a/src/main/java/fr/titionfire/SomePage.java b/src/main/java/fr/titionfire/SomePage.java index 08bd7d1..5bdb78f 100644 --- a/src/main/java/fr/titionfire/SomePage.java +++ b/src/main/java/fr/titionfire/SomePage.java @@ -1,17 +1,15 @@ package fr.titionfire; import io.quarkus.qute.Template; -import io.quarkus.qute.TemplateInstance; - +import io.smallrye.mutiny.Uni; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import static java.util.Objects.requireNonNull; -@Path("/some-page") +@Path("api/some-page") public class SomePage { private final Template page; @@ -22,8 +20,11 @@ public class SomePage { @GET @Produces(MediaType.TEXT_HTML) - public TemplateInstance get(@QueryParam("name") String name) { - return page.data("name", name); + public Uni get() { + return Uni.createFrom() + .completionStage(() -> page + .data("name", "test") + .renderAsync()); } } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java index 6dd6afe..3b8074a 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -8,6 +8,8 @@ import fr.titionfire.ffsaf.data.repository.LicenceRepository; import fr.titionfire.ffsaf.net2.ServerCustom; import fr.titionfire.ffsaf.net2.data.SimpleCombModel; import fr.titionfire.ffsaf.net2.request.SReqComb; +import fr.titionfire.ffsaf.rest.data.MeData; +import fr.titionfire.ffsaf.rest.data.SimpleLicence; import fr.titionfire.ffsaf.rest.data.SimpleMembre; import fr.titionfire.ffsaf.rest.from.ClubMemberForm; import fr.titionfire.ffsaf.rest.from.FullMemberForm; @@ -27,6 +29,7 @@ import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.ForbiddenException; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.jwt.JsonWebToken; +import org.hibernate.reactive.mutiny.Mutiny; import java.util.List; @@ -265,4 +268,14 @@ public class MembreService { StringSimilarity.similarity(m.getLname(), lname) <= 3) .map(SimpleMembre::fromModel).toList()); } + + public Uni getMembre(String subject) { + MeData meData = new MeData(); + return repository.find("userId = ?1", subject).firstResult() + .invoke(meData::setMembre) + .chain(membreModel -> Mutiny.fetch(membreModel.getLicences())) + .map(licences -> licences.stream().map(SimpleLicence::fromModel).toList()) + .invoke(meData::setLicences) + .map(__ -> meData); + } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java index 95654ec..7d3b8c2 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java @@ -2,6 +2,7 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.domain.service.MembreService; +import fr.titionfire.ffsaf.rest.data.MeData; import fr.titionfire.ffsaf.rest.data.SimpleMembre; import fr.titionfire.ffsaf.rest.from.ClubMemberForm; import fr.titionfire.ffsaf.rest.from.FullMemberForm; @@ -18,17 +19,11 @@ import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import jodd.net.MimeTypes; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.jwt.JsonWebToken; -import java.io.*; import java.net.URISyntaxException; -import java.net.URLConnection; -import java.nio.file.Files; import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Future; import java.util.function.Consumer; @Authenticated @@ -198,6 +193,14 @@ public class CombEndpoints { return membreService.delete(id, idToken); } + @GET + @Path("me") + @Authenticated + @Produces(MediaType.APPLICATION_JSON) + public Uni getMe() { + return membreService.getMembre(idToken.getSubject()); + } + @GET @Path("{id}/photo") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CountriesEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CountriesEndpoints.java new file mode 100644 index 0000000..ff87b52 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/CountriesEndpoints.java @@ -0,0 +1,32 @@ +package fr.titionfire.ffsaf.rest; + +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import java.util.HashMap; +import java.util.Locale; + +@Path("api/countries") +public class CountriesEndpoints { + + @GET + @Path("/{lang}/{code}") + @Produces(MediaType.APPLICATION_JSON) + public Uni> getCountries(@PathParam("lang") String lang, @PathParam("code") String code) { + Locale locale = new Locale(lang, code); + return Uni.createFrom().item(new HashMap()) + .invoke(map -> { + String[] locales = Locale.getISOCountries(); + for (String countryCode : locales) { + if (countryCode.equals("AN")) + continue; + Locale obj = new Locale("", countryCode); + map.put(countryCode, obj.getDisplayName(locale)); + } + }); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/MeData.java b/src/main/java/fr/titionfire/ffsaf/rest/data/MeData.java new file mode 100644 index 0000000..d9a79ea --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/MeData.java @@ -0,0 +1,45 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.MembreModel; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.Date; +import java.util.List; + +@Data +@ToString +@NoArgsConstructor +@RegisterForReflection +public class MeData { + private long id; + private String lname = ""; + private String fname = ""; + private String categorie; + private String club; + private String genre; + private int licence; + private String country; + private Date birth_date; + private String email; + private String role; + private String grade_arbitrage; + private List licences; + + public void setMembre(MembreModel membreModel) { + this.id = membreModel.getId(); + this.lname = membreModel.getLname(); + this.fname = membreModel.getFname(); + this.categorie = membreModel.getCategorie().getName(); + this.club = membreModel.getClub().getName(); + this.genre = membreModel.getGenre().str; + this.licence = membreModel.getLicence(); + this.country = membreModel.getCountry(); + this.birth_date = membreModel.getBirth_date(); + this.email = membreModel.getEmail(); + this.role = membreModel.getRole().str; + this.grade_arbitrage = membreModel.getGrade_arbitrage().str; + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/utils/Categorie.java b/src/main/java/fr/titionfire/ffsaf/utils/Categorie.java index eca6c0e..fdfc437 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/Categorie.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/Categorie.java @@ -30,4 +30,20 @@ public enum Categorie { case VETERAN2 -> BUNDLE.getString("Cat.VETERAN2"); }; } + + public String getName() { + return switch (this){ + case SUPER_MINI -> "Super Mini"; + case MINI_POUSSIN -> "Mini Poussin"; + case POUSSIN -> "Poussin"; + case BENJAMIN -> "Benjamin"; + case MINIME -> "Minime"; + case CADET -> "Cadet"; + case JUNIOR -> "Junior"; + case SENIOR1 -> "Senior 1"; + case SENIOR2 -> "Senior 2"; + case VETERAN1 -> "Vétéran 1"; + case VETERAN2 -> "Vétéran 2"; + }; + } } diff --git a/src/main/java/fr/titionfire/ffsaf/utils/Genre.java b/src/main/java/fr/titionfire/ffsaf/utils/Genre.java index b5815c3..3ee557c 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/Genre.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/Genre.java @@ -1,5 +1,18 @@ package fr.titionfire.ffsaf.utils; public enum Genre { - H, F, NA + H("Homme"), + F("Femme"), + NA("Non définie"); + + public final String str; + + Genre(String name) { + this.str = name; + } + + @Override + public String toString() { + return str; + } } diff --git a/src/main/java/fr/titionfire/ffsaf/utils/GradeArbitrage.java b/src/main/java/fr/titionfire/ffsaf/utils/GradeArbitrage.java index 800dca5..35af369 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/GradeArbitrage.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/GradeArbitrage.java @@ -5,14 +5,14 @@ public enum GradeArbitrage { ASSESSEUR("Assesseur"), ARBITRE("Arbitre"); - public final String name; + public final String str; GradeArbitrage(String name) { - this.name = name; + this.str = name; } @Override public String toString() { - return name; + return str; } } diff --git a/src/main/java/fr/titionfire/ffsaf/utils/RoleAsso.java b/src/main/java/fr/titionfire/ffsaf/utils/RoleAsso.java index c591150..b498495 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/RoleAsso.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/RoleAsso.java @@ -13,16 +13,16 @@ public enum RoleAsso { VTRESORIER("Vise-Trésorier", 2), MEMBREBUREAU("Membre bureau", 1); - public final String name; + public final String str; public final int level; RoleAsso(String name, int level) { - this.name = name; + this.str = name; this.level = level; } @Override public String toString() { - return name; + return str; } } diff --git a/src/main/webapp/src/App.jsx b/src/main/webapp/src/App.jsx index e5e6d24..a2fe33a 100644 --- a/src/main/webapp/src/App.jsx +++ b/src/main/webapp/src/App.jsx @@ -12,6 +12,7 @@ import './App.css' import 'react-toastify/dist/ReactToastify.css'; import {ClubRoot, getClubChildren} from "./pages/club/ClubRoot.jsx"; import {DemandeAff, DemandeAffOk} from "./pages/DemandeAff.jsx"; +import {MePage} from "./pages/MePage.jsx"; const router = createBrowserRouter([ { @@ -45,6 +46,10 @@ const router = createBrowserRouter([ element: } ] + }, + { + path: 'me', + element: } ] }, @@ -82,7 +87,7 @@ function Root() {
    Catégorie {canUpdate && } @@ -63,11 +64,28 @@ export function RoleList({name, text, value, disabled = false}) { } export function CountryList({name, text, value, values = undefined, disabled = false}) { - if (values === undefined){ - values = {NA: 'Sélectionner...', fr: 'FR', es: 'ES', be: 'BE'} + const country = useCountries('fr') + const [value_, setValue] = useState(value) + + if (values === undefined) { + values = {...country} } - return + return
    +
    + + +
    +
    } export function TextField({name, text, value, placeholder, type = "text", disabled = false, required = true}) { diff --git a/src/main/webapp/src/components/Nav.jsx b/src/main/webapp/src/components/Nav.jsx index 2b3a4f4..4e5a01d 100644 --- a/src/main/webapp/src/components/Nav.jsx +++ b/src/main/webapp/src/components/Nav.jsx @@ -80,11 +80,23 @@ function AdminMenu() { function LoginMenu() { const {is_authenticated} = useAuth() - return
  • - {!is_authenticated ? ( -
    login()}>Connexion
    - ) : ( -
    logout()}>Déconnexion
    - )} -
  • + return <> + {!is_authenticated ? +
  • +
    login()}>Connexion
    +
  • + : +
  • + +
      +
    • Mon espace
    • +
    • +
      logout()}>Déconnexion
      +
    • +
    +
  • + } + } \ No newline at end of file diff --git a/src/main/webapp/src/hooks/useCountries.jsx b/src/main/webapp/src/hooks/useCountries.jsx new file mode 100644 index 0000000..f4cb55b --- /dev/null +++ b/src/main/webapp/src/hooks/useCountries.jsx @@ -0,0 +1,24 @@ +import {useEffect, useState} from "react"; +import {apiAxios} from "../utils/Tools.js"; + +const countries = {} + +export function useCountries(country = 'fr') { + const [out, setOut] = useState(null) + + useEffect(() => { + if (countries[country] === undefined) { + console.log('fetch') + apiAxios.get(`/countries/${country}/${country}`).then(data => { + console.log(data.data) + countries[country] = data.data + setOut(data.data) + }) + } else { + setOut(countries[country]) + } + }, [country]); + + + return out; +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/MePage.jsx b/src/main/webapp/src/pages/MePage.jsx new file mode 100644 index 0000000..edaa3fa --- /dev/null +++ b/src/main/webapp/src/pages/MePage.jsx @@ -0,0 +1,115 @@ +import {LoadingProvider, useLoadingSwitcher} from "../hooks/useLoading.jsx"; +import {AxiosError} from "../components/AxiosError.jsx"; +import {useFetch} from "../hooks/useFetch.js"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import { + faCalendarDay, + faEnvelope, faFlag, + faInfoCircle, + faMars, + faMarsAndVenus, + faUser, + faUserGroup, + faVenus +} from "@fortawesome/free-solid-svg-icons"; + +const vite_url = import.meta.env.VITE_URL; + +export function MePage() { + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/member/me`, setLoading, 1) + + return
    +

    Mon espace

    + + {data + ?
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + : error && + } +
    +} + +export function LicenceCard({userData}) { + return
    +
    +
    +
    Licence
    +
    +
    +
    +
      + {userData.licences.map((licence, index) => { + return
      +
      {licence?.saison}-{licence?.saison + 1}
      +
      + })} +
    +
    +
    ; +} + +function PhotoCard({data}) { + return
    +
    Licence n°{data.licence}
    +
    +
    + avatar +
    +
    +
    ; +} + +function SelectCard() { + return
    +
    Sélection en équipe de France
    +
    +
    +
    ; +} + + +export function InformationForm({data}) { + const style = {marginRight: '0.7em'} + + return
    +
    Information
    +
    +
    +

    + {data.lname} {data.fname}
    + {data.email}
    + {data.genre === 'Homme' && + || data.genre === 'Femme' && + || }{data.genre}
    + {data.birth_date ? data.birth_date.split('T')[0] : ''}
    + {data.categorie}
    + Nationalité :
    + Rôle au sien du club : {data.role}
    + Formation d'arbitrage : {data.grade_arbitrage} +

    +
    +
    +
    ; +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/admin/club/NewClubPage.jsx b/src/main/webapp/src/pages/admin/club/NewClubPage.jsx index 74903a6..0e01377 100644 --- a/src/main/webapp/src/pages/admin/club/NewClubPage.jsx +++ b/src/main/webapp/src/pages/admin/club/NewClubPage.jsx @@ -62,7 +62,7 @@ function InformationForm() {
    - +
    diff --git a/src/main/webapp/src/pages/admin/member/NewMemberPage.jsx b/src/main/webapp/src/pages/admin/member/NewMemberPage.jsx index c8b0727..a171c8d 100644 --- a/src/main/webapp/src/pages/admin/member/NewMemberPage.jsx +++ b/src/main/webapp/src/pages/admin/member/NewMemberPage.jsx @@ -2,7 +2,7 @@ import {useNavigate} from "react-router-dom"; import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; import {apiAxios} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; -import {BirthDayField, OptionField, RoleList, TextField} from "../../../components/MemberCustomFiels.jsx"; +import {BirthDayField, CountryList, OptionField, RoleList, TextField} from "../../../components/MemberCustomFiels.jsx"; import {ClubSelect} from "../../../components/ClubSelect.jsx"; import {addPhoto} from "./InformationForm.jsx"; @@ -73,8 +73,7 @@ function Form() { - +
    diff --git a/src/main/webapp/src/pages/club/member/NewMemberPage.jsx b/src/main/webapp/src/pages/club/member/NewMemberPage.jsx index b3d588f..ee40289 100644 --- a/src/main/webapp/src/pages/club/member/NewMemberPage.jsx +++ b/src/main/webapp/src/pages/club/member/NewMemberPage.jsx @@ -2,7 +2,7 @@ import {useNavigate} from "react-router-dom"; import {useLoadingSwitcher} from "../../../hooks/useLoading.jsx"; import {apiAxios} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; -import {BirthDayField, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx"; +import {BirthDayField, CountryList, OptionField, TextField} from "../../../components/MemberCustomFiels.jsx"; import {addPhoto} from "../../admin/member/InformationForm.jsx"; export function NewMemberPage() { @@ -69,8 +69,7 @@ function Form() { - +
    From 47daa459e2b2ea1e1c55f4caeceb4aa92ce6925e Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Fri, 19 Jul 2024 14:52:05 +0200 Subject: [PATCH 23/37] feat: better error message --- .../domain/service/AffiliationService.java | 29 ++++++++++--------- .../ffsaf/domain/service/ClubService.java | 18 ++++++------ .../ffsaf/domain/service/KeycloakService.java | 3 +- .../ffsaf/domain/service/LicenceService.java | 4 +-- .../ffsaf/domain/service/MembreService.java | 14 ++++----- .../ffsaf/rest/AffiliationEndpoints.java | 7 +++-- .../titionfire/ffsaf/rest/AssoEndpoints.java | 3 +- .../titionfire/ffsaf/rest/ClubEndpoints.java | 12 +++++--- .../titionfire/ffsaf/rest/CombEndpoints.java | 13 ++++++--- .../ffsaf/rest/CompteEndpoints.java | 3 +- .../ffsaf/rest/LicenceEndpoints.java | 3 +- .../rest/exception/DBadRequestException.java | 14 +++++++++ .../rest/exception/DForbiddenException.java | 18 ++++++++++++ .../ffsaf/rest/exception/DInternalError.java | 15 ++++++++++ .../rest/exception/DNotFoundException.java | 15 ++++++++++ .../ffsaf/rest/exception/DetailException.java | 20 +++++++++++++ .../rest/exception/DetailExceptionMapper.java | 19 ++++++++++++ .../exception/KeycloakExceptionMapper.java | 20 +++++++++++++ src/main/webapp/src/App.jsx | 8 +++++ src/main/webapp/src/hooks/useAuth.jsx | 2 ++ src/main/webapp/src/hooks/useCountries.jsx | 2 -- src/main/webapp/src/pages/DemandeAff.jsx | 26 +++++++++++++---- src/main/webapp/src/pages/MemberList.jsx | 8 +++-- .../admin/affiliation/AffiliationReqPage.jsx | 20 ++++++++++--- .../src/pages/admin/club/AffiliationCard.jsx | 14 +++++++-- .../webapp/src/pages/admin/club/ClubList.jsx | 8 +++-- .../webapp/src/pages/admin/club/ClubPage.jsx | 14 +++++++-- .../src/pages/admin/club/NewClubPage.jsx | 8 +++-- .../src/pages/admin/member/CompteInfo.jsx | 19 +++++++----- .../src/pages/admin/member/LicenceCard.jsx | 14 +++++++-- .../src/pages/admin/member/MemberPage.jsx | 8 +++-- .../webapp/src/pages/club/club/MyClubPage.jsx | 8 +++-- .../src/pages/club/member/LicenceCard.jsx | 14 +++++++-- .../src/pages/club/member/MemberPage.jsx | 8 +++-- src/main/webapp/src/utils/Tools.js | 5 ++++ 35 files changed, 327 insertions(+), 89 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/exception/DBadRequestException.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/exception/DForbiddenException.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/exception/DInternalError.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/exception/DNotFoundException.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/exception/DetailException.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/exception/DetailExceptionMapper.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/exception/KeycloakExceptionMapper.java diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index c380c44..09f864e 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -4,6 +4,8 @@ import fr.titionfire.ffsaf.data.model.*; import fr.titionfire.ffsaf.data.repository.*; import fr.titionfire.ffsaf.rest.data.SimpleAffiliation; import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliation; +import fr.titionfire.ffsaf.rest.exception.DBadRequestException; +import fr.titionfire.ffsaf.rest.exception.DNotFoundException; import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm; import fr.titionfire.ffsaf.rest.from.AffiliationRequestSaveForm; import fr.titionfire.ffsaf.utils.SequenceType; @@ -14,7 +16,6 @@ import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import jakarta.ws.rs.NotFoundException; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.hibernate.reactive.mutiny.Mutiny; @@ -60,42 +61,42 @@ public class AffiliationService { return Uni.createFrom().item(affModel) .invoke(Unchecked.consumer(model -> { if (model.getSaison() != currentSaison && model.getSaison() != currentSaison + 1) { - throw new IllegalArgumentException("Saison not valid"); + throw new DBadRequestException("Saison non valid"); } })) .chain(() -> repositoryRequest.count("siret = ?1 and saison = ?2", affModel.getSiret(), affModel.getSaison())) .onItem().invoke(Unchecked.consumer(count -> { if (count != 0 && unique) { - throw new IllegalArgumentException("Affiliation request already exists"); + throw new DBadRequestException("Demande d'affiliation déjà existante"); } })) .chain(() -> clubRepository.find("SIRET = ?1", affModel.getSiret()).firstResult().chain(club -> repository.count("club = ?1 and saison = ?2", club, affModel.getSaison()))) .onItem().invoke(Unchecked.consumer(count -> { if (count != 0) { - throw new IllegalArgumentException("Affiliation already exists"); + throw new DBadRequestException("Affiliation déjà existante"); } })) .map(o -> affModel) .call(model -> ((model.getM1_lincence() != -1) ? combRepository.find("licence", model.getM1_lincence()).count().invoke(Unchecked.consumer(count -> { if (count == 0) { - throw new IllegalArgumentException("Licence membre n°1 inconnue"); + throw new DBadRequestException("Licence membre n°1 inconnue"); } })) : Uni.createFrom().nullItem()) ) .call(model -> ((model.getM2_lincence() != -1) ? combRepository.find("licence", model.getM2_lincence()).count().invoke(Unchecked.consumer(count -> { if (count == 0) { - throw new IllegalArgumentException("Licence membre n°2 inconnue"); + throw new DBadRequestException("Licence membre n°2 inconnue"); } })) : Uni.createFrom().nullItem()) ) .call(model -> ((model.getM3_lincence() != -1) ? combRepository.find("licence", model.getM3_lincence()).count().invoke(Unchecked.consumer(count -> { if (count == 0) { - throw new IllegalArgumentException("Licence membre n°3 inconnue"); + throw new DBadRequestException("Licence membre n°3 inconnue"); } })) : Uni.createFrom().nullItem()) ); @@ -104,7 +105,7 @@ public class AffiliationService { public Uni saveEdit(AffiliationRequestForm form) { return pre_save(form, false) .chain(model -> repositoryRequest.findById(form.getId()) - .onItem().ifNull().failWith(new NotFoundException("Affiliation request not found")) + .onItem().ifNull().failWith(new DNotFoundException("Demande d'affiliation non trouvé")) .chain(origine -> { origine.setName(model.getName()); origine.setRNA(model.getRNA()); @@ -144,7 +145,7 @@ public class AffiliationService { public Uni saveAdmin(AffiliationRequestSaveForm form) { return repositoryRequest.findById(form.getId()) - .onItem().ifNull().failWith(new NotFoundException("Affiliation request not found")) + .onItem().ifNull().failWith(new DNotFoundException("Demande d'affiliation non trouvé")) .map(model -> { model.setName(form.getName()); model.setSiret(form.getSiret()); @@ -237,7 +238,7 @@ public class AffiliationService { public Uni accept(AffiliationRequestSaveForm form) { return repositoryRequest.findById(form.getId()) - .onItem().ifNull().failWith(new NotFoundException("Affiliation request not found")) + .onItem().ifNull().failWith(new DNotFoundException("Demande d'affiliation non trouvé")) .chain(req -> clubRepository.find("SIRET = ?1", form.getSiret()).firstResult() .chain(model -> (model == null) ? acceptNew(form, req) : acceptOld(form, req, model)) @@ -298,7 +299,7 @@ public class AffiliationService { public Uni getRequest(long id) { return repositoryRequest.findById(id).map(SimpleReqAffiliation::fromModel) - .onItem().ifNull().failWith(new NotFoundException("Affiliation request not found")) + .onItem().ifNull().failWith(new DNotFoundException("Demande d'affiliation non trouvé")) .call(out -> clubRepository.find("SIRET = ?1", out.getSiret()).firstResult().invoke(c -> { if (c != null) { out.setClub(c.getId()); @@ -322,7 +323,7 @@ public class AffiliationService { public Uni> getAffiliation(long id) { return clubRepository.findById(id) - .onItem().ifNull().failWith(new NotFoundException("Affiliation request not found")) + .onItem().ifNull().failWith(new DNotFoundException("Club non trouvé")) .call(model -> Mutiny.fetch(model.getAffiliations())) .chain(model -> repositoryRequest.list("siret = ?1", model.getSIRET()) .map(reqs -> reqs.stream().map(req -> @@ -334,10 +335,10 @@ public class AffiliationService { public Uni setAffiliation(long id, int saison) { return clubRepository.findById(id) - .onItem().ifNull().failWith(new NotFoundException("Club non trouver")) + .onItem().ifNull().failWith(new DNotFoundException("Club non trouvé")) .invoke(Unchecked.consumer(club -> { if (club.getAffiliations().stream().anyMatch(affiliation -> affiliation.getSaison() == saison)) { - throw new IllegalArgumentException("Affiliation deja existante"); + throw new DBadRequestException("Affiliation déjà existante"); } })) .chain(club -> diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java index c624945..61aed55 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java @@ -13,6 +13,9 @@ import fr.titionfire.ffsaf.net2.request.SReqClub; import fr.titionfire.ffsaf.rest.data.DeskMember; import fr.titionfire.ffsaf.rest.data.RenewAffData; import fr.titionfire.ffsaf.rest.data.SimpleClubList; +import fr.titionfire.ffsaf.rest.exception.DBadRequestException; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; +import fr.titionfire.ffsaf.rest.exception.DNotFoundException; import fr.titionfire.ffsaf.rest.from.FullClubForm; import fr.titionfire.ffsaf.rest.from.PartClubForm; import fr.titionfire.ffsaf.utils.*; @@ -26,9 +29,6 @@ import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.ForbiddenException; -import jakarta.ws.rs.NotFoundException; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.jwt.JsonWebToken; import org.hibernate.reactive.mutiny.Mutiny; @@ -105,7 +105,7 @@ public class ClubService { .call(result -> query.count().invoke(result::setResult_count)) .call(result -> query.pageCount() .invoke(Unchecked.consumer(pages -> { - if (page > pages) throw new BadRequestException(); + if (page > pages) throw new DBadRequestException("Page out of range"); })) .invoke(result::setPage_count)) .call(result -> query.page(Page.of(page, limit)).list() @@ -124,7 +124,7 @@ public class ClubService { public Uni getOfUser(JsonWebToken idToken) { return combRepository.find("userId = ?1", idToken.getSubject()).firstResult().invoke(Unchecked.consumer(m -> { if (m == null || m.getClub() == null) - throw new NotFoundException("Club not found"); + throw new DNotFoundException("Club non trouvé"); })) .map(MembreModel::getClub) .call(club -> Mutiny.fetch(club.getContact())); @@ -146,9 +146,9 @@ public class ClubService { return combRepository.find("userId = ?1", idToken.getSubject()).firstResult().invoke(Unchecked.consumer(m -> { if (m == null || m.getClub() == null) - throw new NotFoundException("Club not found"); + throw new DNotFoundException("Club non trouvé"); if (!GroupeUtils.isInClubGroup(m.getClub().getId(), idToken)) - throw new ForbiddenException(); + throw new DForbiddenException(); })) .map(MembreModel::getClub) .call(club -> Mutiny.fetch(club.getContact())) @@ -159,7 +159,7 @@ public class ClubService { try { club.setContact(MAPPER.readValue(form.getContact(), typeRef)); } catch (JsonProcessingException e) { - throw new BadRequestException(); + throw new DBadRequestException("Erreur de format des contacts"); } club.setTraining_location(form.getTraining_location()); @@ -191,7 +191,7 @@ public class ClubService { try { m.setContact(MAPPER.readValue(input.getContact(), typeRef)); } catch (JsonProcessingException e) { - throw new BadRequestException(); + throw new DBadRequestException("Erreur de format des contacts"); } } return Panache.withTransaction(() -> repository.persist(m)); diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java index cb83f09..ce78f51 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java @@ -2,6 +2,7 @@ package fr.titionfire.ffsaf.domain.service; import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.data.model.MembreModel; +import fr.titionfire.ffsaf.rest.exception.DInternalError; import fr.titionfire.ffsaf.utils.*; import io.quarkus.runtime.annotations.RegisterForReflection; import io.smallrye.mutiny.Uni; @@ -78,7 +79,7 @@ public class KeycloakService { public Uni getUserFromMember(MembreModel membreModel) { if (membreModel.getUserId() == null) { return Uni.createFrom() - .failure(new NullPointerException("No keycloak user linked to the user id=" + membreModel.getId())); + .failure(new DInternalError("No keycloak user linked to the user id=" + membreModel.getId())); } return Uni.createFrom().item(membreModel::getUserId); } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java index 155a103..8bbccc4 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java @@ -5,6 +5,7 @@ import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.data.repository.CombRepository; import fr.titionfire.ffsaf.data.repository.LicenceRepository; import fr.titionfire.ffsaf.data.repository.SequenceRepository; +import fr.titionfire.ffsaf.rest.exception.DBadRequestException; import fr.titionfire.ffsaf.rest.from.LicenceForm; import fr.titionfire.ffsaf.utils.SequenceType; import fr.titionfire.ffsaf.utils.Utils; @@ -14,7 +15,6 @@ import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import jakarta.ws.rs.BadRequestException; import org.eclipse.microprofile.jwt.JsonWebToken; import org.hibernate.reactive.mutiny.Mutiny; @@ -91,7 +91,7 @@ public class LicenceService { return repository.find("saison = ?1 AND membre = ?2", Utils.getSaison(), membreModel).count() .invoke(Unchecked.consumer(count -> { if (count > 0) - throw new BadRequestException(); + throw new DBadRequestException("Licence déjà demandée"); })).chain(__ -> combRepository.findById(id).chain(combRepository -> { LicenceModel model = new LicenceModel(); model.setMembre(combRepository); diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java index 3b8074a..b09a107 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -11,6 +11,8 @@ import fr.titionfire.ffsaf.net2.request.SReqComb; import fr.titionfire.ffsaf.rest.data.MeData; import fr.titionfire.ffsaf.rest.data.SimpleLicence; import fr.titionfire.ffsaf.rest.data.SimpleMembre; +import fr.titionfire.ffsaf.rest.exception.DBadRequestException; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.rest.from.ClubMemberForm; import fr.titionfire.ffsaf.rest.from.FullMemberForm; import fr.titionfire.ffsaf.utils.*; @@ -25,8 +27,6 @@ import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.ForbiddenException; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.jwt.JsonWebToken; import org.hibernate.reactive.mutiny.Mutiny; @@ -101,7 +101,7 @@ public class MembreService { .call(result -> query.count().invoke(result::setResult_count)) .call(result -> query.pageCount() .invoke(Unchecked.consumer(pages -> { - if (page > pages) throw new BadRequestException(); + if (page > pages) throw new DBadRequestException("Page out of range"); })) .invoke(result::setPage_count)) .call(result -> query.page(Page.of(page, limit)).list() @@ -155,7 +155,7 @@ public class MembreService { return repository.findById(id) .invoke(Unchecked.consumer(membreModel -> { if (!GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) - throw new ForbiddenException(); + throw new DForbiddenException(); })) .invoke(Unchecked.consumer(membreModel -> { RoleAsso source = RoleAsso.MEMBRE; @@ -163,7 +163,7 @@ public class MembreService { else if (securityIdentity.getRoles().contains("club_secretaire")) source = RoleAsso.SECRETAIRE; else if (securityIdentity.getRoles().contains("club_respo_intra")) source = RoleAsso.MEMBREBUREAU; if (!membre.getRole().equals(membreModel.getRole()) && membre.getRole().level >= source.level) - throw new ForbiddenException(); + throw new DForbiddenException("Permission insuffisante"); })) .onItem().transformToUni(target -> { target.setFname(membre.getFname()); @@ -225,12 +225,12 @@ public class MembreService { return repository.findById(id) .invoke(Unchecked.consumer(membreModel -> { if (!GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) - throw new ForbiddenException(); + throw new DForbiddenException(); })) .call(membreModel -> licenceRepository.find("membre = ?1", membreModel).count() .invoke(Unchecked.consumer(l -> { if (l > 0) - throw new BadRequestException(); + throw new DBadRequestException("Impossible de supprimer un membre avec des licences"); }))) .call(membreModel -> (membreModel.getUserId() != null) ? keycloakService.removeAccount(membreModel.getUserId()) : Uni.createFrom().nullItem()) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java index a9fc32f..6e6448d 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java @@ -4,6 +4,7 @@ import fr.titionfire.ffsaf.domain.service.AffiliationService; import fr.titionfire.ffsaf.rest.data.SimpleAffiliation; import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliation; import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliationResume; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm; import fr.titionfire.ffsaf.rest.from.AffiliationRequestSaveForm; import fr.titionfire.ffsaf.utils.GroupeUtils; @@ -42,7 +43,7 @@ public class AffiliationEndpoints { Consumer checkPerm = Unchecked.consumer(id -> { if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(id, idToken)) - throw new ForbiddenException(); + throw new DForbiddenException(); }); @GET @@ -68,7 +69,7 @@ public class AffiliationEndpoints { public Uni getAffRequest(@PathParam("id") long id) { return service.getRequest(id).invoke(Unchecked.consumer(o -> { if (o.getClub() == null && !securityIdentity.getRoles().contains("federation_admin")) - throw new ForbiddenException(); + throw new DForbiddenException(); })).invoke(o -> checkPerm.accept(o.getClub())); } @@ -79,7 +80,7 @@ public class AffiliationEndpoints { public Uni getDelAffRequest(@PathParam("id") long id) { return service.getRequest(id).invoke(Unchecked.consumer(o -> { if (o.getClub() == null && !securityIdentity.getRoles().contains("federation_admin")) - throw new ForbiddenException(); + throw new DForbiddenException(); })).invoke(o -> checkPerm.accept(o.getClub())) .chain(o -> service.deleteReqAffiliation(id)); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java index 5868f4f..d739ca7 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java @@ -2,6 +2,7 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.rest.client.SirenService; import fr.titionfire.ffsaf.rest.data.UniteLegaleRoot; +import fr.titionfire.ffsaf.rest.exception.DNotFoundException; import io.smallrye.mutiny.Uni; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; @@ -20,7 +21,7 @@ public class AssoEndpoints { return sirenService.get_unite(siren).onFailure().transform(throwable -> { if (throwable instanceof WebApplicationException exception) { if (exception.getResponse().getStatus() == 400) - return new BadRequestException("Not found"); + return new DNotFoundException("Siret introuvable"); } return throwable; }); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index d7e6555..ef778ed 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -7,6 +7,8 @@ import fr.titionfire.ffsaf.rest.data.DeskMember; import fr.titionfire.ffsaf.rest.data.RenewAffData; import fr.titionfire.ffsaf.rest.data.SimpleClub; import fr.titionfire.ffsaf.rest.data.SimpleClubList; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; +import fr.titionfire.ffsaf.rest.exception.DInternalError; import fr.titionfire.ffsaf.rest.from.FullClubForm; import fr.titionfire.ffsaf.rest.from.PartClubForm; import fr.titionfire.ffsaf.utils.Contact; @@ -50,11 +52,11 @@ public class ClubEndpoints { Consumer checkPerm = Unchecked.consumer(clubModel -> { if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(clubModel.getId(), idToken)) - throw new ForbiddenException(); + throw new DForbiddenException(); }); Consumer checkPerm2 = Unchecked.consumer(id -> { if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(id, idToken)) - throw new ForbiddenException(); + throw new DForbiddenException(); }); @GET @@ -112,7 +114,8 @@ public class ClubEndpoints { if (input.getLogo().length > 0) return Uni.createFrom().future(Utils.replacePhoto(id, input.getLogo(), media, "ppClub" )).invoke(Unchecked.consumer(out -> { - if (!out.equals("OK")) throw new InternalError("Fail to get MimeType " + out); + if (!out.equals("OK")) + throw new DInternalError("Impossible de reconnaitre le fichier: " + out); })); else return Uni.createFrom().nullItem(); @@ -120,7 +123,8 @@ public class ClubEndpoints { if (input.getStatus().length > 0) return Uni.createFrom().future(Utils.replacePhoto(id, input.getStatus(), media, "clubStatus" )).invoke(Unchecked.consumer(out -> { - if (!out.equals("OK")) throw new InternalError("Fail to get MimeType " + out); + if (!out.equals("OK")) + throw new DInternalError("Impossible de reconnaitre le fichier: " + out); })); else return Uni.createFrom().nullItem(); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java index 7d3b8c2..36c3492 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java @@ -4,6 +4,8 @@ import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.domain.service.MembreService; import fr.titionfire.ffsaf.rest.data.MeData; import fr.titionfire.ffsaf.rest.data.SimpleMembre; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; +import fr.titionfire.ffsaf.rest.exception.DInternalError; import fr.titionfire.ffsaf.rest.from.ClubMemberForm; import fr.titionfire.ffsaf.rest.from.FullMemberForm; import fr.titionfire.ffsaf.utils.GroupeUtils; @@ -46,7 +48,7 @@ public class CombEndpoints { Consumer checkPerm = Unchecked.consumer(membreModel -> { if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup( membreModel.getClub().getId(), idToken)) - throw new ForbiddenException(); + throw new DForbiddenException(); }); @GET @@ -110,12 +112,14 @@ public class CombEndpoints { public Uni setAdminMembre(@PathParam("id") long id, FullMemberForm input) { return membreService.update(id, input) .invoke(Unchecked.consumer(out -> { - if (!out.equals("OK")) throw new InternalError("Fail to update data: " + out); + if (!out.equals("OK")) + throw new DInternalError("Impossible de reconnaitre le fichier: " + out); })).chain(() -> { if (input.getPhoto_data().length > 0) return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre" )).invoke(Unchecked.consumer(out -> { - if (!out.equals("OK")) throw new InternalError("Fail to get MimeType " + out); + if (!out.equals("OK")) + throw new DInternalError("Impossible de reconnaitre le fichier: " + out); })); else return Uni.createFrom().nullItem(); @@ -160,7 +164,8 @@ public class CombEndpoints { if (input.getPhoto_data().length > 0) return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre" )).invoke(Unchecked.consumer(out -> { - if (!out.equals("OK")) throw new InternalError("Fail to get MimeType " + out); + if (!out.equals("OK")) + throw new DInternalError("Impossible de reconnaitre le fichier: " + out); })); else return Uni.createFrom().nullItem(); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java index d05c38d..f6a5231 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java @@ -1,6 +1,7 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.domain.service.KeycloakService; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.rest.from.MemberPermForm; import fr.titionfire.ffsaf.utils.GroupeUtils; import fr.titionfire.ffsaf.utils.Pair; @@ -38,7 +39,7 @@ public class CompteEndpoints { return service.fetchCompte(id).call(pair -> vertx.getOrCreateContext().executeBlocking(() -> { if (!securityIdentity.getRoles().contains("federation_admin") && pair.getKey().groups().stream().map(GroupRepresentation::getPath) .noneMatch(s -> s.startsWith("/club/") && GroupeUtils.contains(s, accessToken))) - throw new ForbiddenException(); + throw new DForbiddenException(); return pair; })).map(Pair::getValue); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java index 9e4d069..91dd5fd 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java @@ -3,6 +3,7 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.domain.service.LicenceService; import fr.titionfire.ffsaf.rest.data.SimpleLicence; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.rest.from.LicenceForm; import fr.titionfire.ffsaf.utils.GroupeUtils; import io.quarkus.oidc.IdToken; @@ -33,7 +34,7 @@ public class LicenceEndpoints { Consumer checkPerm = Unchecked.consumer(membreModel -> { if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) - throw new ForbiddenException(); + throw new DForbiddenException(); }); @GET diff --git a/src/main/java/fr/titionfire/ffsaf/rest/exception/DBadRequestException.java b/src/main/java/fr/titionfire/ffsaf/rest/exception/DBadRequestException.java new file mode 100644 index 0000000..31a9be3 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/exception/DBadRequestException.java @@ -0,0 +1,14 @@ +package fr.titionfire.ffsaf.rest.exception; + +import jakarta.ws.rs.core.Response; + +import java.io.Serial; + +public class DBadRequestException extends DetailException { + @Serial + private static final long serialVersionUID = 7518556311032332135L; + + public DBadRequestException(String message) { + super(Response.Status.BAD_REQUEST, message); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/exception/DForbiddenException.java b/src/main/java/fr/titionfire/ffsaf/rest/exception/DForbiddenException.java new file mode 100644 index 0000000..d95bf5b --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/exception/DForbiddenException.java @@ -0,0 +1,18 @@ +package fr.titionfire.ffsaf.rest.exception; + +import jakarta.ws.rs.core.Response; + +import java.io.Serial; + +public class DForbiddenException extends DetailException { + @Serial + private static final long serialVersionUID = 8408920537659758038L; + + public DForbiddenException() { + this("Accès a la ressource interdite"); + } + + public DForbiddenException(String message) { + super(Response.Status.FORBIDDEN, message); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/exception/DInternalError.java b/src/main/java/fr/titionfire/ffsaf/rest/exception/DInternalError.java new file mode 100644 index 0000000..777fa99 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/exception/DInternalError.java @@ -0,0 +1,15 @@ +package fr.titionfire.ffsaf.rest.exception; + +import jakarta.ws.rs.core.Response; + +import java.io.Serial; + +public class DInternalError extends DetailException { + + @Serial + private static final long serialVersionUID = -3635595157694180842L; + + public DInternalError(String message) { + super(Response.Status.INTERNAL_SERVER_ERROR, message); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/exception/DNotFoundException.java b/src/main/java/fr/titionfire/ffsaf/rest/exception/DNotFoundException.java new file mode 100644 index 0000000..9baab1d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/exception/DNotFoundException.java @@ -0,0 +1,15 @@ +package fr.titionfire.ffsaf.rest.exception; + +import jakarta.ws.rs.core.Response; + +import java.io.Serial; + +public class DNotFoundException extends DetailException{ + + @Serial + private static final long serialVersionUID = -5193524134675732376L; + + public DNotFoundException(String message) { + super(Response.Status.NOT_FOUND, message); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/exception/DetailException.java b/src/main/java/fr/titionfire/ffsaf/rest/exception/DetailException.java new file mode 100644 index 0000000..0af0f41 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/exception/DetailException.java @@ -0,0 +1,20 @@ +package fr.titionfire.ffsaf.rest.exception; + +import jakarta.ws.rs.core.Response; +import lombok.Getter; + +import java.io.Serial; + +@Getter +public class DetailException extends Exception { + + @Serial + private static final long serialVersionUID = 5349921926328753676L; + + private final Response.Status status; + + public DetailException(Response.Status status, String message) { + super(message); + this.status = status; + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/exception/DetailExceptionMapper.java b/src/main/java/fr/titionfire/ffsaf/rest/exception/DetailExceptionMapper.java new file mode 100644 index 0000000..c37f711 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/exception/DetailExceptionMapper.java @@ -0,0 +1,19 @@ +package fr.titionfire.ffsaf.rest.exception; + +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +@Provider +public class DetailExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(DetailException e) { + return Response.status(e.getStatus()) + .entity(e.getMessage()) + .type(MediaType.TEXT_PLAIN_TYPE) + .build(); + } + +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/exception/KeycloakExceptionMapper.java b/src/main/java/fr/titionfire/ffsaf/rest/exception/KeycloakExceptionMapper.java new file mode 100644 index 0000000..bb40b9d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/exception/KeycloakExceptionMapper.java @@ -0,0 +1,20 @@ +package fr.titionfire.ffsaf.rest.exception; + +import fr.titionfire.ffsaf.utils.KeycloakException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +@Provider +public class KeycloakExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(KeycloakException e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Erreur du gestionnaire d'identité: " + e.getMessage()) + .type(MediaType.TEXT_PLAIN_TYPE) + .build(); + } + +} \ No newline at end of file diff --git a/src/main/webapp/src/App.jsx b/src/main/webapp/src/App.jsx index a2fe33a..aeee804 100644 --- a/src/main/webapp/src/App.jsx +++ b/src/main/webapp/src/App.jsx @@ -80,6 +80,14 @@ function Root() { check_validity(data => dispatch({type: 'init', val: data})) }, []); + + useEffect(() => { + const interval = setInterval(() => { + check_validity(data => dispatch({type: 'update', val: data})) + }, 1000 * 60 * 9) + return () => clearInterval(interval) + }, []); + return <>
    -
    - Certificat médical - - - - -
    + Médecin figurant sur le certificat médical +
    Validation de la licence:
    - {currentSaison && + {currentSaison && !licence.validate && } - {currentSaison && licence.validate === false && + {currentSaison && !licence.validate && }
    From 3ccf8800571804961f67bd5275a6378492b92f5f Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Fri, 19 Jul 2024 15:24:58 +0200 Subject: [PATCH 25/37] feat: change certif type --- .../ffsaf/data/model/LicenceModel.java | 2 +- .../ffsaf/domain/service/LicenceService.java | 8 ++--- .../ffsaf/rest/data/SimpleLicence.java | 4 +-- .../ffsaf/rest/from/LicenceForm.java | 2 +- src/main/webapp/src/App.jsx | 2 +- .../src/pages/admin/member/LicenceCard.jsx | 16 ++++++---- .../src/pages/club/member/LicenceCard.jsx | 29 +++++++------------ 7 files changed, 30 insertions(+), 33 deletions(-) diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java index ae5d0e7..4f3151c 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java @@ -24,7 +24,7 @@ public class LicenceModel { int saison; - boolean certificate; + String certificate; boolean validate; } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java index 8bbccc4..64fb1ab 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java @@ -54,7 +54,7 @@ public class LicenceService { LicenceModel model = new LicenceModel(); model.setMembre(membreModel); model.setSaison(form.getSaison()); - model.setCertificate(form.isCertificate()); + model.setCertificate(form.getCertificate()); model.setValidate(form.isValidate()); return Panache.withTransaction(() -> repository.persist(model) .call(m -> (m.isValidate() && membreModel.getLicence() <= 0) ? @@ -66,7 +66,7 @@ public class LicenceService { }); } else { return repository.findById(form.getId()).chain(model -> { - model.setCertificate(form.isCertificate()); + model.setCertificate(form.getCertificate()); model.setValidate(form.isValidate()); return Panache.withTransaction(() -> repository.persist(model) .call(m -> m.isValidate() ? Mutiny.fetch(m.getMembre()) @@ -96,13 +96,13 @@ public class LicenceService { LicenceModel model = new LicenceModel(); model.setMembre(combRepository); model.setSaison(Utils.getSaison()); - model.setCertificate(form.isCertificate()); + model.setCertificate(form.getCertificate()); model.setValidate(false); return Panache.withTransaction(() -> repository.persist(model)); })); } else { return repository.findById(form.getId()).chain(model -> { - model.setCertificate(form.isCertificate()); + model.setCertificate(form.getCertificate()); return Panache.withTransaction(() -> repository.persist(model)); }); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java index c9fedce..8714d1f 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java @@ -14,7 +14,7 @@ public class SimpleLicence { Long id; Long membre; int saison; - boolean certificate; + String certificate; boolean validate; public static SimpleLicence fromModel(LicenceModel model) { @@ -25,7 +25,7 @@ public class SimpleLicence { .id(model.getId()) .membre(model.getMembre().getId()) .saison(model.getSaison()) - .certificate(model.isCertificate()) + .certificate(model.getCertificate()) .validate(model.isValidate()) .build(); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java index 678acc7..d587e34 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java @@ -17,7 +17,7 @@ public class LicenceForm { private int saison; @FormParam("certificate") - private boolean certificate; + private String certificate = null; @FormParam("validate") private boolean validate; diff --git a/src/main/webapp/src/App.jsx b/src/main/webapp/src/App.jsx index aeee804..85662c9 100644 --- a/src/main/webapp/src/App.jsx +++ b/src/main/webapp/src/App.jsx @@ -95,7 +95,7 @@ function Root() {
    { return
    + (licence.validate ? "success" : (licence.certificate?.length > 0 ? "warning" : "danger"))}>
    {licence?.saison}-{licence?.saison + 1}
    - + + Médecin figurant sur le certificat médical +
    diff --git a/src/main/webapp/src/pages/club/member/LicenceCard.jsx b/src/main/webapp/src/pages/club/member/LicenceCard.jsx index 017990e..ec3c913 100644 --- a/src/main/webapp/src/pages/club/member/LicenceCard.jsx +++ b/src/main/webapp/src/pages/club/member/LicenceCard.jsx @@ -7,6 +7,7 @@ import {AxiosError} from "../../../components/AxiosError.jsx"; import {apiAxios, errFormater, getSaison} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; import {ColoredText} from "../../../components/ColoredCircle.jsx"; +import {TextField} from "../../../components/MemberCustomFiels.jsx"; function licenceReducer(licences, action) { switch (action.type) { @@ -63,11 +64,11 @@ export function LicenceCard({userData}) { {licences.map((licence, index) => { return
    + (licence.validate ? "success" : (licence.certificate?.length > 0 ? "warning" : "danger"))}>
    {licence?.saison}-{licence?.saison + 1}
    })} @@ -126,11 +127,11 @@ function removeLicence(id, dispatch) { } function ModalContent({licence, dispatch}) { - const [certificate, setCertificate] = useState(false) + const [certificate, setCertificate] = useState("") const [isNew, setNew] = useState(true) const handleCertificateChange = (event) => { - setCertificate(event.target.value === 'true'); + setCertificate(event.target.value); } useEffect(() => { @@ -139,7 +140,7 @@ function ModalContent({licence, dispatch}) { setCertificate(licence.certificate) } else { setNew(true) - setCertificate(false) + setCertificate("") } }, [licence]); @@ -156,27 +157,19 @@ function ModalContent({licence, dispatch}) { aria-label="Close">
    -
    - Certificat médical - - - - -
    + Médecin figurant sur le certificat médical +
    Validation de la licence:
    - {currentSaison && + {currentSaison && !licence.validate && } - {currentSaison && licence.validate === false && + {currentSaison && !licence.validate && }
    From 957fcfff8b54a464dfe47cc0e13ecd69acd898cf Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Fri, 19 Jul 2024 17:21:11 +0200 Subject: [PATCH 26/37] wip: swagger --- pom.xml | 9 + src/main/java/fr/titionfire/BlackPage.java | 18 -- src/main/java/fr/titionfire/PingPage.java | 27 +++ .../domain/service/AffiliationService.java | 3 +- .../ffsaf/rest/AffiliationEndpoints.java | 133 ++++-------- .../rest/AffiliationRequestEndpoints.java | 198 ++++++++++++++++++ .../titionfire/ffsaf/rest/AssoEndpoints.java | 15 +- .../titionfire/ffsaf/rest/AuthEndpoints.java | 20 ++ .../titionfire/ffsaf/rest/ClubEndpoints.java | 118 ++++++++++- src/main/resources/application.properties | 1 - .../src/pages/admin/club/AffiliationCard.jsx | 2 +- src/main/webapp/vite.config.js | 4 + 12 files changed, 419 insertions(+), 129 deletions(-) delete mode 100644 src/main/java/fr/titionfire/BlackPage.java create mode 100644 src/main/java/fr/titionfire/PingPage.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/AffiliationRequestEndpoints.java diff --git a/pom.xml b/pom.xml index 059225b..525fd91 100644 --- a/pom.xml +++ b/pom.xml @@ -115,6 +115,15 @@ jmimemagic 0.1.3 + + + io.quarkus + quarkus-smallrye-openapi + + + io.quarkus + quarkus-swagger-ui + diff --git a/src/main/java/fr/titionfire/BlackPage.java b/src/main/java/fr/titionfire/BlackPage.java deleted file mode 100644 index a77d835..0000000 --- a/src/main/java/fr/titionfire/BlackPage.java +++ /dev/null @@ -1,18 +0,0 @@ -package fr.titionfire; - -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; - -@Path("/api") -public class BlackPage { - - @GET - @Produces(MediaType.TEXT_PLAIN) - public Response get() { - return Response.noContent().build(); - } - -} diff --git a/src/main/java/fr/titionfire/PingPage.java b/src/main/java/fr/titionfire/PingPage.java new file mode 100644 index 0000000..2acd198 --- /dev/null +++ b/src/main/java/fr/titionfire/PingPage.java @@ -0,0 +1,27 @@ +package fr.titionfire; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +@Tag(name = "Ping API", description = "API pour tester la connectivité") +@Path("/api") +public class PingPage { + + @Operation(summary = "Renvoie un message de réussite", description = "Cette méthode renvoie un message de réussite si la connexion est établie avec succès.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite") + }) + @GET + @Produces(MediaType.TEXT_PLAIN) + public Response get() { + return Response.ok().build(); + } + +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index 09f864e..3926f5c 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -233,7 +233,7 @@ public class AffiliationService { .call(m -> (m.getUserId() == null) ? keycloakService.initCompte(m.getId()) : keycloakService.setClubGroupMembre(m, club)) .call(m -> Panache.withTransaction(() -> licenceRepository.persist( - new LicenceModel(null, m, saison, false, true)))); + new LicenceModel(null, m, saison, null, true)))); } public Uni accept(AffiliationRequestSaveForm form) { @@ -336,6 +336,7 @@ public class AffiliationService { public Uni setAffiliation(long id, int saison) { return clubRepository.findById(id) .onItem().ifNull().failWith(new DNotFoundException("Club non trouvé")) + .call(model -> Mutiny.fetch(model.getAffiliations())) .invoke(Unchecked.consumer(club -> { if (club.getAffiliations().stream().anyMatch(affiliation -> affiliation.getSaison() == saison)) { throw new DBadRequestException("Affiliation déjà existante"); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java index 6e6448d..35a4492 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java @@ -2,13 +2,8 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.domain.service.AffiliationService; import fr.titionfire.ffsaf.rest.data.SimpleAffiliation; -import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliation; -import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliationResume; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; -import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm; -import fr.titionfire.ffsaf.rest.from.AffiliationRequestSaveForm; import fr.titionfire.ffsaf.utils.GroupeUtils; -import fr.titionfire.ffsaf.utils.Utils; import io.quarkus.oidc.IdToken; import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; @@ -17,14 +12,17 @@ import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; -import java.net.URISyntaxException; import java.util.List; import java.util.function.Consumer; +@Tag(name = "Affiliation API", description = "API pour gérer les affiliations") @Path("api/affiliation") public class AffiliationEndpoints { @@ -38,85 +36,20 @@ public class AffiliationEndpoints { @Inject SecurityIdentity securityIdentity; - @ConfigProperty(name = "upload_dir") - String media; - Consumer checkPerm = Unchecked.consumer(id -> { if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(id, idToken)) throw new DForbiddenException(); }); - @GET - @Path("/request") - @RolesAllowed({"federation_admin"}) - @Produces(MediaType.APPLICATION_JSON) - public Uni> getAllAffRequest() { - return service.getAllReq().map(o -> o.stream().map(SimpleReqAffiliationResume::fromModel).toList()); - } - - @POST - @Path("/request") - @Produces(MediaType.TEXT_PLAIN) - @Consumes(MediaType.MULTIPART_FORM_DATA) - public Uni saveAffRequest(AffiliationRequestForm form) { - return service.save(form); - } - - @GET - @Path("/request/{id}") - @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) - @Produces(MediaType.APPLICATION_JSON) - public Uni getAffRequest(@PathParam("id") long id) { - return service.getRequest(id).invoke(Unchecked.consumer(o -> { - if (o.getClub() == null && !securityIdentity.getRoles().contains("federation_admin")) - throw new DForbiddenException(); - })).invoke(o -> checkPerm.accept(o.getClub())); - } - - @DELETE - @Path("/request/{id}") - @RolesAllowed({"federation_admin"}) - @Produces(MediaType.APPLICATION_JSON) - public Uni getDelAffRequest(@PathParam("id") long id) { - return service.getRequest(id).invoke(Unchecked.consumer(o -> { - if (o.getClub() == null && !securityIdentity.getRoles().contains("federation_admin")) - throw new DForbiddenException(); - })).invoke(o -> checkPerm.accept(o.getClub())) - .chain(o -> service.deleteReqAffiliation(id)); - } - - @PUT - @Path("/request/save") - @RolesAllowed({"federation_admin"}) - @Produces(MediaType.TEXT_PLAIN) - @Consumes(MediaType.MULTIPART_FORM_DATA) - public Uni saveAdminAffRequest(AffiliationRequestSaveForm form) { - return service.saveAdmin(form); - } - - @PUT - @Path("/request/edit") - @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) - @Produces(MediaType.TEXT_PLAIN) - @Consumes(MediaType.MULTIPART_FORM_DATA) - public Uni saveEditAffRequest(AffiliationRequestForm form) { - return service.saveEdit(form); - } - - @PUT - @Path("/request/apply") - @RolesAllowed({"federation_admin"}) - @Produces(MediaType.TEXT_PLAIN) - @Consumes(MediaType.MULTIPART_FORM_DATA) - public Uni acceptAffRequest(AffiliationRequestSaveForm form) { - return service.accept(form); - } - - @GET @Path("/current") @RolesAllowed({"federation_admin"}) @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Renvoie les affiliations pour la saison en cours", description = "Cette méthode renvoie les affiliations pour la saison en cours. Seuls les administrateurs de la fédération peuvent accéder à cette méthode.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite"), + @APIResponse(responseCode = "403", description = "Accès refusé") + }) public Uni> getCurrentSaisonAffiliationAdmin() { return service.getCurrentSaisonAffiliation(); } @@ -125,15 +58,30 @@ public class AffiliationEndpoints { @Path("{id}") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) - public Uni> getAffiliation(@PathParam("id") long id) { + @Operation(summary = "Renvoie les affiliations pour un club", description = "Cette méthode renvoie les affiliations pour un club donné. Seuls les administrateurs de la fédération et les présidents, secrétaires et responsables intranet du club peuvent accéder à cette méthode.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Club non trouvé") + }) + public Uni> getAffiliation( + @Parameter(description = "L'identifiant du club") @PathParam("id") long id) { return Uni.createFrom().item(id).invoke(checkPerm).chain(__ -> service.getAffiliation(id)); } - @PUT + @POST @Path("{id}") @RolesAllowed("federation_admin") @Produces(MediaType.APPLICATION_JSON) - public Uni setAffiliation(@PathParam("id") long id, @QueryParam("saison") int saison) { + @Operation(summary = "Ajoute une affiliation pour un club", description = "Cette méthode ajoute une affiliation pour un club et une saison donné. Seuls les administrateurs de la fédération peuvent accéder à cette méthode.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Club non trouvé") + }) + public Uni setAffiliation( + @Parameter(description = "L'identifiant du club") @PathParam("id") long id, + @Parameter(description = "La saison à pour la quelle ajoute l'affiliation") @QueryParam("saison") int saison) { return service.setAffiliation(id, saison); } @@ -141,22 +89,13 @@ public class AffiliationEndpoints { @Path("{id}") @RolesAllowed("federation_admin") @Produces(MediaType.TEXT_PLAIN) - public Uni deleteAffiliation(@PathParam("id") long id) { + @Operation(summary = "Supprime une affiliation", description = "Cette méthode supprime l'affiliation {id}. Seuls les administrateurs de la fédération peuvent accéder à cette méthode.") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "Réussite"), + @APIResponse(responseCode = "403", description = "Accès refusé") + }) + public Uni deleteAffiliation( + @Parameter(description = "L'identifiant de l'affiliation") @PathParam("id") long id) { return service.deleteAffiliation(id); } - - @GET - @Path("/request/{id}/logo") - @RolesAllowed({"federation_admin"}) - public Uni getLogo(@PathParam("id") long id) throws URISyntaxException { - return Utils.getMediaFile(id, media, "aff_request/logo", Uni.createFrom().nullItem()); - } - - @GET - @Path("/request/{id}/status") - @RolesAllowed({"federation_admin"}) - public Uni getStatus(@PathParam("id") long id) throws URISyntaxException { - return Utils.getMediaFile(id, media, "aff_request/status", "affiliation_request_" + id + ".pdf", - Uni.createFrom().nullItem()); - } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationRequestEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationRequestEndpoints.java new file mode 100644 index 0000000..b8a8bb5 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationRequestEndpoints.java @@ -0,0 +1,198 @@ +package fr.titionfire.ffsaf.rest; + +import fr.titionfire.ffsaf.domain.service.AffiliationService; +import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliation; +import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliationResume; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; +import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm; +import fr.titionfire.ffsaf.rest.from.AffiliationRequestSaveForm; +import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.Utils; +import io.quarkus.oidc.IdToken; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; + +import java.net.URISyntaxException; +import java.util.List; +import java.util.function.Consumer; + +@Path("api/affiliation/request") +public class AffiliationRequestEndpoints { + + @Inject + AffiliationService service; + + @Inject + @IdToken + JsonWebToken idToken; + + @Inject + SecurityIdentity securityIdentity; + + @ConfigProperty(name = "upload_dir") + String media; + + Consumer checkPerm = Unchecked.consumer(id -> { + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(id, idToken)) + throw new DForbiddenException(); + }); + + @GET + @Path("") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Renvoie toutes les demandes d'affiliation", description = "Cette méthode renvoie toutes les " + + "demandes d'affiliation sous forme de résumés.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite"), + @APIResponse(responseCode = "403", description = "Accès refusé") + }) + public Uni> getAllAffRequest() { + return service.getAllReq().map(o -> o.stream().map(SimpleReqAffiliationResume::fromModel).toList()); + } + + @POST + @Path("") + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Enregistre une nouvelle demande d'affiliation", description = "Cette méthode enregistre une " + + "nouvelle demande d'affiliation à partir des données soumises dans le formulaire. Ne nécessite pas d'authentification.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite"), + @APIResponse(responseCode = "400", description = "Données invalides"), + @APIResponse(responseCode = "403", description = "Accès refusé") + }) + public Uni saveAffRequest(AffiliationRequestForm form) { + return service.save(form); + } + + @GET + @Path("/{id}") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Renvoie une demande d'affiliation", description = "Cette méthode renvoie une demande d'affiliation " + + "pour l'identifiant spécifié.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Demande d'affiliation non trouvée") + }) + public Uni getAffRequest( + @Parameter(description = "L'identifiant de la demande d'affiliation") @PathParam("id") long id) { + return service.getRequest(id).invoke(Unchecked.consumer(o -> { + if (o.getClub() == null && !securityIdentity.getRoles().contains("federation_admin")) + throw new DForbiddenException(); + })).invoke(o -> checkPerm.accept(o.getClub())); + } + + @DELETE + @Path("/{id}") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Supprime une demande d'affiliation", description = "Cette méthode supprime une demande " + + "d'affiliation pour l'identifiant spécifié.") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "Réussite"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Demande d'affiliation non trouvée") + }) + public Uni getDelAffRequest( + @Parameter(description = "L'identifiant de la demande d'affiliation") @PathParam("id") long id) { + return service.getRequest(id).invoke(Unchecked.consumer(o -> { + if (o.getClub() == null && !securityIdentity.getRoles().contains("federation_admin")) + throw new DForbiddenException(); + })).invoke(o -> checkPerm.accept(o.getClub())) + .chain(o -> service.deleteReqAffiliation(id)); + } + + @PUT + @Path("/save") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Enregistre une demande d'affiliation en tant qu'admin", description = "Cette méthode " + + "enregistre une demande d'affiliation en tant qu'admin à partir des données soumises dans le formulaire.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite"), + @APIResponse(responseCode = "400", description = "Données invalides"), + @APIResponse(responseCode = "403", description = "Accès refusé") + }) + public Uni saveAdminAffRequest(AffiliationRequestSaveForm form) { + return service.saveAdmin(form); + } + + @PUT + @Path("/edit") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Modifie une demande d'affiliation", description = "Cette méthode modifie une demande " + + "d'affiliation à partir des données soumises dans le formulaire.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite"), + @APIResponse(responseCode = "400", description = "Données invalides"), + @APIResponse(responseCode = "403", description = "Accès refusé") + }) + public Uni saveEditAffRequest(AffiliationRequestForm form) { + return service.saveEdit(form); + } + + @PUT + @Path("/apply") + @RolesAllowed({"federation_admin"}) + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Accepte une demande d'affiliation", description = "Cette méthode accepte une demande " + + "d'affiliation à partir des données soumises dans le formulaire.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite"), + @APIResponse(responseCode = "400", description = "Données invalides"), + @APIResponse(responseCode = "403", description = "Accès refusé") + }) + public Uni acceptAffRequest(AffiliationRequestSaveForm form) { + return service.accept(form); + } + + @GET + @Path("/{id}/logo") + @RolesAllowed({"federation_admin"}) + @Operation(summary = "Renvoie le logo d'une demande d'affiliation", description = "Cette méthode renvoie le logo" + + " d'une demande d'affiliation pour l'identifiant spécifié.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Logo non trouvé") + }) + public Uni getLogo( + @Parameter(description = "L'identifiant de la demande d'affiliation") @PathParam("id") long id) throws URISyntaxException { + return Utils.getMediaFile(id, media, "aff_request/logo", Uni.createFrom().nullItem()); + } + + @GET + @Path("/{id}/status") + @RolesAllowed({"federation_admin"}) + @Operation(summary = "Renvoie le statut d'une demande d'affiliation", description = "Cette méthode renvoie le statut" + + " d'une demande d'affiliation pour l'identifiant spécifié.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Statut non trouvé") + }) + public Uni getStatus( + @Parameter(description = "L'identifiant de la demande d'affiliation") @PathParam("id") long id) throws URISyntaxException { + return Utils.getMediaFile(id, media, "aff_request/status", "affiliation_request_" + id + ".pdf", + Uni.createFrom().nullItem()); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java index d739ca7..7fec841 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java @@ -6,8 +6,14 @@ import fr.titionfire.ffsaf.rest.exception.DNotFoundException; import io.smallrye.mutiny.Uni; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.eclipse.microprofile.rest.client.inject.RestClient; +@Tag(name = "Association", description = "Récupération des informations d'une association depuis la base de données Française") @Path("api/asso") public class AssoEndpoints { @@ -17,7 +23,14 @@ public class AssoEndpoints { @GET @Path("siren/{siren}") @Produces(MediaType.APPLICATION_JSON) - public Uni getInfoSiren(@PathParam("siren") String siren) { + @Operation(summary = "Renvoie les informations d'une association à partir de son numéro SIREN", + description = "Cette méthode renvoie les informations d'une association à partir de son numéro SIREN.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite"), + @APIResponse(responseCode = "404", description = "Numéro SIREN introuvable") + }) + public Uni getInfoSiren( + @Parameter(description = "Le numéro SIREN de l'association à récupérer") @PathParam("siren") String siren) { return sirenService.get_unite(siren).onFailure().transform(throwable -> { if (throwable instanceof WebApplicationException exception) { if (exception.getResponse().getStatus() == 400) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java index 3371743..ee26e80 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java @@ -11,6 +11,9 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; import java.net.URI; import java.net.URISyntaxException; @@ -29,6 +32,12 @@ public class AuthEndpoints { @GET @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "Vérifie si l'utilisateur est authentifié", description = "Cette méthode renvoie true si " + + "l'utilisateur est authentifié et false sinon.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite") + }) + public Boolean auth() { return !securityIdentity.isAnonymous(); } @@ -37,6 +46,12 @@ public class AuthEndpoints { @Path("/userinfo") @Authenticated @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Renvoie les informations de l'utilisateur authentifié", description = "Cette méthode renvoie les" + + " informations de l'utilisateur authentifié sous forme d'objet JSON.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Réussite"), + @APIResponse(responseCode = "401", description = "Utilisateur non authentifié") + }) public UserInfo userinfo() { return UserInfo.makeUserInfo(accessToken, securityIdentity); } @@ -45,6 +60,11 @@ public class AuthEndpoints { @Path("/login") @Authenticated @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "Redirige l'utilisateur vers la page de connexion", description = "Cette méthode redirige " + + "l'utilisateur vers la page de connexion.") + @APIResponses(value = { + @APIResponse(responseCode = "307", description = "Redirection temporaire") + }) public Response login() throws URISyntaxException { return Response.temporaryRedirect(new URI(redirect)).build(); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index ef778ed..cdfa9f8 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -27,12 +27,18 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; import java.net.URISyntaxException; import java.util.HashMap; import java.util.List; import java.util.function.Consumer; +@Tag(name = "Club", description = "Gestion des clubs") @Path("api/club") public class ClubEndpoints { @@ -63,6 +69,12 @@ public class ClubEndpoints { @Path("/no_detail") @Authenticated @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Renvoie la liste de tous les clubs sans détails", description = "Renvoie la liste de tous les " + + "clubs sans les détails des membres et des affiliations") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "La liste de tous les clubs sans détails"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni> getAll() { return clubService.getAll().map(clubModels -> clubModels.stream().map(SimpleClubModel::fromModel).toList()); } @@ -71,6 +83,8 @@ public class ClubEndpoints { @Path("/contact_type") @Authenticated @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Renvoie les types de contacts pour les clubs", description = "Renvoie la liste des types de " + + "contacts possibles pour les clubs") public Uni> getConcatType() { return Uni.createFrom().item(Contact.toSite()); } @@ -79,10 +93,17 @@ public class ClubEndpoints { @Path("/find") @RolesAllowed({"federation_admin"}) @Produces(MediaType.APPLICATION_JSON) - public Uni> getFindAdmin(@QueryParam("limit") Integer limit, - @QueryParam("page") Integer page, - @QueryParam("search") String search, - @QueryParam("country") String country) { + @Operation(summary = "Recherche des clubs en fonction de critères de recherche", description = "Recherche des clubs " + + "en fonction de critères de recherche tels que le nom, le pays, etc.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "La liste des clubs correspondant aux critères de recherche"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni> getFindAdmin( + @Parameter(description = "Nombre max de résulta (max 50)") @QueryParam("limit") Integer limit, + @Parameter(description = "Page à consulter") @QueryParam("page") Integer page, + @Parameter(description = "Text à rechercher") @QueryParam("search") String search, + @Parameter(description = "Pays à filter") @QueryParam("country") String country) { if (limit == null) limit = 50; if (page == null || page < 1) @@ -95,7 +116,16 @@ public class ClubEndpoints { @Path("{id}") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) - public Uni getById(@PathParam("id") long id) { + @Operation(summary = "Renvoie les détails d'un club en fonction de son identifiant", description = "Renvoie les " + + "détails d'un club en fonction de son identifiant, y compris les informations sur les membres et les affiliations") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Les détails du club"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le club n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni getById( + @Parameter(description = "Identifiant de club") @PathParam("id") long id) { return clubService.getById(id).onItem().invoke(checkPerm).map(SimpleClub::fromModel).invoke(m -> { m.setContactMap(Contact.toSite()); }); @@ -106,7 +136,17 @@ public class ClubEndpoints { @RolesAllowed({"federation_admin"}) @Produces(MediaType.TEXT_PLAIN) @Consumes(MediaType.MULTIPART_FORM_DATA) - public Uni setAdminClub(@PathParam("id") long id, FullClubForm input) { + @Operation(summary = "Met à jour les informations d'un club en fonction de son identifiant", description = "Met à " + + "jour les informations d'un club en fonction de son identifiant, y compris les informations sur les membres" + + " et les affiliations") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Le club a été mis à jour avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le club n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni setAdminClub( + @Parameter(description = "Identifiant de club") @PathParam("id") long id, FullClubForm input) { return clubService.update(id, input) .invoke(Unchecked.consumer(out -> { if (!out.equals("OK")) throw new InternalError("Fail to update data: " + out); @@ -135,6 +175,13 @@ public class ClubEndpoints { @RolesAllowed({"federation_admin"}) @Produces(MediaType.TEXT_PLAIN) @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Ajoute un nouveau club", description = "Ajoute un nouveau club avec les informations fournies" + + " dans le formulaire") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Le club a été ajouté avec succès"), + @APIResponse(responseCode = "400", description = "Les données envoyées sont invalides"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni addAdminClub(FullClubForm input) { return clubService.add(input) .invoke(Unchecked.consumer(id -> { @@ -158,7 +205,16 @@ public class ClubEndpoints { @Path("{id}") @RolesAllowed({"federation_admin"}) @Produces(MediaType.TEXT_PLAIN) - public Uni deleteAdminClub(@PathParam("id") long id) { + @Operation(summary = "Supprime un club en fonction de son identifiant", description = "Supprime un club en fonction" + + " de son identifiant, ainsi que toutes les informations associées") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "Le club a été supprimé avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le club n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni deleteAdminClub( + @Parameter(description = "Identifiant de club") @PathParam("id") long id) { return clubService.delete(id); } @@ -166,6 +222,14 @@ public class ClubEndpoints { @Path("/me") @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Renvoie les informations du club de l'utilisateur connecté", description = "Renvoie les " + + "informations du club de l'utilisateur connecté") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Les informations du club de l'utilisateur connecté"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "L'utilisateur n'est pas membre d'un club"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni getOfUser() { return clubService.getOfUser(idToken).map(SimpleClub::fromModel) .invoke(m -> m.setContactMap(Contact.toSite())); @@ -176,6 +240,14 @@ public class ClubEndpoints { @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Met à jour les informations du club de l'utilisateur connecté", description = "Met à jour les" + + " informations du club de l'utilisateur connecté") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Les informations du club de l'utilisateur connecté ont été mises à jour avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "L'utilisateur n'est pas membre d'un club"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni setClubOfUser(PartClubForm form) { return clubService.updateOfUser(idToken, form); } @@ -184,6 +256,7 @@ public class ClubEndpoints { @Path("/renew/{id}") @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) + @Operation(hidden = true) public Uni getOfUser(@PathParam("id") long id, @QueryParam("m1") long m1_id, @QueryParam("m2") long m2_id, @QueryParam("m3") long m3_id) { return Uni.createFrom().item(id).invoke(checkPerm2) @@ -194,14 +267,31 @@ public class ClubEndpoints { @Path("/desk/{id}") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) - public Uni> getClubDesk(@PathParam("id") long id) { + @Operation(summary = "Renvoie la liste des membres du bureau du club", description = "Renvoie la liste des membres " + + "du bureau du club spécifié") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "La liste des membres du bureau du club"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le club n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni> getClubDesk( + @Parameter(description = "Identifiant de club") @PathParam("id") long id) { return clubService.getClubDesk(checkPerm, id); } @GET @Path("{clubId}/logo") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) - public Uni getLogo(@PathParam("clubId") String clubId) { + @Operation(summary = "Renvoie le logo du club", description = "Renvoie le logo du club spécifié") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Le logo du club"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le club n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni getLogo( + @Parameter(description = "Identifiant long (clubId) de club") @PathParam("clubId") String clubId) { return clubService.getByClubId(clubId).onItem().invoke(checkPerm).chain(Unchecked.function(clubModel -> { try { return Utils.getMediaFile((clubModel != null) ? clubModel.getId() : -1, media, "ppClub", @@ -215,7 +305,15 @@ public class ClubEndpoints { @GET @Path("{id}/status") @RolesAllowed({"federation_admin", "club_president", "club_secretaire"}) - public Uni getStatus(@PathParam("id") long id) { + @Operation(summary = "Renvoie le statut du club", description = "Renvoie le statut du club spécifié") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Le statut du club"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le club n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni getStatus( + @Parameter(description = "Identifiant de club") @PathParam("id") long id) { return clubService.getById(id).onItem().invoke(checkPerm).chain(Unchecked.function(clubModel -> { try { return Utils.getMediaFile(clubModel.getId(), media, "clubStatus", diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 92aa416..2ceeada 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,7 +17,6 @@ quarkus.quartz.start-mode=forced %dev.quarkus.log.min-level=ALL %dev.quarkus.log.category."fr.titionfire.ffsaf".level=ALL - quarkus.oidc.auth-server-url=https://auth.safca.fr/realms/safca quarkus.oidc.client-id=backend quarkus.oidc.credentials.secret=secret diff --git a/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx b/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx index 96290e7..0d7e7f7 100644 --- a/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx +++ b/src/main/webapp/src/pages/admin/club/AffiliationCard.jsx @@ -67,7 +67,7 @@ function sendAffiliation(event, dispatch) { const formData = new FormData(event.target); toast.promise( - apiAxios.put(`/affiliation/${formData.get('club')}?saison=${formData.get('saison')}`), + apiAxios.post(`/affiliation/${formData.get('club')}?saison=${formData.get('saison')}`), { pending: "Enregistrement de l'affiliation en cours", success: "Affiliation enregistrée avec succès 🎉", diff --git a/src/main/webapp/vite.config.js b/src/main/webapp/vite.config.js index a9c4187..c532ed0 100644 --- a/src/main/webapp/vite.config.js +++ b/src/main/webapp/vite.config.js @@ -17,6 +17,10 @@ export default ({mode}) => { target: process.env.VITE_API_URL, changeOrigin: true, }, + "/q": { + target: process.env.VITE_API_URL, + changeOrigin: true, + }, }, }, }); From fa54a583944d3fc7b10ed36f4a9c9863133d1837 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Fri, 19 Jul 2024 19:29:30 +0200 Subject: [PATCH 27/37] wip: swagger p2 --- .../titionfire/ffsaf/rest/AuthEndpoints.java | 6 +- .../titionfire/ffsaf/rest/ClubEndpoints.java | 2 +- .../titionfire/ffsaf/rest/CombEndpoints.java | 216 ------------------ .../ffsaf/rest/CompteEndpoints.java | 45 +++- .../ffsaf/rest/CountriesEndpoints.java | 2 + .../ffsaf/rest/LicenceEndpoints.java | 55 +++++ .../ffsaf/rest/MembreAdminEndpoints.java | 152 ++++++++++++ .../ffsaf/rest/MembreClubEndpoints.java | 132 +++++++++++ .../ffsaf/rest/MembreEndpoints.java | 115 ++++++++++ 9 files changed, 501 insertions(+), 224 deletions(-) delete mode 100644 src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java index ee26e80..d9d4127 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java @@ -60,11 +60,7 @@ public class AuthEndpoints { @Path("/login") @Authenticated @Produces(MediaType.TEXT_PLAIN) - @Operation(summary = "Redirige l'utilisateur vers la page de connexion", description = "Cette méthode redirige " + - "l'utilisateur vers la page de connexion.") - @APIResponses(value = { - @APIResponse(responseCode = "307", description = "Redirection temporaire") - }) + @Operation(hidden = true) public Response login() throws URISyntaxException { return Response.temporaryRedirect(new URI(redirect)).build(); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index cdfa9f8..115175f 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -257,7 +257,7 @@ public class ClubEndpoints { @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) @Operation(hidden = true) - public Uni getOfUser(@PathParam("id") long id, @QueryParam("m1") long m1_id, + public Uni getRenew(@PathParam("id") long id, @QueryParam("m1") long m1_id, @QueryParam("m2") long m2_id, @QueryParam("m3") long m3_id) { return Uni.createFrom().item(id).invoke(checkPerm2) .chain(__ -> clubService.getRenewData(id, List.of(m1_id, m2_id, m3_id))); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java deleted file mode 100644 index 36c3492..0000000 --- a/src/main/java/fr/titionfire/ffsaf/rest/CombEndpoints.java +++ /dev/null @@ -1,216 +0,0 @@ -package fr.titionfire.ffsaf.rest; - -import fr.titionfire.ffsaf.data.model.MembreModel; -import fr.titionfire.ffsaf.domain.service.MembreService; -import fr.titionfire.ffsaf.rest.data.MeData; -import fr.titionfire.ffsaf.rest.data.SimpleMembre; -import fr.titionfire.ffsaf.rest.exception.DForbiddenException; -import fr.titionfire.ffsaf.rest.exception.DInternalError; -import fr.titionfire.ffsaf.rest.from.ClubMemberForm; -import fr.titionfire.ffsaf.rest.from.FullMemberForm; -import fr.titionfire.ffsaf.utils.GroupeUtils; -import fr.titionfire.ffsaf.utils.PageResult; -import fr.titionfire.ffsaf.utils.Utils; -import io.quarkus.oidc.IdToken; -import io.quarkus.security.Authenticated; -import io.quarkus.security.identity.SecurityIdentity; -import io.smallrye.mutiny.Uni; -import io.smallrye.mutiny.unchecked.Unchecked; -import jakarta.annotation.security.RolesAllowed; -import jakarta.inject.Inject; -import jakarta.ws.rs.*; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.eclipse.microprofile.jwt.JsonWebToken; - -import java.net.URISyntaxException; -import java.util.List; -import java.util.function.Consumer; - -@Authenticated -@Path("api/member") -public class CombEndpoints { - - @Inject - MembreService membreService; - - @ConfigProperty(name = "upload_dir") - String media; - - @Inject - @IdToken - JsonWebToken idToken; - - @Inject - SecurityIdentity securityIdentity; - - Consumer checkPerm = Unchecked.consumer(membreModel -> { - if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup( - membreModel.getClub().getId(), idToken)) - throw new DForbiddenException(); - }); - - @GET - @Path("/find/admin") - @RolesAllowed({"federation_admin"}) - @Produces(MediaType.APPLICATION_JSON) - public Uni> getFindAdmin(@QueryParam("limit") Integer limit, - @QueryParam("page") Integer page, - @QueryParam("search") String search, - @QueryParam("club") String club) { - if (limit == null) - limit = 50; - if (page == null || page < 1) - page = 1; - return membreService.searchAdmin(limit, page - 1, search, club); - } - - @GET - @Path("/find/similar") - @RolesAllowed({"federation_admin"}) - @Produces(MediaType.APPLICATION_JSON) - public Uni> getSimilar(@QueryParam("fname") String fname, @QueryParam("lname") String lname) { - return membreService.getSimilar(fname, lname); - } - - @GET - @Path("/find/club") - @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) - @Produces(MediaType.APPLICATION_JSON) - public Uni> getFindClub(@QueryParam("limit") Integer limit, - @QueryParam("page") Integer page, - @QueryParam("search") String search) { - if (limit == null) - limit = 50; - if (page == null || page < 1) - page = 1; - return membreService.search(limit, page - 1, search, idToken.getSubject()); - } - - @GET - @Path("{id}") - @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) - @Produces(MediaType.APPLICATION_JSON) - public Uni getById(@PathParam("id") long id) { - return membreService.getById(id).onItem().invoke(checkPerm).map(SimpleMembre::fromModel); - } - - @GET - @Path("/find/licence") - @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) - @Produces(MediaType.APPLICATION_JSON) - public Uni getByLicence(@QueryParam("id") long id) { - return membreService.getByLicence(id).onItem().invoke(checkPerm).map(SimpleMembre::fromModel); - } - - @PUT - @Path("{id}") - @RolesAllowed({"federation_admin"}) - @Produces(MediaType.TEXT_PLAIN) - @Consumes(MediaType.MULTIPART_FORM_DATA) - public Uni setAdminMembre(@PathParam("id") long id, FullMemberForm input) { - return membreService.update(id, input) - .invoke(Unchecked.consumer(out -> { - if (!out.equals("OK")) - throw new DInternalError("Impossible de reconnaitre le fichier: " + out); - })).chain(() -> { - if (input.getPhoto_data().length > 0) - return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre" - )).invoke(Unchecked.consumer(out -> { - if (!out.equals("OK")) - throw new DInternalError("Impossible de reconnaitre le fichier: " + out); - })); - else - return Uni.createFrom().nullItem(); - }); - } - - @POST - @RolesAllowed({"federation_admin"}) - @Produces(MediaType.TEXT_PLAIN) - @Consumes(MediaType.MULTIPART_FORM_DATA) - public Uni addAdminMembre(FullMemberForm input) { - return membreService.add(input) - .invoke(Unchecked.consumer(id -> { - if (id == null) throw new InternalError("Fail to creat member data"); - })).call(id -> { - if (input.getPhoto_data().length > 0) - return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre" - )); - else - return Uni.createFrom().nullItem(); - }); - } - - @DELETE - @Path("{id}") - @RolesAllowed({"federation_admin"}) - @Produces(MediaType.TEXT_PLAIN) - public Uni deleteAdminMembre(@PathParam("id") long id) { - return membreService.delete(id); - } - - @PUT - @Path("club/{id}") - @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) - @Produces(MediaType.TEXT_PLAIN) - @Consumes(MediaType.MULTIPART_FORM_DATA) - public Uni setMembre(@PathParam("id") long id, ClubMemberForm input) { - return membreService.update(id, input, idToken, securityIdentity) - .invoke(Unchecked.consumer(out -> { - if (!out.equals("OK")) throw new InternalError("Fail to update data: " + out); - })).chain(() -> { - if (input.getPhoto_data().length > 0) - return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre" - )).invoke(Unchecked.consumer(out -> { - if (!out.equals("OK")) - throw new DInternalError("Impossible de reconnaitre le fichier: " + out); - })); - else - return Uni.createFrom().nullItem(); - }); - } - - @POST - @Path("club") - @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) - @Produces(MediaType.TEXT_PLAIN) - @Consumes(MediaType.MULTIPART_FORM_DATA) - public Uni addMembre(FullMemberForm input) { - return membreService.add(input, idToken.getSubject()) - .invoke(Unchecked.consumer(id -> { - if (id == null) throw new InternalError("Fail to creat member data"); - })).call(id -> { - if (input.getPhoto_data().length > 0) - return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre" - )); - else - return Uni.createFrom().nullItem(); - }); - } - - @DELETE - @Path("club/{id}") - @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) - @Produces(MediaType.TEXT_PLAIN) - public Uni deleteMembre(@PathParam("id") long id) { - return membreService.delete(id, idToken); - } - - @GET - @Path("me") - @Authenticated - @Produces(MediaType.APPLICATION_JSON) - public Uni getMe() { - return membreService.getMembre(idToken.getSubject()); - } - - @GET - @Path("{id}/photo") - @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) - public Uni getPhoto(@PathParam("id") long id) throws URISyntaxException { - return Utils.getMediaFile(id, media, "ppMembre", membreService.getById(id).onItem().invoke(checkPerm)); - } - -} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java index f6a5231..7a8ec62 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java @@ -10,13 +10,21 @@ import io.smallrye.mutiny.Uni; import io.vertx.mutiny.core.Vertx; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; -import jakarta.ws.rs.*; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.keycloak.representations.idm.GroupRepresentation; import java.util.ArrayList; import java.util.List; +@Tag(name = "Compte", description = "Gestion des comptes utilisateurs") @Path("api/compte") public class CompteEndpoints { @@ -35,6 +43,14 @@ public class CompteEndpoints { @GET @Path("{id}") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) + @Operation(summary = "Renvoie les informations d'un compte utilisateur", description = "Renvoie les informations d'un" + + " compte utilisateur en fonction de son identifiant long (UUID)") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Les informations du compte utilisateur"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le compte utilisateur n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni getCompte(@PathParam("id") String id) { return service.fetchCompte(id).call(pair -> vertx.getOrCreateContext().executeBlocking(() -> { if (!securityIdentity.getRoles().contains("federation_admin") && pair.getKey().groups().stream().map(GroupRepresentation::getPath) @@ -47,6 +63,14 @@ public class CompteEndpoints { @PUT @Path("{id}/init") @RolesAllowed("federation_admin") + @Operation(summary = "Initialise un compte utilisateur", description = "Initialise un compte utilisateur en fonction" + + " de son identifiant") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "Le compte utilisateur a été initialisé avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le membre n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni initCompte(@PathParam("id") long id) { return service.initCompte(id); } @@ -54,6 +78,7 @@ public class CompteEndpoints { @PUT @Path("{id}/setUUID/{nid}") @RolesAllowed("federation_admin") + @Operation(hidden = true) public Uni initCompte(@PathParam("id") long id, @PathParam("nid") String nid) { return service.setId(id, nid); } @@ -61,13 +86,29 @@ public class CompteEndpoints { @GET @Path("{id}/roles") @RolesAllowed("federation_admin") - public Uni getRole(@PathParam("id") String id) { + @Operation(summary = "Renvoie les rôles d'un compte utilisateur", description = "Renvoie les rôles d'un compte" + + " utilisateur en fonction de son identifiant") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Les rôles du compte utilisateur"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le compte utilisateur n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni> getRole(@PathParam("id") String id) { return service.fetchRole(id); } @PUT @Path("{id}/roles") @RolesAllowed("federation_admin") + @Operation(summary = "Met à jour les rôles d'un compte utilisateur", description = "Met à jour les rôles d'un compte" + + " utilisateur en fonction de son identifiant et des rôles fournis dans le formulaire") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "Les rôles du compte utilisateur ont été mis à jour avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le compte utilisateur n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni updateRole(@PathParam("id") String id, MemberPermForm form) { List toAdd = new ArrayList<>(); List toRemove = new ArrayList<>(); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CountriesEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CountriesEndpoints.java index ff87b52..fc49914 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CountriesEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CountriesEndpoints.java @@ -6,6 +6,7 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.openapi.annotations.Operation; import java.util.HashMap; import java.util.Locale; @@ -16,6 +17,7 @@ public class CountriesEndpoints { @GET @Path("/{lang}/{code}") @Produces(MediaType.APPLICATION_JSON) + @Operation(hidden = true) public Uni> getCountries(@PathParam("lang") String lang, @PathParam("code") String code) { Locale locale = new Locale(lang, code); return Uni.createFrom().item(new HashMap()) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java index 91dd5fd..55cf203 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java @@ -15,6 +15,9 @@ import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; import java.util.List; import java.util.function.Consumer; @@ -41,6 +44,14 @@ public class LicenceEndpoints { @Path("{id}") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Renvoie les licences d'un membre", description = "Renvoie les licences d'un membre en fonction " + + "de son identifiant") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "La liste des licences du membre"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le membre n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni> getLicence(@PathParam("id") long id) { return licenceService.getLicence(id, checkPerm).map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList()); } @@ -49,6 +60,12 @@ public class LicenceEndpoints { @Path("current/admin") @RolesAllowed({"federation_admin"}) @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Renvoie les licences de la saison en cours (pour les administrateurs)", description = "Renvoie" + + " les licences de la saison en cours (pour les administrateurs)") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "La liste des licences de la saison en cours"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni> getCurrentSaisonLicenceAdmin() { return licenceService.getCurrentSaisonLicence(null).map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList()); } @@ -57,6 +74,12 @@ public class LicenceEndpoints { @Path("current/club") @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Renvoie les licences de la saison en cours (pour les clubs)", description = "Renvoie les " + + "licences de la saison en cours (pour les clubs)") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "La liste des licences de la saison en cours"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni> getCurrentSaisonLicenceClub() { return licenceService.getCurrentSaisonLicence(idToken).map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList()); } @@ -66,6 +89,14 @@ public class LicenceEndpoints { @RolesAllowed("federation_admin") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Créer une licence", description = "Créer unr licence en fonction de son identifiant et des " + + "informations fournies dans le formulaire (pour les administrateurs)") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "La licence a été mise à jour avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "La licence n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni setLicence(@PathParam("id") long id, LicenceForm form) { return licenceService.setLicence(id, form).map(SimpleLicence::fromModel); } @@ -74,6 +105,14 @@ public class LicenceEndpoints { @Path("{id}") @RolesAllowed("federation_admin") @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "Supprime une licence", description = "Supprime une licence en fonction de son identifiant " + + "(pour les administrateurs)") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "La licence a été supprimée avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "La licence n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni deleteLicence(@PathParam("id") long id) { return licenceService.deleteLicence(id); } @@ -83,6 +122,14 @@ public class LicenceEndpoints { @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Demande une nouvelle licence", description = "Demande une nouvelle licence en fonction de" + + " l'identifiant du membre et des informations fournies dans le formulaire (pour les clubs)") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "La demande de licence a été envoyée avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le membre n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni askLicence(@PathParam("id") long id, LicenceForm form) { return licenceService.askLicence(id, form, checkPerm).map(SimpleLicence::fromModel); } @@ -91,6 +138,14 @@ public class LicenceEndpoints { @Path("club/{id}") @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "Supprime une demande de licence", description = "Supprime une demande de licence en fonction " + + "de son identifiant (pour les clubs)") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "La demande de licence a été supprimée avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "La demande de licence n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) public Uni deleteAskLicence(@PathParam("id") long id) { return licenceService.deleteAskLicence(id, checkPerm); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java new file mode 100644 index 0000000..485d4d1 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java @@ -0,0 +1,152 @@ +package fr.titionfire.ffsaf.rest; + +import fr.titionfire.ffsaf.data.model.MembreModel; +import fr.titionfire.ffsaf.domain.service.MembreService; +import fr.titionfire.ffsaf.rest.data.SimpleMembre; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; +import fr.titionfire.ffsaf.rest.exception.DInternalError; +import fr.titionfire.ffsaf.rest.from.FullMemberForm; +import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.PageResult; +import fr.titionfire.ffsaf.utils.Utils; +import io.quarkus.oidc.IdToken; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +import java.util.List; +import java.util.function.Consumer; + +@Tag(name = "Membre admin", description = "Gestion des membres (pour les administrateurs)") +@Path("api/member") +@RolesAllowed({"federation_admin"}) +public class MembreAdminEndpoints { + + @Inject + MembreService membreService; + + @ConfigProperty(name = "upload_dir") + String media; + + @Inject + @IdToken + JsonWebToken idToken; + + @Inject + SecurityIdentity securityIdentity; + + Consumer checkPerm = Unchecked.consumer(membreModel -> { + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup( + membreModel.getClub().getId(), idToken)) + throw new DForbiddenException(); + }); + + @GET + @Path("/find/admin") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Recherche des membres par critères ", description = "Recherche des membres en fonction de " + + "critères tels que le nom, le prénom, le club, etc. ") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "La liste des membres correspondant aux critères de recherche"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni> getFindAdmin( + @Parameter(description = "Nombre max de résulta (max 50)") @QueryParam("limit") Integer limit, + @Parameter(description = "Page à consulter") @QueryParam("page") Integer page, + @Parameter(description = "Text à rechercher") @QueryParam("search") String search, + @Parameter(description = "Club à filter") @QueryParam("club") String club) { + if (limit == null) + limit = 50; + if (page == null || page < 1) + page = 1; + return membreService.searchAdmin(limit, page - 1, search, club); + } + + @GET + @Path("/find/similar") + @Produces(MediaType.APPLICATION_JSON) + @Operation(hidden = true) + public Uni> getSimilar(@QueryParam("fname") String fname, @QueryParam("lname") String lname) { + return membreService.getSimilar(fname, lname); + } + + @PUT + @Path("{id}") + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Met à jour les informations d'un membre en fonction de son identifiant", description = "Met à " + + "jour les informations d'un membre en fonction de son identifiant") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Le membre a été mis à jour avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le membre n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni setAdminMembre( + @Parameter(description = "Identifiant de membre") @PathParam("id") long id, FullMemberForm input) { + return membreService.update(id, input) + .invoke(Unchecked.consumer(out -> { + if (!out.equals("OK")) + throw new DInternalError("Impossible de reconnaitre le fichier: " + out); + })).chain(() -> { + if (input.getPhoto_data().length > 0) + return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre" + )).invoke(Unchecked.consumer(out -> { + if (!out.equals("OK")) + throw new DInternalError("Impossible de reconnaitre le fichier: " + out); + })); + else + return Uni.createFrom().nullItem(); + }); + } + + @POST + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Ajoute un nouveau membre", description = "Ajoute un nouveau membre avec les informations " + + "fournies dans le formulaire") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Le membre a été ajouté avec succès"), + @APIResponse(responseCode = "400", description = "Les données envoyées sont invalides"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni addAdminMembre(FullMemberForm input) { + return membreService.add(input) + .invoke(Unchecked.consumer(id -> { + if (id == null) throw new InternalError("Fail to creat member data"); + })).call(id -> { + if (input.getPhoto_data().length > 0) + return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre" + )); + else + return Uni.createFrom().nullItem(); + }); + } + + @DELETE + @Path("{id}") + @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "Supprime un membre en fonction de son identifiant", description = "Supprime un membre en " + + "fonction de son identifiant, ainsi que toutes les informations associées") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "Le membre a été supprimé avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le membre n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni deleteAdminMembre( + @Parameter(description = "Identifiant de membre") @PathParam("id") long id) { + return membreService.delete(id); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java new file mode 100644 index 0000000..3ae36e4 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java @@ -0,0 +1,132 @@ +package fr.titionfire.ffsaf.rest; + +import fr.titionfire.ffsaf.domain.service.MembreService; +import fr.titionfire.ffsaf.rest.data.SimpleMembre; +import fr.titionfire.ffsaf.rest.exception.DInternalError; +import fr.titionfire.ffsaf.rest.from.ClubMemberForm; +import fr.titionfire.ffsaf.rest.from.FullMemberForm; +import fr.titionfire.ffsaf.utils.PageResult; +import fr.titionfire.ffsaf.utils.Utils; +import io.quarkus.oidc.IdToken; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +@Tag(name = "Membre club", description = "Gestion des membres (pour les clubs)") +@RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) +@Path("api/member") +public class MembreClubEndpoints { + + @Inject + MembreService membreService; + + @ConfigProperty(name = "upload_dir") + String media; + + @Inject + @IdToken + JsonWebToken idToken; + + @Inject + SecurityIdentity securityIdentity; + + @GET + @Path("/find/club") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Recherche des membres par critères", description = "Recherche des membres en " + + "fonction de critères tels que le nom, le prénom, etc.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "La liste des membres correspondant aux critères de recherche"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni> getFindClub( + @Parameter(description = "Nombre max de résulta (max 50)") @QueryParam("limit") Integer limit, + @Parameter(description = "Page à consulter") @QueryParam("page") Integer page, + @Parameter(description = "Text à rechercher") @QueryParam("search") String search) { + if (limit == null) + limit = 50; + if (page == null || page < 1) + page = 1; + return membreService.search(limit, page - 1, search, idToken.getSubject()); + } + + @PUT + @Path("club/{id}") + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Met à jour les informations d'un membre en fonction de son identifiant", + description = "Met à jour les informations d'un membre en fonction de son identifiant") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Le membre a été mis à jour avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le membre n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni setMembre( + @Parameter(description = "Identifiant de membre") @PathParam("id") long id, ClubMemberForm input) { + return membreService.update(id, input, idToken, securityIdentity) + .invoke(Unchecked.consumer(out -> { + if (!out.equals("OK")) throw new InternalError("Fail to update data: " + out); + })).chain(() -> { + if (input.getPhoto_data().length > 0) + return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre" + )).invoke(Unchecked.consumer(out -> { + if (!out.equals("OK")) + throw new DInternalError("Impossible de reconnaitre le fichier: " + out); + })); + else + return Uni.createFrom().nullItem(); + }); + } + + @POST + @Path("club") + @Produces(MediaType.TEXT_PLAIN) + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Ajoute un nouveau membre", description = "Ajoute un nouveau membre avec les informations " + + "fournies dans le formulaire") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Le membre a été ajouté avec succès"), + @APIResponse(responseCode = "400", description = "Les données envoyées sont invalides"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni addMembre(FullMemberForm input) { + return membreService.add(input, idToken.getSubject()) + .invoke(Unchecked.consumer(id -> { + if (id == null) throw new InternalError("Fail to creat member data"); + })).call(id -> { + if (input.getPhoto_data().length > 0) + return Uni.createFrom().future(Utils.replacePhoto(id, input.getPhoto_data(), media, "ppMembre" + )); + else + return Uni.createFrom().nullItem(); + }); + } + + @DELETE + @Path("club/{id}") + @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "Supprime un membre en fonction de son identifiant", description = "Supprime " + + "un membre en fonction de son identifiant, ainsi que toutes les informations associées") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "Le membre a été supprimé avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le membre n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni deleteMembre( + @Parameter(description = "Identifiant de membre") @PathParam("id") long id) { + return membreService.delete(id, idToken); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java new file mode 100644 index 0000000..7d73fc3 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java @@ -0,0 +1,115 @@ +package fr.titionfire.ffsaf.rest; + +import fr.titionfire.ffsaf.data.model.MembreModel; +import fr.titionfire.ffsaf.domain.service.MembreService; +import fr.titionfire.ffsaf.rest.data.MeData; +import fr.titionfire.ffsaf.rest.data.SimpleMembre; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; +import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.Utils; +import io.quarkus.oidc.IdToken; +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +import java.net.URISyntaxException; +import java.util.function.Consumer; + +@Tag(name = "Membre", description = "Gestion des membres") +@Authenticated +@Path("api/member") +public class MembreEndpoints { + + @Inject + MembreService membreService; + + @ConfigProperty(name = "upload_dir") + String media; + + @Inject + @IdToken + JsonWebToken idToken; + + @Inject + SecurityIdentity securityIdentity; + + Consumer checkPerm = Unchecked.consumer(membreModel -> { + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup( + membreModel.getClub().getId(), idToken)) + throw new DForbiddenException(); + }); + + @GET + @Path("{id}") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Renvoie les détails d'un membre en fonction de son identifiant", description = "Renvoie les " + + "détails d'un membre en fonction de son identifiant") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Les détails du membre"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le membre n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni getById( + @Parameter(description = "Identifiant de membre") @PathParam("id") long id) { + return membreService.getById(id).onItem().invoke(checkPerm).map(SimpleMembre::fromModel); + } + + @GET + @Path("/find/licence") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) + @Operation(summary = "Renvoie les détails d'un membre en fonction de son numéro de licence", description = "Renvoie " + + "les détails d'un membre en fonction de son numéro de licence") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Les détails du membre"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le membre n'existe pas"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + @Produces(MediaType.APPLICATION_JSON) + public Uni getByLicence(@QueryParam("id") long id) { + return membreService.getByLicence(id).onItem().invoke(checkPerm).map(SimpleMembre::fromModel); + } + + @GET + @Path("me") + @Authenticated + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Renvoie les informations du membre connecté", description = "Renvoie les informations du " + + "membre connecté, y compris le club et les licences") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Les informations du membre connecté"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni getMe() { + return membreService.getMembre(idToken.getSubject()); + } + + @GET + @Path("{id}/photo") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) + @Operation(summary = "Renvoie la photo d'un membre", description = "Renvoie la photo d'un membre en fonction de son identifiant") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "La photo du membre"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le membre n'existe pas ou n'a pas de photo"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni getPhoto(@PathParam("id") long id) throws URISyntaxException { + return Utils.getMediaFile(id, media, "ppMembre", membreService.getById(id).onItem().invoke(checkPerm)); + } +} From b3bfb7e267051a428f0d3022213a01fa4ba50f3d Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Fri, 19 Jul 2024 23:30:38 +0200 Subject: [PATCH 28/37] wip: swagger p3 --- .../ffsaf/data/model/AffiliationModel.java | 3 + .../ffsaf/data/model/ClubModel.java | 19 ++++++- .../titionfire/ffsaf/rest/AssoEndpoints.java | 15 +---- .../ffsaf/rest/data/DeskMember.java | 6 ++ .../ffsaf/rest/data/SimpleReqAffiliation.java | 6 ++ .../rest/from/AffiliationRequestForm.java | 41 +++++++++++++ .../rest/from/AffiliationRequestSaveForm.java | 57 ++++++++++++++++++- .../ffsaf/rest/from/ClubMemberForm.java | 11 ++++ .../ffsaf/rest/from/FullClubForm.java | 15 +++++ 9 files changed, 157 insertions(+), 16 deletions(-) diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/AffiliationModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/AffiliationModel.java index d3d0dfd..96688bb 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/AffiliationModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/AffiliationModel.java @@ -3,6 +3,7 @@ package fr.titionfire.ffsaf.data.model; import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.*; import lombok.*; +import org.eclipse.microprofile.openapi.annotations.media.Schema; @Getter @Setter @@ -16,11 +17,13 @@ import lombok.*; public class AffiliationModel { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "Identifiant de l'affiliation", example = "42") Long id; @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "club", referencedColumnName = "id") ClubModel club; + @Schema(description = "Saison de l'affiliation", example = "2021") int saison; } diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java index 72a30de..e69b1fc 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/ClubModel.java @@ -4,6 +4,7 @@ import fr.titionfire.ffsaf.utils.Contact; import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.*; import lombok.*; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import java.util.List; import java.util.Map; @@ -20,12 +21,16 @@ import java.util.Map; public class ClubModel { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "Identifiant du club", example = "1") Long id; + @Schema(description = "Identifiant long du club (UUID)", example = "b94f3167-3f6a-449c-a73b-ec84202bf07e") String clubId; + @Schema(description = "Nom du club", example = "Association sportive") String name; + @Schema(description = "Pays du club", example = "FR") String country; //@Enumerated(EnumType.STRING) @@ -33,28 +38,38 @@ public class ClubModel { @CollectionTable(name = "club_contact_mapping", joinColumns = {@JoinColumn(name = "club_id", referencedColumnName = "id")}) @MapKeyColumn(name = "contact_type") + @Schema(description = "Les contacts du club", example = "{\"SITE\": \"www.test.com\", \"COURRIEL\": \"test@test.com\"}") Map contact; @Lob - @Column(length=4096) + @Column(length = 4096) + @Schema(description = "Liste des lieux d'entraînement", example = "[{\"text\":\"addr 1\",\"lng\":2.24654,\"lat\":52.4868658},{\"text\":\"addr 2\",\"lng\":2.88654,\"lat\":52.7865456}]") String training_location; @Lob - @Column(length=4096) + @Column(length = 4096) + @Schema(description = "Liste des jours et horaires d'entraînement (jours 0-6, 0=>lundi) (temps en minute depuis 00:00, 122=>2h02)", example = "[{\"day\":0,\"time_start\":164,\"time_end\":240},{\"day\":3,\"time_start\":124,\"time_end\":250}]") String training_day_time; + @Schema(description = "Contact interne du club", example = "john.doe@test.com") String contact_intern; + @Schema(description = "Adresse postale du club", example = "1 rue de l'exemple, 75000 Paris") String address; + @Schema(description = "RNA du club", example = "W123456789") String RNA; + @Schema(description = "Numéro SIRET du club", example = "12345678901234") Long SIRET; + @Schema(description = "Numéro d'affiliation du club", example = "12345") Long no_affiliation; + @Schema(description = "Club international", example = "false") boolean international; @OneToMany(mappedBy = "club", fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @Schema(description = "Liste des affiliations du club (optionnel)") List affiliations; } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java index 7fec841..a7904c5 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AssoEndpoints.java @@ -7,13 +7,8 @@ import io.smallrye.mutiny.Uni; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import org.eclipse.microprofile.openapi.annotations.Operation; -import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; -import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.eclipse.microprofile.rest.client.inject.RestClient; -@Tag(name = "Association", description = "Récupération des informations d'une association depuis la base de données Française") @Path("api/asso") public class AssoEndpoints { @@ -23,14 +18,8 @@ public class AssoEndpoints { @GET @Path("siren/{siren}") @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "Renvoie les informations d'une association à partir de son numéro SIREN", - description = "Cette méthode renvoie les informations d'une association à partir de son numéro SIREN.") - @APIResponses(value = { - @APIResponse(responseCode = "200", description = "Réussite"), - @APIResponse(responseCode = "404", description = "Numéro SIREN introuvable") - }) - public Uni getInfoSiren( - @Parameter(description = "Le numéro SIREN de l'association à récupérer") @PathParam("siren") String siren) { + @Operation(hidden = true) + public Uni getInfoSiren(@PathParam("siren") String siren) { return sirenService.get_unite(siren).onFailure().transform(throwable -> { if (throwable instanceof WebApplicationException exception) { if (exception.getResponse().getStatus() == 400) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/DeskMember.java b/src/main/java/fr/titionfire/ffsaf/rest/data/DeskMember.java index c653916..7bffcfc 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/DeskMember.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/DeskMember.java @@ -4,14 +4,20 @@ import fr.titionfire.ffsaf.data.model.MembreModel; import io.quarkus.runtime.annotations.RegisterForReflection; import lombok.Data; import lombok.NoArgsConstructor; +import org.eclipse.microprofile.openapi.annotations.media.Schema; @Data @NoArgsConstructor @RegisterForReflection +@Schema(name = "BureauMembre") public class DeskMember { + @Schema(description = "Identifiant du membre", example = "1") private Long id; + @Schema(description = "Nom du membre", example = "Doe") private String lname; + @Schema(description = "Prénom du membre", example = "John") private String fname; + @Schema(description = "Rôle du membre", example = "Président") private String role; public static DeskMember fromModel(MembreModel membreModel) { diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliation.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliation.java index 9b0a594..4f2b016 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliation.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliation.java @@ -6,6 +6,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import java.util.List; @@ -51,10 +52,15 @@ public class SimpleReqAffiliation { @AllArgsConstructor @RegisterForReflection public static class AffiliationMember { + @Schema(description = "Nom du membre", example = "Doe") String lname; + @Schema(description = "Prénom du membre", example = "John") String fname; + @Schema(description = "Email du membre", example = "john.doe@test.com") String email; + @Schema(description = "Numéro de licence du membre", example = "12345") int licence; + @Schema(description = "Rôle du membre", example = "MEMBRE") RoleAsso role; } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java index 38086de..985510c 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestForm.java @@ -6,63 +6,104 @@ import jakarta.ws.rs.FormParam; import jakarta.ws.rs.core.MediaType; import lombok.Getter; import lombok.ToString; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.jboss.resteasy.reactive.PartType; @Getter @ToString public class AffiliationRequestForm { + @Schema(description = "L'identifiant de l'affiliation. (null si nouvelle demande d'affiliation)") @FormParam("id") private Long id = null; + @Schema(description = "Le nom de l'association.", example = "Association sportive", required = true) @FormParam("name") private String name = null; + + @Schema(description = "Le numéro SIRET de l'association.", example = "12345678901234", required = true) @FormParam("siret") private Long siret = null; + + @Schema(description = "Le numéro RNA de l'association. (peut être null)", example = "W123456789") @FormParam("rna") private String rna = null; + + @Schema(description = "L'adresse de l'association.", example = "1 rue de l'exemple, 75000 Paris", required = true) @FormParam("adresse") private String adresse = null; + + @Schema(description = "La saison de l'affiliation.", example = "2025", required = true) @FormParam("saison") private int saison = -1; + @Schema(description = "Le statut de l'association.", type = SchemaType.ARRAY, implementation = byte.class) @FormParam("status") @PartType(MediaType.APPLICATION_OCTET_STREAM) private byte[] status = new byte[0]; + @Schema(description = "Le logo de l'association.", type = SchemaType.ARRAY, implementation = byte.class) @FormParam("logo") @PartType(MediaType.APPLICATION_OCTET_STREAM) private byte[] logo = new byte[0]; + @Schema(description = "Le nom du premier membre de l'association.", example = "Doe", required = true) @FormParam("m1_nom") private String m1_lname = null; + + @Schema(description = "Le prénom du premier membre de l'association.", example = "John", required = true) @FormParam("m1_prenom") private String m1_fname = null; + + @Schema(description = "L'adresse e-mail du premier membre de l'association.", example = "john.doe@test.com", required = true) @FormParam("m1_mail") private String m1_email = null; + + @Schema(description = "Le numéro de licence du premier membre de l'association. (null si non licencié)", example = "12345") @FormParam("m1_licence") private String m1_lincence = null; + + @Schema(description = "Le rôle du premier membre de l'association. (doit être PRESIDENT)", example = "PRESIDENT", required = true) @FormParam("m1_role") private RoleAsso m1_role = null; + @Schema(description = "Le nom du deuxième membre de l'association.", example = "Xavier", required = true) @FormParam("m2_nom") private String m2_lname = null; + + @Schema(description = "Le prénom du deuxième membre de l'association.", example = "Login", required = true) @FormParam("m2_prenom") private String m2_fname = null; + + @Schema(description = "L'adresse e-mail du deuxième membre de l'association.", example = "xavier.login@test.com", required = true) @FormParam("m2_mail") private String m2_email = null; + + @Schema(description = "Le numéro de licence du deuxième membre de l'association. (null si non licencié)", example = "04242") @FormParam("m2_licence") private String m2_lincence = null; + + @Schema(description = "Le rôle du deuxième membre de l'association.", example = "SECRETAIRE", required = true) @FormParam("m2_role") private RoleAsso m2_role = null; + @Schema(description = "Le nom du troisième membre de l'association.", example = "Doe2", required = true) @FormParam("m3_nom") private String m3_lname = null; + + @Schema(description = "Le prénom du troisième membre de l'association.", example = "John2", required = true) @FormParam("m3_prenom") private String m3_fname = null; + + @Schema(description = "L'adresse e-mail du troisième membre de l'association.", example = "john.doe22@test.com", required = true) @FormParam("m3_mail") private String m3_email = null; + + @Schema(description = "Le numéro de licence du troisième membre de l'association. (null si non licencié)") @FormParam("m3_licence") private String m3_lincence = null; + + @Schema(description = "Le rôle du troisième membre de l'association.", example = "MEMBREBUREAU", required = true) @FormParam("m3_role") private RoleAsso m3_role = null; diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestSaveForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestSaveForm.java index 495b1d9..ac3012b 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestSaveForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/AffiliationRequestSaveForm.java @@ -5,71 +5,126 @@ import jakarta.ws.rs.FormParam; import jakarta.ws.rs.core.MediaType; import lombok.Getter; import lombok.ToString; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.jboss.resteasy.reactive.PartType; @Getter @ToString public class AffiliationRequestSaveForm { + @Schema(description = "L'identifiant de l'affiliation.", example = "1", required = true) @FormParam("id") private Long id = null; + + @Schema(description = "Le nom de l'association.", example = "Association sportive", required = true) @FormParam("name") private String name = null; + + @Schema(description = "Le numéro SIRET de l'association.", example = "12345678901234", required = true) @FormParam("siret") private Long siret = null; + + @Schema(description = "Le numéro RNA de l'association. (peut être null)", example = "W123456789") @FormParam("rna") private String rna = null; + + @Schema(description = "L'adresse de l'association.", example = "1 rue de l'exemple, 75000 Paris", required = true) @FormParam("address") private String address = null; + @Schema(description = "Le statut de l'association.") @FormParam("status") @PartType(MediaType.APPLICATION_OCTET_STREAM) private byte[] status = new byte[0]; + + @Schema(description = "Le logo de l'association.") @FormParam("logo") @PartType(MediaType.APPLICATION_OCTET_STREAM) private byte[] logo = new byte[0]; + @Schema(description = "Mode utiliser pour la sauvegarde du membre 1 (0 = licence mode, 2 = nom, prénom)", example = "0", required = true) @FormParam("m1_mode") private Integer m1_mode = null; + + @Schema(description = "Le rôle du premier membre de l'association.", example = "PRÉSIDENT", required = true) @FormParam("m1_role") private RoleAsso m1_role = null; + + @Schema(description = "Le numéro de licence du premier membre de l'association. (null si non licencié)", example = "1234567", required = true) @FormParam("m1_licence") private String m1_lincence = null; + + @Schema(description = "Le nom du premier membre de l'association.", example = "Dupont", required = true) @FormParam("m1_lname") private String m1_lname = null; + + @Schema(description = "Le prénom du premier membre de l'association.", example = "Jean", required = true) @FormParam("m1_fname") private String m1_fname = null; + + @Schema(description = "L'adresse e-mail du premier membre de l'association.", example = "jean.dupont@example.com", required = true) @FormParam("m1_email") private String m1_email = null; + + @Schema(name = "keep_email", + description = "Conserver l'email de la base de donner (1 = conserve, 0 = replacer par 'm1_email')", example = "1", required = true) @FormParam("m1_email_mode") private Integer m1_email_mode = null; + @Schema(description = "Mode utiliser pour la sauvegarde du membre 2 (0 = licence mode, 2 = nom, prénom)", example = "0", required = true) @FormParam("m2_mode") private Integer m2_mode = null; + + @Schema(description = "Le rôle du deuxième membre de l'association.", example = "TRÉSORIER", required = true) @FormParam("m2_role") private RoleAsso m2_role = null; + + @Schema(description = "Le numéro de licence du deuxième membre de l'association. (null si non licencié)", example = "2345678", required = true) @FormParam("m2_licence") private String m2_lincence = null; + + @Schema(description = "Le nom du deuxième membre de l'association.", example = "Durand", required = true) @FormParam("m2_lname") private String m2_lname = null; + + @Schema(description = "Le prénom du deuxième membre de l'association.", example = "Paul", required = true) @FormParam("m2_fname") private String m2_fname = null; + + @Schema(description = "L'adresse e-mail du deuxième membre de l'association.", example = "paul.durand@example.com", required = true) @FormParam("m2_email") private String m2_email = null; + + @Schema(name = "keep_email", + description = "Conserver l'email de la base de donner (1 = conserve, 0 = replacer par 'm2_email')", example = "1", required = true) @FormParam("m2_email_mode") private Integer m2_email_mode = null; + @Schema(description = "Mode utiliser pour la sauvegarde du membre 3 (0 = licence mode, 2 = nom, prénom)", example = "0", required = true) @FormParam("m3_mode") private Integer m3_mode = null; + + @Schema(description = "Le rôle du troisième membre de l'association.", example = "SECRÉTAIRE", required = true) @FormParam("m3_role") private RoleAsso m3_role = null; + + @Schema(description = "Le numéro de licence du troisième membre de l'association. (null si non licencié)", example = "3456789", required = true) @FormParam("m3_licence") private String m3_lincence = null; + + @Schema(description = "Le nom du troisième membre de l'association.", example = "Martin", required = true) @FormParam("m3_lname") private String m3_lname = null; + + @Schema(description = "Le prénom du troisième membre de l'association.", example = "Pierre", required = true) @FormParam("m3_fname") private String m3_fname = null; + + @Schema(description = "L'adresse e-mail du troisième membre de l'association.", example = "pierre.martin@example.com", required = true) @FormParam("m3_email") private String m3_email = null; + + @Schema(name = "keep_email", + description = "Conserver l'email de la base de donner (1 = conserve, 0 = replacer par 'm3_email')", example = "1", required = true) @FormParam("m3_email_mode") private Integer m3_email_mode = null; @@ -84,7 +139,7 @@ public class AffiliationRequestSaveForm { private String email; private Integer email_mode; - public Member(int n){ + public Member(int n) { if (n == 1) { mode = m1_mode; role = m1_role; diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/ClubMemberForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/ClubMemberForm.java index ac752d3..d65f4bc 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/ClubMemberForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/ClubMemberForm.java @@ -6,39 +6,50 @@ import fr.titionfire.ffsaf.utils.RoleAsso; import jakarta.ws.rs.FormParam; import jakarta.ws.rs.core.MediaType; import lombok.Getter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.jboss.resteasy.reactive.PartType; import java.util.Date; @Getter public class ClubMemberForm { + @Schema(description = "L'identifiant du membre.", example = "1234567", required = true) @FormParam("id") private String id = null; + @Schema(description = "Le nom du membre.", example = "Dupont", required = true) @FormParam("lname") private String lname = null; + @Schema(description = "Le prénom du membre.", example = "Jean", required = true) @FormParam("fname") private String fname = null; + @Schema(description = "La catégorie du membre.", example = "SENIOR", required = true) @FormParam("categorie") private Categorie categorie = null; + @Schema(description = "Le genre du membre.", example = "H", required = true) @FormParam("genre") private Genre genre; + @Schema(description = "Le pays du membre.", example = "FR", required = true) @FormParam("country") private String country; + @Schema(description = "La date de naissance du membre.", required = true) @FormParam("birth_date") private Date birth_date; + @Schema(description = "L'adresse e-mail du membre.", example = "jean.dupont@example.com", required = true) @FormParam("email") private String email; + @Schema(description = "Le rôle du membre dans l'association.", example = "MEMBRE", required = true) @FormParam("role") private RoleAsso role; + @Schema(description = "La photo du membre.") @FormParam("photo_data") @PartType(MediaType.APPLICATION_OCTET_STREAM) private byte[] photo_data = new byte[0]; diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java index a9b27d0..26f81f1 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/FullClubForm.java @@ -4,49 +4,64 @@ import jakarta.ws.rs.FormParam; import jakarta.ws.rs.core.MediaType; import lombok.Getter; import lombok.ToString; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.jboss.resteasy.reactive.PartType; @ToString @Getter public class FullClubForm { @FormParam("id") + @Schema(description = "Identifiant du club", example = "1", required = true) private String id = null; @FormParam("name") + @Schema(description = "Nom du club", example = "Association sportive", required = true) private String name = null; @FormParam("country") + @Schema(description = "Pays du club", example = "FR", required = true) private String country = null; @FormParam("contact") + @Schema(description = "Les contacts du club", example = "{\"SITE\": \"www.test.com\", \"COURRIEL\": \"test@test.com\"}", required = true) private String contact = null; @FormParam("training_location") + @Schema(description = "Liste des lieux d'entraînement", example = "[{\"text\":\"addr 1\",\"lng\":2.24654,\"lat\":52.4868658},{\"text\":\"addr 2\",\"lng\":2.88654,\"lat\":52.7865456}]", required = true) private String training_location = null; @FormParam("training_day_time") + @Schema(description = "Liste des jours et horaires d'entraînement (jours 0-6, 0=>lundi) (temps en minute depuis 00:00, 122=>2h02)", example = "[{\"day\":0,\"time_start\":164,\"time_end\":240},{\"day\":3,\"time_start\":124,\"time_end\":250}]", required = true) private String training_day_time = null; @FormParam("contact_intern") + @Schema(description = "Contact interne du club", example = "john.doe@test.com") private String contact_intern = null; @FormParam("address") + @Schema(description = "Adresse postale du club", example = "1 rue de l'exemple, 75000 Paris", required = true) private String address = null; @FormParam("rna") + @Schema(description = "RNA du club", example = "W123456789") private String rna = null; @FormParam("siret") + @Schema(description = "Numéro SIRET du club", example = "12345678901234", required = true) private String siret = null; @FormParam("international") + @Schema(description = "Club international", example = "false", required = true) private boolean international = false; @FormParam("status") @PartType(MediaType.APPLICATION_OCTET_STREAM) + @Schema(description = "Le statut de l'association.", type = SchemaType.ARRAY, implementation = byte.class) private byte[] status = new byte[0]; @FormParam("logo") @PartType(MediaType.APPLICATION_OCTET_STREAM) + @Schema(description = "Le logo de l'association.", type = SchemaType.ARRAY, implementation = byte.class) private byte[] logo = new byte[0]; } From cc00da4e5e8f5921a35b5671889d0c83ba3e07a9 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Sat, 20 Jul 2024 09:12:50 +0200 Subject: [PATCH 29/37] feat: swagger --- .../ffsaf/data/model/LicenceModel.java | 6 ++++++ .../titionfire/ffsaf/data/model/MembreModel.java | 16 ++++++++++++++++ .../ffsaf/net2/data/SimpleCombModel.java | 2 ++ .../fr/titionfire/ffsaf/rest/data/MeData.java | 14 ++++++++++++++ .../ffsaf/rest/data/SimpleAffiliation.java | 13 +++++++++---- .../titionfire/ffsaf/rest/data/SimpleClub.java | 15 +++++++++++++++ .../ffsaf/rest/data/SimpleClubList.java | 6 ++++++ .../ffsaf/rest/data/SimpleLicence.java | 6 ++++++ .../titionfire/ffsaf/rest/data/SimpleMembre.java | 15 +++++++++++++++ .../ffsaf/rest/data/SimpleReqAffiliation.java | 10 ++++++++++ .../rest/data/SimpleReqAffiliationResume.java | 5 +++++ .../fr/titionfire/ffsaf/rest/data/UserInfo.java | 10 ++++++++++ .../ffsaf/rest/from/FullMemberForm.java | 14 ++++++++++++++ .../titionfire/ffsaf/rest/from/LicenceForm.java | 6 ++++++ .../ffsaf/rest/from/MemberPermForm.java | 5 +++++ .../titionfire/ffsaf/rest/from/PartClubForm.java | 7 +++++++ .../fr/titionfire/ffsaf/utils/PageResult.java | 5 +++++ 17 files changed, 151 insertions(+), 4 deletions(-) diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java index 4f3151c..890be1a 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/LicenceModel.java @@ -3,6 +3,7 @@ package fr.titionfire.ffsaf.data.model; import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.*; import lombok.*; +import org.eclipse.microprofile.openapi.annotations.media.Schema; @Getter @Setter @@ -16,15 +17,20 @@ import lombok.*; public class LicenceModel { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "L'identifiant de la licence.") Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "membre", referencedColumnName = "id") + @Schema(description = "Le membre de la licence. (optionnel)") MembreModel membre; + @Schema(description = "La saison de la licence.", example = "2025") int saison; + @Schema(description = "Nom du médecin sur certificat médical.", example = "M. Jean") String certificate; + @Schema(description = "Licence validée", example = "true") boolean validate; } diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java index a64b1a9..2e181c2 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/MembreModel.java @@ -7,6 +7,7 @@ import fr.titionfire.ffsaf.utils.RoleAsso; import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.*; import lombok.*; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import java.util.Date; import java.util.List; @@ -24,35 +25,50 @@ public class MembreModel { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "L'identifiant du membre.", example = "1") Long id; + @Schema(description = "L'identifiant long du membre (userID).", example = "e81d1d35-d897-421e-8086-6c5e74d13c6e") String userId; + @Schema(description = "Le nom du membre.", example = "Dupont") String lname; + @Schema(description = "Le prénom du membre.", example = "Jean") String fname; + @Schema(description = "La catégorie du membre.", example = "SENIOR") Categorie categorie; @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "club", referencedColumnName = "id") + @Schema(description = "Le club du membre.") ClubModel club; + @Schema(description = "Le genre du membre.", example = "H") Genre genre; + @Schema(description = "Le numéro de licence du membre.", example = "12345") int licence; + @Schema(description = "Le pays du membre.", example = "FR") String country; + @Schema(description = "La date de naissance du membre.") Date birth_date; + @Schema(description = "L'adresse e-mail du membre.", example = "jean.dupont@example.com") String email; + @Schema(description = "Le rôle du membre dans l'association.", example = "MEMBRE") RoleAsso role; + @Schema(description = "Le grade d'arbitrage du membre.", example = "NA") GradeArbitrage grade_arbitrage; + @Schema(hidden = true) String url_photo; @OneToMany(mappedBy = "membre", fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @Schema(description = "Les licences du membre. (optionnel)") List licences; } diff --git a/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleCombModel.java b/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleCombModel.java index 9a062ab..fb9fe39 100644 --- a/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleCombModel.java +++ b/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleCombModel.java @@ -8,12 +8,14 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; @Getter @Setter @AllArgsConstructor @NoArgsConstructor @RegisterForReflection +@Schema(hidden = true) public class SimpleCombModel { Long id; String lname = ""; diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/MeData.java b/src/main/java/fr/titionfire/ffsaf/rest/data/MeData.java index d9a79ea..a65e097 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/MeData.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/MeData.java @@ -5,6 +5,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection; import lombok.Data; import lombok.NoArgsConstructor; import lombok.ToString; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import java.util.Date; import java.util.List; @@ -14,18 +15,31 @@ import java.util.List; @NoArgsConstructor @RegisterForReflection public class MeData { + @Schema(description = "L'identifiant du membre.", example = "1") private long id; + @Schema(description = "Le nom du membre.", example = "Dupont") private String lname = ""; + @Schema(description = "Le prénom du membre.", example = "Jean") private String fname = ""; + @Schema(description = "La catégorie du membre.", example = "SENIOR") private String categorie; + @Schema(description = "Le nom du club du membre.", example = "Association sportive") private String club; + @Schema(description = "Le genre du membre.", example = "Homme") private String genre; + @Schema(description = "Le numéro de licence du membre.", example = "12345") private int licence; + @Schema(description = "Le pays du membre.", example = "FR") private String country; + @Schema(description = "La date de naissance du membre.") private Date birth_date; + @Schema(description = "L'adresse e-mail du membre.", example = "jean.dupont@example.com") private String email; + @Schema(description = "Le rôle du membre dans l'association.", example = "MEMBRE") private String role; + @Schema(description = "Le grade d'arbitrage du membre.", example = "N/A") private String grade_arbitrage; + @Schema(description = "La liste des licences du membre.") private List licences; public void setMembre(MembreModel membreModel) { diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleAffiliation.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleAffiliation.java index 90efb04..2631783 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleAffiliation.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleAffiliation.java @@ -5,16 +5,21 @@ import io.quarkus.runtime.annotations.RegisterForReflection; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import org.eclipse.microprofile.openapi.annotations.media.Schema; @Data @Builder @AllArgsConstructor @RegisterForReflection public class SimpleAffiliation { - Long id; - Long club; - int saison; - boolean validate; + @Schema(description = "L'identifiant de l'affiliation.", example = "1") + private Long id; + @Schema(description = "L'identifiant du club associé à l'affiliation.", example = "123") + private Long club; + @Schema(description = "La saison de l'affiliation.", example = "2022") + private int saison; + @Schema(description = "Indique si l'affiliation est validée ou non.", example = "true") + private boolean validate; public static SimpleAffiliation fromModel(AffiliationModel model) { if (model == null) diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java index d81195c..6229aa6 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClub.java @@ -7,6 +7,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.ToString; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import java.util.HashMap; import java.util.Map; @@ -17,19 +18,33 @@ import java.util.Map; @AllArgsConstructor @RegisterForReflection public class SimpleClub { + @Schema(description = "L'identifiant unique du club.", example = "1") private Long id; + @Schema(description = "Identifiant long du club (UUID)", example = "b94f3167-3f6a-449c-a73b-ec84202bf07e") private String clubId; + @Schema(description = "Le nom du club.", example = "Association sportive") private String name; + @Schema(description = "Le pays du club.", example = "FR") private String country; + @Schema(description = "Les contacts du club", example = "{\"SITE\": \"www.test.com\", \"COURRIEL\": \"test@test.com\"}") private Map contact; + @Schema(description = "Liste des lieux d'entraînement", example = "[{\"text\":\"addr 1\",\"lng\":2.24654,\"lat\":52.4868658},{\"text\":\"addr 2\",\"lng\":2.88654,\"lat\":52.7865456}]") private String training_location; + @Schema(description = "Liste des jours et horaires d'entraînement (jours 0-6, 0=>lundi) (temps en minute depuis 00:00, 122=>2h02)", example = "[{\"day\":0,\"time_start\":164,\"time_end\":240},{\"day\":3,\"time_start\":124,\"time_end\":250}]") private String training_day_time; + @Schema(description = "Contact interne du club", example = "john.doe@test.com") private String contact_intern; + @Schema(description = "Adresse postale du club", example = "1 rue de l'exemple, 75000 Paris") private String address; + @Schema(description = "RNA du club", example = "W123456789") private String RNA; + @Schema(description = "Numéro SIRET du club", example = "12345678901234") private Long SIRET; + @Schema(description = "Numéro d'affiliation du club", example = "12345") private Long no_affiliation; + @Schema(description = "Club international", example = "false") private boolean international; + @Schema(description = "Une map contenant les contacts possible pout un club.") private HashMap contactMap = null; public static SimpleClub fromModel(ClubModel model) { diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClubList.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClubList.java index 09e1084..b42edb6 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClubList.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleClubList.java @@ -6,6 +6,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; @Getter @Setter @@ -13,10 +14,15 @@ import lombok.Setter; @NoArgsConstructor @RegisterForReflection public class SimpleClubList { + @Schema(description = "Identifiant du club", example = "1") Long id; + @Schema(description = "Nom du club", example = "Club de test") String name; + @Schema(description = "Pays du club", example = "FR") String country; + @Schema(description = "Numéro SIRET du club", example = "12345678901234") Long siret; + @Schema(description = "Numéro d'affiliation du club", example = "12345") Long no_affiliation; public static SimpleClubList fromModel(ClubModel model) { diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java index 8714d1f..fad08b3 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleLicence.java @@ -5,16 +5,22 @@ import io.quarkus.runtime.annotations.RegisterForReflection; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import org.eclipse.microprofile.openapi.annotations.media.Schema; @Data @Builder @AllArgsConstructor @RegisterForReflection public class SimpleLicence { + @Schema(description = "ID de la licence", example = "1") Long id; + @Schema(description = "ID du membre", example = "1") Long membre; + @Schema(description = "Saison de la licence", example = "2024") int saison; + @Schema(description = "Nom du médecin sur certificat médical.", example = "M. Jean") String certificate; + @Schema(description = "Validation de la licence", example = "true") boolean validate; public static SimpleLicence fromModel(LicenceModel model) { diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleMembre.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleMembre.java index dc51d65..a7e228b 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleMembre.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleMembre.java @@ -10,6 +10,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import java.util.Date; @@ -18,19 +19,33 @@ import java.util.Date; @AllArgsConstructor @RegisterForReflection public class SimpleMembre { + @Schema(description = "L'identifiant du membre.", example = "1") private long id; + @Schema(description = "L'identifiant long du membre (userID).", example = "e81d1d35-d897-421e-8086-6c5e74d13c6e") private String userId; + @Schema(description = "Le nom du membre.", example = "Dupont") private String lname = ""; + @Schema(description = "Le prénom du membre.", example = "Jean") private String fname = ""; + @Schema(description = "La catégorie du membre.", example = "SENIOR") private Categorie categorie; + @Schema(description = "Le club du membre.") private SimpleClubModel club; + @Schema(description = "Le genre du membre.", example = "H") private Genre genre; + @Schema(description = "Le numéro de licence du membre.", example = "12345") private int licence; + @Schema(description = "Le pays du membre.", example = "FR") private String country; + @Schema(description = "La date de naissance du membre.") private Date birth_date; + @Schema(description = "L'adresse e-mail du membre.", example = "jean.dupont@example.com") private String email; + @Schema(description = "Le rôle du membre dans l'association.", example = "MEMBRE") private RoleAsso role; + @Schema(description = "Le grade d'arbitrage du membre.", example = "N/A") private GradeArbitrage grade_arbitrage; + @Schema(hidden = true) private String url_photo; public static SimpleMembre fromModel(MembreModel model) { diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliation.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliation.java index 4f2b016..30d683f 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliation.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliation.java @@ -15,15 +15,25 @@ import java.util.List; @AllArgsConstructor @RegisterForReflection public class SimpleReqAffiliation { + @Schema(description = "Identifiant de la demande d'affiliation", example = "1") Long id; + @Schema(description = "Identifiant du club", example = "1") Long club; + @Schema(description = "Nom du club si club similar trouver (même siret)", example = "Association sportive") String club_name; + @Schema(description = "Identifiant du club affilié", example = "1") Long club_no_aff; + @Schema(description = "Nom du club demander", example = "Association sportive") String name; + @Schema(description = "Numéro SIRET de l'association", example = "12345678901234") long siret; + @Schema(description = "Numéro RNA de l'association", example = "W123456789") String RNA; + @Schema(description = "Adresse de l'association", example = "1 rue de l'exemple, 75000 Paris") String address; + @Schema(description = "Liste des membres pour la demande d'affiliation") List members; + @Schema(description = "Saison de l'affiliation", example = "2025") int saison; public static SimpleReqAffiliation fromModel(AffiliationRequestModel model) { diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliationResume.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliationResume.java index f3eb4ee..f99ace0 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliationResume.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleReqAffiliationResume.java @@ -5,15 +5,20 @@ import io.quarkus.runtime.annotations.RegisterForReflection; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import org.eclipse.microprofile.openapi.annotations.media.Schema; @Data @Builder @AllArgsConstructor @RegisterForReflection public class SimpleReqAffiliationResume { + @Schema(description = "L'identifiant de la demande d'affiliation.", example = "1") Long id; + @Schema(description = "Le nom de l'association.", example = "Association sportive") String name; + @Schema(description = "Le numéro SIRET de l'association.", example = "12345678901234") long siret; + @Schema(description = "La saison de l'affiliation.", example = "2025") int saison; public static SimpleReqAffiliationResume fromModel(AffiliationRequestModel model) { diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java b/src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java index cc8dc72..7ed707d 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/UserInfo.java @@ -5,6 +5,7 @@ import io.quarkus.security.identity.SecurityIdentity; import lombok.Builder; import lombok.Data; import org.eclipse.microprofile.jwt.JsonWebToken; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import java.util.ArrayList; import java.util.List; @@ -14,14 +15,23 @@ import java.util.Set; @Builder @RegisterForReflection public class UserInfo { + @Schema(description = "L'identifiant de l'utilisateur.", example = "1234567890") String id; + @Schema(description = "Le nom complet de l'utilisateur.", example = "John Doe") String name; + @Schema(description = "Le prénom de l'utilisateur.", example = "John") String givenName; + @Schema(description = "Le nom de famille de l'utilisateur.", example = "Doe") String familyName; + @Schema(description = "L'adresse e-mail de l'utilisateur.", example = "jihn.doe@test.fr") String email; + @Schema(description = "L'adresse e-mail de l'utilisateur a été vérifiée.", example = "true") boolean emailVerified; + @Schema(description = "La date d'expiration du token d'accès.") long expiration; + @Schema(description = "La liste des groupes de l'utilisateur.") List groups; + @Schema(description = "La liste des rôles de l'utilisateur.") Set roles; public static UserInfo makeUserInfo(JsonWebToken accessToken, SecurityIdentity securityIdentity) { diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/FullMemberForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/FullMemberForm.java index a94d3ab..e08aa2d 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/FullMemberForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/FullMemberForm.java @@ -7,48 +7,62 @@ import fr.titionfire.ffsaf.utils.RoleAsso; import jakarta.ws.rs.FormParam; import jakarta.ws.rs.core.MediaType; import lombok.Getter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.jboss.resteasy.reactive.PartType; import java.util.Date; @Getter public class FullMemberForm { + @Schema(description = "L'identifiant du membre.", example = "1") @FormParam("id") private String id = null; + @Schema(description = "Le nom du membre.", example = "Dupont") @FormParam("lname") private String lname = null; + @Schema(description = "Le prénom du membre.", example = "Jean") @FormParam("fname") private String fname = null; + @Schema(description = "La catégorie du membre.", example = "SENIOR") @FormParam("categorie") private Categorie categorie = null; + @Schema(description = "L'identifiant du club du membre.", example = "1") @FormParam("club") private Long club = null; + @Schema(description = "Le genre du membre.", example = "H") @FormParam("genre") private Genre genre; + @Schema(description = "Le numéro de licence du membre.", example = "12345") @FormParam("licence") private int licence; + @Schema(description = "Le pays du membre.", example = "FR") @FormParam("country") private String country; + @Schema(description = "La date de naissance du membre.") @FormParam("birth_date") private Date birth_date = null; + @Schema(description = "L'adresse e-mail du membre.", example = "jean.dupont@example.com") @FormParam("email") private String email; + @Schema(description = "Le rôle du membre dans l'association.", example = "MEMBRE") @FormParam("role") private RoleAsso role; + @Schema(description = "Le grade d'arbitrage du membre.", example = "ASSESSEUR") @FormParam("grade_arbitrage") private GradeArbitrage grade_arbitrage; + @Schema(description = "La photo du membre.") @FormParam("photo_data") @PartType(MediaType.APPLICATION_OCTET_STREAM) private byte[] photo_data = new byte[0]; diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java index d587e34..f6d8cc3 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/LicenceForm.java @@ -3,22 +3,28 @@ package fr.titionfire.ffsaf.rest.from; import jakarta.ws.rs.FormParam; import lombok.Getter; import lombok.ToString; +import org.eclipse.microprofile.openapi.annotations.media.Schema; @Getter @ToString public class LicenceForm { @FormParam("id") + @Schema(description = "L'identifiant de la licence. (-1 si nouvelle demande de licence)", required = true) private long id; @FormParam("membre") + @Schema(description = "L'identifiant du membre.", example = "1", required = true) private long membre; @FormParam("saison") + @Schema(description = "La saison de la licence.", example = "2025", required = true) private int saison; @FormParam("certificate") + @Schema(description = "Nom du médecin sur certificat médical.", example = "M. Jean", required = true) private String certificate = null; @FormParam("validate") + @Schema(description = "Licence validée (seuls les admin pourrons enregistrer cette valeur)", example = "true", required = true) private boolean validate; } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/MemberPermForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/MemberPermForm.java index a99832d..5b24b51 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/MemberPermForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/MemberPermForm.java @@ -3,19 +3,24 @@ package fr.titionfire.ffsaf.rest.from; import jakarta.ws.rs.FormParam; import lombok.Getter; import lombok.ToString; +import org.eclipse.microprofile.openapi.annotations.media.Schema; @Getter @ToString public class MemberPermForm { + @Schema(description = "Indique si le membre est un administrateur de la fédération.", example = "false", required = true) @FormParam("federation_admin") private boolean federation_admin; + @Schema(description = "Indique si le membre est un utilisateur SAFCA.", example = "false", required = true) @FormParam("safca_user") private boolean safca_user; + @Schema(description = "Indique si le membre peut créer des compétitions sur SAFCA.", example = "false", required = true) @FormParam("safca_create_compet") private boolean safca_create_compet; + @Schema(description = "Indique si le membre est un super administrateur SAFCA.", example = "false", required = true) @FormParam("safca_super_admin") private boolean safca_super_admin; } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/from/PartClubForm.java b/src/main/java/fr/titionfire/ffsaf/rest/from/PartClubForm.java index c4c68db..ac68cfb 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/from/PartClubForm.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/from/PartClubForm.java @@ -3,25 +3,32 @@ package fr.titionfire.ffsaf.rest.from; import jakarta.ws.rs.FormParam; import lombok.Getter; import lombok.ToString; +import org.eclipse.microprofile.openapi.annotations.media.Schema; @ToString @Getter public class PartClubForm { @FormParam("id") + @Schema(description = "Identifiant du club", example = "1", required = true) private String id = null; @FormParam("contact") + @Schema(description = "Les contacts du club", example = "{\"SITE\": \"www.test.com\", \"COURRIEL\": \"test@test.com\"}", required = true) private String contact = null; @FormParam("training_location") + @Schema(description = "Liste des lieux d'entraînement", example = "[{\"text\":\"addr 1\",\"lng\":2.24654,\"lat\":52.4868658},{\"text\":\"addr 2\",\"lng\":2.88654,\"lat\":52.7865456}]", required = true) private String training_location = null; @FormParam("training_day_time") + @Schema(description = "Liste des jours et horaires d'entraînement (jours 0-6, 0=>lundi) (temps en minute depuis 00:00, 122=>2h02)", example = "[{\"day\":0,\"time_start\":164,\"time_end\":240},{\"day\":3,\"time_start\":124,\"time_end\":250}]", required = true) private String training_day_time = null; @FormParam("contact_intern") + @Schema(description = "Contact interne du club", example = "john.doe@test.com") private String contact_intern = null; @FormParam("address") + @Schema(description = "Adresse postale du club", example = "1 rue de l'exemple, 75000 Paris", required = true) private String address = null; } diff --git a/src/main/java/fr/titionfire/ffsaf/utils/PageResult.java b/src/main/java/fr/titionfire/ffsaf/utils/PageResult.java index e8d2629..5d86ed1 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/PageResult.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/PageResult.java @@ -2,6 +2,7 @@ package fr.titionfire.ffsaf.utils; import io.quarkus.runtime.annotations.RegisterForReflection; import lombok.Data; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import java.util.ArrayList; import java.util.List; @@ -9,9 +10,13 @@ import java.util.List; @Data @RegisterForReflection public class PageResult { + @Schema(description = "Le numéro de la page courante.", example = "1") private int page; + @Schema(description = "Le nombre d'éléments par page.", example = "10") private int page_size; + @Schema(description = "Le nombre total de pages.", example = "5") private int page_count; + @Schema(description = "Le nombre total d'éléments.", example = "47") private long result_count; private List result = new ArrayList<>(); } From b7665250004ad522734ae92a92ec339e4805ba5b Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Sat, 3 Aug 2024 17:12:03 +0200 Subject: [PATCH 30/37] fix: last-name to upper case --- .../titionfire/ffsaf/domain/service/AffiliationService.java | 4 ++-- .../fr/titionfire/ffsaf/domain/service/MembreService.java | 4 ++-- src/main/java/fr/titionfire/ffsaf/utils/Categorie.java | 3 +++ src/main/java/fr/titionfire/ffsaf/utils/Genre.java | 3 +++ src/main/java/fr/titionfire/ffsaf/utils/GradeArbitrage.java | 3 +++ 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java index 3926f5c..30c300a 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/AffiliationService.java @@ -202,7 +202,7 @@ public class AffiliationService { if (member.getMode() == 2) { MembreModel membreModel = new MembreModel(); membreModel.setFname(member.getFname()); - membreModel.setLname(member.getLname()); + membreModel.setLname(member.getLname().toUpperCase()); membreModel.setClub(club); membreModel.setRole(member.getRole()); membreModel.setEmail(member.getEmail()); @@ -216,7 +216,7 @@ public class AffiliationService { .onItem().ifNull().switchTo(() -> { MembreModel membreModel = new MembreModel(); membreModel.setFname(member.getFname()); - membreModel.setLname(member.getLname()); + membreModel.setLname(member.getLname().toUpperCase()); return Panache.withTransaction( () -> sequenceRepository.getNextValueInTransaction(SequenceType.Licence) .invoke(l -> membreModel.setLicence(Math.toIntExact(l))) diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java index b09a107..7306a23 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -124,7 +124,7 @@ public class MembreService { .onItem().transformToUni(pair -> { MembreModel m = pair.getKey(); m.setFname(membre.getFname()); - m.setLname(membre.getLname()); + m.setLname(membre.getLname().toUpperCase()); m.setClub(pair.getValue()); m.setCountry(membre.getCountry()); m.setBirth_date(membre.getBirth_date()); @@ -167,7 +167,7 @@ public class MembreService { })) .onItem().transformToUni(target -> { target.setFname(membre.getFname()); - target.setLname(membre.getLname()); + target.setLname(membre.getLname().toUpperCase()); target.setCountry(membre.getCountry()); target.setBirth_date(membre.getBirth_date()); target.setGenre(membre.getGenre()); diff --git a/src/main/java/fr/titionfire/ffsaf/utils/Categorie.java b/src/main/java/fr/titionfire/ffsaf/utils/Categorie.java index fdfc437..a9c82be 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/Categorie.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/Categorie.java @@ -1,7 +1,10 @@ package fr.titionfire.ffsaf.utils; +import io.quarkus.runtime.annotations.RegisterForReflection; + import java.util.ResourceBundle; +@RegisterForReflection public enum Categorie { SUPER_MINI, MINI_POUSSIN, diff --git a/src/main/java/fr/titionfire/ffsaf/utils/Genre.java b/src/main/java/fr/titionfire/ffsaf/utils/Genre.java index 3ee557c..7b87357 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/Genre.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/Genre.java @@ -1,5 +1,8 @@ package fr.titionfire.ffsaf.utils; +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection public enum Genre { H("Homme"), F("Femme"), diff --git a/src/main/java/fr/titionfire/ffsaf/utils/GradeArbitrage.java b/src/main/java/fr/titionfire/ffsaf/utils/GradeArbitrage.java index 35af369..df9b927 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/GradeArbitrage.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/GradeArbitrage.java @@ -1,5 +1,8 @@ package fr.titionfire.ffsaf.utils; +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection public enum GradeArbitrage { NA("N/A"), ASSESSEUR("Assesseur"), From 27dd22080c627516374f8820c17e07e71bd12d56 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Fri, 9 Aug 2024 12:09:37 +0200 Subject: [PATCH 31/37] feat: move competition setting to ffsaf site --- pom.xml | 5 + .../java/fr/titionfire/ExampleResource.java | 23 +- .../ffsaf/data/model/CompetitionModel.java | 48 ++++ .../ffsaf/data/model/MatchModel.java | 53 ++++ .../ffsaf/data/model/PouleModel.java | 45 +++ .../ffsaf/data/model/TreeModel.java | 39 +++ .../repository/CompetitionRepository.java | 9 + .../data/repository/MatchRepository.java | 9 + .../data/repository/PouleRepository.java | 9 + .../ffsaf/data/repository/TreeRepository.java | 9 + .../domain/service/CompetPermService.java | 120 ++++++++ .../domain/service/CompetitionService.java | 250 ++++++++++++++++ .../ffsaf/domain/service/KeycloakService.java | 12 +- .../ffsaf/domain/service/MatchService.java | 97 +++++++ .../ffsaf/domain/service/PouleService.java | 88 ++++++ .../titionfire/ffsaf/net2/Client_Thread.java | 4 +- .../ffsaf/net2/data/SimpleCompet.java | 10 + .../ffsaf/net2/request/SReqCompet.java | 40 +++ .../ffsaf/rest/AffiliationEndpoints.java | 1 - .../rest/AffiliationRequestEndpoints.java | 1 - .../titionfire/ffsaf/rest/AuthEndpoints.java | 4 +- .../titionfire/ffsaf/rest/ClubEndpoints.java | 2 - .../ffsaf/rest/CompetitionEndpoints.java | 84 ++++++ .../ffsaf/rest/CompteEndpoints.java | 7 +- .../ffsaf/rest/LicenceEndpoints.java | 1 - .../titionfire/ffsaf/rest/MatchEndpoints.java | 69 +++++ .../ffsaf/rest/MembreAdminEndpoints.java | 1 - .../ffsaf/rest/MembreClubEndpoints.java | 1 - .../ffsaf/rest/MembreEndpoints.java | 1 - .../titionfire/ffsaf/rest/PouleEndpoints.java | 68 +++++ .../ffsaf/rest/data/CompetitionData.java | 31 ++ .../titionfire/ffsaf/rest/data/MatchData.java | 31 ++ .../titionfire/ffsaf/rest/data/PouleData.java | 23 ++ .../ffsaf/rest/data/SimpleCompetData.java | 28 ++ .../titionfire/ffsaf/rest/data/TreeData.java | 28 ++ .../ffsaf/utils/CompetitionSystem.java | 5 + .../ffsaf/utils/ScoreEmbeddable.java | 21 ++ src/main/resources/application.properties | 2 +- src/main/webapp/src/App.jsx | 6 + src/main/webapp/src/components/ClubSelect.jsx | 8 +- .../src/pages/competition/CompetitionEdit.jsx | 270 ++++++++++++++++++ .../src/pages/competition/CompetitionList.jsx | 61 ++++ .../src/pages/competition/CompetitionRoot.jsx | 26 ++ 43 files changed, 1616 insertions(+), 34 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/data/model/CompetitionModel.java create mode 100644 src/main/java/fr/titionfire/ffsaf/data/model/MatchModel.java create mode 100644 src/main/java/fr/titionfire/ffsaf/data/model/PouleModel.java create mode 100644 src/main/java/fr/titionfire/ffsaf/data/model/TreeModel.java create mode 100644 src/main/java/fr/titionfire/ffsaf/data/repository/CompetitionRepository.java create mode 100644 src/main/java/fr/titionfire/ffsaf/data/repository/MatchRepository.java create mode 100644 src/main/java/fr/titionfire/ffsaf/data/repository/PouleRepository.java create mode 100644 src/main/java/fr/titionfire/ffsaf/data/repository/TreeRepository.java create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/CompetPermService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/MatchService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/net2/data/SimpleCompet.java create mode 100644 src/main/java/fr/titionfire/ffsaf/net2/request/SReqCompet.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/CompetitionData.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/MatchData.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/PouleData.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/SimpleCompetData.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/TreeData.java create mode 100644 src/main/java/fr/titionfire/ffsaf/utils/CompetitionSystem.java create mode 100644 src/main/java/fr/titionfire/ffsaf/utils/ScoreEmbeddable.java create mode 100644 src/main/webapp/src/pages/competition/CompetitionEdit.jsx create mode 100644 src/main/webapp/src/pages/competition/CompetitionList.jsx create mode 100644 src/main/webapp/src/pages/competition/CompetitionRoot.jsx diff --git a/pom.xml b/pom.xml index 525fd91..413b042 100644 --- a/pom.xml +++ b/pom.xml @@ -124,6 +124,11 @@ io.quarkus quarkus-swagger-ui + + + io.quarkus + quarkus-cache + diff --git a/src/main/java/fr/titionfire/ExampleResource.java b/src/main/java/fr/titionfire/ExampleResource.java index 617736e..cc432e7 100644 --- a/src/main/java/fr/titionfire/ExampleResource.java +++ b/src/main/java/fr/titionfire/ExampleResource.java @@ -2,6 +2,7 @@ package fr.titionfire; import io.quarkus.oidc.IdToken; import io.quarkus.oidc.RefreshToken; +import io.quarkus.oidc.UserInfo; import io.quarkus.security.identity.SecurityIdentity; import jakarta.inject.Inject; import jakarta.ws.rs.GET; @@ -30,6 +31,9 @@ public class ExampleResource { @IdToken JsonWebToken idToken; + @Inject + UserInfo userInfo; + /** * Injection point for the Access Token issued by the OpenID Connect Provider */ @@ -59,7 +63,8 @@ public class ExampleResource { .append("") .append("
      "); - + System.out.println(idToken); + System.out.println(accessToken); Object userName = this.idToken.getClaim("preferred_username"); if (userName != null) { @@ -69,25 +74,17 @@ public class ExampleResource { response.append("
    • username: ").append(this.idToken.toString()).append("
    • "); } - Object scopes = this.accessToken.getClaim("scope"); + /*Object scopes = this.accessToken.getClaim("scope"); if (scopes != null) { response.append("
    • scopes: ").append(scopes.toString()).append("
    • "); } - if (scopes != null) { - response.append("
    • scopes: ").append(this.accessToken.toString()).append("
    • "); - } + response.append("
    • scopes: ").append(this.accessToken.toString()).append("
    • "); + response.append("
    • scopes: ").append(this.accessToken.getClaim("user_groups").toString()).append("
    • ");*/ - if (scopes != null) { - response.append("
    • scopes: ").append(this.accessToken.getClaim("user_groups").toString()).append("
    • "); - } - - if (scopes != null) { - response.append("
    • getRoles: ").append(this.securityIdentity.getRoles()).append("
    • "); - } - + response.append("
    • getRoles: ").append(this.securityIdentity.getRoles()).append("
    • "); response.append("
    • refresh_token: ").append(refreshToken.getToken() != null).append("
    • "); return response.append("
    ").append("").append("").toString(); diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/CompetitionModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/CompetitionModel.java new file mode 100644 index 0000000..c9f6f15 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/model/CompetitionModel.java @@ -0,0 +1,48 @@ +package fr.titionfire.ffsaf.data.model; + +import fr.titionfire.ffsaf.utils.CompetitionSystem; +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Date; +import java.util.List; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection + +@Entity +@Table(name = "compet") +public class CompetitionModel { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Long id; + + @Column(name = "system_type") + CompetitionSystem system; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "club", referencedColumnName = "id") + ClubModel club; + + String name; + + String uuid; + + Date date; + + @ManyToMany + @JoinTable(name = "register", + uniqueConstraints = @UniqueConstraint(columnNames = {"id_competition", "id_membre"}), + joinColumns = @JoinColumn(name = "id_competition"), + inverseJoinColumns = @JoinColumn(name = "id_membre")) + List insc; + + String owner; +} diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/MatchModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/MatchModel.java new file mode 100644 index 0000000..fba895a --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/model/MatchModel.java @@ -0,0 +1,53 @@ +package fr.titionfire.ffsaf.data.model; + +import fr.titionfire.ffsaf.utils.CompetitionSystem; +import fr.titionfire.ffsaf.utils.ScoreEmbeddable; +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection + +@Entity +@Table(name = "match") +public class MatchModel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Long id; + + @Column(name = "system_type") + CompetitionSystem system; + Long systemId; + + @ManyToOne + @JoinColumn(name = "c1", referencedColumnName = "id") + MembreModel c1_id = null; + + String c1_str = null; + + @ManyToOne + @JoinColumn(name = "c2", referencedColumnName = "id") + MembreModel c2_id = null; + + String c2_str = null; + + @Column(name = "id_poule") + Long poule; + + long poule_ord = 0; + + @ElementCollection + @CollectionTable(name = "score", joinColumns = @JoinColumn(name = "id_match")) + List scores = new ArrayList<>(); +} diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/PouleModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/PouleModel.java new file mode 100644 index 0000000..693042e --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/model/PouleModel.java @@ -0,0 +1,45 @@ +package fr.titionfire.ffsaf.data.model; + +import fr.titionfire.ffsaf.utils.CompetitionSystem; +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection + +@Entity +@Table(name = "poule") +public class PouleModel { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Long id; + + @Column(name = "system_type") + CompetitionSystem system; + Long systemId; + + String name = ""; + + @ManyToOne + @JoinColumn(name = "id_compet", referencedColumnName = "id") + CompetitionModel compet; + + @OneToMany + @JoinColumn(name = "id_poule", referencedColumnName = "id") + List matchs; + + @OneToMany + @JoinColumn(name = "id_poule", referencedColumnName = "id") + List tree; + + Integer type; +} diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/TreeModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/TreeModel.java new file mode 100644 index 0000000..697e49d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/model/TreeModel.java @@ -0,0 +1,39 @@ +package fr.titionfire.ffsaf.data.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection + +@Entity +@Table(name = "tree") +public class TreeModel { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Long id; + + @Column(name = "id_poule") + Long poule; + + Integer level; + + @ManyToOne + @JoinColumn(name = "match_id", referencedColumnName = "id") + MatchModel match; + + @ManyToOne + @JoinColumn(referencedColumnName = "id") + TreeModel left; + + @ManyToOne + @JoinColumn(referencedColumnName = "id") + TreeModel right; +} diff --git a/src/main/java/fr/titionfire/ffsaf/data/repository/CompetitionRepository.java b/src/main/java/fr/titionfire/ffsaf/data/repository/CompetitionRepository.java new file mode 100644 index 0000000..6c277e0 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/repository/CompetitionRepository.java @@ -0,0 +1,9 @@ +package fr.titionfire.ffsaf.data.repository; + +import fr.titionfire.ffsaf.data.model.CompetitionModel; +import io.quarkus.hibernate.reactive.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class CompetitionRepository implements PanacheRepositoryBase { +} diff --git a/src/main/java/fr/titionfire/ffsaf/data/repository/MatchRepository.java b/src/main/java/fr/titionfire/ffsaf/data/repository/MatchRepository.java new file mode 100644 index 0000000..ab284c4 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/repository/MatchRepository.java @@ -0,0 +1,9 @@ +package fr.titionfire.ffsaf.data.repository; + +import fr.titionfire.ffsaf.data.model.MatchModel; +import io.quarkus.hibernate.reactive.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class MatchRepository implements PanacheRepositoryBase { +} diff --git a/src/main/java/fr/titionfire/ffsaf/data/repository/PouleRepository.java b/src/main/java/fr/titionfire/ffsaf/data/repository/PouleRepository.java new file mode 100644 index 0000000..7535bd3 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/repository/PouleRepository.java @@ -0,0 +1,9 @@ +package fr.titionfire.ffsaf.data.repository; + +import fr.titionfire.ffsaf.data.model.PouleModel; +import io.quarkus.hibernate.reactive.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class PouleRepository implements PanacheRepositoryBase { +} diff --git a/src/main/java/fr/titionfire/ffsaf/data/repository/TreeRepository.java b/src/main/java/fr/titionfire/ffsaf/data/repository/TreeRepository.java new file mode 100644 index 0000000..57bb22b --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/repository/TreeRepository.java @@ -0,0 +1,9 @@ +package fr.titionfire.ffsaf.data.repository; + +import fr.titionfire.ffsaf.data.model.TreeModel; +import io.quarkus.hibernate.reactive.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class TreeRepository implements PanacheRepositoryBase { +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/CompetPermService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetPermService.java new file mode 100644 index 0000000..5ec36b6 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetPermService.java @@ -0,0 +1,120 @@ +package fr.titionfire.ffsaf.domain.service; + +import fr.titionfire.ffsaf.data.model.CompetitionModel; +import fr.titionfire.ffsaf.data.repository.CompetitionRepository; +import fr.titionfire.ffsaf.net2.ServerCustom; +import fr.titionfire.ffsaf.net2.data.SimpleCompet; +import fr.titionfire.ffsaf.net2.request.SReqCompet; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; +import fr.titionfire.ffsaf.utils.CompetitionSystem; +import fr.titionfire.ffsaf.utils.GroupeUtils; +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheName; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +@ApplicationScoped +public class CompetPermService { + + @Inject + ServerCustom serverCustom; + + @Inject + @CacheName("safca-config") + Cache cache; + + @Inject + @CacheName("safca-have-access") + Cache cacheAccess; + + @Inject + CompetitionRepository competitionRepository; + + public Uni getSafcaConfig(long id) { + return cache.get(id, k -> { + CompletableFuture f = new CompletableFuture<>(); + SReqCompet.getConfig(serverCustom.clients, id, f); + System.out.println("get config"); + try { + return f.get(1500, TimeUnit.MILLISECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new RuntimeException(e); + } + }); + } + + public Uni> getAllHaveAccess (String subject) { + return cacheAccess.get(subject, k -> { + CompletableFuture> f = new CompletableFuture<>(); + SReqCompet.getAllHaveAccess(serverCustom.clients, subject, f); + System.out.println("get all have access"); + try { + return f.get(1500, TimeUnit.MILLISECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new RuntimeException(e); + } + }); + } + + public Uni hasViewPerm(JsonWebToken idToken, SecurityIdentity sid, long id) { + return competitionRepository.findById(id).call(o -> ( + idToken.getSubject().equals(o.getOwner()) || sid.getRoles().contains("federation_admin")) ? + Uni.createFrom().nullItem() + : + o.getSystem() == CompetitionSystem.SAFCA ? + hasSafcaViewPerm(idToken, sid, id) + : Uni.createFrom().nullItem().invoke(Unchecked.consumer(__ -> { + if (!GroupeUtils.isInClubGroup(o.getClub().getId(), idToken)) + throw new DForbiddenException(); + }) + )); + } + + public Uni hasEditPerm(JsonWebToken idToken, SecurityIdentity sid, long id) { + return competitionRepository.findById(id).call(o -> ( + idToken.getSubject().equals(o.getOwner()) || sid.getRoles().contains("federation_admin")) ? + Uni.createFrom().nullItem() + : + o.getSystem() == CompetitionSystem.SAFCA ? + hasSafcaEditPerm(idToken, sid, id) + : Uni.createFrom().nullItem().invoke(Unchecked.consumer(__ -> { + if (!GroupeUtils.isInClubGroup(o.getClub().getId(), idToken)) + throw new DForbiddenException(); + }) + )); + } + + private Uni hasSafcaViewPerm(JsonWebToken idToken, SecurityIdentity sid, long id) { + return sid.getRoles().contains("safca_super_admin") ? + Uni.createFrom().nullItem() + : + getSafcaConfig(id).chain(Unchecked.function(o -> { + if (!o.admin().contains(UUID.fromString(idToken.getSubject())) && !o.table() + .contains(UUID.fromString(idToken.getSubject()))) + throw new DForbiddenException(); + return Uni.createFrom().nullItem(); + })); + } + + private Uni hasSafcaEditPerm(JsonWebToken idToken, SecurityIdentity sid, long id) { + return sid.getRoles().contains("safca_super_admin") ? + Uni.createFrom().nullItem() + : + getSafcaConfig(id).chain(Unchecked.function(o -> { + if (!o.admin().contains(UUID.fromString(idToken.getSubject()))) + throw new DForbiddenException(); + return Uni.createFrom().nullItem(); + })); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java new file mode 100644 index 0000000..b0997a0 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java @@ -0,0 +1,250 @@ +package fr.titionfire.ffsaf.domain.service; + +import fr.titionfire.ffsaf.data.model.CompetitionModel; +import fr.titionfire.ffsaf.data.repository.ClubRepository; +import fr.titionfire.ffsaf.data.repository.CompetitionRepository; +import fr.titionfire.ffsaf.data.repository.MatchRepository; +import fr.titionfire.ffsaf.data.repository.PouleRepository; +import fr.titionfire.ffsaf.net2.ServerCustom; +import fr.titionfire.ffsaf.net2.data.SimpleCompet; +import fr.titionfire.ffsaf.net2.request.SReqCompet; +import fr.titionfire.ffsaf.rest.data.CompetitionData; +import fr.titionfire.ffsaf.rest.data.SimpleCompetData; +import fr.titionfire.ffsaf.rest.exception.DBadRequestException; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; +import fr.titionfire.ffsaf.utils.CompetitionSystem; +import fr.titionfire.ffsaf.utils.GroupeUtils; +import io.quarkus.hibernate.reactive.panache.Panache; +import io.quarkus.hibernate.reactive.panache.common.WithSession; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import io.vertx.mutiny.core.Vertx; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.keycloak.representations.idm.UserRepresentation; + +import java.util.*; +import java.util.stream.Stream; + +@WithSession +@ApplicationScoped +public class CompetitionService { + + @Inject + CompetitionRepository repository; + + @Inject + PouleRepository pouleRepository; + + @Inject + MatchRepository matchRepository; + + @Inject + KeycloakService keycloakService; + + @Inject + ServerCustom serverCustom; + + @Inject + CompetPermService permService; + + @Inject + Vertx vertx; + + public Uni getById(JsonWebToken idToken, SecurityIdentity sid, Long id) { + if (id == 0) { + return Uni.createFrom() + .item(new CompetitionData(null, "", "", new Date(), CompetitionSystem.SAFCA, + null, "", "")); + } + return permService.hasViewPerm(idToken, sid, id) + .map(CompetitionData::fromModel) + .chain(data -> + vertx.getOrCreateContext().executeBlocking(() -> { + keycloakService.getUser(UUID.fromString(data.getOwner())) + .ifPresent(user -> data.setOwner(user.getUsername())); + return data; + }) + ); + } + + public Uni> getAll(JsonWebToken idToken, SecurityIdentity securityIdentity) { + return repository.listAll() + .chain(o -> + permService.getAllHaveAccess(idToken.getSubject()) + .chain(map -> Uni.createFrom().item(o.stream() + .filter(p -> { + if (idToken.getSubject().equals(p.getOwner())) + return true; + if (p.getSystem() == CompetitionSystem.SAFCA) { + if (map.containsKey(p.getId())) + return map.get(p.getId()).equals("admin"); + return securityIdentity.getRoles().contains("federation_admin") + || securityIdentity.getRoles().contains("safca_super_admin"); + } + return securityIdentity.getRoles().contains("federation_admin"); + }) + .map(CompetitionData::fromModel).toList()) + )); + } + + public Uni> getAllSystem(JsonWebToken idToken, SecurityIdentity securityIdentity, + CompetitionSystem system) { + if (system == CompetitionSystem.SAFCA) { + return permService.getAllHaveAccess(idToken.getSubject()) + .chain(map -> + repository.list("system = ?1", system) + .map(data -> data.stream() + .filter(p -> { + if (idToken.getSubject().equals(p.getOwner())) + return true; + if (map.containsKey(p.getId())) + return map.get(p.getId()).equals("admin"); + return securityIdentity.getRoles().contains("federation_admin") + || securityIdentity.getRoles().contains("safca_super_admin"); + }) + .map(CompetitionData::fromModel).toList()) + ); + } + + return repository.list("system = ?1", system) + .map(data -> data.stream() + .filter(p -> { + if (idToken.getSubject().equals(p.getOwner())) + return true; + return securityIdentity.getRoles().contains("federation_admin") || + GroupeUtils.isInClubGroup(p.getClub().getId(), idToken); + }) + .map(CompetitionData::fromModel).toList()); + } + + public Uni addOrUpdate(JsonWebToken idToken, SecurityIdentity sid, CompetitionData data) { + if (data.getId() == null) { + return new ClubRepository().findById(data.getClub()).invoke(Unchecked.consumer(clubModel -> { + if (!GroupeUtils.isInClubGroup(clubModel.getId(), idToken)) + throw new DForbiddenException(); + })) // TODO check if user can create competition + .chain(clubModel -> { + CompetitionModel model = new CompetitionModel(); + + model.setId(null); + model.setSystem(data.getSystem()); + model.setClub(clubModel); + model.setDate(data.getDate()); + model.setInsc(new ArrayList<>()); + model.setUuid(UUID.randomUUID().toString()); + model.setName(data.getName()); + model.setOwner(idToken.getSubject()); + + return Panache.withTransaction(() -> repository.persist(model)); + }).map(CompetitionData::fromModel) + .call(__ -> permService.cacheAccess.invalidate(idToken.getSubject())); + } else { + return permService.hasEditPerm(idToken, sid, data.getId()) + .chain(model -> { + model.setDate(data.getDate()); + model.setName(data.getName()); + + return vertx.getOrCreateContext().executeBlocking(() -> + keycloakService.getUser(data.getOwner()).map(UserRepresentation::getId).orElse(null)) + .invoke(Unchecked.consumer(newOwner -> { + if (newOwner == null) + throw new DBadRequestException("User " + data.getOwner() + " not found"); + if (!newOwner.equals(model.getOwner())) { + if (!sid.getRoles().contains("federation_admin") + && !sid.getRoles().contains("safca_super_admin") + && !idToken.getSubject().equals(model.getOwner())) + throw new DForbiddenException(); + model.setOwner(newOwner); + } + })) + .chain(__ -> Panache.withTransaction(() -> repository.persist(model))); + }).map(CompetitionData::fromModel) + .call(__ -> permService.cacheAccess.invalidate(idToken.getSubject())); + } + } + + public Uni delete(JsonWebToken idToken, SecurityIdentity sid, Long id) { + return repository.findById(id).invoke(Unchecked.consumer(c -> { + if (!idToken.getSubject().equals(c.getOwner()) || sid.getRoles().contains("federation_admin")) + throw new DForbiddenException(); + })) + .call(competitionModel -> pouleRepository.list("compet = ?1", competitionModel) + .call(pouleModels -> Uni.join() + .all(pouleModels.stream() + .map(pouleModel -> Panache.withTransaction( + () -> matchRepository.delete("poule = ?1", pouleModel.getId()))) + .toList()) + .andCollectFailures())) + .call(competitionModel -> Panache.withTransaction( + () -> pouleRepository.delete("compet = ?1", competitionModel))) + .chain(model -> Panache.withTransaction(() -> repository.delete("id", model.getId()))) + .invoke(o -> SReqCompet.rmCompet(serverCustom.clients, id)) + .call(__ -> permService.cache.invalidate(id)); + } + + public Uni getSafcaData(JsonWebToken idToken, SecurityIdentity sid, Long id) { + return permService.getSafcaConfig(id) + .call(Unchecked.function(o -> { + if (!idToken.getSubject().equals(o.owner()) + && !sid.getRoles().contains("federation_admin") + && !sid.getRoles().contains("safca_super_admin") + && !o.admin().contains(UUID.fromString(idToken.getSubject())) + && !o.table().contains(UUID.fromString(idToken.getSubject()))) + throw new DForbiddenException(); + return Uni.createFrom().nullItem(); + })) + .chain(simpleCompet -> { + SimpleCompetData data = SimpleCompetData.fromModel(simpleCompet); + return vertx.getOrCreateContext().executeBlocking(() -> { + data.setAdmin(simpleCompet.admin().stream().map(uuid -> keycloakService.getUser(uuid)) + .filter(Optional::isPresent) + .map(user -> user.get().getUsername()) + .toList()); + data.setTable(simpleCompet.table().stream().map(uuid -> keycloakService.getUser(uuid)) + .filter(Optional::isPresent) + .map(user -> user.get().getUsername()) + .toList()); + + return data; + }); + }); + } + + public Uni setSafcaData(JsonWebToken idToken, SecurityIdentity sid, SimpleCompetData data) { + return permService.hasEditPerm(idToken, sid, data.getId()) + .chain(__ -> vertx.getOrCreateContext().executeBlocking(() -> { + ArrayList admin = new ArrayList<>(); + ArrayList table = new ArrayList<>(); + for (String username : data.getAdmin()) { + Optional opt = keycloakService.getUser(username); + if (opt.isEmpty()) + throw new DBadRequestException("User " + username + " not found"); + admin.add(UUID.fromString(opt.get().getId())); + } + for (String username : data.getTable()) { + Optional opt = keycloakService.getUser(username); + if (opt.isEmpty()) + throw new DBadRequestException("User " + username + " not found"); + table.add(UUID.fromString(opt.get().getId())); + } + + return new SimpleCompet(data.getId(), "", data.isShow_blason(), + data.isShow_flag(), admin, table); + })) + .invoke(simpleCompet -> SReqCompet.sendUpdate(serverCustom.clients, simpleCompet)) + .call(simpleCompet -> permService.getSafcaConfig(data.getId()) + .call(c -> Uni.join().all(Stream.concat( + Stream.concat( + c.admin().stream().filter(uuid -> !simpleCompet.admin().contains(uuid)), + simpleCompet.admin().stream().filter(uuid -> !c.admin().contains(uuid))), + Stream.concat( + c.table().stream().filter(uuid -> !simpleCompet.table().contains(uuid)), + simpleCompet.table().stream().filter(uuid -> !c.table().contains(uuid)))) + .map(uuid -> permService.cacheAccess.invalidate(uuid.toString())).toList()) + .andCollectFailures())) + .call(__ -> permService.cache.invalidate(data.getId())); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java index ce78f51..a87c2e5 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/KeycloakService.java @@ -24,6 +24,7 @@ import java.text.Normalizer; import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.UUID; @ApplicationScoped public class KeycloakService { @@ -253,7 +254,7 @@ public class KeycloakService { }); } - private Optional getUser(String username) { + public Optional getUser(String username) { List users = keycloak.realm(realm).users().searchByUsername(username, true); if (users.isEmpty()) @@ -262,6 +263,15 @@ public class KeycloakService { return Optional.of(users.get(0)); } + + public Optional getUser(UUID userId) { + UserResource user = keycloak.realm(realm).users().get(userId.toString()); + if (user == null) + return Optional.empty(); + else + return Optional.of(user.toRepresentation()); + } + private String makeLogin(MembreModel model) { return Normalizer.normalize( (model.getFname().toLowerCase() + "." + model.getLname().toLowerCase()).replace(' ', '_'), diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MatchService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MatchService.java new file mode 100644 index 0000000..7637444 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MatchService.java @@ -0,0 +1,97 @@ +package fr.titionfire.ffsaf.domain.service; + +import fr.titionfire.ffsaf.data.model.ClubModel; +import fr.titionfire.ffsaf.data.model.MatchModel; +import fr.titionfire.ffsaf.data.repository.CombRepository; +import fr.titionfire.ffsaf.data.repository.MatchRepository; +import fr.titionfire.ffsaf.data.repository.PouleRepository; +import fr.titionfire.ffsaf.rest.data.MatchData; +import fr.titionfire.ffsaf.utils.CompetitionSystem; +import io.quarkus.hibernate.reactive.panache.Panache; +import io.quarkus.hibernate.reactive.panache.common.WithSession; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.List; +import java.util.function.Consumer; + +@WithSession +@ApplicationScoped +public class MatchService { + + @Inject + MatchRepository repository; + + @Inject + PouleRepository pouleRepository; + + @Inject + CombRepository combRepository; + + public Uni getById(Consumer checkPerm, CompetitionSystem system, Long id) { + return repository.find("systemId = ?1 AND system = ?2", id, system).firstResult() + .onItem().ifNull().failWith(() -> new RuntimeException("Match not found")) + .call(data -> pouleRepository.findById(data.getPoule()) + .invoke(data2 -> checkPerm.accept(data2.getCompet().getClub()))) + .map(MatchData::fromModel); + } + + public Uni> getAllByPoule(Consumer checkPerm, CompetitionSystem system, Long id) { + return pouleRepository.find("systemId = ?1 AND system = ?2", id, system).firstResult() + .onItem().ifNull().failWith(() -> new RuntimeException("Poule not found")) + .invoke(data -> checkPerm.accept(data.getCompet().getClub())) + .chain(data -> repository.list("poule = ?1", data.getId()) + .map(o -> o.stream().map(MatchData::fromModel).toList())); + } + + public Uni addOrUpdate(Consumer checkPerm, CompetitionSystem system, MatchData data) { + return repository.find("systemId = ?1 AND system = ?2", data.getId(), system).firstResult() + .chain(o -> { + if (o == null) { + return pouleRepository.findById(data.getPoule()) + .onItem().ifNull().failWith(() -> new RuntimeException("Poule not found")) + .invoke(data2 -> checkPerm.accept(data2.getCompet().getClub())) + .map(pouleModel -> { + MatchModel model = new MatchModel(); + + model.setId(null); + model.setSystem(system); + model.setSystemId(data.getId()); + model.setPoule(pouleModel.getId()); + return model; + }); + } else { + return pouleRepository.findById(data.getPoule()) + .onItem().ifNull().failWith(() -> new RuntimeException("Poule not found")) + .invoke(data2 -> checkPerm.accept(data2.getCompet().getClub())) + .map(__ -> o); + } + } + ) + .chain(o -> { + o.setC1_str(data.getC1_str()); + o.setC2_str(data.getC2_str()); + o.setPoule_ord(data.getPoule_ord()); + o.setScores(data.getScores()); + + return Uni.createFrom().nullItem() + .chain(() -> (data.getC1_id() == null) ? + Uni.createFrom().nullItem() : combRepository.findById(data.getC1_id())) + .invoke(o::setC1_id) + .chain(() -> (data.getC1_id() == null) ? + Uni.createFrom().nullItem() : combRepository.findById(data.getC2_id())) + .invoke(o::setC2_id) + .chain(() -> Panache.withTransaction(() -> repository.persist(o))); + }) + .map(MatchData::fromModel); + } + + public Uni delete(Consumer checkPerm, Long id) { + return repository.findById(id) + .onItem().ifNull().failWith(() -> new RuntimeException("Match not found")) + .call(data -> pouleRepository.findById(data.getPoule()) + .invoke(data2 -> checkPerm.accept(data2.getCompet().getClub())) + .chain(data2 -> Panache.withTransaction(() -> repository.delete("id", data.getId())))); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java new file mode 100644 index 0000000..2f23ffd --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java @@ -0,0 +1,88 @@ +package fr.titionfire.ffsaf.domain.service; + +import fr.titionfire.ffsaf.data.model.ClubModel; +import fr.titionfire.ffsaf.data.model.PouleModel; +import fr.titionfire.ffsaf.data.repository.CompetitionRepository; +import fr.titionfire.ffsaf.data.repository.PouleRepository; +import fr.titionfire.ffsaf.data.repository.TreeRepository; +import fr.titionfire.ffsaf.rest.data.PouleData; +import fr.titionfire.ffsaf.utils.CompetitionSystem; +import fr.titionfire.ffsaf.utils.GroupeUtils; +import io.quarkus.hibernate.reactive.panache.Panache; +import io.quarkus.hibernate.reactive.panache.common.WithSession; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +@WithSession +@ApplicationScoped +public class PouleService { + + @Inject + PouleRepository repository; + + @Inject + CompetitionRepository competRepository; + + @Inject + TreeRepository treeRepository; + + public Uni getById(Consumer checkPerm, CompetitionSystem system, Long id) { + return repository.find("systemId = ?1 AND system = ?2", id, system) + .firstResult() + .onItem().ifNull().failWith(() -> new RuntimeException("Poule not found")) + .invoke(data -> checkPerm.accept(data.getCompet().getClub())) + .map(PouleData::fromModel); + } + + public Uni> getAll(JsonWebToken idToken, SecurityIdentity securityIdentity, + CompetitionSystem system) { + return repository.list("system = ?1", system) + .map(data -> data.stream() + .filter(p -> securityIdentity.getRoles().contains("federation_admin") || + GroupeUtils.isInClubGroup(p.getCompet().getClub().getId(), idToken)) + .map(PouleData::fromModel).toList()); + } + + public Uni addOrUpdate(Consumer checkPerm, CompetitionSystem system, PouleData data) { + return repository.find("systemId = ?1 AND system = ?2", data.getId(), system).firstResult() + .chain(o -> { + if (o == null) { + return competRepository.findById(data.getCompet()) + .onItem().ifNull().failWith(() -> new RuntimeException("Competition not found")) + .invoke(o2 -> checkPerm.accept(o2.getClub())) + .chain(competitionModel -> { + PouleModel model = new PouleModel(); + + model.setId(null); + model.setSystem(system); + model.setSystemId(data.getId()); + model.setCompet(competitionModel); + model.setName(data.getName()); + model.setMatchs(new ArrayList<>()); + model.setTree(new ArrayList<>()); + model.setType(data.getType()); + + return Panache.withTransaction(() -> repository.persist(model)); + }); + } else { + o.setName(data.getName()); + o.setType(data.getType()); + return Panache.withTransaction(() -> repository.persist(o)); + } + }).map(PouleData::fromModel); + } + + public Uni delete(Consumer checkPerm, Long id) { + return repository.findById(id) + .onItem().ifNull().failWith(() -> new RuntimeException("Poule not found")) + .invoke(data -> checkPerm.accept(data.getCompet().getClub())) + .chain(model -> Panache.withTransaction(() -> repository.delete("id", model.getId()))); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/net2/Client_Thread.java b/src/main/java/fr/titionfire/ffsaf/net2/Client_Thread.java index 67972c5..7651c33 100644 --- a/src/main/java/fr/titionfire/ffsaf/net2/Client_Thread.java +++ b/src/main/java/fr/titionfire/ffsaf/net2/Client_Thread.java @@ -40,7 +40,7 @@ public class Client_Thread extends Thread { private boolean isAuth; - private final HashMap> waitResult = new HashMap<>(); + private final HashMap> waitResult = new HashMap<>(); public Client_Thread(ServerCustom serv, Socket s, PublicKey publicKey) throws IOException { this.serv = serv; @@ -162,7 +162,7 @@ public class Client_Thread extends Thread { sendReq(object, type, null); } - public void sendReq(Object object, String code, JsonConsumer consumer) { + public void sendReq(Object object, String code, JsonConsumer consumer) { UUID uuid; do { uuid = UUID.randomUUID(); diff --git a/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleCompet.java b/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleCompet.java new file mode 100644 index 0000000..54707fb --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/net2/data/SimpleCompet.java @@ -0,0 +1,10 @@ +package fr.titionfire.ffsaf.net2.data; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.util.List; +import java.util.UUID; + +@RegisterForReflection +public record SimpleCompet(long id, String owner, boolean show_blason, boolean show_flag, List admin, List table) { +} diff --git a/src/main/java/fr/titionfire/ffsaf/net2/request/SReqCompet.java b/src/main/java/fr/titionfire/ffsaf/net2/request/SReqCompet.java new file mode 100644 index 0000000..c7aa379 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/net2/request/SReqCompet.java @@ -0,0 +1,40 @@ +package fr.titionfire.ffsaf.net2.request; + +import fr.titionfire.ffsaf.net2.Client_Thread; +import fr.titionfire.ffsaf.net2.data.SimpleCompet; +import fr.titionfire.ffsaf.utils.JsonConsumer; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; + +public class SReqCompet { + + public static void sendUpdate(ArrayList client_Thread, SimpleCompet compet) { + for (Client_Thread client : client_Thread) { + client.sendNotify(compet, "sendConfig"); + } + } + + public static void getConfig(ArrayList client_Thread, long id_compet, + CompletableFuture future) { + if (client_Thread.isEmpty()) return; + client_Thread.get(0).sendReq(id_compet, "getConfig", + new JsonConsumer<>(SimpleCompet.class, future::complete)); + } + + public static void getAllHaveAccess(ArrayList client_Thread, String userId, + CompletableFuture> future) { + if (client_Thread.isEmpty()) return; + client_Thread.get(0).sendReq(userId, "getAllHaveAccess", + new JsonConsumer<>(HashMap.class, future::complete)); + } + + public static void rmCompet(ArrayList client_Thread, long id_compet) { + for (Client_Thread client : client_Thread) { + client.sendNotify(id_compet, "rmCompet"); + } + } + + +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java index 35a4492..1ebc52b 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java @@ -30,7 +30,6 @@ public class AffiliationEndpoints { AffiliationService service; @Inject - @IdToken JsonWebToken idToken; @Inject diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationRequestEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationRequestEndpoints.java index b8a8bb5..b4f5c7e 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationRequestEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationRequestEndpoints.java @@ -35,7 +35,6 @@ public class AffiliationRequestEndpoints { AffiliationService service; @Inject - @IdToken JsonWebToken idToken; @Inject diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java index d9d4127..9cb13ea 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AuthEndpoints.java @@ -28,7 +28,7 @@ public class AuthEndpoints { SecurityIdentity securityIdentity; @Inject - JsonWebToken accessToken; + JsonWebToken IdToken; @GET @Produces(MediaType.TEXT_PLAIN) @@ -53,7 +53,7 @@ public class AuthEndpoints { @APIResponse(responseCode = "401", description = "Utilisateur non authentifié") }) public UserInfo userinfo() { - return UserInfo.makeUserInfo(accessToken, securityIdentity); + return UserInfo.makeUserInfo(IdToken, securityIdentity); } @GET diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index 115175f..c05a165 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -15,7 +15,6 @@ import fr.titionfire.ffsaf.utils.Contact; import fr.titionfire.ffsaf.utils.GroupeUtils; import fr.titionfire.ffsaf.utils.PageResult; import fr.titionfire.ffsaf.utils.Utils; -import io.quarkus.oidc.IdToken; import io.quarkus.security.Authenticated; import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; @@ -46,7 +45,6 @@ public class ClubEndpoints { ClubService clubService; @Inject - @IdToken JsonWebToken idToken; @Inject diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java new file mode 100644 index 0000000..e2b282f --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java @@ -0,0 +1,84 @@ +package fr.titionfire.ffsaf.rest; + +import fr.titionfire.ffsaf.domain.service.CompetitionService; +import fr.titionfire.ffsaf.rest.data.CompetitionData; +import fr.titionfire.ffsaf.rest.data.SimpleCompetData; +import fr.titionfire.ffsaf.utils.CompetitionSystem; +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.List; + +@Path("api/competition/") +public class CompetitionEndpoints { + + @Inject + CompetitionService service; + + @Inject + JsonWebToken idToken; + + @Inject + SecurityIdentity securityIdentity; + + @GET + @Path("{id}") + @Authenticated + @Produces(MediaType.APPLICATION_JSON) + public Uni getById(@PathParam("id") Long id) { + return service.getById(idToken, securityIdentity, id); + } + + @GET + @Path("{id}/safcaData") + @Authenticated + @Produces(MediaType.APPLICATION_JSON) + public Uni getSafcaData(@PathParam("id") Long id) { + return service.getSafcaData(idToken, securityIdentity, id); + } + + + @GET + @Path("all") + @Authenticated + @Produces(MediaType.APPLICATION_JSON) + public Uni> getAll() { + return service.getAll(idToken, securityIdentity); + } + + @GET + @Path("all/{system}") + @Authenticated + @Produces(MediaType.APPLICATION_JSON) + public Uni> getAllSystem(@PathParam("system") CompetitionSystem system) { + return service.getAllSystem(idToken, securityIdentity, system); + } + + @POST + @Authenticated + @Produces(MediaType.APPLICATION_JSON) + public Uni addOrUpdate(CompetitionData data) { + return service.addOrUpdate(idToken, securityIdentity, data); + } + + @POST + @Path("/safcaData") + @Authenticated + @Produces(MediaType.APPLICATION_JSON) + public Uni setSafcaData(SimpleCompetData data) { + return service.setSafcaData(idToken, securityIdentity, data); + } + + @DELETE + @Path("{id}") + @Authenticated + @Produces(MediaType.APPLICATION_JSON) + public Uni delete(@PathParam("id") Long id) { + return service.delete(idToken, securityIdentity, id); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java index 7a8ec62..f2bf094 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java @@ -32,7 +32,7 @@ public class CompteEndpoints { KeycloakService service; @Inject - JsonWebToken accessToken; + JsonWebToken idToken; @Inject SecurityIdentity securityIdentity; @@ -53,8 +53,9 @@ public class CompteEndpoints { }) public Uni getCompte(@PathParam("id") String id) { return service.fetchCompte(id).call(pair -> vertx.getOrCreateContext().executeBlocking(() -> { - if (!securityIdentity.getRoles().contains("federation_admin") && pair.getKey().groups().stream().map(GroupRepresentation::getPath) - .noneMatch(s -> s.startsWith("/club/") && GroupeUtils.contains(s, accessToken))) + if (!securityIdentity.getRoles().contains("federation_admin") && pair.getKey().groups().stream() + .map(GroupRepresentation::getPath) + .noneMatch(s -> s.startsWith("/club/") && GroupeUtils.contains(s, idToken))) throw new DForbiddenException(); return pair; })).map(Pair::getValue); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java index 55cf203..5eaabd9 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java @@ -29,7 +29,6 @@ public class LicenceEndpoints { LicenceService licenceService; @Inject - @IdToken JsonWebToken idToken; @Inject diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java new file mode 100644 index 0000000..3b0345b --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java @@ -0,0 +1,69 @@ +package fr.titionfire.ffsaf.rest; + +import fr.titionfire.ffsaf.data.model.ClubModel; +import fr.titionfire.ffsaf.domain.service.MatchService; +import fr.titionfire.ffsaf.rest.data.MatchData; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; +import fr.titionfire.ffsaf.utils.CompetitionSystem; +import fr.titionfire.ffsaf.utils.GroupeUtils; +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.List; +import java.util.function.Consumer; + +@Authenticated +@Path("api/match/{system}/") +public class MatchEndpoints { + + @PathParam("system") + private CompetitionSystem system; + + @Inject + MatchService service; + + @Inject + JsonWebToken idToken; + + @Inject + SecurityIdentity securityIdentity; + + Consumer checkPerm = Unchecked.consumer(clubModel -> { + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(clubModel.getId(), + idToken)) + throw new DForbiddenException(); + }); + + @GET + @Path("{id}") + @Produces(MediaType.APPLICATION_JSON) + public Uni getById(@PathParam("id") Long id) { + return service.getById(checkPerm, system, id); + } + + @GET + @Path("getAllByPoule/{id}") + @Produces(MediaType.APPLICATION_JSON) + public Uni> getAllByPoule(@PathParam("id") Long id) { + return service.getAllByPoule(checkPerm, system, id); + } + + @POST + @Produces(MediaType.APPLICATION_JSON) + public Uni addOrUpdate(MatchData data) { + return service.addOrUpdate(checkPerm, system, data); + } + + @DELETE + @Path("{id}") + @Produces(MediaType.APPLICATION_JSON) + public Uni delete(@PathParam("id") Long id) { + return service.delete(checkPerm, id); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java index 485d4d1..b65df9b 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java @@ -40,7 +40,6 @@ public class MembreAdminEndpoints { String media; @Inject - @IdToken JsonWebToken idToken; @Inject diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java index 3ae36e4..41e24c5 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java @@ -35,7 +35,6 @@ public class MembreClubEndpoints { String media; @Inject - @IdToken JsonWebToken idToken; @Inject diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java index 7d73fc3..e06a2c9 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java @@ -40,7 +40,6 @@ public class MembreEndpoints { String media; @Inject - @IdToken JsonWebToken idToken; @Inject diff --git a/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java new file mode 100644 index 0000000..63173f5 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java @@ -0,0 +1,68 @@ +package fr.titionfire.ffsaf.rest; + +import fr.titionfire.ffsaf.data.model.ClubModel; +import fr.titionfire.ffsaf.domain.service.PouleService; +import fr.titionfire.ffsaf.rest.data.PouleData; +import fr.titionfire.ffsaf.rest.exception.DForbiddenException; +import fr.titionfire.ffsaf.utils.CompetitionSystem; +import fr.titionfire.ffsaf.utils.GroupeUtils; +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.List; +import java.util.function.Consumer; + +@Authenticated +@Path("api/poule/{system}/") +public class PouleEndpoints { + + @PathParam("system") + private CompetitionSystem system; + + @Inject + PouleService service; + + @Inject + JsonWebToken idToken; + + @Inject + SecurityIdentity securityIdentity; + + Consumer checkPerm = Unchecked.consumer(clubModel -> { + if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(clubModel.getId(), + idToken)) + throw new DForbiddenException(); + }); + + @GET + @Path("{id}") + @Produces(MediaType.APPLICATION_JSON) + public Uni getById(@PathParam("id") Long id) { + return service.getById(checkPerm, system, id); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Uni> getAll() { + return service.getAll(idToken, securityIdentity, system); + } + + @POST + @Produces(MediaType.APPLICATION_JSON) + public Uni addOrUpdate(PouleData data) { + return service.addOrUpdate(checkPerm, system, data); + } + + @DELETE + @Path("{id}") + @Produces(MediaType.APPLICATION_JSON) + public Uni delete(@PathParam("id") Long id) { + return service.delete(checkPerm, id); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/CompetitionData.java b/src/main/java/fr/titionfire/ffsaf/rest/data/CompetitionData.java new file mode 100644 index 0000000..eae4c16 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/CompetitionData.java @@ -0,0 +1,31 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.CompetitionModel; +import fr.titionfire.ffsaf.utils.CompetitionSystem; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.Date; + +@Data +@AllArgsConstructor +@RegisterForReflection +public class CompetitionData { + private Long id; + private String name; + private String uuid; + private Date date; + private CompetitionSystem system; + private Long club; + private String clubName; + private String owner; + + public static CompetitionData fromModel(CompetitionModel model) { + if (model == null) + return null; + + return new CompetitionData(model.getId(), model.getName(), model.getUuid(), model.getDate(), model.getSystem(), + model.getClub().getId(), model.getClub().getName(), model.getOwner()); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/MatchData.java b/src/main/java/fr/titionfire/ffsaf/rest/data/MatchData.java new file mode 100644 index 0000000..f7ce375 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/MatchData.java @@ -0,0 +1,31 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.MatchModel; +import fr.titionfire.ffsaf.utils.ScoreEmbeddable; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +@Data +@AllArgsConstructor +@RegisterForReflection +public class MatchData { + private Long id; + private Long c1_id; + private String c1_str; + private Long c2_id; + private String c2_str; + private Long poule; + private long poule_ord; + private List scores; + + public static MatchData fromModel(MatchModel model) { + if (model == null) + return null; + + return new MatchData(model.getSystemId(), model.getC1_id().getId(), model.getC1_str(), model.getC2_id().getId(), + model.getC2_str(), model.getPoule(), model.getPoule_ord(), model.getScores()); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/PouleData.java b/src/main/java/fr/titionfire/ffsaf/rest/data/PouleData.java new file mode 100644 index 0000000..f89da27 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/PouleData.java @@ -0,0 +1,23 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.PouleModel; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +@RegisterForReflection +public class PouleData { + private Long id; + private String name; + private Long compet; + private Integer type; + + public static PouleData fromModel(PouleModel model) { + if (model == null) + return null; + + return new PouleData(model.getSystemId(), model.getName(), model.getCompet().getId(), model.getType()); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleCompetData.java b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleCompetData.java new file mode 100644 index 0000000..3adaac7 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/SimpleCompetData.java @@ -0,0 +1,28 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.net2.data.SimpleCompet; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@AllArgsConstructor +@RegisterForReflection +public class SimpleCompetData { + private long id; + private boolean show_blason; + private boolean show_flag; + private List admin; + private List table; + + public static SimpleCompetData fromModel(SimpleCompet compet) { + if (compet == null) + return null; + + return new SimpleCompetData(compet.id(), compet.show_blason(), compet.show_flag(), + new ArrayList<>(), new ArrayList<>()); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/TreeData.java b/src/main/java/fr/titionfire/ffsaf/rest/data/TreeData.java new file mode 100644 index 0000000..cd421df --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/TreeData.java @@ -0,0 +1,28 @@ +package fr.titionfire.ffsaf.rest.data; + +import fr.titionfire.ffsaf.data.model.TreeModel; +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +@RegisterForReflection +public class TreeData { + private Long id; + private Long poule; + private Integer level; + private MatchData match; + private TreeData left; + private TreeData right; + private TreeData associatedNode; + + public static TreeData fromModel(TreeModel model) { + if (model == null) + return null; + + return new TreeData(model.getId(), model.getPoule(), model.getLevel(), MatchData.fromModel(model.getMatch()), + fromModel(model.getLeft()), + fromModel(model.getRight()), null); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/utils/CompetitionSystem.java b/src/main/java/fr/titionfire/ffsaf/utils/CompetitionSystem.java new file mode 100644 index 0000000..79dbeeb --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/utils/CompetitionSystem.java @@ -0,0 +1,5 @@ +package fr.titionfire.ffsaf.utils; + +public enum CompetitionSystem { + SAFCA, +} diff --git a/src/main/java/fr/titionfire/ffsaf/utils/ScoreEmbeddable.java b/src/main/java/fr/titionfire/ffsaf/utils/ScoreEmbeddable.java new file mode 100644 index 0000000..9468597 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/utils/ScoreEmbeddable.java @@ -0,0 +1,21 @@ +package fr.titionfire.ffsaf.utils; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection + +@Embeddable +public class ScoreEmbeddable { + int n_round; + int s1; + int s2; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2ceeada..c0346aa 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -21,7 +21,7 @@ quarkus.oidc.auth-server-url=https://auth.safca.fr/realms/safca quarkus.oidc.client-id=backend quarkus.oidc.credentials.secret=secret quarkus.oidc.tls.verification=required -quarkus.oidc.application-type=web-app +quarkus.oidc.application-type=hybrid quarkus.oidc.roles.source=accesstoken quarkus.http.limits.max-body-size=10M diff --git a/src/main/webapp/src/App.jsx b/src/main/webapp/src/App.jsx index 85662c9..bc53786 100644 --- a/src/main/webapp/src/App.jsx +++ b/src/main/webapp/src/App.jsx @@ -13,6 +13,7 @@ import 'react-toastify/dist/ReactToastify.css'; import {ClubRoot, getClubChildren} from "./pages/club/ClubRoot.jsx"; import {DemandeAff, DemandeAffOk} from "./pages/DemandeAff.jsx"; import {MePage} from "./pages/MePage.jsx"; +import {CompetitionRoot, getCompetitionChildren} from "./pages/competition/CompetitionRoot.jsx"; const router = createBrowserRouter([ { @@ -47,6 +48,11 @@ const router = createBrowserRouter([ } ] }, + { + path: 'competition', + element: , + children: getCompetitionChildren() + }, { path: 'me', element: diff --git a/src/main/webapp/src/components/ClubSelect.jsx b/src/main/webapp/src/components/ClubSelect.jsx index fe23be9..9d76665 100644 --- a/src/main/webapp/src/components/ClubSelect.jsx +++ b/src/main/webapp/src/components/ClubSelect.jsx @@ -2,15 +2,15 @@ import {LoadingProvider, useLoadingSwitcher} from "../hooks/useLoading.jsx"; import {useFetch} from "../hooks/useFetch.js"; import {AxiosError} from "./AxiosError.jsx"; -export function ClubSelect({defaultValue, name, na = false}) { +export function ClubSelect({defaultValue, name, na = false, disabled = false}) { return
    - +
    } -function ClubSelect_({defaultValue, name, na}) { +function ClubSelect_({defaultValue, name, na, disabled}) { const setLoading = useLoadingSwitcher() const {data, error} = useFetch(`/club/no_detail`, setLoading, 1) @@ -18,7 +18,7 @@ function ClubSelect_({defaultValue, name, na}) { {data ?
    - +
    +
    Configuration SAFCA
    +
    + +
    +
    + +
    + Afficher le blason du club sur les écrans +
    + +
    +
    + +
    + Afficher le pays du combattant sur les écrans +
    + + Administrateur +
      + {state.map((d, index) => { + return
      + { + dispatch({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: e.target.value}}) + }}/> + +
      + })} +
      + +
      +
    + +
    + Table +
      + {state2.map((d, index) => { + return
      + { + dispatch2({type: 'UPDATE_OR_ADD', payload: {id: d.id, data: e.target.value}}) + }}/> + +
      + })} +
      + +
      +
    +
    +
    +
    + +
    +
    +
    + + : error && } + +} + +function Content({data}) { + const navigate = useNavigate(); + + const handleSubmit = (event) => { + event.preventDefault(); + + const out = {} + out['id'] = (data.id === "") ? null : data.id + out['name'] = event.target.name?.value + out['date'] = event.target.date?.value + out['system'] = event.target.system?.value + out['club'] = event.target.club?.value + out['owner'] = event.target.owner?.value + + toast.promise( + apiAxios.post(`/competition`, out), + { + pending: "Enregistrement du club en cours", + success: "Club enregistrée avec succès 🎉", + error: { + render({data}) { + return errFormater(data, "Échec de l'enregistrement du club") + } + }, + } + ).then(data => { + navigate("/competition/" + data.id) + }) + } + + return
    +
    + +
    {data.id ? "Edition competition" : "Création competition"}
    +
    + + + +
    + Date + +
    + + {data.id !== null && } + + + +
    + +
    + +
    + +
    +
    + +
    +
    +
    +
    +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/competition/CompetitionList.jsx b/src/main/webapp/src/pages/competition/CompetitionList.jsx new file mode 100644 index 0000000..0f885f9 --- /dev/null +++ b/src/main/webapp/src/pages/competition/CompetitionList.jsx @@ -0,0 +1,61 @@ +import {useNavigate} from "react-router-dom"; +import {useLoadingSwitcher} from "../../hooks/useLoading.jsx"; +import {useFetch} from "../../hooks/useFetch.js"; +import {AxiosError} from "../../components/AxiosError.jsx"; +import {ThreeDots} from "react-loader-spinner"; + + +export function CompetitionList() { + const navigate = useNavigate(); + const setLoading = useLoadingSwitcher() + const {data, error} = useFetch(`/competition/all`, setLoading, 1) + + + return <> +
    +
    + {data + ? + : error + ? + : + } +
    +
    + +} + +function MakeCentralPanel({data, navigate}) { + + return <> +
    + +
    +
    +
    + {data.map(req => ())} +
    +
    + +} + +function MakeRow({data, navigate}) { + return
    navigate("" + data.id)}> +
    +
    {data.name}
    + {data.date.split('T')[0]} +
    + {data.clubName}
    {data.system}
    +
    +} + +function Def() { + return
    +
  • +
  • +
  • +
  • +
  • +
    +} \ No newline at end of file diff --git a/src/main/webapp/src/pages/competition/CompetitionRoot.jsx b/src/main/webapp/src/pages/competition/CompetitionRoot.jsx new file mode 100644 index 0000000..dadae75 --- /dev/null +++ b/src/main/webapp/src/pages/competition/CompetitionRoot.jsx @@ -0,0 +1,26 @@ +import {LoadingProvider} from "../../hooks/useLoading.jsx"; +import {Outlet} from "react-router-dom"; +import {CompetitionList} from "./CompetitionList.jsx"; +import {CompetitionEdit} from "./CompetitionEdit.jsx"; + +export function CompetitionRoot() { + return <> +

    Competition

    + + + + +} + +export function getCompetitionChildren() { + return [ + { + path: '', + element: + }, + { + path: ':id', + element: + } + ] +} \ No newline at end of file From 4be7b28efd9e0b23e43a68f10b020b779b84f83d Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Fri, 9 Aug 2024 12:09:37 +0200 Subject: [PATCH 32/37] feat: move competition setting to ffsaf site --- src/main/java/fr/titionfire/ffsaf/data/model/MatchModel.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/MatchModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/MatchModel.java index fba895a..9ac0e47 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/MatchModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/MatchModel.java @@ -50,4 +50,6 @@ public class MatchModel { @ElementCollection @CollectionTable(name = "score", joinColumns = @JoinColumn(name = "id_match")) List scores = new ArrayList<>(); + + char groupe = 'A'; } From 7f80c876d3568478c1cad6a6799d835fd06ea284 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Wed, 14 Aug 2024 21:33:11 +0200 Subject: [PATCH 33/37] wip: send competition data --- .../ffsaf/data/model/MatchModel.java | 19 +- .../ffsaf/data/model/PouleModel.java | 4 +- .../ffsaf/data/model/TreeModel.java | 6 +- .../ffsaf/domain/service/MatchService.java | 33 ++- .../ffsaf/domain/service/PouleService.java | 196 +++++++++++++++++- .../ffsaf/domain/service/TreeService.java | 4 + .../titionfire/ffsaf/rest/MatchEndpoints.java | 10 +- .../titionfire/ffsaf/rest/PouleEndpoints.java | 14 +- .../titionfire/ffsaf/rest/data/MatchData.java | 9 +- .../ffsaf/rest/data/PouleFullData.java | 28 +++ .../titionfire/ffsaf/rest/data/TreeData.java | 8 +- .../ffsaf/utils/ScoreEmbeddable.java | 3 +- 12 files changed, 291 insertions(+), 43 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/TreeService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/data/PouleFullData.java diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/MatchModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/MatchModel.java index 9ac0e47..ece2466 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/MatchModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/MatchModel.java @@ -4,10 +4,7 @@ import fr.titionfire.ffsaf.utils.CompetitionSystem; import fr.titionfire.ffsaf.utils.ScoreEmbeddable; import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; import java.util.ArrayList; import java.util.List; @@ -19,6 +16,7 @@ import java.util.List; @RegisterForReflection @Entity +@ToString @Table(name = "match") public class MatchModel { @@ -30,24 +28,27 @@ public class MatchModel { CompetitionSystem system; Long systemId; - @ManyToOne + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "c1", referencedColumnName = "id") MembreModel c1_id = null; String c1_str = null; - @ManyToOne + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "c2", referencedColumnName = "id") MembreModel c2_id = null; String c2_str = null; - @Column(name = "id_poule") - Long poule; + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "id_poule", referencedColumnName = "id") + PouleModel poule = null; long poule_ord = 0; - @ElementCollection + boolean isEnd = true; + + @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "score", joinColumns = @JoinColumn(name = "id_match")) List scores = new ArrayList<>(); diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/PouleModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/PouleModel.java index 693042e..e18590b 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/PouleModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/PouleModel.java @@ -33,11 +33,11 @@ public class PouleModel { @JoinColumn(name = "id_compet", referencedColumnName = "id") CompetitionModel compet; - @OneToMany + @OneToMany(fetch = FetchType.LAZY) @JoinColumn(name = "id_poule", referencedColumnName = "id") List matchs; - @OneToMany + @OneToMany(fetch = FetchType.LAZY) @JoinColumn(name = "id_poule", referencedColumnName = "id") List tree; diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/TreeModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/TreeModel.java index 697e49d..c4a701b 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/TreeModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/TreeModel.java @@ -25,15 +25,15 @@ public class TreeModel { Integer level; - @ManyToOne + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "match_id", referencedColumnName = "id") MatchModel match; - @ManyToOne + @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST) @JoinColumn(referencedColumnName = "id") TreeModel left; - @ManyToOne + @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST) @JoinColumn(referencedColumnName = "id") TreeModel right; } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MatchService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MatchService.java index 7637444..fb7b97f 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MatchService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MatchService.java @@ -7,6 +7,7 @@ import fr.titionfire.ffsaf.data.repository.MatchRepository; import fr.titionfire.ffsaf.data.repository.PouleRepository; import fr.titionfire.ffsaf.rest.data.MatchData; import fr.titionfire.ffsaf.utils.CompetitionSystem; +import fr.titionfire.ffsaf.utils.ScoreEmbeddable; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.common.WithSession; import io.smallrye.mutiny.Uni; @@ -32,7 +33,7 @@ public class MatchService { public Uni getById(Consumer checkPerm, CompetitionSystem system, Long id) { return repository.find("systemId = ?1 AND system = ?2", id, system).firstResult() .onItem().ifNull().failWith(() -> new RuntimeException("Match not found")) - .call(data -> pouleRepository.findById(data.getPoule()) + .call(data -> pouleRepository.findById(data.getPoule().getId()) .invoke(data2 -> checkPerm.accept(data2.getCompet().getClub()))) .map(MatchData::fromModel); } @@ -49,7 +50,8 @@ public class MatchService { return repository.find("systemId = ?1 AND system = ?2", data.getId(), system).firstResult() .chain(o -> { if (o == null) { - return pouleRepository.findById(data.getPoule()) + return pouleRepository.find("systemId = ?1 AND system = ?2", data.getPoule(), system) + .firstResult() .onItem().ifNull().failWith(() -> new RuntimeException("Poule not found")) .invoke(data2 -> checkPerm.accept(data2.getCompet().getClub())) .map(pouleModel -> { @@ -58,11 +60,12 @@ public class MatchService { model.setId(null); model.setSystem(system); model.setSystemId(data.getId()); - model.setPoule(pouleModel.getId()); + model.setPoule(pouleModel); return model; }); } else { - return pouleRepository.findById(data.getPoule()) + return pouleRepository.find("systemId = ?1 AND system = ?2", data.getPoule(), system) + .firstResult() .onItem().ifNull().failWith(() -> new RuntimeException("Poule not found")) .invoke(data2 -> checkPerm.accept(data2.getCompet().getClub())) .map(__ -> o); @@ -73,7 +76,8 @@ public class MatchService { o.setC1_str(data.getC1_str()); o.setC2_str(data.getC2_str()); o.setPoule_ord(data.getPoule_ord()); - o.setScores(data.getScores()); + o.getScores().clear(); + o.getScores().addAll(data.getScores()); return Uni.createFrom().nullItem() .chain(() -> (data.getC1_id() == null) ? @@ -87,11 +91,22 @@ public class MatchService { .map(MatchData::fromModel); } - public Uni delete(Consumer checkPerm, Long id) { - return repository.findById(id) + public Uni updateScore(CompetitionSystem system, Long id, List scores) { + return repository.find("systemId = ?1 AND system = ?2", id, system).firstResult() .onItem().ifNull().failWith(() -> new RuntimeException("Match not found")) - .call(data -> pouleRepository.findById(data.getPoule()) + .invoke(data -> { + data.getScores().clear(); + data.getScores().addAll(scores); + }) + .chain(data -> Panache.withTransaction(() -> repository.persist(data))) + .map(o -> "OK"); + } + + public Uni delete(Consumer checkPerm, CompetitionSystem system, Long id) { + return repository.find("systemId = ?1 AND system = ?2", id, system).firstResult() + .onItem().ifNull().failWith(() -> new RuntimeException("Match not found")) + .chain(data -> pouleRepository.findById(data.getPoule().getId()) .invoke(data2 -> checkPerm.accept(data2.getCompet().getClub())) - .chain(data2 -> Panache.withTransaction(() -> repository.delete("id", data.getId())))); + .chain(data2 -> Panache.withTransaction(() -> repository.delete(data)))); } } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java index 2f23ffd..cab86e6 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java @@ -1,11 +1,10 @@ package fr.titionfire.ffsaf.domain.service; -import fr.titionfire.ffsaf.data.model.ClubModel; -import fr.titionfire.ffsaf.data.model.PouleModel; -import fr.titionfire.ffsaf.data.repository.CompetitionRepository; -import fr.titionfire.ffsaf.data.repository.PouleRepository; -import fr.titionfire.ffsaf.data.repository.TreeRepository; +import fr.titionfire.ffsaf.data.model.*; +import fr.titionfire.ffsaf.data.repository.*; import fr.titionfire.ffsaf.rest.data.PouleData; +import fr.titionfire.ffsaf.rest.data.PouleFullData; +import fr.titionfire.ffsaf.rest.data.TreeData; import fr.titionfire.ffsaf.utils.CompetitionSystem; import fr.titionfire.ffsaf.utils.GroupeUtils; import io.quarkus.hibernate.reactive.panache.Panache; @@ -15,10 +14,14 @@ import io.smallrye.mutiny.Uni; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.jwt.JsonWebToken; +import org.hibernate.reactive.mutiny.Mutiny; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Objects; import java.util.function.Consumer; +import java.util.stream.Stream; @WithSession @ApplicationScoped @@ -30,9 +33,15 @@ public class PouleService { @Inject CompetitionRepository competRepository; + @Inject + MatchRepository matchRepository; + @Inject TreeRepository treeRepository; + @Inject + CombRepository combRepository; + public Uni getById(Consumer checkPerm, CompetitionSystem system, Long id) { return repository.find("systemId = ?1 AND system = ?2", id, system) .firstResult() @@ -79,10 +88,181 @@ public class PouleService { }).map(PouleData::fromModel); } - public Uni delete(Consumer checkPerm, Long id) { - return repository.findById(id) + private MatchModel findMatch(List matchModelList, Long id) { + return matchModelList.stream().filter(m -> m.getSystemId().equals(id)) + .findFirst().orElse(null); + } + + private TreeModel findNode(List node, Long match_id) { + return node.stream().filter(m -> m.getMatch().getSystemId().equals(match_id)) + .findFirst().orElse(null); + } + + private void flatTreeChild(TreeModel current, ArrayList node) { + if (current != null) { + node.add(current); + flatTreeChild(current.getLeft(), node); + flatTreeChild(current.getRight(), node); + } + } + + private void flatTreeChild(TreeData current, ArrayList node) { + if (current != null) { + node.add(current); + flatTreeChild(current.getLeft(), node); + flatTreeChild(current.getRight(), node); + } + } + + private Uni persisteTree(TreeData data, List node, PouleModel poule, + List matchModelList) { + TreeModel mm = findNode(node, data.getMatch()); + if (mm == null) { + mm = new TreeModel(); + mm.setId(null); + } + mm.setLevel(data.getLevel()); + mm.setPoule(poule.getId()); + mm.setMatch(findMatch(matchModelList, data.getMatch())); + + return Uni.createFrom().item(mm) + .call(o -> (data.getLeft() == null ? Uni.createFrom().nullItem().invoke(o1 -> o.setLeft(null)) : + persisteTree(data.getLeft(), node, poule, matchModelList).invoke(o::setLeft))) + .call(o -> (data.getRight() == null ? Uni.createFrom().nullItem().invoke(o1 -> o.setRight(null)) : + persisteTree(data.getRight(), node, poule, matchModelList).invoke(o::setRight))) + .chain(o -> Panache.withTransaction(() -> treeRepository.persist(o))); + } + + public Uni syncPoule(CompetitionSystem system, PouleFullData data) { + System.out.println(data); + return repository.find("systemId = ?1 AND system = ?2", data.getId(), system) + .firstResult() + .onItem().ifNull().switchTo( + () -> competRepository.findById(data.getCompet()) + .onItem().ifNull().failWith(() -> new RuntimeException("Compet not found")) + .map(o -> { + PouleModel model = new PouleModel(); + model.setId(null); + model.setSystem(system); + model.setSystemId(data.getId()); + model.setMatchs(new ArrayList<>()); + model.setTree(new ArrayList<>()); + model.setCompet(o); + return model; + })) + .call(o -> Mutiny.fetch(o.getMatchs())) + .call(o -> Mutiny.fetch(o.getTree())) + .map(o -> { + o.setName(data.getName()); + o.setType(data.getType()); + + WorkData workData = new WorkData(); + workData.poule = o; + return workData; + }) + .call(o -> Panache.withTransaction(() -> repository.persist(o.poule))) + .call(o -> (data.getMatches() == null || data.getMatches().isEmpty()) ? Uni.createFrom().nullItem() : + Uni.createFrom() + .item(data.getMatches().stream().flatMap(m -> Stream.of(m.getC1_id(), m.getC2_id()) + .filter(Objects::nonNull)).distinct().toList()) + .chain(ids -> ids.isEmpty() ? Uni.createFrom().nullItem() + : combRepository.list("id IN ?1", ids) + .invoke(o2 -> o2.forEach(m -> o.membres.put(m.getId(), m))) + ) + ) + .invoke(in -> { + ArrayList node = new ArrayList<>(); + for (TreeModel treeModel : in.poule.getTree()) + flatTreeChild(treeModel, node); + + ArrayList new_node = new ArrayList<>(); + for (TreeData treeModel : data.getTrees()) + flatTreeChild(treeModel, new_node); + + in.toRmNode = node.stream().filter(m -> new_node.stream() + .noneMatch(m2 -> m2.getMatch().equals(m.getMatch().getSystemId()))) + .map(TreeModel::getId).toList(); + + in.unlinkNode = node; + in.unlinkNode.forEach(n -> { + n.setRight(null); + n.setLeft(null); + }); + + in.toRmMatch = in.poule.getMatchs().stream() + .filter(m -> data.getMatches().stream().noneMatch(m2 -> m2.getId().equals(m.getSystemId()))) + .map(MatchModel::getId).toList(); + }) + .call(in -> in.unlinkNode.isEmpty() ? Uni.createFrom().nullItem() : + Panache.withTransaction(() -> treeRepository.persist(in.unlinkNode))) + .call(in -> in.toRmNode.isEmpty() ? Uni.createFrom().nullItem() : + Panache.withTransaction(() -> treeRepository.delete("id IN ?1", in.toRmNode))) + .call(in -> in.toRmMatch.isEmpty() ? Uni.createFrom().nullItem() : + Panache.withTransaction(() -> Uni.join().all( + in.toRmMatch.stream().map(l -> matchRepository.deleteById(l)).toList()) + .andCollectFailures())) + .call(in -> data.getMatches().isEmpty() ? Uni.createFrom().nullItem() : + Uni.join().all( + data.getMatches().stream().map(m -> { + MatchModel mm = findMatch(in.poule.getMatchs(), m.getId()); + if (mm == null) { + mm = new MatchModel(); + mm.setId(null); + mm.setSystem(system); + mm.setSystemId(m.getId()); + } + mm.setPoule(in.poule); + mm.setPoule_ord(m.getPoule_ord()); + mm.setC1_str(m.getC1_str()); + mm.setC2_str(m.getC2_str()); + mm.setC1_id(in.membres.getOrDefault(m.getC1_id(), null)); + mm.setC2_id(in.membres.getOrDefault(m.getC2_id(), null)); + mm.setEnd(m.isEnd()); + mm.setGroupe(m.getGroupe()); + mm.getScores().clear(); + mm.getScores().addAll(m.getScores()); + + MatchModel finalMm = mm; + return Panache.withTransaction(() -> matchRepository.persist(finalMm) + .invoke(o -> in.match.add(o))); + }).toList()) + .andCollectFailures()) + .call(in -> data.getTrees().isEmpty() ? Uni.createFrom().nullItem() : + Uni.join().all(data.getTrees().stream() + .map(m -> persisteTree(m, in.poule.getTree(), in.poule, in.match)).toList()) + .andCollectFailures()) + .map(__ -> "OK"); + } + + private static class WorkData { + PouleModel poule; + HashMap membres = new HashMap<>(); + List match = new ArrayList<>(); + List toRmMatch; + List unlinkNode; + List toRmNode; + } + + public Uni delete(Consumer checkPerm, CompetitionSystem system, Long id) { + return repository.find("systemId = ?1 AND system = ?2", id, system).firstResult() .onItem().ifNull().failWith(() -> new RuntimeException("Poule not found")) - .invoke(data -> checkPerm.accept(data.getCompet().getClub())) + .call(o -> Mutiny.fetch(o.getMatchs())) + .call(o -> Mutiny.fetch(o.getTree()) + .call(o2 -> o2.isEmpty() ? Uni.createFrom().nullItem() : + Uni.createFrom().item(o2.stream().peek(m -> { + m.setRight(null); + m.setLeft(null); + }).toList()) + .call(in -> Panache.withTransaction(() -> treeRepository.persist(in))) + .map(in -> in.stream().map(TreeModel::getId).toList()) + .call(in -> in.isEmpty() ? Uni.createFrom().nullItem() : + Panache.withTransaction(() -> treeRepository.delete("id IN ?1", in))) + ) + ) + .call(o -> o.getMatchs().isEmpty() ? Uni.createFrom().nullItem() : + Panache.withTransaction(() -> Uni.join().all( + o.getMatchs().stream().map(l -> matchRepository.deleteById(l.getId())).toList()) + .andCollectFailures())) .chain(model -> Panache.withTransaction(() -> repository.delete("id", model.getId()))); } } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/TreeService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/TreeService.java new file mode 100644 index 0000000..2e10cc7 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/TreeService.java @@ -0,0 +1,4 @@ +package fr.titionfire.ffsaf.domain.service; + +public class TreeService { +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java index 3b0345b..d67fec7 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java @@ -6,6 +6,7 @@ import fr.titionfire.ffsaf.rest.data.MatchData; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.utils.CompetitionSystem; import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.ScoreEmbeddable; import io.quarkus.security.Authenticated; import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; @@ -60,10 +61,17 @@ public class MatchEndpoints { return service.addOrUpdate(checkPerm, system, data); } + @POST + @Path("score/{id}") + @Produces(MediaType.APPLICATION_JSON) + public Uni updateScore(@PathParam("id") Long id, List scores) { + return service.updateScore(system, id, scores); + } + @DELETE @Path("{id}") @Produces(MediaType.APPLICATION_JSON) public Uni delete(@PathParam("id") Long id) { - return service.delete(checkPerm, id); + return service.delete(checkPerm, system, id); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java index 63173f5..d0c299a 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java @@ -3,10 +3,10 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.domain.service.PouleService; import fr.titionfire.ffsaf.rest.data.PouleData; +import fr.titionfire.ffsaf.rest.data.PouleFullData; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.utils.CompetitionSystem; import fr.titionfire.ffsaf.utils.GroupeUtils; -import io.quarkus.security.Authenticated; import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; @@ -18,7 +18,7 @@ import org.eclipse.microprofile.jwt.JsonWebToken; import java.util.List; import java.util.function.Consumer; -@Authenticated +// @Authenticated @Path("api/poule/{system}/") public class PouleEndpoints { @@ -54,15 +54,23 @@ public class PouleEndpoints { } @POST + @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Uni addOrUpdate(PouleData data) { return service.addOrUpdate(checkPerm, system, data); } + @POST + @Path("sync") + @Consumes(MediaType.APPLICATION_JSON) + public Uni syncPoule(PouleFullData data) { + return service.syncPoule(system, data); + } + @DELETE @Path("{id}") @Produces(MediaType.APPLICATION_JSON) public Uni delete(@PathParam("id") Long id) { - return service.delete(checkPerm, id); + return service.delete(checkPerm, system, id); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/MatchData.java b/src/main/java/fr/titionfire/ffsaf/rest/data/MatchData.java index f7ce375..b7c942b 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/MatchData.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/MatchData.java @@ -19,13 +19,18 @@ public class MatchData { private String c2_str; private Long poule; private long poule_ord; + private boolean isEnd = true; + private char groupe; private List scores; public static MatchData fromModel(MatchModel model) { if (model == null) return null; - return new MatchData(model.getSystemId(), model.getC1_id().getId(), model.getC1_str(), model.getC2_id().getId(), - model.getC2_str(), model.getPoule(), model.getPoule_ord(), model.getScores()); + return new MatchData(model.getSystemId(), + (model.getC1_id() == null) ? null : model.getC1_id().getId(), model.getC1_str(), + (model.getC2_id() == null) ? null : model.getC2_id().getId(), model.getC2_str(), + model.getPoule().getId(), model.getPoule_ord(), model.isEnd(), model.getGroupe(), + model.getScores()); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/PouleFullData.java b/src/main/java/fr/titionfire/ffsaf/rest/data/PouleFullData.java new file mode 100644 index 0000000..b949111 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/PouleFullData.java @@ -0,0 +1,28 @@ +package fr.titionfire.ffsaf.rest.data; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +@Data +@AllArgsConstructor +public class PouleFullData { + private Long id; + private String name; + private Long compet; + private Integer type; + private List matches; + private List trees; + + /*public static PouleFullData fromModel(PouleModel pouleModel) { + if (pouleModel == null) + return null; + + PouleEntity pouleEntity = PouleEntity.fromModel(pouleModel); + + return new PouleFullData(pouleEntity.getId(), pouleEntity.getName(), pouleEntity.getCompet().getId(), + pouleEntity.getType(), pouleModel.getMatchs().stream().map(MatchData::fromModel).toList(), + pouleEntity.getTrees().stream().map(TreeData::fromEntity).toList()); + }*/ +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/data/TreeData.java b/src/main/java/fr/titionfire/ffsaf/rest/data/TreeData.java index cd421df..b4cff47 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/data/TreeData.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/data/TreeData.java @@ -12,17 +12,15 @@ public class TreeData { private Long id; private Long poule; private Integer level; - private MatchData match; + private Long match; private TreeData left; private TreeData right; - private TreeData associatedNode; public static TreeData fromModel(TreeModel model) { if (model == null) return null; - return new TreeData(model.getId(), model.getPoule(), model.getLevel(), MatchData.fromModel(model.getMatch()), - fromModel(model.getLeft()), - fromModel(model.getRight()), null); + return new TreeData(model.getId(), model.getPoule(), model.getLevel(), model.getMatch().getId(), + fromModel(model.getLeft()), fromModel(model.getRight())); } } diff --git a/src/main/java/fr/titionfire/ffsaf/utils/ScoreEmbeddable.java b/src/main/java/fr/titionfire/ffsaf/utils/ScoreEmbeddable.java index 9468597..53ce863 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/ScoreEmbeddable.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/ScoreEmbeddable.java @@ -1,12 +1,13 @@ package fr.titionfire.ffsaf.utils; import io.quarkus.runtime.annotations.RegisterForReflection; -import jakarta.persistence.Embeddable; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import jakarta.persistence.*; + @Getter @Setter @AllArgsConstructor From bd386d1b0ad90f65efe5a70ddc62386c198b8f8a Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Thu, 15 Aug 2024 11:44:54 +0200 Subject: [PATCH 34/37] feat: add SecurityContext class --- .../ffsaf/domain/service/ClubService.java | 11 ++- .../domain/service/CompetPermService.java | 36 +++++---- .../domain/service/CompetitionService.java | 74 +++++++++---------- .../ffsaf/domain/service/LicenceService.java | 8 +- .../ffsaf/domain/service/MembreService.java | 18 ++--- .../ffsaf/domain/service/PouleService.java | 11 +-- .../ffsaf/rest/AffiliationEndpoints.java | 12 +-- .../rest/AffiliationRequestEndpoints.java | 16 ++-- .../titionfire/ffsaf/rest/ClubEndpoints.java | 18 ++--- .../ffsaf/rest/CompetitionEndpoints.java | 22 +++--- .../ffsaf/rest/CompteEndpoints.java | 13 +--- .../ffsaf/rest/LicenceEndpoints.java | 14 +--- .../titionfire/ffsaf/rest/MatchEndpoints.java | 12 +-- .../ffsaf/rest/MembreAdminEndpoints.java | 13 +--- .../ffsaf/rest/MembreClubEndpoints.java | 17 ++--- .../ffsaf/rest/MembreEndpoints.java | 15 +--- .../titionfire/ffsaf/rest/PouleEndpoints.java | 14 +--- .../titionfire/ffsaf/utils/GroupeUtils.java | 25 ------- .../titionfire/ffsaf/utils/SecurityCtx.java | 56 ++++++++++++++ 19 files changed, 181 insertions(+), 224 deletions(-) delete mode 100644 src/main/java/fr/titionfire/ffsaf/utils/GroupeUtils.java create mode 100644 src/main/java/fr/titionfire/ffsaf/utils/SecurityCtx.java diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java index 61aed55..e64f69a 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/ClubService.java @@ -30,7 +30,6 @@ import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.eclipse.microprofile.jwt.JsonWebToken; import org.hibernate.reactive.mutiny.Mutiny; import java.util.Collection; @@ -121,8 +120,8 @@ public class ClubService { return repository.find("clubId", clubId).firstResult(); } - public Uni getOfUser(JsonWebToken idToken) { - return combRepository.find("userId = ?1", idToken.getSubject()).firstResult().invoke(Unchecked.consumer(m -> { + public Uni getOfUser(SecurityCtx securityCtx) { + return combRepository.find("userId = ?1", securityCtx.getSubject()).firstResult().invoke(Unchecked.consumer(m -> { if (m == null || m.getClub() == null) throw new DNotFoundException("Club non trouvé"); })) @@ -140,14 +139,14 @@ public class ClubService { .toList()); } - public Uni updateOfUser(JsonWebToken idToken, PartClubForm form) { + public Uni updateOfUser(SecurityCtx securityCtx, PartClubForm form) { TypeReference> typeRef = new TypeReference<>() { }; - return combRepository.find("userId = ?1", idToken.getSubject()).firstResult().invoke(Unchecked.consumer(m -> { + return combRepository.find("userId = ?1", securityCtx.getSubject()).firstResult().invoke(Unchecked.consumer(m -> { if (m == null || m.getClub() == null) throw new DNotFoundException("Club non trouvé"); - if (!GroupeUtils.isInClubGroup(m.getClub().getId(), idToken)) + if (!securityCtx.isInClubGroup(m.getClub().getId())) throw new DForbiddenException(); })) .map(MembreModel::getClub) diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/CompetPermService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetPermService.java index 5ec36b6..254a335 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/CompetPermService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetPermService.java @@ -7,15 +7,13 @@ import fr.titionfire.ffsaf.net2.data.SimpleCompet; import fr.titionfire.ffsaf.net2.request.SReqCompet; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.utils.CompetitionSystem; -import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.SecurityCtx; import io.quarkus.cache.Cache; import io.quarkus.cache.CacheName; -import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import org.eclipse.microprofile.jwt.JsonWebToken; import java.util.HashMap; import java.util.UUID; @@ -54,7 +52,7 @@ public class CompetPermService { }); } - public Uni> getAllHaveAccess (String subject) { + public Uni> getAllHaveAccess(String subject) { return cacheAccess.get(subject, k -> { CompletableFuture> f = new CompletableFuture<>(); SReqCompet.getAllHaveAccess(serverCustom.clients, subject, f); @@ -67,52 +65,52 @@ public class CompetPermService { }); } - public Uni hasViewPerm(JsonWebToken idToken, SecurityIdentity sid, long id) { + public Uni hasViewPerm(SecurityCtx securityCtx, long id) { return competitionRepository.findById(id).call(o -> ( - idToken.getSubject().equals(o.getOwner()) || sid.getRoles().contains("federation_admin")) ? + securityCtx.getSubject().equals(o.getOwner()) || securityCtx.roleHas("federation_admin")) ? Uni.createFrom().nullItem() : o.getSystem() == CompetitionSystem.SAFCA ? - hasSafcaViewPerm(idToken, sid, id) + hasSafcaViewPerm(securityCtx, id) : Uni.createFrom().nullItem().invoke(Unchecked.consumer(__ -> { - if (!GroupeUtils.isInClubGroup(o.getClub().getId(), idToken)) + if (!securityCtx.isInClubGroup(o.getClub().getId())) throw new DForbiddenException(); }) )); } - public Uni hasEditPerm(JsonWebToken idToken, SecurityIdentity sid, long id) { + public Uni hasEditPerm(SecurityCtx securityCtx, long id) { return competitionRepository.findById(id).call(o -> ( - idToken.getSubject().equals(o.getOwner()) || sid.getRoles().contains("federation_admin")) ? + securityCtx.getSubject().equals(o.getOwner()) || securityCtx.roleHas("federation_admin")) ? Uni.createFrom().nullItem() : o.getSystem() == CompetitionSystem.SAFCA ? - hasSafcaEditPerm(idToken, sid, id) + hasSafcaEditPerm(securityCtx, id) : Uni.createFrom().nullItem().invoke(Unchecked.consumer(__ -> { - if (!GroupeUtils.isInClubGroup(o.getClub().getId(), idToken)) + if (!securityCtx.isInClubGroup(o.getClub().getId())) throw new DForbiddenException(); }) )); } - private Uni hasSafcaViewPerm(JsonWebToken idToken, SecurityIdentity sid, long id) { - return sid.getRoles().contains("safca_super_admin") ? + private Uni hasSafcaViewPerm(SecurityCtx securityCtx, long id) { + return securityCtx.roleHas("safca_super_admin") ? Uni.createFrom().nullItem() : getSafcaConfig(id).chain(Unchecked.function(o -> { - if (!o.admin().contains(UUID.fromString(idToken.getSubject())) && !o.table() - .contains(UUID.fromString(idToken.getSubject()))) + if (!o.admin().contains(UUID.fromString(securityCtx.getSubject())) && !o.table() + .contains(UUID.fromString(securityCtx.getSubject()))) throw new DForbiddenException(); return Uni.createFrom().nullItem(); })); } - private Uni hasSafcaEditPerm(JsonWebToken idToken, SecurityIdentity sid, long id) { - return sid.getRoles().contains("safca_super_admin") ? + private Uni hasSafcaEditPerm(SecurityCtx securityCtx, long id) { + return securityCtx.roleHas("safca_super_admin") ? Uni.createFrom().nullItem() : getSafcaConfig(id).chain(Unchecked.function(o -> { - if (!o.admin().contains(UUID.fromString(idToken.getSubject()))) + if (!o.admin().contains(UUID.fromString(securityCtx.getSubject()))) throw new DForbiddenException(); return Uni.createFrom().nullItem(); })); diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java index b0997a0..3a338ac 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetitionService.java @@ -13,16 +13,14 @@ import fr.titionfire.ffsaf.rest.data.SimpleCompetData; import fr.titionfire.ffsaf.rest.exception.DBadRequestException; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.utils.CompetitionSystem; -import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.SecurityCtx; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.common.WithSession; -import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import io.vertx.mutiny.core.Vertx; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import org.eclipse.microprofile.jwt.JsonWebToken; import org.keycloak.representations.idm.UserRepresentation; import java.util.*; @@ -53,13 +51,13 @@ public class CompetitionService { @Inject Vertx vertx; - public Uni getById(JsonWebToken idToken, SecurityIdentity sid, Long id) { + public Uni getById(SecurityCtx securityCtx, Long id) { if (id == 0) { return Uni.createFrom() .item(new CompetitionData(null, "", "", new Date(), CompetitionSystem.SAFCA, null, "", "")); } - return permService.hasViewPerm(idToken, sid, id) + return permService.hasViewPerm(securityCtx, id) .map(CompetitionData::fromModel) .chain(data -> vertx.getOrCreateContext().executeBlocking(() -> { @@ -70,40 +68,40 @@ public class CompetitionService { ); } - public Uni> getAll(JsonWebToken idToken, SecurityIdentity securityIdentity) { + public Uni> getAll(SecurityCtx securityCtx) { return repository.listAll() .chain(o -> - permService.getAllHaveAccess(idToken.getSubject()) + permService.getAllHaveAccess(securityCtx.getSubject()) .chain(map -> Uni.createFrom().item(o.stream() .filter(p -> { - if (idToken.getSubject().equals(p.getOwner())) + if (securityCtx.getSubject().equals(p.getOwner())) return true; if (p.getSystem() == CompetitionSystem.SAFCA) { if (map.containsKey(p.getId())) return map.get(p.getId()).equals("admin"); - return securityIdentity.getRoles().contains("federation_admin") - || securityIdentity.getRoles().contains("safca_super_admin"); + return securityCtx.roleHas("federation_admin") + || securityCtx.roleHas("safca_super_admin"); } - return securityIdentity.getRoles().contains("federation_admin"); + return securityCtx.roleHas("federation_admin"); }) .map(CompetitionData::fromModel).toList()) )); } - public Uni> getAllSystem(JsonWebToken idToken, SecurityIdentity securityIdentity, + public Uni> getAllSystem(SecurityCtx securityCtx, CompetitionSystem system) { if (system == CompetitionSystem.SAFCA) { - return permService.getAllHaveAccess(idToken.getSubject()) + return permService.getAllHaveAccess(securityCtx.getSubject()) .chain(map -> repository.list("system = ?1", system) .map(data -> data.stream() .filter(p -> { - if (idToken.getSubject().equals(p.getOwner())) + if (securityCtx.getSubject().equals(p.getOwner())) return true; if (map.containsKey(p.getId())) return map.get(p.getId()).equals("admin"); - return securityIdentity.getRoles().contains("federation_admin") - || securityIdentity.getRoles().contains("safca_super_admin"); + return securityCtx.roleHas("federation_admin") + || securityCtx.roleHas("safca_super_admin"); }) .map(CompetitionData::fromModel).toList()) ); @@ -112,18 +110,18 @@ public class CompetitionService { return repository.list("system = ?1", system) .map(data -> data.stream() .filter(p -> { - if (idToken.getSubject().equals(p.getOwner())) + if (securityCtx.getSubject().equals(p.getOwner())) return true; - return securityIdentity.getRoles().contains("federation_admin") || - GroupeUtils.isInClubGroup(p.getClub().getId(), idToken); + return securityCtx.roleHas("federation_admin") || + securityCtx.isInClubGroup(p.getClub().getId()); }) .map(CompetitionData::fromModel).toList()); } - public Uni addOrUpdate(JsonWebToken idToken, SecurityIdentity sid, CompetitionData data) { + public Uni addOrUpdate(SecurityCtx securityCtx, CompetitionData data) { if (data.getId() == null) { return new ClubRepository().findById(data.getClub()).invoke(Unchecked.consumer(clubModel -> { - if (!GroupeUtils.isInClubGroup(clubModel.getId(), idToken)) + if (!securityCtx.isInClubGroup(clubModel.getId())) throw new DForbiddenException(); })) // TODO check if user can create competition .chain(clubModel -> { @@ -136,13 +134,13 @@ public class CompetitionService { model.setInsc(new ArrayList<>()); model.setUuid(UUID.randomUUID().toString()); model.setName(data.getName()); - model.setOwner(idToken.getSubject()); + model.setOwner(securityCtx.getSubject()); return Panache.withTransaction(() -> repository.persist(model)); }).map(CompetitionData::fromModel) - .call(__ -> permService.cacheAccess.invalidate(idToken.getSubject())); + .call(__ -> permService.cacheAccess.invalidate(securityCtx.getSubject())); } else { - return permService.hasEditPerm(idToken, sid, data.getId()) + return permService.hasEditPerm(securityCtx, data.getId()) .chain(model -> { model.setDate(data.getDate()); model.setName(data.getName()); @@ -153,22 +151,22 @@ public class CompetitionService { if (newOwner == null) throw new DBadRequestException("User " + data.getOwner() + " not found"); if (!newOwner.equals(model.getOwner())) { - if (!sid.getRoles().contains("federation_admin") - && !sid.getRoles().contains("safca_super_admin") - && !idToken.getSubject().equals(model.getOwner())) + if (!securityCtx.roleHas("federation_admin") + && !securityCtx.roleHas("safca_super_admin") + && !securityCtx.getSubject().equals(model.getOwner())) throw new DForbiddenException(); model.setOwner(newOwner); } })) .chain(__ -> Panache.withTransaction(() -> repository.persist(model))); }).map(CompetitionData::fromModel) - .call(__ -> permService.cacheAccess.invalidate(idToken.getSubject())); + .call(__ -> permService.cacheAccess.invalidate(securityCtx.getSubject())); } } - public Uni delete(JsonWebToken idToken, SecurityIdentity sid, Long id) { + public Uni delete(SecurityCtx securityCtx, Long id) { return repository.findById(id).invoke(Unchecked.consumer(c -> { - if (!idToken.getSubject().equals(c.getOwner()) || sid.getRoles().contains("federation_admin")) + if (!securityCtx.getSubject().equals(c.getOwner()) || securityCtx.roleHas("federation_admin")) throw new DForbiddenException(); })) .call(competitionModel -> pouleRepository.list("compet = ?1", competitionModel) @@ -185,14 +183,14 @@ public class CompetitionService { .call(__ -> permService.cache.invalidate(id)); } - public Uni getSafcaData(JsonWebToken idToken, SecurityIdentity sid, Long id) { + public Uni getSafcaData(SecurityCtx securityCtx, Long id) { return permService.getSafcaConfig(id) .call(Unchecked.function(o -> { - if (!idToken.getSubject().equals(o.owner()) - && !sid.getRoles().contains("federation_admin") - && !sid.getRoles().contains("safca_super_admin") - && !o.admin().contains(UUID.fromString(idToken.getSubject())) - && !o.table().contains(UUID.fromString(idToken.getSubject()))) + if (!securityCtx.getSubject().equals(o.owner()) + && !securityCtx.roleHas("federation_admin") + && !securityCtx.roleHas("safca_super_admin") + && !o.admin().contains(UUID.fromString(securityCtx.getSubject())) + && !o.table().contains(UUID.fromString(securityCtx.getSubject()))) throw new DForbiddenException(); return Uni.createFrom().nullItem(); })) @@ -213,8 +211,8 @@ public class CompetitionService { }); } - public Uni setSafcaData(JsonWebToken idToken, SecurityIdentity sid, SimpleCompetData data) { - return permService.hasEditPerm(idToken, sid, data.getId()) + public Uni setSafcaData(SecurityCtx securityCtx, SimpleCompetData data) { + return permService.hasEditPerm(securityCtx, data.getId()) .chain(__ -> vertx.getOrCreateContext().executeBlocking(() -> { ArrayList admin = new ArrayList<>(); ArrayList table = new ArrayList<>(); diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java index 64fb1ab..ac99ae2 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java @@ -7,6 +7,7 @@ import fr.titionfire.ffsaf.data.repository.LicenceRepository; import fr.titionfire.ffsaf.data.repository.SequenceRepository; import fr.titionfire.ffsaf.rest.exception.DBadRequestException; import fr.titionfire.ffsaf.rest.from.LicenceForm; +import fr.titionfire.ffsaf.utils.SecurityCtx; import fr.titionfire.ffsaf.utils.SequenceType; import fr.titionfire.ffsaf.utils.Utils; import io.quarkus.hibernate.reactive.panache.Panache; @@ -15,7 +16,6 @@ import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import org.eclipse.microprofile.jwt.JsonWebToken; import org.hibernate.reactive.mutiny.Mutiny; import java.util.List; @@ -39,11 +39,11 @@ public class LicenceService { .chain(combRepository -> Mutiny.fetch(combRepository.getLicences())); } - public Uni> getCurrentSaisonLicence(JsonWebToken idToken) { - if (idToken == null) + public Uni> getCurrentSaisonLicence(SecurityCtx securityCtx) { + if (securityCtx.getSubject() == null) return repository.find("saison = ?1", Utils.getSaison()).list(); - return combRepository.find("userId = ?1", idToken.getSubject()).firstResult().map(MembreModel::getClub) + return combRepository.find("userId = ?1", securityCtx.getSubject()).firstResult().map(MembreModel::getClub) .chain(clubModel -> combRepository.find("club = ?1", clubModel).list()) .chain(membres -> repository.find("saison = ?1 AND membre IN ?2", Utils.getSaison(), membres).list()); } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java index 7306a23..6b6b4c4 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -21,14 +21,12 @@ import io.quarkus.hibernate.reactive.panache.PanacheQuery; import io.quarkus.hibernate.reactive.panache.common.WithSession; import io.quarkus.panache.common.Page; import io.quarkus.panache.common.Sort; -import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.vertx.VertxContextSupport; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.eclipse.microprofile.jwt.JsonWebToken; import org.hibernate.reactive.mutiny.Mutiny; import java.util.List; @@ -151,17 +149,17 @@ public class MembreService { .map(__ -> "OK"); } - public Uni update(long id, ClubMemberForm membre, JsonWebToken idToken, SecurityIdentity securityIdentity) { + public Uni update(long id, ClubMemberForm membre, SecurityCtx securityCtx) { return repository.findById(id) .invoke(Unchecked.consumer(membreModel -> { - if (!GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) + if (!securityCtx.isInClubGroup(membreModel.getClub().getId())) throw new DForbiddenException(); })) .invoke(Unchecked.consumer(membreModel -> { RoleAsso source = RoleAsso.MEMBRE; - if (securityIdentity.getRoles().contains("club_president")) source = RoleAsso.PRESIDENT; - else if (securityIdentity.getRoles().contains("club_secretaire")) source = RoleAsso.SECRETAIRE; - else if (securityIdentity.getRoles().contains("club_respo_intra")) source = RoleAsso.MEMBREBUREAU; + if (securityCtx.roleHas("club_president")) source = RoleAsso.PRESIDENT; + else if (securityCtx.roleHas("club_secretaire")) source = RoleAsso.SECRETAIRE; + else if (securityCtx.roleHas("club_respo_intra")) source = RoleAsso.MEMBREBUREAU; if (!membre.getRole().equals(membreModel.getRole()) && membre.getRole().level >= source.level) throw new DForbiddenException("Permission insuffisante"); })) @@ -173,7 +171,7 @@ public class MembreService { target.setGenre(membre.getGenre()); target.setCategorie(membre.getCategorie()); target.setEmail(membre.getEmail()); - if (!idToken.getSubject().equals(target.getUserId())) + if (!securityCtx.getSubject().equals(target.getUserId())) target.setRole(membre.getRole()); return Panache.withTransaction(() -> repository.persist(target)); }) @@ -221,10 +219,10 @@ public class MembreService { .map(__ -> "Ok"); } - public Uni delete(long id, JsonWebToken idToken) { + public Uni delete(long id, SecurityCtx securityCtx) { return repository.findById(id) .invoke(Unchecked.consumer(membreModel -> { - if (!GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) + if (!securityCtx.isInClubGroup(membreModel.getClub().getId())) throw new DForbiddenException(); })) .call(membreModel -> licenceRepository.find("membre = ?1", membreModel).count() diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java index cab86e6..90585ae 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java @@ -6,14 +6,12 @@ import fr.titionfire.ffsaf.rest.data.PouleData; import fr.titionfire.ffsaf.rest.data.PouleFullData; import fr.titionfire.ffsaf.rest.data.TreeData; import fr.titionfire.ffsaf.utils.CompetitionSystem; -import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.SecurityCtx; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.common.WithSession; -import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import org.eclipse.microprofile.jwt.JsonWebToken; import org.hibernate.reactive.mutiny.Mutiny; import java.util.ArrayList; @@ -50,12 +48,11 @@ public class PouleService { .map(PouleData::fromModel); } - public Uni> getAll(JsonWebToken idToken, SecurityIdentity securityIdentity, - CompetitionSystem system) { + public Uni> getAll(SecurityCtx securityCtx, CompetitionSystem system) { return repository.list("system = ?1", system) .map(data -> data.stream() - .filter(p -> securityIdentity.getRoles().contains("federation_admin") || - GroupeUtils.isInClubGroup(p.getCompet().getClub().getId(), idToken)) + .filter(p -> securityCtx.roleHas("federation_admin") || + securityCtx.isInClubGroup(p.getCompet().getClub().getId())) .map(PouleData::fromModel).toList()); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java index 1ebc52b..a65e9a7 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationEndpoints.java @@ -3,16 +3,13 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.domain.service.AffiliationService; import fr.titionfire.ffsaf.rest.data.SimpleAffiliation; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; -import fr.titionfire.ffsaf.utils.GroupeUtils; -import io.quarkus.oidc.IdToken; -import io.quarkus.security.identity.SecurityIdentity; +import fr.titionfire.ffsaf.utils.SecurityCtx; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; -import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; @@ -30,13 +27,10 @@ public class AffiliationEndpoints { AffiliationService service; @Inject - JsonWebToken idToken; - - @Inject - SecurityIdentity securityIdentity; + SecurityCtx securityCtx; Consumer checkPerm = Unchecked.consumer(id -> { - if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(id, idToken)) + if (!securityCtx.roleHas("federation_admin") && !securityCtx.isInClubGroup(id)) throw new DForbiddenException(); }); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationRequestEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationRequestEndpoints.java index b4f5c7e..1ad6b57 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/AffiliationRequestEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/AffiliationRequestEndpoints.java @@ -6,10 +6,8 @@ import fr.titionfire.ffsaf.rest.data.SimpleReqAffiliationResume; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.rest.from.AffiliationRequestForm; import fr.titionfire.ffsaf.rest.from.AffiliationRequestSaveForm; -import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.SecurityCtx; import fr.titionfire.ffsaf.utils.Utils; -import io.quarkus.oidc.IdToken; -import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.annotation.security.RolesAllowed; @@ -18,7 +16,6 @@ import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; @@ -35,16 +32,13 @@ public class AffiliationRequestEndpoints { AffiliationService service; @Inject - JsonWebToken idToken; - - @Inject - SecurityIdentity securityIdentity; + SecurityCtx securityCtx; @ConfigProperty(name = "upload_dir") String media; Consumer checkPerm = Unchecked.consumer(id -> { - if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(id, idToken)) + if (!securityCtx.roleHas("federation_admin") && !securityCtx.isInClubGroup(id)) throw new DForbiddenException(); }); @@ -91,7 +85,7 @@ public class AffiliationRequestEndpoints { public Uni getAffRequest( @Parameter(description = "L'identifiant de la demande d'affiliation") @PathParam("id") long id) { return service.getRequest(id).invoke(Unchecked.consumer(o -> { - if (o.getClub() == null && !securityIdentity.getRoles().contains("federation_admin")) + if (o.getClub() == null && !securityCtx.roleHas("federation_admin")) throw new DForbiddenException(); })).invoke(o -> checkPerm.accept(o.getClub())); } @@ -110,7 +104,7 @@ public class AffiliationRequestEndpoints { public Uni getDelAffRequest( @Parameter(description = "L'identifiant de la demande d'affiliation") @PathParam("id") long id) { return service.getRequest(id).invoke(Unchecked.consumer(o -> { - if (o.getClub() == null && !securityIdentity.getRoles().contains("federation_admin")) + if (o.getClub() == null && !securityCtx.roleHas("federation_admin")) throw new DForbiddenException(); })).invoke(o -> checkPerm.accept(o.getClub())) .chain(o -> service.deleteReqAffiliation(id)); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index c05a165..c9acaf9 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -12,11 +12,10 @@ import fr.titionfire.ffsaf.rest.exception.DInternalError; import fr.titionfire.ffsaf.rest.from.FullClubForm; import fr.titionfire.ffsaf.rest.from.PartClubForm; import fr.titionfire.ffsaf.utils.Contact; -import fr.titionfire.ffsaf.utils.GroupeUtils; import fr.titionfire.ffsaf.utils.PageResult; +import fr.titionfire.ffsaf.utils.SecurityCtx; import fr.titionfire.ffsaf.utils.Utils; import io.quarkus.security.Authenticated; -import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.annotation.security.RolesAllowed; @@ -25,7 +24,6 @@ import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; @@ -45,21 +43,17 @@ public class ClubEndpoints { ClubService clubService; @Inject - JsonWebToken idToken; - - @Inject - SecurityIdentity securityIdentity; + SecurityCtx securityCtx; @ConfigProperty(name = "upload_dir") String media; Consumer checkPerm = Unchecked.consumer(clubModel -> { - if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(clubModel.getId(), - idToken)) + if (!securityCtx.roleHas("federation_admin") && !securityCtx.isInClubGroup(clubModel.getId())) throw new DForbiddenException(); }); Consumer checkPerm2 = Unchecked.consumer(id -> { - if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(id, idToken)) + if (!securityCtx.roleHas("federation_admin") && !securityCtx.isInClubGroup(id)) throw new DForbiddenException(); }); @@ -229,7 +223,7 @@ public class ClubEndpoints { @APIResponse(responseCode = "500", description = "Erreur interne du serveur") }) public Uni getOfUser() { - return clubService.getOfUser(idToken).map(SimpleClub::fromModel) + return clubService.getOfUser(securityCtx).map(SimpleClub::fromModel) .invoke(m -> m.setContactMap(Contact.toSite())); } @@ -247,7 +241,7 @@ public class ClubEndpoints { @APIResponse(responseCode = "500", description = "Erreur interne du serveur") }) public Uni setClubOfUser(PartClubForm form) { - return clubService.updateOfUser(idToken, form); + return clubService.updateOfUser(securityCtx, form); } @GET diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java index e2b282f..3cde90e 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompetitionEndpoints.java @@ -4,13 +4,12 @@ import fr.titionfire.ffsaf.domain.service.CompetitionService; import fr.titionfire.ffsaf.rest.data.CompetitionData; import fr.titionfire.ffsaf.rest.data.SimpleCompetData; import fr.titionfire.ffsaf.utils.CompetitionSystem; +import fr.titionfire.ffsaf.utils.SecurityCtx; import io.quarkus.security.Authenticated; -import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; -import org.eclipse.microprofile.jwt.JsonWebToken; import java.util.List; @@ -21,17 +20,14 @@ public class CompetitionEndpoints { CompetitionService service; @Inject - JsonWebToken idToken; - - @Inject - SecurityIdentity securityIdentity; + SecurityCtx securityCtx; @GET @Path("{id}") @Authenticated @Produces(MediaType.APPLICATION_JSON) public Uni getById(@PathParam("id") Long id) { - return service.getById(idToken, securityIdentity, id); + return service.getById(securityCtx, id); } @GET @@ -39,7 +35,7 @@ public class CompetitionEndpoints { @Authenticated @Produces(MediaType.APPLICATION_JSON) public Uni getSafcaData(@PathParam("id") Long id) { - return service.getSafcaData(idToken, securityIdentity, id); + return service.getSafcaData(securityCtx, id); } @@ -48,7 +44,7 @@ public class CompetitionEndpoints { @Authenticated @Produces(MediaType.APPLICATION_JSON) public Uni> getAll() { - return service.getAll(idToken, securityIdentity); + return service.getAll(securityCtx); } @GET @@ -56,14 +52,14 @@ public class CompetitionEndpoints { @Authenticated @Produces(MediaType.APPLICATION_JSON) public Uni> getAllSystem(@PathParam("system") CompetitionSystem system) { - return service.getAllSystem(idToken, securityIdentity, system); + return service.getAllSystem(securityCtx, system); } @POST @Authenticated @Produces(MediaType.APPLICATION_JSON) public Uni addOrUpdate(CompetitionData data) { - return service.addOrUpdate(idToken, securityIdentity, data); + return service.addOrUpdate(securityCtx, data); } @POST @@ -71,7 +67,7 @@ public class CompetitionEndpoints { @Authenticated @Produces(MediaType.APPLICATION_JSON) public Uni setSafcaData(SimpleCompetData data) { - return service.setSafcaData(idToken, securityIdentity, data); + return service.setSafcaData(securityCtx, data); } @DELETE @@ -79,6 +75,6 @@ public class CompetitionEndpoints { @Authenticated @Produces(MediaType.APPLICATION_JSON) public Uni delete(@PathParam("id") Long id) { - return service.delete(idToken, securityIdentity, id); + return service.delete(securityCtx, id); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java index f2bf094..4d08b15 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/CompteEndpoints.java @@ -3,9 +3,8 @@ package fr.titionfire.ffsaf.rest; import fr.titionfire.ffsaf.domain.service.KeycloakService; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.rest.from.MemberPermForm; -import fr.titionfire.ffsaf.utils.GroupeUtils; import fr.titionfire.ffsaf.utils.Pair; -import io.quarkus.security.identity.SecurityIdentity; +import fr.titionfire.ffsaf.utils.SecurityCtx; import io.smallrye.mutiny.Uni; import io.vertx.mutiny.core.Vertx; import jakarta.annotation.security.RolesAllowed; @@ -14,7 +13,6 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; -import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; @@ -32,10 +30,7 @@ public class CompteEndpoints { KeycloakService service; @Inject - JsonWebToken idToken; - - @Inject - SecurityIdentity securityIdentity; + SecurityCtx securityCtx; @Inject Vertx vertx; @@ -53,9 +48,9 @@ public class CompteEndpoints { }) public Uni getCompte(@PathParam("id") String id) { return service.fetchCompte(id).call(pair -> vertx.getOrCreateContext().executeBlocking(() -> { - if (!securityIdentity.getRoles().contains("federation_admin") && pair.getKey().groups().stream() + if (!securityCtx.roleHas("federation_admin") && pair.getKey().groups().stream() .map(GroupRepresentation::getPath) - .noneMatch(s -> s.startsWith("/club/") && GroupeUtils.contains(s, idToken))) + .noneMatch(s -> s.startsWith("/club/") && securityCtx.contains(s))) throw new DForbiddenException(); return pair; })).map(Pair::getValue); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java index 5eaabd9..f3ddb59 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java @@ -5,16 +5,13 @@ import fr.titionfire.ffsaf.domain.service.LicenceService; import fr.titionfire.ffsaf.rest.data.SimpleLicence; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.rest.from.LicenceForm; -import fr.titionfire.ffsaf.utils.GroupeUtils; -import io.quarkus.oidc.IdToken; -import io.quarkus.security.identity.SecurityIdentity; +import fr.titionfire.ffsaf.utils.SecurityCtx; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; -import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; @@ -29,13 +26,10 @@ public class LicenceEndpoints { LicenceService licenceService; @Inject - JsonWebToken idToken; - - @Inject - SecurityIdentity securityIdentity; + SecurityCtx securityCtx; Consumer checkPerm = Unchecked.consumer(membreModel -> { - if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(membreModel.getClub().getId(), idToken)) + if (!securityCtx.roleHas("federation_admin") && !securityCtx.isInClubGroup(membreModel.getClub().getId())) throw new DForbiddenException(); }); @@ -80,7 +74,7 @@ public class LicenceEndpoints { @APIResponse(responseCode = "500", description = "Erreur interne du serveur") }) public Uni> getCurrentSaisonLicenceClub() { - return licenceService.getCurrentSaisonLicence(idToken).map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList()); + return licenceService.getCurrentSaisonLicence(securityCtx).map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList()); } @POST diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java index d67fec7..370a624 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java @@ -5,16 +5,14 @@ import fr.titionfire.ffsaf.domain.service.MatchService; import fr.titionfire.ffsaf.rest.data.MatchData; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.utils.CompetitionSystem; -import fr.titionfire.ffsaf.utils.GroupeUtils; import fr.titionfire.ffsaf.utils.ScoreEmbeddable; +import fr.titionfire.ffsaf.utils.SecurityCtx; import io.quarkus.security.Authenticated; -import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; -import org.eclipse.microprofile.jwt.JsonWebToken; import java.util.List; import java.util.function.Consumer; @@ -30,14 +28,10 @@ public class MatchEndpoints { MatchService service; @Inject - JsonWebToken idToken; - - @Inject - SecurityIdentity securityIdentity; + SecurityCtx securityCtx; Consumer checkPerm = Unchecked.consumer(clubModel -> { - if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(clubModel.getId(), - idToken)) + if (!securityCtx.roleHas("federation_admin") && !securityCtx.isInClubGroup(clubModel.getId())) throw new DForbiddenException(); }); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java index b65df9b..82865fd 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreAdminEndpoints.java @@ -6,11 +6,9 @@ import fr.titionfire.ffsaf.rest.data.SimpleMembre; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.rest.exception.DInternalError; import fr.titionfire.ffsaf.rest.from.FullMemberForm; -import fr.titionfire.ffsaf.utils.GroupeUtils; import fr.titionfire.ffsaf.utils.PageResult; +import fr.titionfire.ffsaf.utils.SecurityCtx; import fr.titionfire.ffsaf.utils.Utils; -import io.quarkus.oidc.IdToken; -import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.annotation.security.RolesAllowed; @@ -18,7 +16,6 @@ import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; @@ -40,14 +37,10 @@ public class MembreAdminEndpoints { String media; @Inject - JsonWebToken idToken; - - @Inject - SecurityIdentity securityIdentity; + SecurityCtx securityCtx; Consumer checkPerm = Unchecked.consumer(membreModel -> { - if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup( - membreModel.getClub().getId(), idToken)) + if (!securityCtx.roleHas("federation_admin") && !securityCtx.isInClubGroup(membreModel.getClub().getId())) throw new DForbiddenException(); }); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java index 41e24c5..aa9b49e 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreClubEndpoints.java @@ -6,9 +6,8 @@ import fr.titionfire.ffsaf.rest.exception.DInternalError; import fr.titionfire.ffsaf.rest.from.ClubMemberForm; import fr.titionfire.ffsaf.rest.from.FullMemberForm; import fr.titionfire.ffsaf.utils.PageResult; +import fr.titionfire.ffsaf.utils.SecurityCtx; import fr.titionfire.ffsaf.utils.Utils; -import io.quarkus.oidc.IdToken; -import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.annotation.security.RolesAllowed; @@ -16,7 +15,6 @@ import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; @@ -35,10 +33,7 @@ public class MembreClubEndpoints { String media; @Inject - JsonWebToken idToken; - - @Inject - SecurityIdentity securityIdentity; + SecurityCtx securityCtx; @GET @Path("/find/club") @@ -57,7 +52,7 @@ public class MembreClubEndpoints { limit = 50; if (page == null || page < 1) page = 1; - return membreService.search(limit, page - 1, search, idToken.getSubject()); + return membreService.search(limit, page - 1, search, securityCtx.getSubject()); } @PUT @@ -74,7 +69,7 @@ public class MembreClubEndpoints { }) public Uni setMembre( @Parameter(description = "Identifiant de membre") @PathParam("id") long id, ClubMemberForm input) { - return membreService.update(id, input, idToken, securityIdentity) + return membreService.update(id, input, securityCtx) .invoke(Unchecked.consumer(out -> { if (!out.equals("OK")) throw new InternalError("Fail to update data: " + out); })).chain(() -> { @@ -101,7 +96,7 @@ public class MembreClubEndpoints { @APIResponse(responseCode = "500", description = "Erreur interne du serveur") }) public Uni addMembre(FullMemberForm input) { - return membreService.add(input, idToken.getSubject()) + return membreService.add(input, securityCtx.getSubject()) .invoke(Unchecked.consumer(id -> { if (id == null) throw new InternalError("Fail to creat member data"); })).call(id -> { @@ -126,6 +121,6 @@ public class MembreClubEndpoints { }) public Uni deleteMembre( @Parameter(description = "Identifiant de membre") @PathParam("id") long id) { - return membreService.delete(id, idToken); + return membreService.delete(id, securityCtx); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java index e06a2c9..c281ea1 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java @@ -5,11 +5,9 @@ import fr.titionfire.ffsaf.domain.service.MembreService; import fr.titionfire.ffsaf.rest.data.MeData; import fr.titionfire.ffsaf.rest.data.SimpleMembre; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; -import fr.titionfire.ffsaf.utils.GroupeUtils; +import fr.titionfire.ffsaf.utils.SecurityCtx; import fr.titionfire.ffsaf.utils.Utils; -import io.quarkus.oidc.IdToken; import io.quarkus.security.Authenticated; -import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.annotation.security.RolesAllowed; @@ -18,7 +16,6 @@ import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; @@ -40,14 +37,10 @@ public class MembreEndpoints { String media; @Inject - JsonWebToken idToken; - - @Inject - SecurityIdentity securityIdentity; + SecurityCtx securityCtx; Consumer checkPerm = Unchecked.consumer(membreModel -> { - if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup( - membreModel.getClub().getId(), idToken)) + if (!securityCtx.roleHas("federation_admin") && !securityCtx.isInClubGroup(membreModel.getClub().getId())) throw new DForbiddenException(); }); @@ -95,7 +88,7 @@ public class MembreEndpoints { @APIResponse(responseCode = "500", description = "Erreur interne du serveur") }) public Uni getMe() { - return membreService.getMembre(idToken.getSubject()); + return membreService.getMembre(securityCtx.getSubject()); } @GET diff --git a/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java index d0c299a..88f908e 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java @@ -6,14 +6,12 @@ import fr.titionfire.ffsaf.rest.data.PouleData; import fr.titionfire.ffsaf.rest.data.PouleFullData; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.utils.CompetitionSystem; -import fr.titionfire.ffsaf.utils.GroupeUtils; -import io.quarkus.security.identity.SecurityIdentity; +import fr.titionfire.ffsaf.utils.SecurityCtx; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; -import org.eclipse.microprofile.jwt.JsonWebToken; import java.util.List; import java.util.function.Consumer; @@ -29,14 +27,10 @@ public class PouleEndpoints { PouleService service; @Inject - JsonWebToken idToken; - - @Inject - SecurityIdentity securityIdentity; + SecurityCtx securityCtx; Consumer checkPerm = Unchecked.consumer(clubModel -> { - if (!securityIdentity.getRoles().contains("federation_admin") && !GroupeUtils.isInClubGroup(clubModel.getId(), - idToken)) + if (!securityCtx.roleHas("federation_admin") && !securityCtx.isInClubGroup(clubModel.getId())) throw new DForbiddenException(); }); @@ -50,7 +44,7 @@ public class PouleEndpoints { @GET @Produces(MediaType.APPLICATION_JSON) public Uni> getAll() { - return service.getAll(idToken, securityIdentity, system); + return service.getAll(securityCtx, system); } @POST diff --git a/src/main/java/fr/titionfire/ffsaf/utils/GroupeUtils.java b/src/main/java/fr/titionfire/ffsaf/utils/GroupeUtils.java deleted file mode 100644 index 3910ea9..0000000 --- a/src/main/java/fr/titionfire/ffsaf/utils/GroupeUtils.java +++ /dev/null @@ -1,25 +0,0 @@ -package fr.titionfire.ffsaf.utils; - -import org.eclipse.microprofile.jwt.JsonWebToken; - -public class GroupeUtils { - public static boolean isInClubGroup(long id, JsonWebToken accessToken) { - if (accessToken.getClaim("user_groups") instanceof Iterable) { - for (Object str : (Iterable) accessToken.getClaim("user_groups")) { - if (str.toString().substring(1, str.toString().length() - 1).startsWith("/club/" + id + "-")) - return true; - } - } - return false; - } - - public static boolean contains(String string, JsonWebToken accessToken) { - if (accessToken.getClaim("user_groups") instanceof Iterable) { - for (Object str : (Iterable) accessToken.getClaim("user_groups")) { - if (str.toString().substring(1, str.toString().length() - 1).contains(string)) - return true; - } - } - return false; - } -} diff --git a/src/main/java/fr/titionfire/ffsaf/utils/SecurityCtx.java b/src/main/java/fr/titionfire/ffsaf/utils/SecurityCtx.java new file mode 100644 index 0000000..348e1b3 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/utils/SecurityCtx.java @@ -0,0 +1,56 @@ +package fr.titionfire.ffsaf.utils; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.Set; + +@RequestScoped +public class SecurityCtx { + @Inject + JsonWebToken idToken; + + @Inject + SecurityIdentity securityIdentity; + + public Set getRoles() { + return securityIdentity.getRoles(); + } + + public String getSubject() { + if (idToken == null) + return null; + return idToken.getSubject(); + } + + public boolean roleHas(String role) { + if (role == null) + return false; + return securityIdentity.getRoles().contains(role); + } + + public boolean isInClubGroup(long id) { + if (idToken == null || idToken.getClaim("user_groups") == null) + return false; + + if (idToken.getClaim("user_groups") instanceof Iterable) { + for (Object str : (Iterable) idToken.getClaim("user_groups")) { + if (str.toString().substring(1, str.toString().length() - 1).startsWith("/club/" + id + "-")) + return true; + } + } + return false; + } + + public boolean contains(String string) { + if (idToken.getClaim("user_groups") instanceof Iterable) { + for (Object str : (Iterable) idToken.getClaim("user_groups")) { + if (str.toString().substring(1, str.toString().length() - 1).contains(string)) + return true; + } + } + return false; + } +} From 6b38405e94a588428e5631c5751dc434418ca1f9 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Thu, 15 Aug 2024 13:56:54 +0200 Subject: [PATCH 35/37] feat: secure match ans poule endpoints --- .../ffsaf/data/model/PouleModel.java | 2 +- .../domain/service/CompetPermService.java | 24 ++++++++-- .../ffsaf/domain/service/MatchService.java | 32 ++++++------- .../ffsaf/domain/service/PouleService.java | 45 +++++++++++++------ .../titionfire/ffsaf/rest/ClubEndpoints.java | 3 +- .../titionfire/ffsaf/rest/MatchEndpoints.java | 18 +++----- .../titionfire/ffsaf/rest/PouleEndpoints.java | 19 +++----- 7 files changed, 82 insertions(+), 61 deletions(-) diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/PouleModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/PouleModel.java index e18590b..f48bafb 100644 --- a/src/main/java/fr/titionfire/ffsaf/data/model/PouleModel.java +++ b/src/main/java/fr/titionfire/ffsaf/data/model/PouleModel.java @@ -29,7 +29,7 @@ public class PouleModel { String name = ""; - @ManyToOne + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "id_compet", referencedColumnName = "id") CompetitionModel compet; diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/CompetPermService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetPermService.java index 254a335..3064021 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/CompetPermService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/CompetPermService.java @@ -65,13 +65,21 @@ public class CompetPermService { }); } + public Uni hasViewPerm(SecurityCtx securityCtx, CompetitionModel competitionModel) { + return hasViewPerm(securityCtx, Uni.createFrom().item(competitionModel)); + } + public Uni hasViewPerm(SecurityCtx securityCtx, long id) { - return competitionRepository.findById(id).call(o -> ( + return hasViewPerm(securityCtx, competitionRepository.findById(id)); + } + + private Uni hasViewPerm(SecurityCtx securityCtx, Uni in) { + return in.call(o -> ( securityCtx.getSubject().equals(o.getOwner()) || securityCtx.roleHas("federation_admin")) ? Uni.createFrom().nullItem() : o.getSystem() == CompetitionSystem.SAFCA ? - hasSafcaViewPerm(securityCtx, id) + hasSafcaViewPerm(securityCtx, o.getId()) : Uni.createFrom().nullItem().invoke(Unchecked.consumer(__ -> { if (!securityCtx.isInClubGroup(o.getClub().getId())) throw new DForbiddenException(); @@ -79,13 +87,21 @@ public class CompetPermService { )); } + public Uni hasEditPerm(SecurityCtx securityCtx, CompetitionModel competitionModel) { + return hasEditPerm(securityCtx, Uni.createFrom().item(competitionModel)); + } + public Uni hasEditPerm(SecurityCtx securityCtx, long id) { - return competitionRepository.findById(id).call(o -> ( + return hasEditPerm(securityCtx, competitionRepository.findById(id)); + } + + public Uni hasEditPerm(SecurityCtx securityCtx, Uni in) { + return in.call(o -> ( securityCtx.getSubject().equals(o.getOwner()) || securityCtx.roleHas("federation_admin")) ? Uni.createFrom().nullItem() : o.getSystem() == CompetitionSystem.SAFCA ? - hasSafcaEditPerm(securityCtx, id) + hasSafcaEditPerm(securityCtx, o.getId()) : Uni.createFrom().nullItem().invoke(Unchecked.consumer(__ -> { if (!securityCtx.isInClubGroup(o.getClub().getId())) throw new DForbiddenException(); diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MatchService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MatchService.java index fb7b97f..71c6896 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MatchService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MatchService.java @@ -1,6 +1,5 @@ package fr.titionfire.ffsaf.domain.service; -import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.data.model.MatchModel; import fr.titionfire.ffsaf.data.repository.CombRepository; import fr.titionfire.ffsaf.data.repository.MatchRepository; @@ -8,6 +7,7 @@ import fr.titionfire.ffsaf.data.repository.PouleRepository; import fr.titionfire.ffsaf.rest.data.MatchData; import fr.titionfire.ffsaf.utils.CompetitionSystem; import fr.titionfire.ffsaf.utils.ScoreEmbeddable; +import fr.titionfire.ffsaf.utils.SecurityCtx; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.hibernate.reactive.panache.common.WithSession; import io.smallrye.mutiny.Uni; @@ -15,7 +15,6 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import java.util.List; -import java.util.function.Consumer; @WithSession @ApplicationScoped @@ -30,30 +29,32 @@ public class MatchService { @Inject CombRepository combRepository; - public Uni getById(Consumer checkPerm, CompetitionSystem system, Long id) { + @Inject + CompetPermService permService; + + public Uni getById(SecurityCtx securityCtx, CompetitionSystem system, Long id) { return repository.find("systemId = ?1 AND system = ?2", id, system).firstResult() .onItem().ifNull().failWith(() -> new RuntimeException("Match not found")) - .call(data -> pouleRepository.findById(data.getPoule().getId()) - .invoke(data2 -> checkPerm.accept(data2.getCompet().getClub()))) + .call(data -> permService.hasViewPerm(securityCtx, data.getPoule().getCompet())) .map(MatchData::fromModel); } - public Uni> getAllByPoule(Consumer checkPerm, CompetitionSystem system, Long id) { + public Uni> getAllByPoule(SecurityCtx securityCtx, CompetitionSystem system, Long id) { return pouleRepository.find("systemId = ?1 AND system = ?2", id, system).firstResult() .onItem().ifNull().failWith(() -> new RuntimeException("Poule not found")) - .invoke(data -> checkPerm.accept(data.getCompet().getClub())) + .call(data -> permService.hasViewPerm(securityCtx, data.getCompet())) .chain(data -> repository.list("poule = ?1", data.getId()) .map(o -> o.stream().map(MatchData::fromModel).toList())); } - public Uni addOrUpdate(Consumer checkPerm, CompetitionSystem system, MatchData data) { + public Uni addOrUpdate(SecurityCtx securityCtx, CompetitionSystem system, MatchData data) { return repository.find("systemId = ?1 AND system = ?2", data.getId(), system).firstResult() .chain(o -> { if (o == null) { return pouleRepository.find("systemId = ?1 AND system = ?2", data.getPoule(), system) .firstResult() .onItem().ifNull().failWith(() -> new RuntimeException("Poule not found")) - .invoke(data2 -> checkPerm.accept(data2.getCompet().getClub())) + .call(o2 -> permService.hasEditPerm(securityCtx, o2.getCompet())) .map(pouleModel -> { MatchModel model = new MatchModel(); @@ -67,7 +68,7 @@ public class MatchService { return pouleRepository.find("systemId = ?1 AND system = ?2", data.getPoule(), system) .firstResult() .onItem().ifNull().failWith(() -> new RuntimeException("Poule not found")) - .invoke(data2 -> checkPerm.accept(data2.getCompet().getClub())) + .call(o2 -> permService.hasEditPerm(securityCtx, o2.getCompet())) .map(__ -> o); } } @@ -91,9 +92,11 @@ public class MatchService { .map(MatchData::fromModel); } - public Uni updateScore(CompetitionSystem system, Long id, List scores) { + public Uni updateScore(SecurityCtx securityCtx, CompetitionSystem system, Long id, + List scores) { return repository.find("systemId = ?1 AND system = ?2", id, system).firstResult() .onItem().ifNull().failWith(() -> new RuntimeException("Match not found")) + .call(o2 -> permService.hasEditPerm(securityCtx, o2.getPoule().getCompet())) .invoke(data -> { data.getScores().clear(); data.getScores().addAll(scores); @@ -102,11 +105,10 @@ public class MatchService { .map(o -> "OK"); } - public Uni delete(Consumer checkPerm, CompetitionSystem system, Long id) { + public Uni delete(SecurityCtx securityCtx, CompetitionSystem system, Long id) { return repository.find("systemId = ?1 AND system = ?2", id, system).firstResult() .onItem().ifNull().failWith(() -> new RuntimeException("Match not found")) - .chain(data -> pouleRepository.findById(data.getPoule().getId()) - .invoke(data2 -> checkPerm.accept(data2.getCompet().getClub())) - .chain(data2 -> Panache.withTransaction(() -> repository.delete(data)))); + .call(o2 -> permService.hasEditPerm(securityCtx, o2.getPoule().getCompet())) + .chain(data -> Panache.withTransaction(() -> repository.delete(data))); } } diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java index 90585ae..82a72d4 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/PouleService.java @@ -1,6 +1,9 @@ package fr.titionfire.ffsaf.domain.service; -import fr.titionfire.ffsaf.data.model.*; +import fr.titionfire.ffsaf.data.model.MatchModel; +import fr.titionfire.ffsaf.data.model.MembreModel; +import fr.titionfire.ffsaf.data.model.PouleModel; +import fr.titionfire.ffsaf.data.model.TreeModel; import fr.titionfire.ffsaf.data.repository.*; import fr.titionfire.ffsaf.rest.data.PouleData; import fr.titionfire.ffsaf.rest.data.PouleFullData; @@ -18,7 +21,6 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Objects; -import java.util.function.Consumer; import java.util.stream.Stream; @WithSession @@ -40,29 +42,44 @@ public class PouleService { @Inject CombRepository combRepository; - public Uni getById(Consumer checkPerm, CompetitionSystem system, Long id) { + @Inject + CompetPermService permService; + + public Uni getById(SecurityCtx securityCtx, CompetitionSystem system, Long id) { return repository.find("systemId = ?1 AND system = ?2", id, system) .firstResult() .onItem().ifNull().failWith(() -> new RuntimeException("Poule not found")) - .invoke(data -> checkPerm.accept(data.getCompet().getClub())) + .call(data -> permService.hasViewPerm(securityCtx, data.getCompet())) .map(PouleData::fromModel); } public Uni> getAll(SecurityCtx securityCtx, CompetitionSystem system) { return repository.list("system = ?1", system) - .map(data -> data.stream() - .filter(p -> securityCtx.roleHas("federation_admin") || - securityCtx.isInClubGroup(p.getCompet().getClub().getId())) - .map(PouleData::fromModel).toList()); + .chain(o -> + permService.getAllHaveAccess(securityCtx.getSubject()) + .chain(map -> Uni.createFrom().item(o.stream() + .filter(p -> { + if (securityCtx.getSubject().equals(p.getCompet().getOwner())) + return true; + if (p.getSystem() == CompetitionSystem.SAFCA) { + if (map.containsKey(p.getCompet().getId())) + return map.get(p.getId()).equals("admin"); + return securityCtx.roleHas("federation_admin") + || securityCtx.roleHas("safca_super_admin"); + } + return securityCtx.roleHas("federation_admin"); + }) + .map(PouleData::fromModel).toList()) + )); } - public Uni addOrUpdate(Consumer checkPerm, CompetitionSystem system, PouleData data) { + public Uni addOrUpdate(SecurityCtx securityCtx, CompetitionSystem system, PouleData data) { return repository.find("systemId = ?1 AND system = ?2", data.getId(), system).firstResult() .chain(o -> { if (o == null) { return competRepository.findById(data.getCompet()) .onItem().ifNull().failWith(() -> new RuntimeException("Competition not found")) - .invoke(o2 -> checkPerm.accept(o2.getClub())) + .call(o2 -> permService.hasEditPerm(securityCtx, o2)) .chain(competitionModel -> { PouleModel model = new PouleModel(); @@ -130,13 +147,14 @@ public class PouleService { .chain(o -> Panache.withTransaction(() -> treeRepository.persist(o))); } - public Uni syncPoule(CompetitionSystem system, PouleFullData data) { - System.out.println(data); + public Uni syncPoule(SecurityCtx securityCtx, CompetitionSystem system, PouleFullData data) { return repository.find("systemId = ?1 AND system = ?2", data.getId(), system) .firstResult() + .onItem().ifNotNull().call(o2 -> permService.hasEditPerm(securityCtx, o2.getCompet())) .onItem().ifNull().switchTo( () -> competRepository.findById(data.getCompet()) .onItem().ifNull().failWith(() -> new RuntimeException("Compet not found")) + .call(o -> permService.hasEditPerm(securityCtx, o)) .map(o -> { PouleModel model = new PouleModel(); model.setId(null); @@ -240,9 +258,10 @@ public class PouleService { List toRmNode; } - public Uni delete(Consumer checkPerm, CompetitionSystem system, Long id) { + public Uni delete(SecurityCtx securityCtx, CompetitionSystem system, Long id) { return repository.find("systemId = ?1 AND system = ?2", id, system).firstResult() .onItem().ifNull().failWith(() -> new RuntimeException("Poule not found")) + .call(o -> permService.hasEditPerm(securityCtx, o.getCompet())) .call(o -> Mutiny.fetch(o.getMatchs())) .call(o -> Mutiny.fetch(o.getTree()) .call(o2 -> o2.isEmpty() ? Uni.createFrom().nullItem() : diff --git a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java index c9acaf9..e3e473c 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/ClubEndpoints.java @@ -274,7 +274,6 @@ public class ClubEndpoints { @GET @Path("{clubId}/logo") - @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) @Operation(summary = "Renvoie le logo du club", description = "Renvoie le logo du club spécifié") @APIResponses(value = { @APIResponse(responseCode = "200", description = "Le logo du club"), @@ -284,7 +283,7 @@ public class ClubEndpoints { }) public Uni getLogo( @Parameter(description = "Identifiant long (clubId) de club") @PathParam("clubId") String clubId) { - return clubService.getByClubId(clubId).onItem().invoke(checkPerm).chain(Unchecked.function(clubModel -> { + return clubService.getByClubId(clubId).chain(Unchecked.function(clubModel -> { try { return Utils.getMediaFile((clubModel != null) ? clubModel.getId() : -1, media, "ppClub", Uni.createFrom().nullItem()); diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java index 370a624..097dac9 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MatchEndpoints.java @@ -1,21 +1,17 @@ package fr.titionfire.ffsaf.rest; -import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.domain.service.MatchService; import fr.titionfire.ffsaf.rest.data.MatchData; -import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.utils.CompetitionSystem; import fr.titionfire.ffsaf.utils.ScoreEmbeddable; import fr.titionfire.ffsaf.utils.SecurityCtx; import io.quarkus.security.Authenticated; import io.smallrye.mutiny.Uni; -import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import java.util.List; -import java.util.function.Consumer; @Authenticated @Path("api/match/{system}/") @@ -30,42 +26,38 @@ public class MatchEndpoints { @Inject SecurityCtx securityCtx; - Consumer checkPerm = Unchecked.consumer(clubModel -> { - if (!securityCtx.roleHas("federation_admin") && !securityCtx.isInClubGroup(clubModel.getId())) - throw new DForbiddenException(); - }); @GET @Path("{id}") @Produces(MediaType.APPLICATION_JSON) public Uni getById(@PathParam("id") Long id) { - return service.getById(checkPerm, system, id); + return service.getById(securityCtx, system, id); } @GET @Path("getAllByPoule/{id}") @Produces(MediaType.APPLICATION_JSON) public Uni> getAllByPoule(@PathParam("id") Long id) { - return service.getAllByPoule(checkPerm, system, id); + return service.getAllByPoule(securityCtx, system, id); } @POST @Produces(MediaType.APPLICATION_JSON) public Uni addOrUpdate(MatchData data) { - return service.addOrUpdate(checkPerm, system, data); + return service.addOrUpdate(securityCtx, system, data); } @POST @Path("score/{id}") @Produces(MediaType.APPLICATION_JSON) public Uni updateScore(@PathParam("id") Long id, List scores) { - return service.updateScore(system, id, scores); + return service.updateScore(securityCtx, system, id, scores); } @DELETE @Path("{id}") @Produces(MediaType.APPLICATION_JSON) public Uni delete(@PathParam("id") Long id) { - return service.delete(checkPerm, system, id); + return service.delete(securityCtx, system, id); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java index 88f908e..8bc9dc1 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/PouleEndpoints.java @@ -1,22 +1,19 @@ package fr.titionfire.ffsaf.rest; -import fr.titionfire.ffsaf.data.model.ClubModel; import fr.titionfire.ffsaf.domain.service.PouleService; import fr.titionfire.ffsaf.rest.data.PouleData; import fr.titionfire.ffsaf.rest.data.PouleFullData; -import fr.titionfire.ffsaf.rest.exception.DForbiddenException; import fr.titionfire.ffsaf.utils.CompetitionSystem; import fr.titionfire.ffsaf.utils.SecurityCtx; +import io.quarkus.security.Authenticated; import io.smallrye.mutiny.Uni; -import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import java.util.List; -import java.util.function.Consumer; -// @Authenticated +@Authenticated @Path("api/poule/{system}/") public class PouleEndpoints { @@ -29,16 +26,12 @@ public class PouleEndpoints { @Inject SecurityCtx securityCtx; - Consumer checkPerm = Unchecked.consumer(clubModel -> { - if (!securityCtx.roleHas("federation_admin") && !securityCtx.isInClubGroup(clubModel.getId())) - throw new DForbiddenException(); - }); @GET @Path("{id}") @Produces(MediaType.APPLICATION_JSON) public Uni getById(@PathParam("id") Long id) { - return service.getById(checkPerm, system, id); + return service.getById(securityCtx, system, id); } @GET @@ -51,20 +44,20 @@ public class PouleEndpoints { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Uni addOrUpdate(PouleData data) { - return service.addOrUpdate(checkPerm, system, data); + return service.addOrUpdate(securityCtx, system, data); } @POST @Path("sync") @Consumes(MediaType.APPLICATION_JSON) public Uni syncPoule(PouleFullData data) { - return service.syncPoule(system, data); + return service.syncPoule(securityCtx, system, data); } @DELETE @Path("{id}") @Produces(MediaType.APPLICATION_JSON) public Uni delete(@PathParam("id") Long id) { - return service.delete(checkPerm, system, id); + return service.delete(securityCtx, system, id); } } From aac126cb877a9b9aee3542ae56145923fb346722 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Sun, 29 Dec 2024 11:34:39 +0100 Subject: [PATCH 36/37] feat: pdf generation --- pom.xml | 6 + .../ffsaf/domain/service/MembreService.java | 219 ++++++++++++++++++ .../titionfire/ffsaf/net2/packet/RFile.java | 42 ---- .../ffsaf/net2/packet/RegisterAction.java | 1 - .../ffsaf/rest/MembreEndpoints.java | 31 +++ .../java/fr/titionfire/ffsaf/utils/Utils.java | 50 ++-- .../fr/titionfire/ffsaf/ws/FileSocket.java | 165 ------------- src/main/resources/asset/DMSans-Regular.ttf | Bin 0 -> 56348 bytes .../FFSSAF-bord-blanc-fond-transparent.png | Bin 0 -> 64129 bytes .../resources/asset/blank-profile-picture.png | Bin 0 -> 37098 bytes .../webapp/public/blank-profile-picture.png | Bin 0 -> 37098 bytes src/main/webapp/src/pages/MePage.jsx | 9 +- .../src/pages/admin/member/MemberPage.jsx | 8 + .../src/pages/club/member/MemberPage.jsx | 8 + 14 files changed, 316 insertions(+), 223 deletions(-) delete mode 100644 src/main/java/fr/titionfire/ffsaf/net2/packet/RFile.java delete mode 100644 src/main/java/fr/titionfire/ffsaf/ws/FileSocket.java create mode 100644 src/main/resources/asset/DMSans-Regular.ttf create mode 100644 src/main/resources/asset/FFSSAF-bord-blanc-fond-transparent.png create mode 100644 src/main/resources/asset/blank-profile-picture.png create mode 100644 src/main/webapp/public/blank-profile-picture.png diff --git a/pom.xml b/pom.xml index 413b042..609c526 100644 --- a/pom.xml +++ b/pom.xml @@ -129,6 +129,12 @@ io.quarkus quarkus-cache + + + com.github.librepdf + openpdf + 2.0.3 + diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java index 6b6b4c4..672f059 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/MembreService.java @@ -1,6 +1,12 @@ package fr.titionfire.ffsaf.domain.service; +import com.lowagie.text.*; +import com.lowagie.text.pdf.BaseFont; +import com.lowagie.text.pdf.PdfPCell; +import com.lowagie.text.pdf.PdfPTable; +import com.lowagie.text.pdf.PdfWriter; import fr.titionfire.ffsaf.data.model.ClubModel; +import fr.titionfire.ffsaf.data.model.LicenceModel; import fr.titionfire.ffsaf.data.model.MembreModel; import fr.titionfire.ffsaf.data.repository.ClubRepository; import fr.titionfire.ffsaf.data.repository.CombRepository; @@ -13,6 +19,7 @@ import fr.titionfire.ffsaf.rest.data.SimpleLicence; import fr.titionfire.ffsaf.rest.data.SimpleMembre; import fr.titionfire.ffsaf.rest.exception.DBadRequestException; import fr.titionfire.ffsaf.rest.exception.DForbiddenException; +import fr.titionfire.ffsaf.rest.exception.DNotFoundException; import fr.titionfire.ffsaf.rest.from.ClubMemberForm; import fr.titionfire.ffsaf.rest.from.FullMemberForm; import fr.titionfire.ffsaf.utils.*; @@ -26,10 +33,17 @@ import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.unchecked.Unchecked; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.hibernate.reactive.mutiny.Mutiny; +import java.io.*; +import java.nio.file.Files; +import java.text.SimpleDateFormat; import java.util.List; +import java.util.Objects; @WithSession @@ -111,6 +125,11 @@ public class MembreService { return repository.findById(id); } + public Uni getByIdWithLicence(long id) { + return repository.findById(id) + .call(m -> Mutiny.fetch(m.getLicences())); + } + public Uni getByLicence(long licence) { return repository.find("licence = ?1", licence).firstResult(); } @@ -276,4 +295,204 @@ public class MembreService { .invoke(meData::setLicences) .map(__ -> meData); } + + public Uni getLicencePdf(String subject) { + return getLicencePdf(repository.find("userId = ?1", subject).firstResult() + .call(m -> Mutiny.fetch(m.getLicences()))); + } + + public Uni getLicencePdf(Uni uniBase) { + return uniBase + .map(Unchecked.function(m -> { + LicenceModel licence = m.getLicences().stream() + .filter(licenceModel -> licenceModel.getSaison() == Utils.getSaison() && licenceModel.isValidate()) + .findFirst() + .orElseThrow(() -> new DNotFoundException("Pas de licence pour la saison en cours")); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + make_pdf(m, out, licence); + + byte[] buff = out.toByteArray(); + String mimeType = "application/pdf"; + + Response.ResponseBuilder resp = Response.ok(buff); + resp.type(MediaType.APPLICATION_OCTET_STREAM); + resp.header(HttpHeaders.CONTENT_LENGTH, buff.length); + resp.header(HttpHeaders.CONTENT_TYPE, mimeType); + resp.header(HttpHeaders.CONTENT_DISPOSITION, + "inline; " + "filename=\"Attestation d'adhésion " + Utils.getSaison() + "-" + + (Utils.getSaison() + 1) + " de " + m.getLname() + " " + m.getFname() + ".pdf\""); + return resp.build(); + } catch (Exception e) { + throw new IOException(e); + } + })); + } + + private void make_pdf(MembreModel m, ByteArrayOutputStream out, LicenceModel licence) throws IOException { + SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy"); + Document document = new Document(); + PdfWriter.getInstance(document, out); + document.open(); + + document.addCreator("FFSAF"); + document.addTitle( + "Attestation d'adhésion " + Utils.getSaison() + "-" + (Utils.getSaison() + 1) + " de " + m.getLname() + " " + m.getFname()); + document.addCreationDate(); + document.addProducer("https://www.ffsaf.fr"); + + InputStream fontStream = MembreService.class.getClassLoader().getResourceAsStream("asset/DMSans-Regular.ttf"); + if (fontStream == null) { + throw new IOException("Font file not found"); + } + BaseFont customFont = BaseFont.createFont("asset/DMSans-Regular.ttf", BaseFont.WINANSI, BaseFont.EMBEDDED, true, + null, fontStream.readAllBytes()); + + // Adding font + Font headerFont = new Font(customFont, 26, Font.BOLD); + Font subHeaderFont = new Font(customFont, 16, Font.BOLD); + Font bigFont = new Font(customFont, 18, Font.BOLD); + Font bodyFont = new Font(customFont, 15, Font.BOLD); + Font smallFont = new Font(customFont, 10, Font.NORMAL); + + // Creating the main table + PdfPTable mainTable = new PdfPTable(2); + mainTable.setWidthPercentage(100); + mainTable.setSpacingBefore(20f); + mainTable.setSpacingAfter(0f); + mainTable.setWidths(new float[]{120, 300}); + mainTable.getDefaultCell().setBorder(PdfPCell.NO_BORDER); + + // Adding logo + Image logo = Image.getInstance( + Objects.requireNonNull( + getClass().getClassLoader().getResource("asset/FFSSAF-bord-blanc-fond-transparent.png"))); + logo.scaleToFit(120, 120); + PdfPCell logoCell = new PdfPCell(logo); + logoCell.setHorizontalAlignment(Element.ALIGN_CENTER); + logoCell.setVerticalAlignment(Element.ALIGN_MIDDLE); + logoCell.setPadding(0); + logoCell.setRowspan(1); + logoCell.setBorder(PdfPCell.NO_BORDER); + mainTable.addCell(logoCell); + + // Adding header + PdfPCell headerCell = new PdfPCell(new Phrase("FEDERATION FRANCE\nSOFT ARMORED FIGHTING", headerFont)); + headerCell.setHorizontalAlignment(Element.ALIGN_CENTER); + headerCell.setVerticalAlignment(Element.ALIGN_MIDDLE); + headerCell.setBorder(PdfPCell.NO_BORDER); + mainTable.addCell(headerCell); + + document.add(mainTable); + + Paragraph addr = new Paragraph("5 place de la Barreyre\n63320 Champeix", subHeaderFont); + addr.setAlignment(Element.ALIGN_CENTER); + addr.setSpacingAfter(2f); + document.add(addr); + + Paragraph association = new Paragraph("Association loi 1901 W633001595\nSIRET 829 458 355 00015", smallFont); + association.setAlignment(Element.ALIGN_CENTER); + document.add(association); + + // Adding spacing + document.add(new Paragraph("\n\n")); + + // Adding attestation + PdfPTable attestationTable = new PdfPTable(1); + attestationTable.setWidthPercentage(60); + PdfPCell attestationCell = new PdfPCell( + new Phrase("ATTESTATION D'ADHESION\nSaison " + Utils.getSaison() + "-" + (Utils.getSaison() + 1), + bigFont)); + attestationCell.setHorizontalAlignment(Element.ALIGN_CENTER); + attestationCell.setVerticalAlignment(Element.ALIGN_MIDDLE); + attestationCell.setPadding(20f); + attestationTable.addCell(attestationCell); + document.add(attestationTable); + + // Adding spacing + document.add(new Paragraph("\n\n")); + + // Adding member details table + PdfPTable memberTable = new PdfPTable(2); + memberTable.setWidthPercentage(100); + memberTable.setWidths(new float[]{130, 300}); + memberTable.getDefaultCell().setBorder(PdfPCell.NO_BORDER); + + // Adding member photo + Image memberPhoto; + FilenameFilter filter = (directory, filename) -> filename.startsWith(m.getId() + "."); + File[] files = new File(media, "ppMembre").listFiles(filter); + if (files != null && files.length > 0) { + File file = files[0]; + memberPhoto = Image.getInstance(Files.readAllBytes(file.toPath())); + } else { + memberPhoto = Image.getInstance( + Objects.requireNonNull(getClass().getClassLoader().getResource("asset/blank-profile-picture.png"))); + } + memberPhoto.scaleToFit(120, 150); + PdfPCell photoCell = new PdfPCell(memberPhoto); + photoCell.setHorizontalAlignment(Element.ALIGN_CENTER); + photoCell.setVerticalAlignment(Element.ALIGN_MIDDLE); + photoCell.setRowspan(5); + photoCell.setBorder(PdfPCell.NO_BORDER); + memberTable.addCell(photoCell); + + // Adding member details + memberTable.addCell(new Phrase("NOM : " + m.getLname().toUpperCase(), bodyFont)); + memberTable.addCell(new Phrase("Prénom : " + m.getFname(), bodyFont)); + memberTable.addCell(new Phrase("Licence n° : " + m.getLicence(), bodyFont)); + memberTable.addCell(new Phrase("Certificat médical par Dr " + licence.getCertificate(), bodyFont)); + memberTable.addCell(new Phrase("")); // Empty cell for spacing + + document.add(memberTable); + + // Adding spacing + document.add(new Paragraph("\n")); + + Paragraph memberClub = new Paragraph("CLUB : " + m.getClub().getName().toUpperCase(), bodyFont); + document.add(memberClub); + + Paragraph memberClubNumber = new Paragraph("N° club : " + m.getClub().getNo_affiliation(), bodyFont); + document.add(memberClubNumber); + + // Adding spacing + document.add(new Paragraph("\n")); + + Paragraph memberBirthdate = new Paragraph( + "Date de naissance : " + ((m.getBirth_date() == null) ? "--" : sdf.format(m.getBirth_date())), + bodyFont); + document.add(memberBirthdate); + + Paragraph memberGender = new Paragraph("Sexe : " + m.getGenre().str, bodyFont); + document.add(memberGender); + + Paragraph memberAgeCategory = new Paragraph("Catégorie d'âge : " + m.getCategorie().getName(), bodyFont); + document.add(memberAgeCategory); + + // Adding spacing + document.add(new Paragraph("\n\n")); + + // Adding attestation text + PdfPTable textTable = new PdfPTable(1); + textTable.setWidthPercentage(100); + PdfPCell textCell = new PdfPCell(new Phrase( + """ + Ce document atteste que l’adhérent + - est valablement enregistré auprès de la FFSAF, + - est assuré dans sa pratique du Béhourd Léger et du Battle Arc en entraînement et en compétition. + + Il peut donc s’inscrire à tout tournoi organisé sous l’égide de la FFSAF s’il remplit les éventuelles + conditions de qualification. + Il peut participer à tout entraînement dans un club affilié si celui ci autorise les visiteurs.""", + smallFont)); + textCell.setHorizontalAlignment(Element.ALIGN_LEFT); + textCell.setVerticalAlignment(Element.ALIGN_MIDDLE); + textCell.setBorder(PdfPCell.NO_BORDER); + textTable.addCell(textCell); + document.add(textTable); + + // Close the document + document.close(); + } } diff --git a/src/main/java/fr/titionfire/ffsaf/net2/packet/RFile.java b/src/main/java/fr/titionfire/ffsaf/net2/packet/RFile.java deleted file mode 100644 index 2820eb1..0000000 --- a/src/main/java/fr/titionfire/ffsaf/net2/packet/RFile.java +++ /dev/null @@ -1,42 +0,0 @@ -package fr.titionfire.ffsaf.net2.packet; - -import fr.titionfire.ffsaf.ws.FileSocket; -import jakarta.enterprise.context.ApplicationScoped; -import org.jboss.logging.Logger; - -import java.util.HashMap; -import java.util.UUID; - -@ApplicationScoped -public class RFile { - private static final Logger LOGGER = Logger.getLogger(RFile.class); - - final IAction requestSend = (client_Thread, message) -> { - try { - switch (message.data().get("type").asText()) { - case "match": - String code = UUID.randomUUID() + "-" + UUID.randomUUID(); - - FileSocket.FileRecv fileRecv = new FileSocket.FileRecv(null, message.data().get("name").asText(), null, null, - System.currentTimeMillis()); - FileSocket.sessions.put(code, fileRecv); - - client_Thread.sendRepTo(code, message); - break; - default: - client_Thread.sendErrTo("", message); - break; - - } - } catch (Throwable e) { - LOGGER.error(e.getMessage(), e); - client_Thread.sendErrTo(e.getMessage(), message); - } - }; - - public static void register(HashMap iMap) { - RFile rFile = new RFile(); - - iMap.put("requestSend", rFile.requestSend); - } -} diff --git a/src/main/java/fr/titionfire/ffsaf/net2/packet/RegisterAction.java b/src/main/java/fr/titionfire/ffsaf/net2/packet/RegisterAction.java index 3078ab9..bc7fe66 100644 --- a/src/main/java/fr/titionfire/ffsaf/net2/packet/RegisterAction.java +++ b/src/main/java/fr/titionfire/ffsaf/net2/packet/RegisterAction.java @@ -9,6 +9,5 @@ public class RegisterAction { RComb.register(iMap); RClub.register(iMap); - RFile.register(iMap); } } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java index c281ea1..46ef260 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/MembreEndpoints.java @@ -85,12 +85,29 @@ public class MembreEndpoints { "membre connecté, y compris le club et les licences") @APIResponses(value = { @APIResponse(responseCode = "200", description = "Les informations du membre connecté"), + @APIResponse(responseCode = "403", description = "Accès refusé"), @APIResponse(responseCode = "500", description = "Erreur interne du serveur") }) public Uni getMe() { return membreService.getMembre(securityCtx.getSubject()); } + @GET + @Path("me/licence") + @Authenticated + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Renvoie l'attestation d'adhesion du membre connecté", description = "Renvoie l'attestation d'adhesion du " + + "membre connecté, y compris le club et les licences") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "L'attestation d'adhesion"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le membre n'a pas de licence active"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni getMeLicence() { + return membreService.getLicencePdf(securityCtx.getSubject()); + } + @GET @Path("{id}/photo") @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) @@ -104,4 +121,18 @@ public class MembreEndpoints { public Uni getPhoto(@PathParam("id") long id) throws URISyntaxException { return Utils.getMediaFile(id, media, "ppMembre", membreService.getById(id).onItem().invoke(checkPerm)); } + + @GET + @Path("{id}/licence") + @RolesAllowed({"federation_admin", "club_president", "club_secretaire", "club_respo_intra"}) + @Operation(summary = "Renvoie le pdf de la licence d'un membre", description = "Renvoie le pdf de la licence d'un membre en fonction de son identifiant") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Le pdf de la licence"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "404", description = "Le membre n'existe pas ou n'a pas de licence active"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni getLicencePDF(@PathParam("id") long id) { + return membreService.getLicencePdf(membreService.getByIdWithLicence(id).onItem().invoke(checkPerm)); + } } diff --git a/src/main/java/fr/titionfire/ffsaf/utils/Utils.java b/src/main/java/fr/titionfire/ffsaf/utils/Utils.java index 360e492..f93aa36 100644 --- a/src/main/java/fr/titionfire/ffsaf/utils/Utils.java +++ b/src/main/java/fr/titionfire/ffsaf/utils/Utils.java @@ -12,7 +12,6 @@ import net.sf.jmimemagic.MagicParseException; import org.jboss.logging.Logger; import java.io.*; -import java.net.URI; import java.net.URISyntaxException; import java.net.URLConnection; import java.nio.file.Files; @@ -95,7 +94,7 @@ public class Utils { if (!dirFile.mkdirs()) throw new IOException("Fail to create directory " + dir); - FilenameFilter filter = (directory, filename) -> filename.startsWith(id +"."); + FilenameFilter filter = (directory, filename) -> filename.startsWith(id + "."); File[] files = dirFile.listFiles(filter); if (files != null) { for (File file : files) { @@ -134,22 +133,45 @@ public class Utils { return null; }); - URI uri = new URI("https://mdbcdn.b-cdn.net/img/Photos/new-templates/bootstrap-chat/ava2.webp"); + Future future2 = CompletableFuture.supplyAsync(() -> { + try (InputStream st = Utils.class.getClassLoader().getResourceAsStream("asset/blank-profile-picture.png")) { + if (st == null) + return null; + return st.readAllBytes(); + } catch (IOException ignored) { + } + return null; + }); return uniBase.chain(__ -> Uni.createFrom().future(future) - .map(filePair -> { - if (filePair == null) - return Response.temporaryRedirect(uri).build(); + .chain(filePair -> { + if (filePair == null) { + return Uni.createFrom().future(future2).map(data -> { + if (data == null) + return Response.noContent().build(); - String mimeType = URLConnection.guessContentTypeFromName(filePair.getKey().getName()); + String mimeType = "image/apng"; + Response.ResponseBuilder resp = Response.ok(data); + resp.type(MediaType.APPLICATION_OCTET_STREAM); + resp.header(HttpHeaders.CONTENT_LENGTH, data.length); + resp.header(HttpHeaders.CONTENT_TYPE, mimeType); + resp.header(HttpHeaders.CONTENT_DISPOSITION, + "inline; " + ((out_filename == null) ? "" : "filename=\"" + out_filename + "\"")); + return resp.build(); + }); + } else { + return Uni.createFrom().item(() -> { + String mimeType = URLConnection.guessContentTypeFromName(filePair.getKey().getName()); - Response.ResponseBuilder resp = Response.ok(filePair.getValue()); - resp.type(MediaType.APPLICATION_OCTET_STREAM); - resp.header(HttpHeaders.CONTENT_LENGTH, filePair.getValue().length); - resp.header(HttpHeaders.CONTENT_TYPE, mimeType); - resp.header(HttpHeaders.CONTENT_DISPOSITION, - "inline; " + ((out_filename == null) ? "" : "filename=\"" + out_filename + "\"")); - return resp.build(); + Response.ResponseBuilder resp = Response.ok(filePair.getValue()); + resp.type(MediaType.APPLICATION_OCTET_STREAM); + resp.header(HttpHeaders.CONTENT_LENGTH, filePair.getValue().length); + resp.header(HttpHeaders.CONTENT_TYPE, mimeType); + resp.header(HttpHeaders.CONTENT_DISPOSITION, + "inline; " + ((out_filename == null) ? "" : "filename=\"" + out_filename + "\"")); + return resp.build(); + }); + } })); } diff --git a/src/main/java/fr/titionfire/ffsaf/ws/FileSocket.java b/src/main/java/fr/titionfire/ffsaf/ws/FileSocket.java deleted file mode 100644 index 283a09b..0000000 --- a/src/main/java/fr/titionfire/ffsaf/ws/FileSocket.java +++ /dev/null @@ -1,165 +0,0 @@ -package fr.titionfire.ffsaf.ws; - -import io.quarkus.runtime.annotations.RegisterForReflection; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.websocket.*; -import jakarta.websocket.server.PathParam; -import jakarta.websocket.server.ServerEndpoint; -import lombok.AllArgsConstructor; -import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.jboss.logging.Logger; - -import java.io.*; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -@ServerEndpoint("/api/ws/file/{code}") -@ApplicationScoped -public class FileSocket { - private static final Logger logger = Logger.getLogger(FileSocket.class); - public static Map sessions = new ConcurrentHashMap<>(); - - @ConfigProperty(name = "upload_dir") - String media; - - /*@Scheduled(every = "10s") - void increment() { - sessions.forEach((key, value) -> { - if (System.currentTimeMillis() - value.time > 60000) { - closeAndDelete(value); - if (value.session != null && value.session.isOpen()) { - try { - value.session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, "Timeout")); - } catch (IOException e) { - StringWriter errors = new StringWriter(); - e.printStackTrace(new PrintWriter(errors)); - logger.error(errors.toString()); - } - } - sessions.remove(key); - } - }); - }*/ - - @OnOpen - public void onOpen(Session session, @PathParam("code") String code) { - try { - if (sessions.containsKey(code)) { - FileRecv fileRecv = sessions.get(code); - fileRecv.session = session; - fileRecv.file = new File(media + "-ext", "record/" + fileRecv.name); - fileRecv.fos = new FileOutputStream(fileRecv.file, false); - logger.info("Start reception of file: " + fileRecv.file.getAbsolutePath()); - } else { - session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, "File not found")); - } - } catch (IOException e) { - StringWriter errors = new StringWriter(); - e.printStackTrace(new PrintWriter(errors)); - logger.error(errors.toString()); - } - } - - @OnClose - public void onClose(Session session, @PathParam("code") String code) { - if (sessions.containsKey(code)) { - FileRecv fileRecv = sessions.get(code); - if (fileRecv.fos != null) { - try { - fileRecv.fos.close(); - } catch (IOException e) { - StringWriter errors = new StringWriter(); - e.printStackTrace(new PrintWriter(errors)); - logger.error(errors.toString()); - } - } - logger.info("File received: " + fileRecv.file.getAbsolutePath()); - sessions.remove(code); - } - } - - @OnError - public void onError(Session session, @PathParam("code") String code, Throwable throwable) { - if (sessions.containsKey(code)) { - closeAndDelete(sessions.get(code)); - sessions.remove(code); - } - logger.error("Error on file reception: " + throwable.getMessage()); - } - - - @OnMessage - public void onMessage(String message, @PathParam("code") String code) { - if (message.equals("cancel")) { - if (sessions.containsKey(code)) { - closeAndDelete(sessions.get(code)); - sessions.remove(code); - } - logger.error("Error file " + code + " are cancel by the client"); - } - } - - private void closeAndDelete(FileRecv fileRecv) { - if (fileRecv.fos != null) { - try { - fileRecv.fos.close(); - } catch (IOException e) { - StringWriter errors = new StringWriter(); - e.printStackTrace(new PrintWriter(errors)); - logger.error(errors.toString()); - } - } - if (fileRecv.file.exists()) { - //noinspection ResultOfMethodCallIgnored - fileRecv.file.delete(); - } - } - - @OnMessage - public void onMessage(byte[] data, @PathParam("code") String code) { - int length = (data[1] << 7) | data[2]; - - byte check_sum = 0; - for (int j = 3; j < length + 3; j++) { - check_sum = (byte) (check_sum ^ data[j]); - } - // System.out.println(length + " - " + data[1] + " - " + data[0] + " - " + check_sum); - - if (sessions.containsKey(code)) { - FileRecv fileRecv = sessions.get(code); - - if (check_sum != data[0]) { - fileRecv.session.getAsyncRemote().sendText("Error: Checksum error", result -> { - if (result.getException() != null) { - logger.error("Unable to send message: " + result.getException()); - } - }); - return; - } - - try { - fileRecv.fos.write(data, 3, length); - } catch (IOException e) { - StringWriter errors = new StringWriter(); - e.printStackTrace(new PrintWriter(errors)); - logger.error(errors.toString()); - } - - fileRecv.session.getAsyncRemote().sendText("ok", result -> { - if (result.getException() != null) { - logger.error("Unable to send message: " + result.getException()); - } - }); - } - } - - @AllArgsConstructor - @RegisterForReflection - public static class FileRecv { - Session session; - String name; - File file; - FileOutputStream fos; - long time; - } -} diff --git a/src/main/resources/asset/DMSans-Regular.ttf b/src/main/resources/asset/DMSans-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..07266ae18b44ea06b673b8abed76fc056864585f GIT binary patch literal 56348 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/asset/blank-profile-picture.png b/src/main/resources/asset/blank-profile-picture.png new file mode 100644 index 0000000000000000000000000000000000000000..9e342938b026768edad7fdfbe04b6e551388efd9 GIT binary patch literal 37098 zcmeFZ`9IWM{6BogUfC%_N|v&u?E6+iSt841UrP3!?As`1&y|F%MWV6OkbO!hYbBIr zC>Ilh8Dz`$J>&Y^_rGx8-^b(gi{|k@=e(BZYdNp8m2kuGD$`M(qW}OV-D_GV0MNpp zw19yM{*(F8^*I3l)aYtmHVYYB97QmJGmN-u`UB}B-*NOFG8{RPl|s5{Lb_88bg(-W zqa^%$gboP+nueMYfJ^rf@cWr}fErS9KqCOSCW(VxgpdyKFZ=&?|Nj{>Yu~G=p^*T% zygzc^<^=pH&OkBgF)Cv`}zM0OwMc&U9mB&iG`)XuMQ5nzu-Uo^Zp-A(NGnSlz3)=HgAAt zprY9K|NFn{&Kn^F|3$^H7QfadE^4rqS%m<*@5)r|i^G28x0a5L*Oe%DcQobfVQc2R zMoJ0w{pvQuCE?0DiOTN8Sqs#rNbuXR43l}dU-_&#{Cki4RrU7|ulR-Vp@b_8>MQ)$BYstE7LG*} z8GE&bE5=t6OLbMgJ&PnFSg&boq)9$X1zV17p?yeKME0hh!9xjj*BL3<}1+i_E zNR%>6@0($}WYtVTQtkse%lbgGN%$@q1C`ViKzzQbk4**?r6;2dg zQ{6IlYu&<9X|IR#`#f^ukcw>~n!gb#@5eX!n85{uad!kfge89z;o4HB- zXoZpW=l-eTtxuLeS!$371bZh-OE$tHZo4Cz*&lmLCq?#ru7;B6`TU`#M}g%PS!=-C z$#S7&d`L#dbjZ}y?OFv!KDwq7vv%fHECS5j19TucHC4DZ0Xq=xadvR6ZYZi@lo%8~ zIdpj|f28Anscd3=puAs#>^Z{27tVyL4?4$-5{v`43U#k!y z{>3-zG$eNshNa}H`m;aGguK?Fq5A{I4dD}NO9gp(8QMR2>ixcadcx7F(V3W)`YRgQ zghC}{-5hamsCzd5`utizfJ~ik8j|f?2S9((hREued{gATW98AhIWiW7*HBR-ZWywp z(mmP2l?#OWrOZ24Uh&8zs)R@+Bt_$pj;9<^BLZPk+;$cg zSvmP9JhWd{Vt&SaO_v1IUj^Vqk~v9!SdcW7(`uC1qNp@~DkwE5NM-Vgx98ssT@sf= z#qg`xxF@&Hvuj#3g$4dxnE3YX&)w$Z!|6`jGb=+qa!|KHir1X8@@!J@BbZ?;XMV1% zEWg9A7?WO(mO19V{jl?r+ssPt@nzBj33={H;$PK=j-CzHGNv--RRaypaDBWi=cD}5)K1){oL^N#7>*1m@C#aWeMP*2{E zy;)gYT>SceczZ4B>8mTh5;5h((*AlcDjN9}a(z3cA*nht`3G{v}UYmx;vUXBuZPkVcjPHh_APEI&=F(?B$ zkFOCB2(-;ao$U%CRb9An@#4k4L4%zSg#LG5Y|b%)GtkhE+Nhj_s?-|%6v@+@#CwWw zcJKY!f$%9tD(>w^GHi{CDr3lJdHGg07H?~6bFo&Ko=Zq@iQ=|)-&{MxqVbI-B_%35 z^e1yj;Vvaz$SLu_b78{QP2YE1e;@d34_e{m`O!HSm(H~xe|js8-I8EE?H z)jIiP`S08S{@uP?dqLRX3Hx0|)<-B*BKAILi}KnY`&D?|Cb>pjpXWc87gbQl**v@! zA}vw$c>{k(f(XA$D?JKwi^5mz>E3Izp~fwPX8!i9{(9fnXNJmikt7x(01b5MG?U@! zR;;|lYX7E+N^^7fWPYH0$lyn=g1RejMS5A^{~A88P*o*MEG+(*bPc*2#YxV-xL%-0 z&?I+HK6!U>>hf-elN-g)uG#O72^RI5l;`E-HAb$Dd>sxY+8A?6=}7-4_ zDgO_?(r0mb>A2IZTJZL-2Ikn_+HzuuQ&dp(#V{wgPs-dal9&F66_;G@-!M`h_Uh!! zdP>DoUaQy$ekZmrZ)Ha`yZN4r^DCbz2>zSjX|ZmS01fk51iyPSk|Tmus=Ir#;be}= z&JN1fh2O$M*M8S{{C=6cbJ)VjI#JhR-t(lL8- z_3!zOZz;r4@2R)T?ADyell}+gP2H)vSiQ8`I>^asdusxiSmlVU{@&n0>Cn2j3Fucy=X5|Neh|C2ZKVy8m)Cym2GQ z#bwHBrj_vWeDVpl*BFd=@9T|klPV3B_SQEYzatp|$0KOB-8k7;<*o+eNeC&*@_{V2 zi?t1DEFf(|)fzIpaDOM*#l>Y+)rAvwPlDZZO;J<1Yb~SmEg|cFOUKvMuk*&KI{wR) znlO;`HDRJLU~Rd=dSI+YoC!Fr!myH@zc>&&u(N0G;iFKGx31s0y=YS&pP!$<|99V9 zKPkz{l1cI?9Xdf1P~)^rhgt8*)viSC43Am07nF!L?A-R$4M3Tg*;-nK`I5@Yhs6>F zFzoMuBtHy?tQ(jvE8U?#TW?h?)>_8rw}e(~{9=zh@A^0N^u6|j;pw~O#ICG+$G-qc z?!)f*@RXJQ=5?rRV6qznT^3TnL~gFCFFIgzd1BYoI6d%`B`Xt{Nda`|PySL{YKp&A zBexe`j<4f|8Ka~gYZ(})cYQR6h&Km}$NL(wgJcEh0_Q&Zc2^lkt}RdIU;_moc2r9K z)Dr5-B6RNVPBgIWORLD_anbr51xRxk_69U^U5PK%c?)t?BUe_*WXW~Ba(8!x!=;CH z_JYxk(S(7D94Q_hs9^>a85!O2t_r#49YCDT!76w4g@5JR%8GxDX%guzeo!{0y47pY zJV`N)V;q!ON?3qD7C%3~##I%xxk;@O6;tq(5K7*Ex7Vy-ezT`(t`brdhC(D?KwVG! z5Z+MJxc6&4r%>dptQ=6ELI1AB+^oD19YFHVb_&fu32+uL89S)Hma@#A+uLWUn(}O& zT2a46J{F;ldcqZYzJLGk^#0ecnwB@Ebf^K+k3yNBO?Rj|*mvvCkF}vF4JJ{2#Ta{i ztwao_?C(IuN&Bg07ugfyadc=lI1}S6jEC>w8Wl(*_HA|nx1ZpqxwZ~WhgAow>>5`m z6Fn%m76_qcPOOZe zD4qfY|Lw2;1T}S+85I=d=GNDy*GQ0e@{>{BA*6w&cjjKVQPsRWK!*iJqo7fW-v}LP z%FBC?yzo?qiNAW0du#HwyT)K97gyU3@xJVlP)i26j`_Y zY=d@Raj(U*;r-P9-~Qq4?G}-(TO1&n6*`5^cXDOX6a4(b8X+$3s#|vLIgYKOglm?Z zVyo+|&B5MykuuEOLm_}+QG#L#@O@R^t9f|NW;Py`lMaywu?01xn9{7+upTl_d?U$e^#p`mf1@KX7M_M*~sA zEX&b>n*(`;;!hoG!1SmfoXh!27tAu2?kXGIESN<-^&67#{Z#7SF5*4ava+H4IS{EPq;SHgZK7|JRk`>9hV`!wx6zLdmO|-B%VA7+Z7tY5+aX+c6JU2fc6_X zr@Ok^KSpfuiA=K~GNOC+l5>x5|1k^q6(2DR7aI)Nn5V`OC^q}0L@59GgrMQsHZ`EQ zdK5|9;aFqyYk$ANhik4XcUFmH4KuQM49q~q7<@QtZEr@Hu%6@eO+u30OfHtSXrK7= zN#11Z&Fs|?TJ+#$=(VvtQRvFDRr^#v(o*o4Wf#4xLu{%0FS^AJVIzh5`k~~VCd70m z8%%u(57);B*P>M;{6j*%xjXT`q)rwf*rdG`9?WmIo$9$)5`&l~P&)dV$ua%>v$$Bm z!WU>1W|{5(kGN!^*{~Hca`jJVuek@&&4HN_Xi$RuQz6DJBQU(={@?yJf!-E`rIW8sJ-3wvTri4vq*i3_pc;RT=RFa-{!T=ZMna%` z5ugw5LxKq}y57{@B|Tr;E9%B9sMXm!l*eB?@qRp|zq@R(#i)E%1_>hIf(8BQj7}|U zeNYgey4`W%gaCs$^vY0Q%uxU4`2C_66#OZsuVV0_bpA z>{1ms>+9-vey?EU(u5KaAOcovxVNT`Us>zxgKrkJlJV9a+zg1B>%(tqJ$<4Nem+5+ z&rpJ1Ovylf*sph8d`K$`EC~URen?1if6G-MdzY;Jo;MYi*WFU30|ZJ~SYysH-3pE- z{Tw0;SQ5oe&p7xqnn%N`dw=JLj0?xW13EMUiq|zn?kZ{>_}L*mm|M-lnx*x%FJ{Vz zq#m`Q8jEZEOsOMrNS+bW<&vRAz!?OnIP_C=>#EnI2rEM_6D*9B;w||ks=xNnZ}RV* z?y@eVb2~1UL(q*!z5%Zhxgkw6$cv;@Ye)`vgAAwc$(d;NsN2aaeNl*AiSW%AlUA z$onJNO#sk1fVbWPX(8>Cp8M;)*Nx+Y7bED%E_C~%4=-oS=gU-ou|fa_9PlmH5{j+X z$lG5hUe71sE%PDwr>>_oT{q+U4J9643TFVUXvDNwLh1B`lE%UA&y8FHKAaXDz?F;8 zi%BB0&WzOsV_4!q8=N8}Bln2cBZCzmvT}lmWYtYgdai|(k-O@tQsko$5Gd0*D-Vyw zep^B7iyn{bbwI(bXve75R{JW2giTJkp${<)WUX*yND11xCH;EXo%aqRA|u|y8 z^xMe-!7SeV8QHb|c_K~v>qY6=Dd)@iZg-nHDCJ)}H zG!$^irysD&k55W8Z0el!sGvee=f-$o<37Fo;@!;}%Xp|zh{Z*Yea;g0DGisP*`1PhS1~pR1{ISR=;6JvzPHb+dE*e--Ae+P6A>EM<7Vj9)u_l`3Go5T^Yg1emkkpq zc=TbIK=gN!0%<~iH8u*_r%1f>T)55GGEuER@bXsb79E=6hoFsfu#qNyhV1&9D`a2? zL7Pj7ZiReypYr78Kl#n_hh3~?x?C%Kh?}XYE?*_kqNp}mYwX4`S&9;KWDiH4L{@&}iX(~|Eem<)E{hVIHT>p~h7vz<$Z3!R;y@b+=~!QT?- z;LBPq0Vy|K8m7WXqSs+|phNo%?W$hup7|uSN|Zovez3IW>|Bb}KTg{(de~egp!a~a zzB#gNas|IeJcl+9DdWLfRpd$?Ic_)`D{W8AT15$4Mx7%3*Cpzxt3LQBD(-RcpsML`Z zLz3@z`l}lJ=<{a;__un9%&7S(N{lQ76mV*A;FImy4z=ne`F*H<+cR%T}ef-Gb z@X&#=lC{~kAM!dwyNuI`@z;{0>OX${a)n_xk>(IcYk^;_q(9+19qKeyU>f6E+!E05 zz3HnM3zG;%!UFtmZeN|}KkC)n+&HWq*sConq=4XG}WP&2%=6#R#Y3g*LKc^<({B{N@EnHit2QgnO#Wy&&xNykxM zgIWYZn3$h`{bfF+SH~5guCmdf4{lXFEqv?q25Zvw8OpwpaE)>0mb8&DhrA|rsUjul zbhR|elka>Rrs7x?&r*rdJHFYh99mLSSF`ds4$O{20rs<_I-HxAN(A{rg%CGR3;fDe z;VlT;yY(*{^3nq~m|EN4jOY-9Tzo$TbZ7CR{n^+^OTN3+W9614`scNZKM^>RjK9>t zT#{S8!{%|ciT}WHfeJ&T^PLCY0FwaWX|EZvTW$F=Z&9(m#dS$E;o6_5{0qWEp$rp$ z5Tyc8ND){xZa$okigwTzL;Kf_V8bR$)}DD;0I_&DT#Tk&g*U9@Te#6*+8f^Q#yfs~ zC$Th=sBc_`PJTmY+_@z9QvfBgnQ$1ZcfAXF^TYcS4qzSv#^+zPgmg4=21|yNnyY|W ztjt`Re)Eu=ry95+1;;+!XxY&lYt6^*`MEbGS3mF!2ZVOwiKPF5>ca)ME88LI$5EzcZ*;3fT~oPEdihE^5nt}8DR#5HNdcd0Di}_ zG5f-an0b3}qes#D-%Rgl*G3MS8$szxVEFc%5xqeAD~@mA1bk_>mGPm$+*+r5XXZ7~ zlL9f-)DrrjXn!YZIPqx-AVYs*A11P3jT3t>gIR4rcZ0tx>(}bUT#4K~7uY{jn8tb{ zX*S~V*$EaZ5eX<7CS-o)q>)S6Qdf8t0MW%yYnl|U6^mG~GCVIYg%0x`8SrT>D%;9Y zmMIF5Yfi-^&4$%x#o;LIp));ax66+%soM@}aQVvx)H^L@u5}9}CTW6VEy(*w*Y98c z^C_ourQn>q#Yx(8uge>(`&i0=mKFfiA{sEM8gl)JYH)&M_aBaoxvC*kl!T~6&bpsO zuMS!18^0`iJd*uN9c=Zbyx9EV|MbG>yPbO(++bD{QumjLYwUE#+IVH{)1oDA|8bC& zw>o)|36Xvb+U(Q3OUEq?W49vM68N&St!nLsJc+m=4{hyqPA`;{NnF2;eS}}zJqGNR zl?2@Xc~m}A&hXq!#2x5e|KKiTU@965tr60{d`TfsVx%lD_)+r}P<$D(wGx2)z$E!P z&(1#*pe0di&csG%hTf6;3=CFCzPK1$-^DnzGV3puI|ZiYv_7aPuIt>9U%&g`bi>>UFK9|HLE2A#V* zzNnOd>Ax`g)Bh2f%~LtW$CcFQ=Sq!^w$jfF*cqA;?-ehr2I*8#bME60{Jh)xHoB<` zEU0kifkPIV>Z+WX$693%;2~7T%v_2jV^QRE5KxE6E_)kCx3q;0l($o%2SZF-l}AULyzDWF^*liPumcK( z^-?a7P`T(1$lPXu&j@=fydkW}OFtDmfoisEkmBi*(3^DtY^4kMqV^&pt$mXG-cX5f zBapZ&Y>&s6HfGkbh0ult-F*-Jlz9hHD8G6t5iZy~6T)Yvl4~@hXj}x)MnVFZ)qwWR zT+2%E+y)vcwXv>O?9I;3j>%>=;g^DJ2EcTH zE<^s>;GP|7>l4HC8*ntsuRZx7)x6R1o_#9kV0$_hRiP${aQAV%q4btOasD@y8ChRxkTu+EVzq5CCj^wEd zbVMBo&qW~XPSfEiWMv>nS>hH9iIv6No&D-)DMv=+B}>S-XKq=ddBH2%BH-0=7SQ$( zlV76JQ`;${=E+Ce;(+{A>)-nFouSv#MG#R@*hTN@l~N4Gd(17nkD^(o35rcA@1naN z1Wi=20;KCroUhwk5s6ubn7S*uB-Jd9U1ZXMk<4It9u%`b4z*mUM4g2r72=)Ua>v?| z7Vp6}sFy%1DcZZK&BHYL@e1_sF!pnXdUVl^8sgvT8`yZiEQ6QC2+8=HUBb@>A{>Hjqv1BxCS~y zSXu+e6X{?X zohlJ!iahQJfM;_2oz31^cERv!;K@nJr~VAiL5<%+oi>jH&-B>O^|k|s5OJ?xo>VvJ z{Hny7zhRrtOTcyL#@q1Z8vWNKdKpJ^4Rk7;;c(FjA65HpHR8F=#lZJ-rl-1!vKo zxlp!iW5suRD9i%$1BU7`yR|fAO5L^9X_j4Szow^g3V7yX=pI)Rub-8CLp3c3V~f>) zz`cfORgwlhm$3YbC^qe(G<}c!(Tc{6*GGMaj+;VkLP=3x!TK7UmU-4?Pdgp*;STvCT_zw(| z^xi98?-J(*>1PPM{n_;agFQUr!211t0Z@Pa!xh)&<;{{C2s*Stsrw}MwBj2?1kG^= zU}UK6KX83idqzzch&+5YIj2-CszV_QO^jyM%5HKxkOAqPS6s7oVy7|>Ka&^D0<%kI z>m^}fOKUv5h%YfSM$q{xz_@U6agNlW0hj!=zDmB0J0t#{%9jOxCS(NM9Ihm}6;Pw! zzr8vf+SuRA?)DSm%Xc{BoG24NjWZhY{LBwqREb;e+=IUoXcfv#DTlHhRW*F_S)Cpf z8xMyz^{;tKef>y1O{p1-uA>NU`z)wgS_dGKgqau#%yzE)A2?d@^yrdV`Tn}DYfy`? z@t8lNPF)sY;sKnV8(%M|8m$?u1JE;*-~UEGf&T;uhJF{~E|C9Fs@YT-pntukM=kB} zt5D;>GAx?NEoO3WU&`{pVz5U@U`V#|{DBl=;y#6z)1c@&rswYsAo3XFy!mYt%C!j# zk`Vu|#V?%^;D*TNMW%D_s|-Li#Y!y{+?*_3;r^y(t2ufnUDiL1dP{}^kQ2rY8M?y& zdd^8FKVfK91^aN>Fl{-Ti*n*A!L7A}8_97VY0iKP)+w zu?-Uo*#+_+>^10iJE{ur?{)eIqgpRC%w}lEMrVV44GN#x({pY?j{y+LTnMusbz)<4 zVUs2MY%HJA_2;B}UV3Blpyi3%rR?Q+OGBkmH@phXJB2d=`hYs_FX@8=sgLn09k3_5 zBsb1Y^j?de7DO=eipf;TZCY`E`S}4%Tc=227P2#URB^&oNJW#te~}*j8q!0&uIQ?a zXH}ypHfLq9Nh9g|vXa-aN^P^&nrbV^F?n>IN8N>rkHKNuOfX=%b&jV)nwH#&5tEs! zDl@nX)Y)*j*E%isq-9AOp6T(F$5orf(J@98GJT7WHUph{?9o-3O8C)XJfJS1P=QL= zIquScsf%?ZRspoQ3s6iBO(1JD_!vw19^?vOt+jSv%%9>rjTz^)-=&ZD8U$ul`3 zf_C{BkFiB5JtV5(e4wbWqzIlyXhgoC`7mCk>-Q=Vrt8p zJs*R7=X9eS922zM>jO+YnGQLv+k;`AV24zc3v=0ks-d~%M9fJFt93p$!KEkQ^c_#f z=P@eqi)nEBb#`1`@|Ve5e)Lgi(J^bu@%v@sMj%zXF!<_d`{E=q?W6)T-Hm&Z!^3{7 z`w~0~@35zQ+)c4X&$f?a|MJdrTWs;1v4AmyKq@|uw@ciYz$GP^guOA*+&j_&7c|W0 zdW~9L*+5Hv-`A%FxN#cjGYeB*_dX-&LgcPg7>BJq;0S>kg4d9wCg84^2ki7nXWi3F zwW;XvFh5-7Je&BJVVd^YGU7BVI_3(-xKQVlnFylg5k8JzZ{>pu`5a_i7++c|fh(K; za%G>E(oHat@1aTgd#VsosjZeHs*nyKi@7s3pkty(EkM4mGxHUuR(muT$JDSl4Nu4= zdMG=OoV)J0Cc^Godf~w_RUsJ2oNRK1cbcdf$vo0Bg%3|z;I2?f`dSo11?LBXrlk(~ z`qCq(JGH)(fa*Co;-wDUwuNn{V8=@bt-^3|L00}1XZ3ZSxdqM=%%TFXZi&EET_3N< zsW^W9hH2IiSds9W)ZfZ|@Aw!<+$q(#vmpkkeJDCCv>L445HRRLv^?xf_CwM=6(++> z$7`G+&1{;$8jY~KRAH>g{m?2dyUmlzhXaDMppeM4RoD@6&e*$gQ`@5J?4#O--{)a-km-m%aBC{keDc-KjO{}xw z`n0|L2{b__^}h0vY1Exm>3&C_xq_JPyO0@AT;s?)loXolAIri_H)P&C&Jk*@rBm!A_GZgo$lP)@j4!cmd8DdO~U82ia6nK;4q)#;Zrt zGnz&~fiWVzs+ssQI|OcDJ0Ubbr=J-oNCCc8T<q?{l)%{|%@~6#Ow*=Q%}#8AYdhDqOzPTvH$0ty20!eK4ES z*VlL2?^I>e0hI=E{gAO!A+WaQH6tnQr9XQ$2R6>jbi47!UEbje$)tRD9#J+NdTB%T z9ov`NRX_-pPMH(jG4vtEl07N(9l6* z?iY#;riMQ;$dch6q9Vr1!?|+JCeH+fk4SgFc(j&o3+V9tfVz7)+>(O^N_c&M8g#Mz$PChX!|1La(Sy zlF9{KI5f>?fnQgjUQcsAernC5hXA({~HjT4J40 zGIc0fW#6ucZ{?=!_+G>dkZ0Ko1sQWW_!)ZY%`0p@}lHS+px9^MK+U}5gTP_aeKEne#iWHA3 zMlP%cDG!@`1*dRP->lZEn90)3fH8vN{8kr=H>28e`!uK*4T+JK8MlzM4%7E31f|y^ zl$qqd83C2FXV{V6?Fp;%fDbM*u4X>Fa;mbyLx!5E`n++^UQq7{<|Gam2MgON?TM<< z5&5&6IJ-N}DQ_n$`*)eix1g`xea3CP+@N27Gx3}xPW=vvKn!K%vOpWb8d}dI)M{`@ zwc$FT`yONEFFlPvQSe#_lha~vOheMIMc+^ z_UP@eR&`~F5JP5R2L(7+MD4B4T6;_mOQVJByvDchS{apHXTl9Zci)TuT`}pM>6Y6x z?gR#3jx=HSclhiJw=Nw~l|UfBr|{J5O-O__mPdPAyHZiBrsV&SX~!?Oejgr;{@%=W z_UdE7qa9Dr{PWKx^gowI=kuRorw*1TF!~v-;~e;E$HmE%XUiwZ(JaQsCDP|PLMVGt zj_kMlGA$eNA6Vnt0a@|hg8S2|(HvGPmUM9X1Y9g;X|WMBtw#k4?_Q?HIkK{mA{*^| zQvpUKOG}o`& zamb%RggE5h06N$HtsdW==4e_3(}-BsRdJ66u~-K%9Rrsu$;p`PQZL&Qw;lz!Ky;nb z`9o?x_cRu6OR+oxj1G6wglaA~pe!pD@Zoy!RHUX*isyR6IQLI#N%U2?!y&0I6{~9P zS@zXKRtS#2fp1=SxAf8@sqw*;5M@(ghC|!t^L{lKhub|mG)uaWE`yapgeaSLQiyRF@@hpSsA}zrr+1ixY{!t|H{S?U9lWMliZEaH!4i4-cwE%hto@8v_xz zI95M?-i>g<_m}^l=Hw&z%uWnO&uLi%M8qN;^PK`w+Nh3en!pkFlnrOmi5$+VoP36K zYo$g9pA#6^_G)~MNm*CFC3*@i1uGe3nUHR7z>c@DF3f1?;1iB!!>=&fim`}fI;dIv z>jkmFV6P5oArKOsb}q7=r7xOJ@^tP^pksha$!a6DvIxAHLg04%2D}uU8ZE;~OduI< zqT%9o(?a;hmV#RCl)9N10X`xAE$gF>cML;SAo&QTJwXb;(;qf0f1N@77Tqz>lEnI6 zdT{)eaY}9r(20dsn<$jx|NWUSIbJM~0fYp=Q#7CYKvZRG03GOp1)jqMI~&2KdGbWr z*`jJNed+@;MQZOB*L|@yE|7c_a`>(7m?o>X(Hi*3*aDD8G#M;2)@=!z(_%*)Y!tB| zIls%baCtoFvH0zm$9Mp~-@G88ge3tIS5 zlY!SWWP(|7BG6q8VlkB&W%WOlN%PR38dx0-l9f(}?dJd_Ot9#L&&ZUlHD3liym@xg zcVPIsZRIH3FG_~HJ2)*Nfq|x1UcDIq#Vb_khOC3~pVEKQgd*e-k|r=XhuF-_dw1WG zA{-<|!S3#=kELf*<;-I?TnL40T>J!SS(Y(LTQB@H*tNm$bVN1uNi$o38JNB#MKMic z8FAhOUJZB~rzGKvcx~tG-Iu$^9KJy>JU6>;J?4fqc)!O8qL|rg2A0NOi6lPXl|o3~ zhJ2q02#hq@wpMUIpamL6-$Im`yO3S>2&5cT=k*ul6KDo{)NCOg&~R*d--;=#_qIGZ z=CB9}zd8qr<^ApOe-fGorA{(4z-0{Yx5ubOu*LTrK`>Ll?`VD))5BOaG-4K@|7`CaG<3! zfe}ngB(^u^4H&_mc|jixF&WeVQw4MtDFL4OI6NJDbPS zi*b~b+lBscKpv$T9^V$%eSei5Jb|khNheJ9#S^1L#&W(f)MyReWWRyY;d)Zg2tM=@ zjU+ckCm5n*j_neOfei29u6V4g!DnA3Cf#hzY8ZoqGQ|`ZKfL|8IzIn_!4zygv0-pB z=H0>j`%`66_O=r^9CHJ%OjRV^gV#5rufZexQKFLVOFa@J+70?{RvW8kjn%2RcL7fz z_g}f^M{{nq6TYzG{=j;{pdfxCuQ@+XwYO!H4v;l;8GhbfT$@e%as<4B8}be(;E9KX zgz$bH0(Zc1&wg%uTlU62t)wm#I)JU^bev;80pkw9lzqYSMZ%0&33Q!oDDT{R+J+0g zrqOSwez+Y0Q$JfJ5{I`xexLzQC|<`UaJ!<_R-#vxAPg@yC;)rI?%SPx5&ty`UdCEf zRA$NqtAMR#+^$ikOdUHgKtcnxgpee#I+xL2^7a(ro&L6MdiP zB`LelbwxZ}NsIGAD=`;cLCA%JnHb7r~*Vgv7t`^+K z&r*eSl}v_ZuNQbLq#V3AasV=C1%e4puaxdC?G4;NgPw=eUzQ+S&1U#iaPzC60lyh) zNh6F{muh6w>s3w$hZ9h4XFbPsk-^L|ceBLPVD73`IW}@Mu(`|1131tfHfz}}Z&rG+ zOb)cQeBPpW@HUgXeqyk}gTY;q8qr7b+=ztuuC4XnfSx%PAR{QSxwGf5MoD33_o=du zKoKqLMsCjj9h!}3pAw~_&SD`&8gmv@5i(Vd{RDjskU)7td~0(F(aF@i9PZjXU((9m zw9ogpYlwZ zi9-K;7iZOU<+55aS>KnC8;CzS9`bzdZUM$WbCRxT_5jZxxN5G251)n??Y5e)I1D?l zy$lZbIK}Y-u_(!>iM8TkTE0&hvZuT!na&xP-ixdncm0M^l0j5IG10piTeWU|@o6hT z{TSux2bqaq>(d=&o|oxTY&^IDHny^`$k5Qp&|-siYRV=5eZLFj(6u+C^OBV26})-R z>7qT8+icf{Pk!YqXThf)R1ZndYLRmFj}q z=^yxyqEDR>XslW4?akTDw27jl(2sKE$*{}K3sf0x#J1>AeUHNL=)G24-i%~OD7bK_ z!BzTCjHK`qVWy%*HbnKXiJp4wCs&KtzT4b@0)U^Cj*@crbsf^9N16^#8PFHt96q+( zAGot%a_am9<#`7BAQ6)X~u9{9*72H`1;Qd5I(0XJ_xwHG_&P;yaIqoB;fsgx02 z!J+xjVrsLPrvyDkI=g>+o8I{q7xrWZg8KM)Mbg!douRG-KZEm!FCe0_H`{E)24Bsj z-`0y@WOT6px9d&SMUuBlquA0hivBHtO{&W#KW{y}0X}pT2l(80MZ%-=Pv>2ya~&O( z4h>MqyQaT?y+g14E-P0u>xR;n#TLP?^qgSJ+( zd;2JSSRvDc7d(`|I3XTWb((LG?h3)?-}n6JhBE>q3!Ro_(Noxr`9JVt7B%jC6r*R= zcxLPJ1yUZs$F8;6TgUHgWt-={hh(?}~{o;q``?isHxgC`vwc|l7$ zhv-FkHQx9sMKB89?V>~5YiT79&CP$htLT4Gi0(EbWF0}XTt1xp=Gtmq^m?qPRl&FB-D@{VJ1e z4TIj<+L@eVhlo_XOpi!T`tkN=1xES_9q!nZ^ly!yo=4+7tx!?74>fM!)D$_M*t@Ou zh@H9yw47o4RI%qse(K*3Z%I0U?^HVLFa1^}d6Y=p@wC(0*D_|iatpAp?zf?iM_C^d z@H!*Vm6fxxbF0cFxi|)~s~T8&Q^iiz!1OlxIK{5j@YuBUA6J4EDWq9`q2 z<`BI1DU0C-y9{gzQek|AAwS}uZx;GHhv1L*hD%)i;V-Xt(L6VR8$>i9i!eF+Q`%|A zioQu4N;9dYHEgb5yuSLvLgX#$+ct*j=4iFMzR|&>jh`xJ;tt+>djOtom$3!uGp+q+h;MZtd?igjqk2fq^BeEb$=vwZvW`GTR2jq$8-pt zRGO0xHquY(6pPtYxqQ%O+dlWk&RWf&!+iJVVbW;6R@z;qZ{LFRh8Dctxerly82q_;fPmg!zb@6+ziLNl>YzW;KM%7p}rz@jhD zgU3-Q9we@Pp~39$+iY{Ov-PhI<0f37>rRDX_nvo&qc{^IukvF9lz#E5Gq&`mnBLwY zYT;Le^yeF6=z|r!JTAa4;%vr&owtAbDXCQ6tZQ&Tn#6~pOYaJYAm z#Z8BAzaYQ07)kZW{g2R&u9k@&##<`KuA!>j_)#SKzJS1j=8GHO%jZ)5*8E)L0@BcJ zH>{G4QCvfxhIe`=kap2A9WU^Ftxx)QUl>=J!gX~r43Nl;2xi_wd!rI_b6H7WRxN41 z_r@7sCs;~Kgh%`lG$0?|nD+%Osr;S8o?}t&4m!6H;Rdw^dEvsfmf}|ZuZ)ksfY%c! zk2C2nX=yU3znqQOkmcx=eYCVty~SVS)>mKNRESuG_pNkb_IMY9XDMHBnfX~gS|=-6eO2oN=4RP2OAcz! zZAwC+05L5=AtfoQ?GgBmltLd#v`LntuAb2n8oXWO31rr{A~Q+2d6_)*GLc;x`*PTrU4X~v_i-h;wJ(;epA=u<+X zy#WD!__6X6&*B5rsX+t>)H6=3IK3=r4DJ9~GDyYZlq9RIP4slUcJ_vySN@6kB}#{e zhk?|%pK0Q~6^)7-(GkijCMdi77~97t(mFnsR6~S}?1>4U;9FCr4p?}@# z%FWIn2Pr}?fu0MrQBwdmf~8u@%veePLQCl1vx9C94ju{d z%3PXO>IQ#XE3rI+JAGhU1rkMzbFdXvT8OA$B;*iHCri%k`fzxM zybsutf+|lZT${^Xle*aTWQA;(=O`;%?X^1b&7-?Oo_Mq9@yQMp6WB7Q^s_ffUYH=a zDHIsqHW*mZm8@pUziRiatSl*&=u~VXk5SD5)p?XWc6N~TqhV>2ZzRd&wQKTEwUZ&% zQs>IXi1gZ@Zy;eR$Z?+dwa&!F$@AovkSBu24?r;{IG0Mn?zkypO;q zFb=|MshxV3^_G2C_uAI*Yr2x;vX|+rsv56{HPpS~`&Z-^BWS({J^$1R%^QIoc0P5v z?dLhm-RY*~J`Dx9R0MffYIxo3ewrwCjv6dd{^NxpYrRjUjfx&oVH0l*jZ$vuOOma$ zvFF>DTx>u-V;~Atuv*+Nn5wHq@@dOv7s=k64dl zl3L9|;Jgp}#>4mSA0%@a%?Fz;zH1$A$z{D~96lL!FdjFoyYwB|i#i6rYeVQ4Q{i?? z&gN3@U}{-dMLwaC{D`^bL%Y6I@sH>2xkg32-21jv=sft49M>k*#cO`PK%sY_f1Rt$ zCXOyj$J?OM>~FQzA5xRM?XMUrv@IpN+Su4od?r=~!bv~2uEmOCajEQ&Sv9@Ab{1CG zW?l_@?z4>MBOEp<*vDzrB8yN%6WjO(TW8|;)AxvT{r5-D;s@tG&-VbcgoSKr|ZF6u=@*~sy% zt2U^f0@az6l)x##?%0>2F|bz_UMV+0=WRk+sSDw;sA$-FXyMHcl|g$ zmxgC&NqnPw_&k=&C|w8M_yfLmPa~d`)Uw%*kbk75x|{(z@+vrhk5t;yucbXesMY~d zR@P!RYgRVa%caK{HWvGK+COzIQ~$XUx_ZZH#J?sbKY#K^0K_`WyM+z6?MkNN z8?5@Asb2#;qv-5@hG6^etD7^f(ii_~Ysbz91N0TRT?T;B3OauEfu>|*Qit1X8NEo03=_P3LOpa8$`XBmKtfjfr)Ex}e3=>5IjuD@eLyEiL6ixx-ZFP42hY?T?I z(bAqnU)SSD8)u6+eDMKxt73G;cJ@6Z8shKk?8U~XNRQma;E{ zXhBhkj4iv6eK%tlN?DVAuk6DlG{hK7GSXOzW@H&lV(d%y{d1?+`};q9Kaa2aE{;ebHQR>5u4PVus z6AZ?&$dTL|rbeig=@6*|`*qW}k8+M85GYRq5&_35ikT_Aj==_Bdlub{Jb(I!TIW3E zPCMEvFW8n!%1jp}PArF1F-F+0-ghP`yyyMjsIq(Ldw0_a2Y(hfJoymUMgsLOr!zfo zYFf9mmGRs!I5Uy}U@5v6kxo^z(mDef`8U^+)`d?o;)a; zF01wXA)kHi^BZE2!!K63aZqgvd^kfv)y6jHd&z%e+J0y+MX3jQlT%>gn#mYLhH-(6 zW+R3149s|6-;Q$jwRdBC^T}}ny=P4=A>f$;{=47Ta=eeq@ZR2A7_;ZN(|UkeQx??5 zTe*wu)v4R%JXGtPGRHMC*k|=r2beowMpu>qicsaaF)NtMvs+lbEkFNe#pZ-pMxPZi>7UE`nJXWYs_!?L#xm~0)_v_-H`-gPT2eUm*#9KV z`FDqTccr1QZ!*CgBhEi@g}JD!`Z?=>qGs+%>w=uyA1qn?YWZH27>F>r1*nQql7?=g zO^KN=*B0$BJe>q&1IHlHvZRwn^*%(mw)pn1dYMX($BO=sX|<2}-DRo=YeGXR0x_oa1cm9EA@xxs zwStcr=9|;5$ZH#MmCl8ctZ{vPGf&ng{#r|MXXoT9I-eG#@Vdo;(pKQe2SR53dHxE| zC1|5lMx5P8eqfAHXlQyO`;1*-YZDX5w*uFa%=+u;=%p0&qSapp8#H=K|AS-!rZoXD z-|wHC#_g8#$;?F-2#w~*hIdT26I+p=N{`KzV&*VX_%{MU9Dh=g^%Id2(m7S~VA^+Z^@{PA79Zu3RKjy6wzIZ=lM)BCG(d;_Qvl-wcP>z}^|hSeCx0_q z*bq$LpIEUDQc`wrQ2NHnE?oFkCFfk+nU&pF`ggb2S_jW;&0WOQgy_#%T3+RBVJT~S z^4&cerz4^3_WfM=*DK=@P#GD*b@dVb6SM2kcpU12e9Ite;~j6>xYnz z&t25~yKOd6?kPX-syJ@PwWhRSiWYo!S1wr#y`P*{80Fe)8L{WmUWGAqCs?%M*%S6+ z485i5_Z=~@4oNz@!G#7><{S-6ftAFw;LMqo%F{4QpU*wspEKhm9WjP==ho6zfF!^3 z2p~K1r+ovto;vJoZ_gfZYrAKP2%=@HSG?+`u=stlN8P!e+-H3qc5`N0r7(Cj!zvD} zziQlw9fBHEde{i*KIj*)t0@FD9{Xd=9>XBtwY=u`)6w#$j);DU)ANUO*?|Xsksl8S zKL*~~tDm{L-Da3CIlq}2GhN&sHSlK+Zm4E!WI{Mz1Mn^tdiGzBb7U zF$3PQs=ErQAVa27CvM$KisGM@cHNPRdLvhhZ;Kh5q3D;38-gY$+s@sX%25lqxod|lGN16$ z{Bx*h{NlAs)NQ@-{G@xK4^s1gNL1@6;$>Q*-k5jY8#@B=fne(_-% zuEI{8_<}3Zr%wt67RPD#Xgam^W|QT{-*To#b<(6|u|>t_m;7L*?nPXWGWPO7$0S;^K}5a^h=lNim#lv#x;IokL7PMzVcfGaa8Pa2Mr zLoET^ogusz|+P4CI zet2z)S;!XXuD5U%H8iu7&{7IsJx9-LkO}nb~nb#jT|F)F==56r3I7SG`2ACJt zWXUi-xBQVmL+p3`BTn7!LVS(mHmhX_qR*w)7vA4hGa}>q8;vS@Uvo3UDcT+X;+o9=S?ER}VTZ2aVIh*k912p&QGQwK@XGu|b&>`Zr z)?_D#zu_OrlNA5NcJGQb>{jzYr()XR6!M#kayDDO%6ur@`hn)l?8UF`P?#!+nLxa6 zg3p{1J*68coN|P?k(r5CR3CK0lJVYfqBc1<6JJ{?@6&oNw4h(T_*{J#`LY-1O@81!WP#n{aervAxc=>yU{=_A7&ur~?ab*#6dKQ*rSk~M|{u%&WYUG;Pe)f|7BP|F$BMrvA#$!W2~}pITx7*2KL=kFkCT)rDKR zaAfRT)Oj0MQEcBMPG06J;fuac<;~HxV2>WaF9la&V4Y-|sxM zSFisfMtY(B#Dk?LpN7I6{=C3BP~Epy#yB&4SQanIR%|t#kd~ZW{qCG$&Lc_#OX6n8 z;nha3dP<9GQ`0l~aLnZVkm>uj`yr zZCr1SU;ZS?>?9+L7pC8gNq-uce-p;Vd`wbcbBFBrl@yISp1>OQui8L!o)h9U2P(@~Bm*|Bvu1FJ}am%WI^s27ZX(;&V zFXXC%#A(Dz-%GAUp;1$nixw023}W7Wjd?ffHekSLx^E2=BlJcmMu{_DmeIDlCJFJv zfb!&JlNBCNF@+7(EnWmEhplO~SzTVIPcJEIjCT2*Hi&aVPESf@6+B1`k<<{MaPJG| zaqdO(H}v1R4az3ogt@eOG$cpJ32a7J^&{%;<;~r`rOF810cjLalz8NM&8ci(9W@zx zxNCTxcbpW1o58ss8Hog zxST)kA>;A;VsIf!PWv_hdkkDQg^o?m#Uq+jR%_g9vbF~CUI-P-Ve!H9(`m^=N!;0XtI<;khfzLsx%rd&e(}Icf%un5srdcn zyXALxPQll;CuX8(#;AFj%`xn^Se*;!kVCC)_i*I*wF^@4GU851<)@~+cDxZWh`ge| zLfaJcMJ1#lPm`uD;B+B0hKH->HSg3rr-yMd55 zfA@nt<+Hld2)`EHNGl(i@CpxE4%29$3co2%ts4f zunlPQZ8$bvek}nhhXPZGY^>)<=kJ;7%2~HC%1Za{m)z`LT$-qJ;|?BNIfZzdclKqH zy?m(g@1&3l`JDSjeH|s{4)vqryEX+YXx7j9lH5=jKd_i#w(Rt1L%k4iE<w-%g8AO_S~w5K9S_!0HH+ob4^b@Gl)tj(h>rh2lw`$;k? zY?X%kr`*cMtH{qkbyjuuYwy2`?8|Tip=^+`4Mmkdsvm%**=4-YBfhzOlstsp4wAG) z8xg=YF(-$@eMZWLK*sP%Wl_>#aN~lXo<7sU*Xb%%ZblpFAgP><-Dg~E&hX%evf5fb zGOf@uHAK#=&0wusEZ?(=v?zYV!!x4LuV5wbttZD>AOH3aL}C4U?* zm`|(Z%+(QUZShd%WhaOCjvy8!ouAO$0C_!ObZmU+5ixv;B!M`)F}2|)msgP{NfSsM zuG{dgFEUb#)4RMzD?YGP#3Zl1+}IssW6Z3P-?F{7Ht&*2@t1e_WKilsOAym~?F}`US^X-OH3>p*~HQ zXUFVJo`dXt#$!&_RL`nex_>`QR(sHwtb$wJ-rI3IbJga6?4mw+cb;!*&52>Hp;<4{ zU>oHmf$#U7z;P&XrA~MurOvfmAQh&0`aiL4=eM>QM*9JX6SqIFcOqAUrE= zZfM%AX21|7VpDG*hW}u~7B-50ODrfKYUFs7iZh~6BpF%0wRkA76Y;zCj(#x|jPV`r ze>Ezc3ynq96JLHB3;ynbX!=xFmvQyX!e_&wZ(u`W9dYxf^kXb{_dfWkT7Pol)_b@S z*g9^dn3J8C9oOEv=v_aFDS7q}qaWg?cvwOF$G#XzMWP zFn)~X@d{0ABjSs@O-9+d+C4?ZK$Y{x=Sce!Wm=>k{a4h&XmP;#zDOV?&GP+{Lv@>z zdB1ipY%aUnQ0*%Y3OTb|az+PNk)Vl`;LgptKOr%rXOhVtoP}~SxOzMPA0DCcC_N^* zj+|0aq2kWd_{`vKr-F&aR0R4WCNm5%Du={k667h!J@d9+u^hQE6z> zeWH2QNWkpHRi+hG>atq9EamscgN4>$Qps z_a*z)HXGc)qR?hE=KjKwW#xrq1@ojAhSFk$mB&7w0Rf&NsUamoV*E_hqnA4(^cch6 z+I%LD0{ICeyVz|?*4%awTR3N;+a-s~o3u!idX56Dz;~Ni!hFXcnQnHlk@Clzxl3mU zquy}GS`Uap?bOt~>G_=zL20}bLnQ@Qf8d4SpBu4zC~va(SIl9^0y_4F=iIql%Om~1 z<{P{DKc;1_@dtaVh~29s71Q);2{Dd60f!O1F|6U*0sf1()30*M_t)l$HW`W z8hu~Q_{WLno3En;zN17)+LtG4E3S3x=xF3WMUi}42Y*2EAWfrr{2@H8(89eYboRVkR28Sqf_A9t-R_>NV9WStdCE;%1$%QLGx<2Kq)Gqz2un+|`|bH56(u~QexIFU z_3V$+DO?1EjM9K(gLsxAHfyV~x@ne+i}q#fm(h+XkDOmLLf_dE_XmHVq_~-MiiMhR z!Pn0+Uv%9ztn`oxqXS3Z9t<+RjW+1&nrRMga_1taXZ9+EP`2*pY+#-`X`(TGBy6eD z@YeUsmECbs5w#VTdAWEkXJV@plL!}vn9{pRYrUzSoDrB3qx%8{@<-iX@_zJ2t>@i(E0IR_h4T^)}FNjpXF zc}`eE;?z6Bgym0vIYc*CCyB(gggn*uVdvP6z6TDsKrsQ|@)t$>N!&|l%*2mn@{l}d zadAY;o42e-mu~@)QATiwSIUhVHP+VhtD2L$7iUq6ek+AbN5U*M25*DH#KA#KJNv7B zeV?3sj2Ps#$qyLlka*y~<_g1Q1vfk=cP9z$x_R=Yf4ibPkEek7a?}bm6Z=h-&N77t z?r!{Ed#u1UZ_gka$UwIxK;15-F&O_`M9Nav!a|g;5n>ZDvPrBeby#&?4O|nkTe3d3zU4jJz2*6zQSF_%z`}JJzFp0Wxq|^e6LD8GRuG=ukF^> zLRLrmEw1K6P?u!vyQ2vGUqOV;QkrM#l5h4T_TAqjVQIQ*vqg4oIzyD_I@YDVYKq`9)WtgXdAX{}Cj=hA@5&UFTxwj+T z_#6DI2pa0J_%}NriWAK%`aONibkouZL`A}C_BT!_KH^1*6C6}InR&dkxX?D#?{1d* zjCHoYi0LTsX_kMNq6(&+bB0H{RD*ZcR{qAYjF@CmV0B<%SL>(g%k-m5o1?hk)v5Ke z6^~zA_3y9u{hP1hF3ag)Z=y$V;ATZ==%>0Y|E1OPui0F-UmGEhkM&&hxC#_%bjdtB z=dgdbcH)O|-SMX6yf2#7SJ|0`{P}x}XUDI0NJN$W=9{e$U5WHcz~&eBo)v^hs!o zkL@@L4(G`)?$hKwvp+>tpUQR!_%|cvvX9cvR9*E(h@^Y|5`}<5Rf*ZY6Nkpy4LeY? zZ>%3|j2><*E|jf$NtDbD1{NKo16qFzTZ*cd6F+hKbJzSq-r)h#8!_}#J9k4?>SH7L z;#InieAFKivb5gTwu@+DI!0HW#piOjg=)$}WZ@Xn8FjYIo!P0yJbsm7GMOjeUr)ig z0|$lWNnZa{>b#U4?CZI4P+RLDByZjcIw>a|uAUAC zcdBzh?I9HEjHo1ZxT!jlmzPtJ*!$(*h^B(|^_I|`p!JH-kWZFZdy~wPUE?6=kT8;i zk@vK7<{ibNHqP5F$5M_*^mTZ5Zyugs`I(vv6 z3wP#2S>Vb@JN4c_tl5tX;WgZCmZz6ruwHxjpSZvujYi=0&>`RJKQTN3xNSD-F*Y*c zGO#f%w)q!6zkJpAj|7b#b6FSTCML8nBa3E7YeR1@9)W@a^-oY##J+H!J8PPr!^6*i zMkkU{{U51!(e435JBD-X8`^U`S2(`!&_a#*yK)Ng6P7*^>}bVR2~e*Kv7enU4yp@+xv-j{l@|FNGw3H|b*tauvPw z5A+t5c~LW)uFGGSzk@Q@Tfa)y9&`N<@{_goC=YC*l%vdL^l_ZQ$jP%FeoUY|D*!kZRY^2VzD% zcZAxsFl0B;LRt{JyV3N?J)-YtTtb}K)$Rmlra}{Lb;+|_U%@@vSC)uJ=n?szdnw;` z?FnhGu~f)s@RdF`vol|Qe;%G_X}J>JD|6{6T^+E3UhS~r_eXA)1?!;_1Nu9G#HQWfMlDm&vyG7HM0%o*1sLxWPgt5>RF`{ z66V7hk*^S%N^Mez5 z<*oniN_3ytIX-Svi*$bebJp52-$4awW|*e`B>?EvB@23&ak`qG)=d)2EE5edep znbyFyW)KqcmcQiAYUZO@=(WE5=L^i?qkV@o1m#s-R*V(op!b&UTa}i^a|6KpApK>H zg_2G2H=X-1bhUqDb1v?px5hD-dRk~~m82%#-P3jdI@8|ySV19r0j_CZdaF0O3PK+Z z^`rMAT5amQhzq8v${(XS@ds(aKZHLd;5oh=EjX*$r80;AB5vKV}K%2{G3Ea_%x z9gDuy1J1rPWPj+T)|HkPi=I45NB8ViOv?>%@G*J46E^Oz?|e?fy3~y9E+Zurt4|Ah z&#*+qkxqJ7pRAV7O}6Ge30~(40FHSNy`6q~y2Vq`LBey!fuR6ro zUbx`+T21p72jd#ZR`?#6?cKP%GFlF?|e*CnHy_1)tr}c*i(^VI^k-r$4nQN`vJOtb((A zI*+Ccim@Mwgw@*9KlJ@3(&-tM?}JRw@tyt7RkuZ!P_PoPls<~sKDTOi= z*tXe~YN)tOMfQexqWJ0Bj0Qtf!fE!^5IArdRXh+EXq-&`x)hh=OLWPHe?Ban7SB3|gA`LF($kWaI}^y9`l7 z)Z}2J2YV9)tu;w7Vj4@sDMdokX&P*BiDhvh<}75+recS#1+vZD-Z0mKe-IVX!`(yp zvc9!d7+5wF4cGJ?jX4=@En;vq5GAjBt*AI4zW47 zaPq%Cw>fw(O`Ogz6Yb$+Ve}~trvb?G51aR8)#0@2OwlKWuY}M*&E_1uS1?t;TwKpM zuF1g&&>p#5C}UbXNO7;-7^GaaWo!ck-USt}?juT9LxwA`W7 zW0zJs3g(ZRa~n!N?eL3$Lh4yF?b z-S~}-8IwM))`=5fk4+^OT+GPG-W%VpzOhYq>r^@dJVCw_6tgjKmw&$%PtuWm`OujI zM@2Sk;(Nl;V)BwKvi9}dGQ+{!ORCuUyWl{56E1(X7 zj&;M%(OZnAiC}PE>I&U^Aly}PiSf2==j>|A%*)DGx2fFT!pBOE6N%Uya?<8fL^FNk zMR1`ytOCZI^Z1&1dX9f%=YPON^|f4oc61D2(F<-|rndBemT~T0SAdFJS6G{9Y1iZW zyQ>6N-FSwg#=3Qrb^gq~HZS7pMcW?0%15vD{qn!29VCe7cHP(Y8(?M%lG25??(y$C zT{5fRm|AcvEyvX3FB}7_zSj*a!z3@RPmxG&dPkoA4Ys%o(yqtuy}tFtc5or4pC>Dg zGmRv*0U~paS-YOoFJD&X8Cp)?@ZG*hl@H6vRN zw{JEH;K{ohO$#{QL&-iF-n`j>^C~qSH2StlmnQ@)9`bxavM2lTo?Yw7f#Ed+u6DD# zv|C5Hw)Wgzdd18Gt1FFAPWj4tYoIG1EA_U-i4}X>KwAg-<^8a8mq3_r*$5_FE=O5d z1@C4Mx>JazAo!=^2l!mT(2mOPnBK!r; z#r*5(;r2?~^7jW^&l;Mb*c)`Aco=23QNLxwVr2oVE&PevHz4BlJ1bW%-@|PuoT2K6 zK_s8}9+jJ`2_Z>q{r&DSlhJbDC?EJBX!;x?k3LYpkvteui<<~+{GCJOzrmwV%ux%6^#_-u1)b3lVCbTz@JZ8tW@d}`=qPK051E!) zG|-Ku*ZL||8|B2E?KDiaeIIwL_%4|J)A(*9b9fhyJZCM?!)Yol45TC=_w?r{=Za?t z{qCssdQv81Z1x4{!qTEH1s77QC)R)BTL51g&>lDPJi1HIFBb;Y)>S_eJ6l+g+%R*# zmZABpEG4_@s7S$*?P;5@%2C6nRdMGAp8p(&z8Ugj|Pl& zsu{#{jXZq?a=`w?C--uhqevD_HC3i+{ki1^@BL2Txz$DsLJp zpI_ox90Qv@Xf;@M***Gcj*X`1WS|<|n(9Hy1uodPGulY;?5^QP!+Px9ef11hP!$Z=Sl1r6K=Rs2b_vc?Wl*#ADJ$~`&A`;Hr*bKU@u1@~enHCC@w!;k^ z`1z^ibVnzrr-x76h9(qDoV`;uiXXZAs=eDZ+n{=_bhY+htm0tTo4B;y^oyQ)x-eMr z3OJ)LmdcxU8K%v}gXu%EQ2|T088|0rCznM$5l7=s(!|=@qEooa*6qtiRbk_X2oTlJ z+dw}JE*+eV6MaZZP40+UG8bl1$XHtKCm?_ zxS%d0E9?;gdfW*@qofdjVIX%6A#}9?`&!kPPN63Y&N6e*#L^Yvzp&QUD`889?^Dzh zO?H8RAoGuQ3z84s=$FQ*QKlJqFY=l3iojF25cMj!E4mIbfUFWW&o^jyPYPYyRa8z};rJiI^%kvrtyniltuj*nRtTdnZ62 zC9kj5`3#>sLDNRFJi_DLl>DuF``z6R(8J2TL{hlJ$_I|Fef^!Dk>1cOzO1}f8bc5NrlP(J1Zb$5B(Fd)JxI3-adht;7@MTrnDfYBZJpVi!&)`N~Yl}H2+uI8n=9;%o(o3i$ zidB8WT^BXV#*73~g@vvdAIS=iJ7)a1?J&^--Q5@h@lv-?QOfOdz`>tDJa4y13?z3pKa2U?BEy_zvA6} zgwjoj!$!4d!$2*1$RpAKb0vCw-M6}rApE0w3)Fz)g_UKhp}ID<>;*r&+yMCG7ouQ= zhQ&kt%+(A8zAaGwEZ$5cdU31@;cvozq=^RXmv|6Xvni3wlwTdncC?~Cp3#1fcrkGW zsqZXv=`Sdp52`UYY?A7$y(Wj+?%ITfQk7k)Q}@c^_(6l|NdaV!H+XM#S-3<}GIGIw zwa{R|g=7};GV~5K!BFbNIPPDkh%G7MUb;>I&NP%41-3-%#NP(9)e#fQ7WWrWR4x7i zLyLm9Vd+()GOjHO@V%C20E;Gk0lzYR~i z>#pE}2!Kg$oCRR;raTSe@XYEV_4q__HJ|~GqB6ZRJMD=94{biA-U|f;T81;UD0*AG zSH7+q^MR8a?8DL8o1uVx#K86sfdIx{4C?4o|J=HNT@Bgo76ZEkV1&d{&Y~YBW!AoW zl6p}$?C`Q+40hKE$MFs^e}*0?LOXaqOG?1%!J>-PC<_1*DkpxqZ5%l~$MRZ2{3w{@ z;kP`^7wu0JYFLXpHzsR5z z=Ry*oZ{)uTO-PhT3Pg6<2kMpair*tKkFau-sRU8NtRlEjVtA-A|&SiN$f;GDbJ@4S9_Cx6@Gx zV#yW`9_C0fB^y(=P=FxUAEVd1Q=Hd}DI7_8RH_pUw9jO8PsRDkpWHoh6SD0Af%spG zA=lA*kKeliscaU#pE|Q1n7^XnaG4MW;Z-^`@qPYfLtQVad%9VaeD#zqW(0{0VrNs4 z1k{)Sg<)1^wkO{Knf0*c0Z3Xr<34@smrHG-mN^33OeMyaM>j0r^gT&FOPk>-5!gAX zVbPTE+fC28#^#SbwZjuwp+>EAR#y^uF90-Gh;y`AA!WJZytJzgk1EkKzzkjIJ5CTRoGwW-0S!^zj zTc|MtV;&sIYLP2X)DWe2-`=NE88MzSwCIP?Aeh=-h0)P+x%Zgz;3{fO32AAp;eg7} z$TO6E;!Mzwne~T^E|EuRVrvUgf>QO3eA@VI@SO@ZD?Nn2d`spLTU?Cn(PM2!J`{+g zOjZrfzDP*jrV2k2(Gr+%>iE&O);qogFuK+n%`{^4Xo2W75g|_nfPHAI_Smy_dhHlY z{mfH90cp|G+{sjsh2A&Go>RlUz!`JCn^}H(2mLxzSzrgg*|{Rs}f6OBNQEE}gXTa=-$V(tWj^*0K(tOu=F? z(EfqbK>PJC{`kv%XPFpjxhig}E%NbY<>w35)`w$em;qDw+7{_T4tj}$+$F?0 zB6rt<8)2NfU*>s^kB^VP{o&NkWkL(6V_A&9nAqYW14`3h^h*I9tVTodBl zJuF!0B|=h=hg%=#*LFVtWAD7^rZ)ISUi_&fbqqAVWO++oXe>9p4|487S`;WhU5CXk zSu)q1;R3CWf4yVntG9`No+kzX*uT6{B9a?B5yi!YYDLz7{XNSgO9ojB@Ap-X0SgHI zau5v1w*#UbTZ3z50khrWt4F{q z2@oV9IHJUMm;jzl=%($VjeuegI|BI)@+>TV1%vGt_Cr$`uDz;ksZRjmWXrl;l(8#f zz7bfd=INk}slbq&TJto6y6@potSX0E$UyMANcVBw62M+7V8xbpiz#VuzNr|ohOM-N z1M}V>jEY>l+BWbipoz!!)yZ;}HP)B7-`<4q{F(wjrBQ3$ceehFcDj)Vn~I?(uvMmpwjywG`;Cb!m|ty9Y9;?F}9s!ee-1RIlc3Bs4aeuN&kblO)B z1k?_tw8c6+6#^%i4pX-n`U&g4mG=|LMxe*nXoOC~;ch$Us`4t#mseG#GWj=HP@ecd zdfW)n*J>{^ajj`sgL%?hDG)styDJF9D3-NI>eRRG&!|~&65985*pS~8c1xwJ3wD$( zH)^pApa=>C{ShuSkY8}Z<})bU(T{0LS0}F_XH&CWv-J)D7jD#a-iKSBS#96vqtzAE zW~V{+Hf-KpoAmW;+SC#SRvM~Gy)~_qcjr)W?oDxYr?jIa-gf9c0(&<2JZSuy11m&L~Vqp$6wS z%BdT*SuK4aii?vlIq7A63K`DOe0mbcxs+#-HicvYnN-l}QwHK`a|D~5Q%TK9xBq4c zO5c+_6Xj$%?+~;Bn$u57$4I`Hn_K*;4!_o#bPLKATxvipPWoOG90{b7{{Bmp#ryuW zHmN5b!%@YNkTT3(ZkH9IZeqaENA)ez&r9!0WL=dX+W2`19@TLk(v3%+?F`Nq{}R|M z4%&+})fN-lSLe)bj~ObTqaSmWtd_YqM1GH&<;n*J1-jhPX)o#Kw~K=Ha)J}9F-Nw< z_KB4hUpCtRz{{|mRT+&-Yf)OnpS}s@vP$0*cJ>Ijf=2j%scxf!k#+V2$%Pc(6!+=r~Xyz z?C%HFlb}QzfjH>$W`|0xASO`YI;7E}r$k`#1(c0G-|I)Nxgw*{Xa*X`L>(FC7B;+a zN1~et#?@AV;^E>fBjT21KO9hP!PfJepKE3|;AbClU%52-49YHM{S&mXxf>y(Gz?Z8 z=5AdiGPh33INloRF910_;SzIF<}It46U?Vh-CQd{v56&Cy6K)|`dLkwzkYxqml@tT-@szWR4tOwv0s{?;4+l)V7 zeK|08Wn?s}JpgOBPgSqW5nn=UMmgQMc&lze-BCbxV%~kSa2KGbZq>14jP{wJAX9r$ zPM5twx)kUU&Dk`j{O*Kh3J80mR775g4Fab@TIoG%RLY${7g2!r9yg zZGkka#a5!SrhY#Ax};B?QCB+s2=O+&mJHMI<&viLVD7ws+t}T&I~Kg_0h)U_;8?=s z1NhCyZ;mS4(~t2+vNi_266C!?owhoojrvrZl}?F#+*yCewdd9kh!OD8%Ey$%+`o&<+;x0X&;618}ZJjwmw?!zP@P49>w)pf{}Sb&F0%v*w6x@MDf23 zIugd|Il=+IB*Ol*Jtm0<$9gMpwth7OLH&3StCeEpE^y53n>oeMoZr?FHzdKR>1&y6 zkKdtsK4VPnVY0bB{WY!;BIe&Os6%}6Y-h9Kz6;ljkAvoht4riYR41(d&7Ejl9!cqk z#F~Y{jRKVY-U+H^XjoXsifK7$KzzdSu3tLEA|J?iZF|{5RFOeC3q%qY4EYlTBtjLm z=*QSjvq_5*r0~>&?9>&ljttrUS~^Mm!nMrB7Ix>p+G4OJpA#q%MTL7(ywNE$Ftqq& zK&(P_CHM22x384#9DJ&d0w;G6@|b;*aL(YvA>YhXuIc%=J-9`eaQF}6_iX9;sm28S-$Ogjj}9E9udHErX6_w&59O-t zm!hcpYT4oz1Drg(!;_Pq-}*G}HK-C)hT?mE%#u3ua<1ARNo7Afj{M$O*tN&SD^;#~ zc&_a~|G-`xQSxpZKYvQu{CoZ_i0={{=1W=hd3Q*4@sSP@Cf~oy6~CXO6@MLnf(9A% z+?qkf0N&LLzA&M&!&|qfyVDlEUfz7t!OKRwtU7?jni=VzV{ARJ?g=dkEPNeKi&{46 zP26d8SJVi|V{Ezg*Qf<~8+FibxrA6=r0V!(Ci|Uo$|_7<8=36DpX{Vsq}ZB1`3wDT0~%%M&fum>rMo3N zn?310={=$IQ+C}E+GQP*Y1_Q-UOB_jFsfc$^4c5;EF*S3+x~KnH4O0%^){fPhgIN! zY8FQen>xX*+ZS9zul$;MXLN>fHHH;s3OxS?ia&DR7oPOxz2)6_yiwo9woSyX-xpKH zS?mEuf;R|fDSMs%t1QcBuJmf$C#q1kl@lbv&0Pq(Xl2jl8N`fmIH90QyDWGA(nX7k z)~*bQYP4P>1rPY{2&+fZXP=`%@?1V8P_c>qgl>za8m;BvjeCFgRVI$R-K5-kwqfl@ z8r=19AjpBHSGUU74_%uv^0Xcy22WU=(W-3;n?m8>B{tx9oe|L2svG1r5-${+3iG}I zx!)!_;i#_Ujm!1{qX?cHj}(5{!H5H?vIVp9UsN=_^rTBMKguXZ^hytiKSH&oZU39) zZe(2^=H>SihxW~vOp!0J3A=ooV*d)GK~O=Z;P8C8>@VVL;3Lpgrj~7}>=5f7QW1Zg zk3wT8CLiZI^m4D4nD9?D3JFnd)>nL|p&Itu;QF5{Jx70vsX3v)c~a@_6}^>RjH+S~ zmZl{hHMmGZVa_1Nm8p zhBBh5o`dT5Km(EN)&7SEMM^XZ;VnD70~)8 zVpS)J&WzRplAob;_s0#URXQvGsCL}C+RVyjiiT|O&OR6@#;e`r_d%GAO z3a!Akhwf_}A__B=kVZwRJa+_i{-4*~}; z0T-wgcY%TS%Ak3ELP`tXk%5wzo`_M}m$?$LUuM^>`Sxu3y>+_tSe&8SN68}ot@3g$ zC%tdm3g$FS5VXCZFKDwNxwCvMFA3@fPoXB{$5n1z`?og9er{V5gD#EgJ;-v%lx@;8 z7Y>064KdT+-LfuEyE{-9Inln`K*Z%s2a8RmXZDWKDgIN?F%NEnLV5=mh+v3`4O@Si0TSj`J5O2HY7M{}g zNTAI0p7X?9NT~AQpxuHUCt%uR!+Van!~H$>X4vtu&e`nM&5FFG+99@aIuI$|n=(~( zTcTAEqRH+bQ4%}|3Q|x8xTh&ESXs_w)Z1G<{9 literal 0 HcmV?d00001 diff --git a/src/main/webapp/public/blank-profile-picture.png b/src/main/webapp/public/blank-profile-picture.png new file mode 100644 index 0000000000000000000000000000000000000000..9e342938b026768edad7fdfbe04b6e551388efd9 GIT binary patch literal 37098 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 literal 0 HcmV?d00001 diff --git a/src/main/webapp/src/pages/MePage.jsx b/src/main/webapp/src/pages/MePage.jsx index edaa3fa..3ae80a2 100644 --- a/src/main/webapp/src/pages/MePage.jsx +++ b/src/main/webapp/src/pages/MePage.jsx @@ -4,7 +4,7 @@ import {useFetch} from "../hooks/useFetch.js"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import { faCalendarDay, - faEnvelope, faFlag, + faEnvelope, faFilePdf, faFlag, faInfoCircle, faMars, faMarsAndVenus, @@ -77,6 +77,13 @@ function PhotoCard({data}) { alt="avatar" className="rounded-circle img-fluid" style={{object_fit: 'contain'}}/>
    + + + + ; } diff --git a/src/main/webapp/src/pages/admin/member/MemberPage.jsx b/src/main/webapp/src/pages/admin/member/MemberPage.jsx index dc02918..7cf7cad 100644 --- a/src/main/webapp/src/pages/admin/member/MemberPage.jsx +++ b/src/main/webapp/src/pages/admin/member/MemberPage.jsx @@ -9,6 +9,8 @@ import {LicenceCard} from "./LicenceCard.jsx"; import {toast} from "react-toastify"; import {apiAxios, errFormater} from "../../../utils/Tools.js"; import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faFilePdf} from "@fortawesome/free-solid-svg-icons"; const vite_url = import.meta.env.VITE_URL; @@ -83,6 +85,12 @@ function PhotoCard({data}) { alt="avatar" className="rounded-circle img-fluid" style={{object_fit: 'contain'}}/> + + + ; } diff --git a/src/main/webapp/src/pages/club/member/MemberPage.jsx b/src/main/webapp/src/pages/club/member/MemberPage.jsx index 1d44a65..66a9709 100644 --- a/src/main/webapp/src/pages/club/member/MemberPage.jsx +++ b/src/main/webapp/src/pages/club/member/MemberPage.jsx @@ -8,6 +8,8 @@ import {LicenceCard} from "./LicenceCard.jsx"; import {ConfirmDialog} from "../../../components/ConfirmDialog.jsx"; import {apiAxios, errFormater} from "../../../utils/Tools.js"; import {toast} from "react-toastify"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faFilePdf} from "@fortawesome/free-solid-svg-icons"; const vite_url = import.meta.env.VITE_URL; @@ -81,6 +83,12 @@ function PhotoCard({data}) { alt="avatar" className="rounded-circle img-fluid" style={{object_fit: 'contain'}}/> + + + ; } From b17abd776ee944a20356e86f2feef8a95cb526f1 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Sun, 29 Dec 2024 13:13:12 +0100 Subject: [PATCH 37/37] update: ci/cd --- .gitea/workflows/deploy_in_prod.yml | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .gitea/workflows/deploy_in_prod.yml diff --git a/.gitea/workflows/deploy_in_prod.yml b/.gitea/workflows/deploy_in_prod.yml new file mode 100644 index 0000000..7367f7c --- /dev/null +++ b/.gitea/workflows/deploy_in_prod.yml @@ -0,0 +1,41 @@ +name: Deploy Production Server + +# Only run the workflow when a PR is merged on main and closed +on: + pull_request: + types: + - closed + branches: + - 'master' + +# Here we check that the PR was correctly merged to main +jobs: + if_merged: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'graalvm' + cache: 'maven' + + - name: Build site + run: | + cp vite.env src/main/webapp/.env + cd src/main/webapp + npm install + npm run build + cd ../../.. + rm -rf src/main/resources/META-INF/resources + mkdir -p src/main/resources/META-INF/ + mv dist src/main/resources/META-INF/resources + + - name: Build application + run: | + cp ../vite.env src/main/webapp/.env + chmod 740 mvnw + ./mvnw package -Pnative -DskipTests \ No newline at end of file