From f8119647ab9c97531617c9f8c15d6125f172e680 Mon Sep 17 00:00:00 2001 From: niladic Date: Mon, 18 Nov 2024 16:10:31 +0100 Subject: [PATCH] =?UTF-8?q?Ajoute=20l=E2=80=99=C3=A9tat=20du=20d=C3=A9ploi?= =?UTF-8?q?ement=20sur=20la=20page=20de=20stats=20publique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/actions/LoginAction.scala | 6 +- app/controllers/ApiController.scala | 123 ++++---------------- app/controllers/ApplicationController.scala | 8 +- app/controllers/SignupController.scala | 8 +- app/serializers/ApiModel.scala | 1 + app/services/DataService.scala | 119 +++++++++++++++++++ app/services/UserGroupService.scala | 3 +- app/services/UserService.scala | 4 +- app/views/publicStats.scala | 122 ++++++++++++++++++- package.json | 1 + typescript/webpack/webpack.common.js | 3 +- 11 files changed, 281 insertions(+), 117 deletions(-) create mode 100644 app/services/DataService.scala diff --git a/app/actions/LoginAction.scala b/app/actions/LoginAction.scala index 183902d6d..1d61fb5b1 100644 --- a/app/actions/LoginAction.scala +++ b/app/actions/LoginAction.scala @@ -65,7 +65,7 @@ class LoginAction @Inject() ( userService, ) { - def withPublicPage(publicPage: Result): BaseLoginAction = + def withPublicPage(publicPage: IO[Result]): BaseLoginAction = new BaseLoginAction( config, dependencies, @@ -89,7 +89,7 @@ class BaseLoginAction( signupService: SignupService, tokenService: TokenService, userService: UserService, - publicPage: Option[Result] = none, + publicPage: Option[IO[Result]] = none, ) extends ActionBuilder[RequestWithUserData, AnyContent] with ActionRefiner[Request, RequestWithUserData] { @@ -202,7 +202,7 @@ class BaseLoginAction( log.warn(s"Accès à la page ${request.path} non autorisé") Future(userNotLogged("Vous devez vous identifier pour accéder à cette page.")) case Some(page) => - Future.successful(page.asLeft) + page.map(_.asLeft).unsafeToFuture() } } } diff --git a/app/controllers/ApiController.scala b/app/controllers/ApiController.scala index 89cfa4121..b023ad6e7 100644 --- a/app/controllers/ApiController.scala +++ b/app/controllers/ApiController.scala @@ -7,7 +7,7 @@ import helper.StringHelper import java.time.ZonedDateTime import java.util.UUID import javax.inject.{Inject, Singleton} -import models.{Area, Authorization, Error, EventType, Organisation, User, UserGroup} +import models.{Area, Authorization, Error, EventType, Organisation, UserGroup} import play.api.libs.json.{JsValue, Json} import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents, Result} import scala.concurrent.{ExecutionContext, Future} @@ -15,8 +15,10 @@ import serializers.ApiModel._ import serializers.Keys import services.{ AnonymizedDataService, + DataService, EventService, OrganisationService, + ServicesDependencies, UserGroupService, UserService } @@ -25,16 +27,20 @@ import services.{ case class ApiController @Inject() ( anonymizedDataService: AnonymizedDataService, val controllerComponents: ControllerComponents, - loginAction: LoginAction, + dataService: DataService, + dependencies: ServicesDependencies, eventService: EventService, + loginAction: LoginAction, organisationService: OrganisationService, + userGroupService: UserGroupService, userService: UserService, - userGroupService: UserGroupService )(implicit val ec: ExecutionContext) extends BaseController with UserOperators { import OrganisationService.FranceServiceInstance + import dependencies.ioRuntime + def franceServices: Action[AnyContent] = loginAction.async { implicit request => asUserWithAuthorization(Authorization.isAdminOrObserver)( @@ -396,112 +402,25 @@ case class ApiController @Inject() ( } } - private val organisationSetAll: List[Set[Organisation]] = { - val groups: List[Set[Organisation]] = List( - Set("DDFIP", "DRFIP"), - Set("CPAM", "CRAM", "CNAM"), - Set("CARSAT", "CNAV") - ).map(_.flatMap(id => Organisation.byId(Organisation.Id(id)))) - val groupedSet: Set[Organisation.Id] = groups.flatMap(_.map(_.id)).toSet - val nonGrouped: List[Organisation] = - Organisation.organismesOperateurs.filterNot(org => groupedSet.contains(org.id)) - groups ::: nonGrouped.map(Set(_)) - } - - private val organisationSetFranceService: List[Set[Organisation]] = ( - List( - Set("DDFIP", "DRFIP"), - Set("CPAM", "CRAM", "CNAM"), - Set("CARSAT", "CNAV"), - Set("ANTS", "Préf") - ) ::: - List( - "CAF", - "CDAD", - "La Poste", - "MSA", - "Pôle emploi" - ).map(Set(_)) - ).map(_.flatMap(id => Organisation.byId(Organisation.Id(id)))) - - private def organisationSetId(organisations: Set[Organisation]): String = - organisations.map(_.id.toString).mkString - def deploymentData: Action[AnyContent] = loginAction.async { implicit request => asUserWithAuthorization(Authorization.isAdminOrObserver)( EventType.DeploymentDashboardUnauthorized, "Accès non autorisé au dashboard de déploiement" ) { () => - val userGroups = userGroupService.allOrThrow - userService.allNoNameNoEmail.map { users => - def usersIn(area: Area, organisationSet: Set[Organisation]): List[User] = - for { - group <- userGroups.filter(group => - group.areaIds.contains[UUID](area.id) - && organisationSet.exists( - group.organisation - .orElse(Organisation.deductedFromName(group.name)) - .contains[Organisation] - ) - ) - user <- users if user.groupIds.contains[UUID](group.id) - } yield user - - val organisationSets: List[Set[Organisation]] = - if (request.getQueryString(Keys.QueryParam.uniquementFs).getOrElse("oui") === "oui") { - organisationSetFranceService - } else { - organisationSetAll - } - - val areasData = for { - area <- request.currentUser.areas.flatMap(Area.fromId).filterNot(_.name === "Demo") - } yield { - val numOfInstructors: Map[Set[Organisation], Int] = ( - for { - organisations <- organisationSets - users = usersIn(area, organisations) - userSum = users - .filter(user => user.instructor && !user.disabled) - .map(_.id) - .distinct - .size - } yield (organisations, userSum) - ).toMap - - DeploymentData.AreaData( - areaId = area.id.toString, - areaName = area.toString, - numOfInstructorByOrganisationSet = numOfInstructors.map { - case (organisations, count) => (organisationSetId(organisations), count) - }, - numOfOrganisationSetWithOneInstructor = numOfInstructors - .count { case (_, numOfInstructors) => numOfInstructors > 0 } - ) + val organisationSets: List[Set[Organisation]] = + if (request.getQueryString(Keys.QueryParam.uniquementFs).getOrElse("oui") === "oui") { + DataService.organisationSetFranceService + } else { + DataService.organisationSetAll } - - val numOfAreasWithOneInstructorByOrganisationSet = - organisationSets.map { organisations => - val id = organisationSetId(organisations) - val count = - areasData.count(data => data.numOfInstructorByOrganisationSet.getOrElse(id, 0) > 0) - (id, count) - }.toMap - - val data = DeploymentData( - organisationSets = organisationSets.map(organisations => - DeploymentData.OrganisationSet( - id = organisationSetId(organisations), - organisations = organisations - ) - ), - areasData = areasData, - numOfAreasWithOneInstructorByOrganisationSet = - numOfAreasWithOneInstructorByOrganisationSet - ) - Ok(Json.toJson(data)) - } + val areas = request.currentUser.areas.flatMap(Area.fromId).filterNot(_.name === "Demo") + dataService + .operateursDeploymentData(organisationSets, areas) + .map { data => + Ok(Json.toJson(data)) + } + .unsafeToFuture() } } diff --git a/app/controllers/ApplicationController.scala b/app/controllers/ApplicationController.scala index 3485dddc0..b5d4a3497 100644 --- a/app/controllers/ApplicationController.scala +++ b/app/controllers/ApplicationController.scala @@ -67,6 +67,7 @@ import serializers.Keys import services.{ ApplicationService, BusinessDaysService, + DataService, EventService, FileService, MandatService, @@ -88,6 +89,7 @@ case class ApplicationController @Inject() ( businessDaysService: BusinessDaysService, config: AppConfig, val controllerComponents: ControllerComponents, + dataService: DataService, dependencies: ServicesDependencies, eventService: EventService, fileService: FileService, @@ -872,7 +874,11 @@ case class ApplicationController @Inject() ( } } - val statsAction: BaseLoginAction = loginAction.withPublicPage(Ok(views.publicStats.page)) + val statsAction: BaseLoginAction = loginAction.withPublicPage( + dataService + .operateursDeploymentData(DataService.organisationSetFranceService, Area.allExcludingDemo) + .map(deploymentData => Ok(views.publicStats.page(deploymentData))) + ) def stats: Action[AnyContent] = statsAction.async { implicit request => diff --git a/app/controllers/SignupController.scala b/app/controllers/SignupController.scala index bff85e97d..5716f5dfd 100644 --- a/app/controllers/SignupController.scala +++ b/app/controllers/SignupController.scala @@ -56,9 +56,9 @@ case class SignupController @Inject() ( EventType.SignupFormShowed, s"Visualisation de la page d'inscription ${signupRequest.id}" ) - groupService.all.map(groups => - Ok(views.signup.page(signupRequest, SignupFormData.form, groups)) - ) + groupService.all + .map(groups => Ok(views.signup.page(signupRequest, SignupFormData.form, groups))) + .unsafeToFuture() } def createSignup: Action[AnyContent] = @@ -73,6 +73,7 @@ case class SignupController @Inject() ( ) groupService.all .map(groups => BadRequest(views.signup.page(signupRequest, formWithError, groups))) + .unsafeToFuture() }, form => { // `SignupFormData` is supposed to validate that boolean, @@ -132,6 +133,7 @@ case class SignupController @Inject() ( .map(groups => InternalServerError(views.signup.page(signupRequest, formWithError, groups)) ) + .unsafeToFuture() }, _ => LoginAction.readUserRights(user).flatMap { userRights => diff --git a/app/serializers/ApiModel.scala b/app/serializers/ApiModel.scala index 231843bb3..97d033274 100644 --- a/app/serializers/ApiModel.scala +++ b/app/serializers/ApiModel.scala @@ -126,6 +126,7 @@ object ApiModel { case class AreaData( areaId: String, areaName: String, + areaCode: String, numOfInstructorByOrganisationSet: Map[String, Int], numOfOrganisationSetWithOneInstructor: Int ) diff --git a/app/services/DataService.scala b/app/services/DataService.scala new file mode 100644 index 000000000..1f0d87fe8 --- /dev/null +++ b/app/services/DataService.scala @@ -0,0 +1,119 @@ +package services + +import cats.effect.IO +import java.util.UUID +import javax.inject.{Inject, Singleton} +import models.{Area, Organisation, User} +import serializers.ApiModel.DeploymentData + +object DataService { + + val organisationSetAll: List[Set[Organisation]] = { + val groups: List[Set[Organisation]] = List( + Set("DDFIP", "DRFIP"), + Set("CPAM", "CRAM", "CNAM"), + Set("CARSAT", "CNAV") + ).map(_.flatMap(id => Organisation.byId(Organisation.Id(id)))) + val groupedSet: Set[Organisation.Id] = groups.flatMap(_.map(_.id)).toSet + val nonGrouped: List[Organisation] = + Organisation.organismesOperateurs.filterNot(org => groupedSet.contains(org.id)) + groups ::: nonGrouped.map(Set(_)) + } + + val organisationSetFranceService: List[Set[Organisation]] = ( + List( + Set("DDFIP", "DRFIP"), + Set("CPAM", "CRAM", "CNAM"), + Set("CARSAT", "CNAV"), + Set("ANTS", "Préf") + ) ::: + List( + "CAF", + "CDAD", + "La Poste", + "MSA", + "Pôle emploi" + ).map(Set(_)) + ).map(_.flatMap(id => Organisation.byId(Organisation.Id(id)))) + +} + +@Singleton +class DataService @Inject() ( + userService: UserService, + userGroupService: UserGroupService, +) { + + private def organisationSetId(organisations: Set[Organisation]): String = + organisations.map(_.id.toString).mkString + + def operateursDeploymentData( + organisationSets: List[Set[Organisation]], + areas: List[Area] + ): IO[DeploymentData] = + userGroupService.all.flatMap { userGroups => + userService.allNoNameNoEmail.map { users => + def usersIn(area: Area, organisationSet: Set[Organisation]): List[User] = + for { + group <- userGroups.filter(group => + group.areaIds.contains[UUID](area.id) + && organisationSet.exists( + group.organisation + .orElse(Organisation.deductedFromName(group.name)) + .contains[Organisation] + ) + ) + user <- users if user.groupIds.contains[UUID](group.id) + } yield user + + val areasData = for { + area <- areas + } yield { + val numOfInstructors: Map[Set[Organisation], Int] = ( + for { + organisations <- organisationSets + users = usersIn(area, organisations) + userSum = users + .filter(user => user.instructor && !user.disabled) + .map(_.id) + .distinct + .size + } yield (organisations, userSum) + ).toMap + + DeploymentData.AreaData( + areaId = area.id.toString, + areaName = area.toString, + areaCode = area.inseeCode, + numOfInstructorByOrganisationSet = numOfInstructors.map { case (organisations, count) => + (organisationSetId(organisations), count) + }, + numOfOrganisationSetWithOneInstructor = numOfInstructors + .count { case (_, numOfInstructors) => numOfInstructors > 0 } + ) + } + + val numOfAreasWithOneInstructorByOrganisationSet = + organisationSets.map { organisations => + val id = organisationSetId(organisations) + val count = + areasData.count(data => data.numOfInstructorByOrganisationSet.getOrElse(id, 0) > 0) + (id, count) + }.toMap + + val data = DeploymentData( + organisationSets = organisationSets.map(organisations => + DeploymentData.OrganisationSet( + id = organisationSetId(organisations), + organisations = organisations + ) + ), + areasData = areasData, + numOfAreasWithOneInstructorByOrganisationSet = + numOfAreasWithOneInstructorByOrganisationSet + ) + data + } + } + +} diff --git a/app/services/UserGroupService.scala b/app/services/UserGroupService.scala index 6fb5d9763..08c8fe786 100644 --- a/app/services/UserGroupService.scala +++ b/app/services/UserGroupService.scala @@ -2,6 +2,7 @@ package services import anorm._ import aplus.macros.Macros +import cats.effect.IO import cats.syntax.all._ import helper.{Time, UUIDHelper} import java.sql.ResultSet @@ -144,7 +145,7 @@ class UserGroupService @Inject() ( SQL(s"SELECT $fieldsInSelect FROM user_group").as(simpleUserGroup.*) } - def all: Future[List[UserGroup]] = Future(allOrThrow) + def all: IO[List[UserGroup]] = IO.blocking(allOrThrow) def byIds(groupIds: List[UUID]): List[UserGroup] = db.withConnection { implicit connection => diff --git a/app/services/UserService.scala b/app/services/UserService.scala index d5324da47..9e38c53a2 100644 --- a/app/services/UserService.scala +++ b/app/services/UserService.scala @@ -105,8 +105,8 @@ class UserService @Inject() ( private val fieldsInSelect: String = tableFields.mkString(", ") - def allNoNameNoEmail: Future[List[User]] = - Future { + def allNoNameNoEmail: IO[List[User]] = + IO.blocking { db.withConnection { implicit connection => SQL(s"""SELECT $fieldsInSelect, '' as name, '' as email, '' as qualite FROM "user"""") .as(simpleUser.*) diff --git a/app/views/publicStats.scala b/app/views/publicStats.scala index e872cd423..d6acf4c3b 100644 --- a/app/views/publicStats.scala +++ b/app/views/publicStats.scala @@ -1,18 +1,32 @@ package views -import controllers.routes.ApplicationController +import controllers.routes.{ApplicationController, Assets} +import play.api.libs.json.Json import scalatags.Text.all._ +import scalatags.Text.tags2 +import serializers.ApiModel.DeploymentData object publicStats { - def page: Tag = + def page(deploymentData: DeploymentData): Tag = views.main.publicLayout( "Statistiques - Administration+", - mainContent, + mainContent(deploymentData), breadcrumbs = ("Statistiques", ApplicationController.stats.url) :: Nil, + additionalHeadTags = frag( + link( + rel := "stylesheet", + href := Assets.versioned("generated-js/dsfr-chart/Charts/dsfr-chart.css").url + ) + ), + additionalFooterTags = frag( + script( + src := Assets.versioned("generated-js/dsfr-chart/Charts/dsfr-chart.umd.js").url + ), + ), ) - private val mainContent: Frag = + private def mainContent(deploymentData: DeploymentData): Frag = frag( div( cls := "fr-container fr-my-6w", @@ -91,7 +105,107 @@ object publicStats { ) ) ), + div( + cls := "fr-grid-row fr-grid-row--gutters", + deployment(deploymentData) + ), + ) + ) + + def deployment(deploymentData: DeploymentData): Frag = + div( + cls := "fr-col-12", + h2("Déploiement territorial"), + div(cls := "fr-mb-4w")( + deploymentMap(deploymentData), + ), + tags2.section(cls := "fr-accordion")( + h3(cls := "fr-accordion__title")( + button( + cls := "fr-accordion__btn", + aria.expanded := "false", + aria.controls := "deployment-table-accordion" + )("Chiffres de la couverture territoriale sous forme de tableau") + ), + div(cls := "fr-collapse", id := "deployment-table-accordion")( + deploymentTable(deploymentData) + ) + ), + ) + + def deploymentMap(deploymentData: DeploymentData): Frag = { + val totalCount = deploymentData.organisationSets.length + val data = Json.stringify( + Json.obj( + deploymentData.areasData.map(area => + area.areaCode -> area.numOfOrganisationSetWithOneInstructor + ): _* + ) + ) + div(cls := "part_container")( + tag("map-chart")( + id := "deploiment-map-chart", + attr("data") := data, + attr("valuenat") := totalCount, + attr( + "name" + ) := "Nombre d’organismes ayant au moins un agent habilité à répondre aux demandes", + attr("color") := "blue-ecume", + ) + ) + } + + def deploymentTable(deploymentData: DeploymentData): Frag = { + val tableHeader = "Département" :: "Couverture" :: deploymentData.organisationSets.map( + _.organisations.map(_.shortName).mkString(" / ") + ) + + val tableRows: List[List[Tag]] = deploymentData.areasData.map(area => + th(attr("scope") := "row")(area.areaName) :: + td(cls := "fr-cell--right")( + area.numOfOrganisationSetWithOneInstructor.toString + " / " + deploymentData.organisationSets.length + ) :: + deploymentData.organisationSets.map(orgSet => + td(cls := "fr-cell--right")( + area.numOfInstructorByOrganisationSet.get(orgSet.id).map(_.toString).getOrElse("N/A") + ) + ) + ) + + div(cls := "fr-table fr-table--bordered")( + div(cls := "fr-table__wrapper")( + div(cls := "fr-table__container")( + div(cls := "fr-table__content")( + table( + caption( + "Couverture territoriale", + div(cls := "fr-table__caption__desc")( + "La colonne de couverture indique le nombre d’organismes ayant au moins un agent habilité à répondre aux demandes sur Administration+. Les autres colonnes indiquent le nombre d’agents par organisme." + ) + ), + thead( + tr( + frag( + tableHeader.map(header => + th(attr("scope") := "col", cls := "fr-cell--multiline")(header) + ) + ) + ) + ), + tbody( + frag( + tableRows.map(row => + tr( + frag(row) + ) + ) + ) + ) + ) + ) + ) ) ) + } } diff --git a/package.json b/package.json index e4494d90e..1faec85d4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@gouvfr/dsfr": "1.12.1", + "@gouvfr/dsfr-chart": "1.0.0", "dialog-polyfill": "0.5.6", "material-icons": "^1.13.12", "proxy-polyfill": "0.3.2", diff --git a/typescript/webpack/webpack.common.js b/typescript/webpack/webpack.common.js index 339c1ca87..df44e5852 100644 --- a/typescript/webpack/webpack.common.js +++ b/typescript/webpack/webpack.common.js @@ -9,7 +9,7 @@ const CopyPlugin = require("copy-webpack-plugin"); module.exports = { target: 'es5', entry: { - index: path.join(__dirname, '../src/index.ts') + index: path.join(__dirname, '../src/index.ts'), }, output: { path: path.join(__dirname, '../../public/generated-js'), @@ -62,6 +62,7 @@ module.exports = { { context: "../node_modules/@gouvfr/dsfr/dist", from: "utility/utility.min.css*" }, { context: "../node_modules/@gouvfr/dsfr/dist", from: "dsfr/dsfr.module.min.js*" }, { context: "../node_modules/@gouvfr/dsfr/dist", from: "dsfr/dsfr.nomodule.min.js*" }, + { context: "../node_modules/@gouvfr", from: "dsfr-chart/Charts/dsfr-chart*" }, { from: "../node_modules/@gouvfr/dsfr/dist/fonts", to: "fonts" }, { from: "../node_modules/@gouvfr/dsfr/dist/icons", to: "icons" }, ],