diff --git a/app/controllers/admin/ReportController.java b/app/controllers/admin/ReportController.java index 5e4657f15..7c6adeda0 100644 --- a/app/controllers/admin/ReportController.java +++ b/app/controllers/admin/ReportController.java @@ -28,6 +28,7 @@ import javax.inject.Inject; import miscellaneous.excel.ExcelBuilder; import models.enrolment.ExamEnrolment; +import models.enrolment.Reservation; import models.exam.Course; import models.exam.Exam; import models.facility.ExamRoom; @@ -228,21 +229,46 @@ public Result getPublishedExams(Optional dept, Optional start, O @Restrict({ @Group("ADMIN") }) public Result getReservations(Optional dept, Optional start, Optional end) { ExpressionList query = DB.find(ExamEnrolment.class).where(); - query = - applyFilters( - query, - "exam.course", - "reservation.startAt", - dept.orElse(null), - start.orElse(null), - end.orElse(null) - ); + query = applyFilters( + query, + "exam.course", + "reservation.startAt", + dept.orElse(null), + start.orElse(null), + end.orElse(null) + ); Set enrolments = query.findSet(); long noShows = enrolments.stream().filter(ExamEnrolment::isNoShow).count(); long appearances = enrolments.size() - noShows; return ok(Json.newObject().put("noShows", noShows).put("appearances", appearances)); } + @Restrict({ @Group("ADMIN") }) + public Result getIopReservations(Optional dept, Optional start, Optional end) { + ExpressionList query = DB.find(Reservation.class) + .fetch("externalReservation") + .fetch("enrolment") + .where() + .or() + .isNotNull("externalRef") + .isNotNull("externalReservation.orgName") + .endOr(); + query = applyFilters( + query, + "examEnrolment.exam.course", + "startAt", + dept.orElse(null), + start.orElse(null), + end.orElse(null) + ); + Set reservations = query + .findSet() + .stream() + .filter(r -> r.getExternalOrgName() != null || (r.getExternalReservation() != null)) + .collect(Collectors.toSet()); + return ok(reservations); + } + @Restrict({ @Group("ADMIN") }) public Result getResponses(Optional dept, Optional start, Optional end) { ExpressionList query = DB.find(Exam.class).where().isNotNull("parent").isNotNull("course"); @@ -272,8 +298,7 @@ public Result getResponses(Optional dept, Optional start, Option ) ) .count(); - JsonNode node = Json - .newObject() + JsonNode node = Json.newObject() .put("aborted", aborted) .put("assessed", assessed) .put("unAssessed", unAssessed); diff --git a/app/controllers/iop/transfer/impl/ExternalCalendarController.java b/app/controllers/iop/transfer/impl/ExternalCalendarController.java index 596c9e440..420a98215 100644 --- a/app/controllers/iop/transfer/impl/ExternalCalendarController.java +++ b/app/controllers/iop/transfer/impl/ExternalCalendarController.java @@ -109,6 +109,8 @@ public Result provideReservation(Http.Request request) { DateTime start = ISODateTimeFormat.dateTimeParser().parseDateTime(node.get("start").asText()); DateTime end = ISODateTimeFormat.dateTimeParser().parseDateTime(node.get("end").asText()); String userEppn = node.get("user").asText(); + String orgRef = node.get("orgRef").asText(); + String orgName = node.get("orgName").asText(); if (start.isBeforeNow() || end.isBefore(start)) { return badRequest("invalid dates"); } @@ -133,6 +135,8 @@ public Result provideReservation(Http.Request request) { reservation.setStartAt(start); reservation.setMachine(machine.get()); reservation.setExternalUserRef(userEppn); + reservation.setExternalOrgRef(orgRef); + reservation.setExternalOrgName(orgName); reservation.save(); PathProperties pp = PathProperties.parse("(*, machine(*, room(*, mailAddress(*))))"); diff --git a/app/impl/ExternalCourseHandlerImpl.scala b/app/impl/ExternalCourseHandlerImpl.scala index f3262d4d1..ab35acd5b 100644 --- a/app/impl/ExternalCourseHandlerImpl.scala +++ b/app/impl/ExternalCourseHandlerImpl.scala @@ -7,7 +7,7 @@ package impl import io.ebean.DB import miscellaneous.config.ConfigReader import miscellaneous.scala.DbApiHelper -import models._ +import models.* import models.exam.{Course, Grade, GradeScale} import models.facility.Organisation import models.user.User @@ -18,15 +18,15 @@ import play.api.Logging import play.api.libs.json.{JsValue, Json} import play.api.libs.ws.{WSClient, WSResponse} import play.mvc.Http -import validators.ExternalCourseValidator.{CourseUnitInfo, GradeScale => ExtGradeScale} +import validators.ExternalCourseValidator.{CourseUnitInfo, GradeScale as ExtGradeScale} -import java.net._ +import java.net.* import java.nio.charset.StandardCharsets import java.text.SimpleDateFormat import javax.inject.Inject import scala.collection.immutable.TreeSet import scala.concurrent.{ExecutionContext, Future} -import scala.jdk.CollectionConverters._ +import scala.jdk.CollectionConverters.* class ExternalCourseHandlerImpl @Inject ( private val wsClient: WSClient, @@ -229,7 +229,7 @@ class ExternalCourseHandlerImpl @Inject ( val path = configReader.getString(configPath.getOrElse("")) if (!path.contains(COURSE_CODE_PLACEHOLDER)) throw new RuntimeException("exam.integration.courseUnitInfo.url is malformed") - val url = path.replace(COURSE_CODE_PLACEHOLDER, courseCode) + val url = path.replace(COURSE_CODE_PLACEHOLDER, URLEncoder.encode(courseCode, StandardCharsets.UTF_8)) URI.create(url).toURL private def parseUrl(user: User) = diff --git a/app/models/enrolment/Reservation.java b/app/models/enrolment/Reservation.java index 2991a41fa..7908c5783 100644 --- a/app/models/enrolment/Reservation.java +++ b/app/models/enrolment/Reservation.java @@ -55,6 +55,8 @@ public class Reservation extends GeneratedIdentityModel implements Comparable{{ 'i18n_most_popular_exams' | translate }} -
- @if (exams.length > 0) { + @if (exams.length > 0) { +
@@ -46,16 +46,14 @@ import { StatisticsService } from 'src/app/administrative/statistics/statistics. {{ 'i18n_total' | translate }}
- @if (exams) { - {{ totalExams }} - } + {{ totalExams }}
- } -
+
+ } `, selector: 'xm-exam-statistics', standalone: true, diff --git a/ui/src/app/administrative/statistics/categories/iop-reservation-statistics.component.ts b/ui/src/app/administrative/statistics/categories/iop-reservation-statistics.component.ts new file mode 100644 index 000000000..9472c9ea9 --- /dev/null +++ b/ui/src/app/administrative/statistics/categories/iop-reservation-statistics.component.ts @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium +// +// SPDX-License-Identifier: EUPL-1.2 + +import { KeyValuePipe } from '@angular/common'; +import type { OnInit } from '@angular/core'; +import { Component, Input } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { groupBy } from 'ramda'; +import { QueryParams } from 'src/app/administrative/administrative.model'; +import { StatisticsService } from 'src/app/administrative/statistics/statistics.service'; +import { Reservation } from 'src/app/reservation/reservation.model'; + +@Component({ + template: ` +
+
+ +
+
+ @if (grouped) { +
+
+ + + + + + + + + + + + @for (rg of grouped | keyvalue; track rg) { + + + + + + + + } + + + + + + + + + + +
{{ 'i18n_faculty_name' | translate }}{{ 'i18n_outbound_reservations' | translate }} + {{ 'i18n_outbound_reservations' | translate }} - + {{ 'i18n_unused_reservation' | translate }} + {{ 'i18n_inbound_reservations' | translate }} + {{ 'i18n_inbound_reservations' | translate }} - + {{ 'i18n_unused_reservation' | translate }} +
{{ rg.key }}{{ outgoingTo(rg.key) }}{{ outgoingNoShowsTo(rg.key) }}{{ incomingFrom(rg.key) }}{{ incomingNoShowsFrom(rg.key) }}
+ {{ 'i18n_total' | translate }} + + {{ totalOutgoing() }} + + {{ totalOutgoingNoShows() }} + + {{ totalIncoming() }} + + {{ totalIncomingNoShows() }} +
+
+
+ } + `, + selector: 'xm-iop-reservation-statistics', + standalone: true, + imports: [KeyValuePipe, TranslateModule], +}) +export class IopReservationStatisticsComponent implements OnInit { + @Input() queryParams: QueryParams = {}; + + reservations: Reservation[] = []; + grouped!: Record; + + constructor(private Statistics: StatisticsService) {} + + ngOnInit() { + this.listReservations(); + } + + listReservations = () => + this.Statistics.listIopReservations$(this.queryParams).subscribe((resp) => { + const byOrg = groupBy((r: Reservation) => r.externalOrgName || r.externalReservation?.orgName || ''); + this.grouped = byOrg(resp) as Record; + }); + + incomingFrom = (org: keyof typeof this.grouped): number => + this.grouped[org].filter((r) => r.externalOrgRef && !r.enrolment?.noShow).length; + incomingNoShowsFrom = (org: keyof typeof this.grouped): number => + this.grouped[org].filter((r) => r.externalOrgRef && r.enrolment?.noShow === true).length; + outgoingTo = (org: keyof typeof this.grouped): number => + this.grouped[org].filter((r) => r.externalReservation?.orgName && !r.enrolment?.noShow).length; + outgoingNoShowsTo = (org: keyof typeof this.grouped): number => + this.grouped[org].filter((r) => r.externalReservation?.orgName && r.enrolment?.noShow === true).length; + totalIncoming = () => + Object.keys(this.grouped) + .map((k) => this.incomingFrom(k)) + .reduce((a, b) => a + b); + totalOutgoing = () => + Object.keys(this.grouped) + .map((k) => this.outgoingTo(k)) + .reduce((a, b) => a + b); + totalIncomingNoShows = () => + Object.keys(this.grouped) + .map((k) => this.incomingNoShowsFrom(k)) + .reduce((a, b) => a + b); + totalOutgoingNoShows = () => + Object.keys(this.grouped) + .map((k) => this.outgoingNoShowsTo(k)) + .reduce((a, b) => a + b); +} diff --git a/ui/src/app/administrative/statistics/statistics.component.html b/ui/src/app/administrative/statistics/statistics.component.html index cec286037..af849731a 100644 --- a/ui/src/app/administrative/statistics/statistics.component.html +++ b/ui/src/app/administrative/statistics/statistics.component.html @@ -23,6 +23,9 @@
  • {{ 'i18n_reservations' | translate }}
  • +
  • + {{ 'i18n_iop_reservations' | translate }} +
  • @@ -60,12 +63,7 @@ > {{ 'i18n_choose' | translate }}  -
    +