diff --git a/app/controllers/ApplicationController.scala b/app/controllers/ApplicationController.scala index 30ab7c278..ea97ff6c4 100644 --- a/app/controllers/ApplicationController.scala +++ b/app/controllers/ApplicationController.scala @@ -21,10 +21,12 @@ import play.api.cache.AsyncCacheApi import play.api.data.Forms._ import play.api.data._ import play.api.data.validation.Constraints._ +import play.api.libs.json.Json import play.api.libs.ws.WSClient import play.api.mvc._ import play.twirl.api.Html import serializers.Keys +import serializers.ApiModel.{ApplicationMetadata, ApplicationMetadataResult} import services._ import views.stats.StatsData @@ -419,17 +421,28 @@ case class ApplicationController @Inject() ( applicationService.allForAreas(List(area.id), numOfMonthsDisplayed.some) case (false, None) if user.groupAdmin => val userIds = userService.byGroupIds(user.groupIds, includeDisabled = true).map(_.id) - applicationService.allForUserIds(userIds) + applicationService.allForUserIds(userIds, numOfMonthsDisplayed.some) case (false, Some(area)) if user.groupAdmin => - val userGroupIds = - userGroupService.byIds(user.groupIds).filter(_.areaIds.contains[UUID](area.id)).map(_.id) - val userIds = userService.byGroupIds(userGroupIds, includeDisabled = true).map(_.id) - applicationService.allForUserIds(userIds) + val userIds = userService.byGroupIds(user.groupIds, includeDisabled = true).map(_.id) + applicationService + .allForUserIds(userIds, numOfMonthsDisplayed.some) + .map(_.filter(application => application.area === area.id)) case _ => - Future(Nil) + Future.successful(Nil) } - def all(areaId: UUID): Action[AnyContent] = + private def extractApplicationsAdminQuery(implicit + request: RequestWithUserData[_] + ): (Option[Area], Int) = { + val areaOpt = areaInQueryString.filterNot(_.id === Area.allArea.id) + val numOfMonthsDisplayed: Int = request + .getQueryString(Keys.QueryParam.numOfMonthsDisplayed) + .flatMap(s => Try(s.toInt).toOption) + .getOrElse(3) + (areaOpt, numOfMonthsDisplayed) + } + + def applicationsAdmin: Action[AnyContent] = loginAction.async { implicit request => (request.currentUser.admin, request.currentUser.groupAdmin) match { case (false, false) => @@ -443,29 +456,60 @@ case class ApplicationController @Inject() ( ) ) case _ => - val numOfMonthsDisplayed: Int = request - .getQueryString(Keys.QueryParam.numOfMonthsDisplayed) - .flatMap(s => Try(s.toInt).toOption) - .getOrElse(3) - val area = if (areaId === Area.allArea.id) None else Area.fromId(areaId) - allApplicationVisibleByUserAdmin(request.currentUser, area, numOfMonthsDisplayed).map { - unfilteredApplications => - val filteredApplications = - request.getQueryString(Keys.QueryParam.filterIsOpen) match { - case Some(_) => unfilteredApplications.filterNot(_.closed) - case None => unfilteredApplications - } + val (areaOpt, numOfMonthsDisplayed) = extractApplicationsAdminQuery + eventService.log( + AllApplicationsShowed, + s"Accède à la page des métadonnées des demandes [$areaOpt ; $numOfMonthsDisplayed]" + ) + Future( + Ok( + views.applicationsAdmin + .page(request.currentUser, request.rights, areaOpt, numOfMonthsDisplayed) + ) + ) + } + } + + def applicationsMetadata: Action[AnyContent] = + loginAction.async { implicit request => + (request.currentUser.admin, request.currentUser.groupAdmin) match { + case (false, false) => + eventService.log( + AllApplicationsUnauthorized, + "Liste des metadata des demandes non autorisée" + ) + Future.successful(Unauthorized(Json.toJson(ApplicationMetadataResult(Nil)))) + case _ => + val (areaOpt, numOfMonthsDisplayed) = extractApplicationsAdminQuery + allApplicationVisibleByUserAdmin(request.currentUser, areaOpt, numOfMonthsDisplayed).map { + applications => eventService.log( AllApplicationsShowed, - s"Visualise la liste des demandes de $areaId - taille = ${filteredApplications.size}" + "Accède à la liste des metadata des demandes " + + s"[territoire ${areaOpt.map(_.name).getOrElse("tous")} ; " + + s"taille : ${applications.size}]" ) - Ok( - views.html - .allApplications(request.currentUser, request.rights)( - filteredApplications, - area.getOrElse(Area.allArea) + val userIds: List[UUID] = (applications.flatMap(_.invitedUsers.keys) ++ + applications.map(_.creatorUserId)).toList.distinct + val users = userService.byIds(userIds, includeDisabled = true) + val groupIds = + (users.flatMap(_.groupIds) ::: applications.flatMap(application => + application.invitedGroupIdsAtCreation ::: application.answers.flatMap( + _.invitedGroupIds ) + )).distinct + val groups = userGroupService.byIds(groupIds) + val idToUser = users.map(user => (user.id, user)).toMap + val idToGroup = groups.map(group => (group.id, group)).toMap + val metadata = applications.map(application => + ApplicationMetadata.fromApplication( + application, + request.rights, + idToUser, + idToGroup + ) ) + Ok(Json.toJson(ApplicationMetadataResult(metadata))) } } } @@ -532,7 +576,7 @@ case class ApplicationController @Inject() ( ): Future[(List[User], List[Application])] = for { users <- userService.byGroupIdsAnonymous(groups.map(_.id)) - applications <- applicationService.allForUserIds(users.map(_.id)) + applications <- applicationService.allForUserIds(users.map(_.id), none) } yield (users, applications) private def generateStats( @@ -562,7 +606,7 @@ case class ApplicationController @Inject() ( .map(_.filter(_.areaIds.intersect(areaIds).nonEmpty)) users <- userService.byGroupIdsAnonymous(groups.map(_.id)) applications <- applicationService - .allForUserIds(users.map(_.id)) + .allForUserIds(users.map(_.id), none) .map(_.filter(application => areaIds.contains(application.area))) } yield (users, applications) case (_, _ :: _, _) => @@ -824,30 +868,6 @@ case class ApplicationController @Inject() ( .as("text/csv") } - def allCSV(areaId: UUID): Action[AnyContent] = - loginAction.async { implicit request => - val area = if (areaId === Area.allArea.id) Option.empty else Area.fromId(areaId) - val exportedApplicationsFuture = - if (request.currentUser.admin || request.currentUser.groupAdmin) { - allApplicationVisibleByUserAdmin(request.currentUser, area, 24) - } else { - Future(Nil) - } - - exportedApplicationsFuture.map { exportedApplications => - val date = Time.formatPatternFr(Time.nowParis(), "YYY-MM-dd-HH'h'mm") - val csvContent = applicationsToCSV(exportedApplications) - - eventService.log(AllCSVShowed, s"Visualise un CSV pour la zone $area") - val filenameAreaPart: String = area.map(_.name.stripSpecialChars).getOrElse("tous") - Ok(csvContent) - .withHeaders( - "Content-Disposition" -> s"""attachment; filename="aplus-demandes-$date-$filenameAreaPart.csv"""" - ) - .as("text/csv") - } - } - private def usersWhoCanBeInvitedOn(application: Application, currentAreaId: UUID)(implicit request: RequestWithUserData[_] ): Future[List[User]] = diff --git a/app/controllers/GroupController.scala b/app/controllers/GroupController.scala index f42f9176e..2d0cef259 100644 --- a/app/controllers/GroupController.scala +++ b/app/controllers/GroupController.scala @@ -407,7 +407,7 @@ case class GroupController @Inject() ( for { groups <- groupService.byIdsFuture(user.groupIds) users <- userService.byGroupIdsFuture(groups.map(_.id), includeDisabled = true) - applications <- applicationService.allForUserIds(users.map(_.id)) + applications <- applicationService.allForUserIds(users.map(_.id), none) } yield { eventService.log(EventType.EditMyGroupShowed, "Visualise la modification de ses groupes") Ok( @@ -431,7 +431,7 @@ case class GroupController @Inject() ( eventService.log(EditGroupShowed, s"Visualise la vue de modification du groupe") val isEmpty = groupService.isGroupEmpty(group.id) applicationService - .allForUserIds(groupUsers.map(_.id)) + .allForUserIds(groupUsers.map(_.id), none) .map(applications => Ok( views.html.editGroup(request.currentUser, request.rights)( diff --git a/app/controllers/JavascriptController.scala b/app/controllers/JavascriptController.scala index 2af933387..9842dec6a 100644 --- a/app/controllers/JavascriptController.scala +++ b/app/controllers/JavascriptController.scala @@ -16,7 +16,9 @@ class JavascriptController() extends InjectedController { routes.javascript.ApiController.deploymentData, routes.javascript.GroupController.deleteUnusedGroupById, routes.javascript.GroupController.editGroup, - routes.javascript.ApplicationController.all, + routes.javascript.ApplicationController.applicationsAdmin, + routes.javascript.ApplicationController.applicationsMetadata, + routes.javascript.ApplicationController.show, routes.javascript.UserController.all, routes.javascript.UserController.deleteUnusedUserById, routes.javascript.UserController.editUser, diff --git a/app/controllers/PathValidator.scala b/app/controllers/PathValidator.scala index 07173dec0..533646f14 100644 --- a/app/controllers/PathValidator.scala +++ b/app/controllers/PathValidator.scala @@ -34,7 +34,7 @@ object PathValidator { routes.AreaController.all, routes.AreaController.deploymentDashboard, routes.AreaController.franceServiceDeploymentDashboard, - routes.ApplicationController.all(placeholderUUID), + routes.ApplicationController.applicationsAdmin, routes.UserController.all(placeholderUUID), ) val uuidRegex = "([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})" diff --git a/app/filters/SentryFilter.scala b/app/filters/SentryFilter.scala index 70cdf0709..e91d2a4db 100644 --- a/app/filters/SentryFilter.scala +++ b/app/filters/SentryFilter.scala @@ -20,8 +20,7 @@ class SentryFilter @Inject() (implicit val mat: Materializer, ec: ExecutionConte val queryParamsWhitelist = List( Keys.QueryParam.vue, Keys.QueryParam.uniquementFs, - Keys.QueryParam.numOfMonthsDisplayed, - Keys.QueryParam.filterIsOpen + Keys.QueryParam.numOfMonthsDisplayed ) def apply( diff --git a/app/serializers/ApiModel.scala b/app/serializers/ApiModel.scala index 900b0d60c..fc6a03691 100644 --- a/app/serializers/ApiModel.scala +++ b/app/serializers/ApiModel.scala @@ -1,8 +1,10 @@ package serializers +import cats.syntax.all._ +import helper.Time import java.time.Instant import java.util.UUID -import models.{Area, Organisation, User, UserGroup} +import models.{Application, Area, Authorization, Organisation, User, UserGroup} import play.api.libs.json._ object ApiModel { @@ -168,4 +170,111 @@ object ApiModel { implicit val searchResultFormat = Json.format[SearchResult] } + // Embedded classes are here to avoid the 22 fields limit in Play Json + case class ApplicationMetadata( + id: UUID, + creationDateFormatted: String, + creationDay: String, + creatorUserName: String, + creatorUserId: UUID, + areaName: String, + pertinence: String, + internalId: Int, + closed: Boolean, + usefulness: String, + closedDateFormatted: Option[String], + closedDay: Option[String], + status: String, + currentUserCanSeeAnonymousApplication: Boolean, + creatorGroupNames: String, + invitedGroupNames: String, + stats: ApplicationMetadata.Stats, + ) + + object ApplicationMetadata { + + case class Stats( + numberOfInvitedUsers: Int, + numberOfMessages: Int, + numberOfAnswers: Int, + firstAnswerTimeInMinutes: String, + resolutionTimeInMinutes: String, + firstAnswerTimeInDays: String, + resolutionTimeInDays: String, + ) + + implicit val applicationMetadataStatsWrites = Json.writes[ApplicationMetadata.Stats] + implicit val applicationMetadataWrites = Json.writes[ApplicationMetadata] + + def fromApplication( + application: Application, + rights: Authorization.UserRights, + idToUser: Map[UUID, User], + idToGroup: Map[UUID, UserGroup] + ) = { + val areaName = Area.fromId(application.area).map(_.name).getOrElse("Sans territoire") + val pertinence = if (!application.irrelevant) "Oui" else "Non" + val creatorUser = idToUser.get(application.creatorUserId) + val creatorUserGroupNames = creatorUser.toList + .flatMap(_.groupIds) + .distinct + .flatMap(idToGroup.get) + .map(_.name) + .mkString(",") + val invitedGroupNames = application.answers + .flatMap(_.invitedGroupIds) + .distinct + .flatMap(idToGroup.get) + .map(_.name) + .mkString(",") + ApplicationMetadata( + id = application.id, + creationDateFormatted = Time.formatForAdmins(application.creationDate.toInstant), + creationDay = Time.formatPatternFr(application.creationDate, "YYY-MM-dd"), + creatorUserName = application.creatorUserName, + creatorUserId = application.creatorUserId, + areaName = areaName, + pertinence = pertinence, + internalId = application.internalId, + closed = application.closed, + usefulness = application.usefulness.getOrElse(""), + closedDateFormatted = + application.closedDate.map(date => Time.formatForAdmins(date.toInstant)), + closedDay = application.closedDate.map(date => + Time.formatPatternFr(application.creationDate, "YYY-MM-dd") + ), + status = application.status.show, + currentUserCanSeeAnonymousApplication = + Authorization.canSeeApplication(application)(rights), + creatorGroupNames = creatorUserGroupNames, + invitedGroupNames = invitedGroupNames, + stats = ApplicationMetadata.Stats( + numberOfInvitedUsers = application.invitedUsers.size, + numberOfMessages = application.answers.length + 1, + numberOfAnswers = + application.answers.count(_.creatorUserID =!= application.creatorUserId), + firstAnswerTimeInMinutes = + application.firstAnswerTimeInMinutes.map(_.toString).getOrElse(""), + resolutionTimeInMinutes = + application.resolutionTimeInMinutes.map(_.toString).getOrElse(""), + firstAnswerTimeInDays = application.firstAnswerTimeInMinutes + .map(_.toDouble / (60.0 * 24.0)) + .map(days => f"$days%.2f".reverse.dropWhile(_ === '0').reverse.stripSuffix(".")) + .getOrElse(""), + resolutionTimeInDays = application.resolutionTimeInMinutes + .map(_.toDouble / (60.0 * 24.0)) + .map(days => f"$days%.2f".reverse.dropWhile(_ === '0').reverse.stripSuffix(".")) + .getOrElse("") + ) + ) + } + + } + + case class ApplicationMetadataResult(applications: List[ApplicationMetadata]) + + object ApplicationMetadataResult { + implicit val applicationMetadataResultWrites = Json.writes[ApplicationMetadataResult] + } + } diff --git a/app/serializers/Keys.scala b/app/serializers/Keys.scala index 5559283b7..5b9dbc6a4 100644 --- a/app/serializers/Keys.scala +++ b/app/serializers/Keys.scala @@ -49,7 +49,6 @@ object Keys { val areaId: String = "areaId" val action: String = "action" val uniquementFs: String = "uniquement-fs" - val filterIsOpen: String = "filtre-ouverte" // Users diff --git a/app/services/ApplicationService.scala b/app/services/ApplicationService.scala index f35aae43f..4055f8721 100644 --- a/app/services/ApplicationService.scala +++ b/app/services/ApplicationService.scala @@ -213,14 +213,29 @@ class ApplicationService @Inject() ( } } - def allForUserIds(userIds: List[UUID]): Future[List[Application]] = + private def monthsFilter(numOfMonths: Option[Int]): String = + numOfMonths + .filter(_ >= 1) + .map(months => + "AND creation_date >= date_trunc('month', now()) - " + + s"interval '$months month'" + ) + .orEmpty + + def allForUserIds(userIds: List[UUID], numOfMonths: Option[Int]): Future[List[Application]] = Future { db.withConnection { implicit connection => + val additionalFilter = monthsFilter(numOfMonths) SQL( s"""SELECT $fieldsInSelect FROM application - WHERE ARRAY[{userIds}]::uuid[] @> ARRAY[creator_user_id]::uuid[] - OR ARRAY(select jsonb_object_keys(invited_users))::uuid[] && ARRAY[{userIds}]::uuid[] + WHERE + ( + ARRAY[{userIds}]::uuid[] @> ARRAY[creator_user_id]::uuid[] + OR + ARRAY(select jsonb_object_keys(invited_users))::uuid[] && ARRAY[{userIds}]::uuid[] + ) + $additionalFilter ORDER BY creation_date DESC""" ).on("userIds" -> userIds) .as(simpleApplication.*) @@ -248,13 +263,7 @@ class ApplicationService @Inject() ( def allForAreas(areaIds: List[UUID], numOfMonths: Option[Int]): Future[List[Application]] = Future { db.withConnection { implicit connection => - val additionalFilter = numOfMonths - .filter(_ >= 1) - .map(months => - "AND creation_date >= date_trunc('month', now()) - " + - s"interval '$months month'" - ) - .orEmpty + val additionalFilter = monthsFilter(numOfMonths) SQL(s"""SELECT $fieldsInSelect FROM application WHERE ARRAY[{areaIds}]::uuid[] @> ARRAY[area]::uuid[] diff --git a/app/views/allApplications.scala.html b/app/views/allApplications.scala.html deleted file mode 100644 index a5f206c9e..000000000 --- a/app/views/allApplications.scala.html +++ /dev/null @@ -1,129 +0,0 @@ -@import cats.implicits.catsSyntaxEq -@import _root_.helper.Time -@import serializers.Keys - -@(currentUser: User, currentUserRights: Authorization.UserRights)(applications: Seq[Application], selectedArea: Area)(implicit webJarsUtil: org.webjars.play.WebJarsUtil, flash: Flash, request: RequestHeader, mainInfos: MainInfos) - -@main(currentUser, currentUserRights, maxWidth = false)(s"Demandes") { - - -}{ - -
Afficher : - -
- } - - - - Filtre demande ouvertes - -# | } -Date | - @if(currentUser.admin) { -Territoires | - } -Avancement | - @if(currentUser.admin) { -Créateur | - } -Invités | -Messages | -Réponses | -Utile | -Non pertinente | -Sujet | -Catégorie | -- @if(currentUser.admin){ | Détails | } -
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
- | |||||||||||||
@application.internalId | } -@Time.formatForAdmins(application.creationDate.toInstant) | - @if(currentUser.admin) { -@Area.fromId(application.area).get.name | - } -@application.status | - @if(currentUser.admin) { -@application.creatorUserName | - } -@application.invitedUsers.size | -@(application.answers.length + 1) | -@application.answers.count(_.creatorUserID != application.creatorUserId) | -@application.usefulness match { - case Some("Oui") => { Oui} - case Some("Je ne sais pas") => { Je ne sais pas} - case Some("Non") => { Non} - case _ => { ? } - } | -@if(application.irrelevant) { Oui } else { Non } | -@application.subject | -@application.category.getOrElse("") | -- @if(currentUser.admin){ | - info_outline - | } -