@if (!disableLinks) {
- {{ question.id }}
+ {{ question.id }}
+
}
@if (disableLinks) {
#{{ question.id }}
@@ -184,9 +185,9 @@
@if (question.attachment) {
-
+
+
}
|
{{ printTags(question) }} |
@@ -211,25 +212,25 @@
@if (question.allowedToRemove) {
-
+
+
}
|
-
+
+
|
{{ question.examSectionQuestions.length }}
diff --git a/ui/src/app/question/library/search/library-search.component.html b/ui/src/app/question/library/search/library-search.component.html
index 261d60138..e7c320b47 100644
--- a/ui/src/app/question/library/search/library-search.component.html
+++ b/ui/src/app/question/library/search/library-search.component.html
@@ -26,17 +26,18 @@
placeholder="{{ 'i18n_search_course_hint' | translate }}"
aria-describedby="search-icon-1"
/>
-
+
- @for (course of filteredCourses; track course) {
-
- {{ formatCourse(course) }} {{ course.name }}
-
- }
+
@@ -64,16 +65,18 @@
- @for (exam of filteredExams; track exam) {
-
- {{ formatCourse(exam) }} {{ exam.name }}
- @if (exam.period) {
- ({{ exam.period }})
- }
-
-
- }
+
@@ -103,15 +106,23 @@
- @for (tag of filteredTags; track tag) {
-
- @if (tag.usage && tag.usage > 0) {
-
- {{ tag.name }} ({{ tag.usage }})
-
- }
-
- }
+
@@ -141,13 +152,19 @@
- @for (section of filteredSections; track section) {
-
-
- {{ section.name }}
-
-
- }
+
+
@@ -176,17 +193,18 @@
-
{{ owner.name }}
-
+
From 5629c1dbbbf78b212fc3369184bc9eb21f1f3c96 Mon Sep 17 00:00:00 2001
From: Matti Lupari
Date: Thu, 12 Dec 2024 06:58:23 +0200
Subject: [PATCH 2/4] CSCEXAM-1398 Encode query parameters in course search
---
app/impl/ExternalCourseHandlerImpl.scala | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
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) =
From 9bccb960136f8a07472e36632a46fe258767da7f Mon Sep 17 00:00:00 2001
From: Matti Lupari
Date: Thu, 12 Dec 2024 06:19:56 +0200
Subject: [PATCH 3/4] CSCEXAM-459 External reservation statistics view
---
app/controllers/admin/ReportController.java | 47 +++++--
.../impl/ExternalCalendarController.java | 4 +
app/models/enrolment/Reservation.java | 26 +++-
conf/evolutions/default/135.sql | 11 ++
conf/routes | 1 +
.../categories/exam-statistics.component.ts | 12 +-
.../iop-reservation-statistics.component.ts | 123 ++++++++++++++++++
.../statistics/statistics.component.html | 13 +-
.../statistics/statistics.component.ts | 3 +
.../statistics/statistics.service.ts | 3 +
ui/src/app/reservation/reservation.model.ts | 2 +
ui/src/assets/i18n/en.json | 6 +-
ui/src/assets/i18n/fi.json | 6 +-
ui/src/assets/i18n/sv.json | 24 ++--
14 files changed, 241 insertions(+), 40 deletions(-)
create mode 100644 conf/evolutions/default/135.sql
create mode 100644 ui/src/app/administrative/statistics/categories/iop-reservation-statistics.component.ts
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/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) {
+
+
+
+
+
+ {{ '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 }}
+ |
+
+
+
+ @for (rg of grouped | keyvalue; track rg) {
+
+ {{ 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 }}
- |