diff --git a/src/app/dashboards/dashboard-details/dashboard-details.component.html b/src/app/dashboards/dashboard-details/dashboard-details.component.html
index 758ce553..fc23cf05 100644
--- a/src/app/dashboards/dashboard-details/dashboard-details.component.html
+++ b/src/app/dashboards/dashboard-details/dashboard-details.component.html
@@ -4,7 +4,7 @@
Sessions for {{ selectedEvent.event_name }}
} @else if (vmDashboardActive && rbacSuccessVms) {
VMs for {{ selectedEvent.event_name }}
- } @else if (usersDashboardActive && rbacSuccessVms) {
+ } @else if (usersDashboardActive && rbacSuccessUsers) {
Users participating in {{ selectedEvent.event_name }}
} @else if (statisticsDashboardActive && rbacSuccessSessions) {
Statistics for {{ selectedEvent.event_name }}
@@ -52,7 +52,7 @@ Costs for {{ selectedEvent.event_name }}
}
- @if (rbacSuccessSessions) {
+ @if (rbacSuccessUsers) {
diff --git a/src/app/dashboards/dashboard-details/dashboard-details.component.ts b/src/app/dashboards/dashboard-details/dashboard-details.component.ts
index 2c9c36a5..a6b26945 100644
--- a/src/app/dashboards/dashboard-details/dashboard-details.component.ts
+++ b/src/app/dashboards/dashboard-details/dashboard-details.component.ts
@@ -23,6 +23,7 @@ export class DashboardDetailsComponent implements OnInit, OnDestroy {
public rbacSuccessSessions: boolean = false;
public rbacSuccessVms: boolean = false;
+ public rbacSuccessUsers: boolean = false;
private eventId: string;
private eventSubscription: Subscription;
@@ -72,6 +73,22 @@ export class DashboardDetailsComponent implements OnInit, OnDestroy {
).then((rbacCheck: boolean) => {
this.rbacSuccessVms = rbacCheck;
});
+
+ this.setRbacCheck(
+ [
+ 'scheduledevents',
+ 'sessions',
+ 'progresses',
+ 'users',
+ 'courses',
+ 'scenarios',
+ 'quizes',
+ 'quizevaluations',
+ ],
+ ['list', 'get'],
+ ).then((rbacCheck: boolean) => {
+ this.rbacSuccessUsers = rbacCheck;
+ });
}
/**
diff --git a/src/app/dashboards/users-dashboard/users-dashboard.component.html b/src/app/dashboards/users-dashboard/users-dashboard.component.html
index 69b626eb..3a972aa1 100644
--- a/src/app/dashboards/users-dashboard/users-dashboard.component.html
+++ b/src/app/dashboards/users-dashboard/users-dashboard.component.html
@@ -15,6 +15,9 @@
Unique Scenarios
+ Quiz Names
+ Score (%)
+ Quiz Results
Status
@@ -34,6 +37,48 @@
{{ user.progresses?.length ?? 0 }}
{{ user.uniqueScenarios }}
+
+ @for (result of user.quizResults; track result.key) {
+ {{ result.title }}
+ } @empty {
+ -
+ }
+
+
+ @for (result of user.quizResults; track result.key) {
+
+ @if (result.score === undefined) {
+ -
+ } @else {
+ {{ result.score }}/100
+ }
+
+ } @empty {
+ -
+ }
+
+
+ @if (user.quizResultStatus === 'not-applicable') {
+ -
+ } @else if (user.quizResultStatus === 'not-started') {
+ NOT STARTED
+ } @else if (user.quizResultStatus === 'failed') {
+ NO PASS
+ } @else if (user.quizResultStatus === 'passed') {
+ PASS
+ } @else {
+
+ {{ user.passedQuizzes }}/{{ user.totalQuizzes }}
+
+ }
+
@if (user.status === 'completed') {
COMPLETED
diff --git a/src/app/dashboards/users-dashboard/users-dashboard.component.scss b/src/app/dashboards/users-dashboard/users-dashboard.component.scss
index 56a44a87..9178cc07 100644
--- a/src/app/dashboards/users-dashboard/users-dashboard.component.scss
+++ b/src/app/dashboards/users-dashboard/users-dashboard.component.scss
@@ -1,3 +1,7 @@
.badge {
padding: 10px;
}
+
+.label {
+ margin-right: 0.25rem;
+}
diff --git a/src/app/dashboards/users-dashboard/users-dashboard.component.ts b/src/app/dashboards/users-dashboard/users-dashboard.component.ts
index 1be6fa11..f81311a9 100644
--- a/src/app/dashboards/users-dashboard/users-dashboard.component.ts
+++ b/src/app/dashboards/users-dashboard/users-dashboard.component.ts
@@ -1,4 +1,4 @@
-import { Component, Input, OnInit, OnChanges } from '@angular/core';
+import { Component, Input, OnChanges } from '@angular/core';
import { combineLatest, forkJoin, of, retry, switchMap, take } from 'rxjs';
import { CourseService } from 'src/app/data/course.service';
import { OTAC } from 'src/app/data/otac.type';
@@ -9,13 +9,49 @@ import { ScheduledeventService } from 'src/app/data/scheduledevent.service';
import { User } from 'src/app/data/user';
import { UserService } from 'src/app/data/user.service';
import parse from 'parse-duration';
+import { Scenario } from 'src/app/data/scenario';
+import { ScenarioService } from 'src/app/data/scenario.service';
+import {
+ Quiz,
+ QuizEvaluation,
+ QuizEvaluationAttempt,
+ QuizService,
+} from 'src/app/data/quiz.service';
+
+interface EventQuiz {
+ key: string;
+ id: string;
+ title: string;
+ scenarioId: string;
+ scenarioName: string;
+ maxAttempts: number;
+}
+
+interface QuizResult extends EventQuiz {
+ score?: number;
+ passed: boolean;
+ started: boolean;
+ attemptsExhausted: boolean;
+}
+
+type QuizResultStatus =
+ | 'not-applicable'
+ | 'not-started'
+ | 'in-progress'
+ | 'passed'
+ | 'failed';
interface dashboardUsers extends User {
progresses?: Progress[];
uniqueScenarios?: number;
- otac?: OTAC;
+ otac?: OTAC | null;
started?: Date;
status?: string;
+ quizResults: QuizResult[];
+ passedQuizzes: number;
+ totalQuizzes: number;
+ quizResultStatus: QuizResultStatus;
+ quizResultLabel: string;
}
@Component({
@@ -23,7 +59,7 @@ interface dashboardUsers extends User {
templateUrl: './users-dashboard.component.html',
styleUrls: ['./users-dashboard.component.scss'],
})
-export class UsersDashboardComponent implements OnInit, OnChanges {
+export class UsersDashboardComponent implements OnChanges {
@Input()
selectedEvent: ScheduledEventBase;
@@ -32,32 +68,49 @@ export class UsersDashboardComponent implements OnInit, OnChanges {
public progressService: ProgressService,
public scheduledEventService: ScheduledeventService,
public courseService: CourseService,
+ public scenarioService: ScenarioService,
+ public quizService: QuizService,
) {}
public dashboardUsers: dashboardUsers[] = [];
public loading: boolean = false;
- ngOnInit(): void {
- this.getList();
- }
-
ngOnChanges() {
this.getList();
}
downloadCSV(): void {
- let userCSV = 'id,email,otac,started,sessionCount,uniqueScenarios,status\n'; // Add a header row
- this.dashboardUsers.forEach((userData) => {
- userCSV = userCSV.concat(
- `${userData.id || ''},` + // ID column
- `${userData.email || ''},` + // Email column
- `${userData.otac?.name || ''},` + // OTAC name column
- `${userData.started?.toISOString() || ''},` + // Started column
- `${userData.progresses?.length || 0},` + // Progress count column
- `${userData.uniqueScenarios || 0},` + // Unique scenarios column
- `${userData.status || ''}\n`, // Status column and newline
- );
- });
+ const header = [
+ 'ID',
+ 'Email',
+ 'OTAC',
+ 'Started',
+ 'Session Count',
+ 'Unique Scenarios',
+ 'Quiz Names',
+ 'Scores (%)',
+ 'Quiz Result',
+ 'Status',
+ ];
+ const rows = this.dashboardUsers.map((userData) => [
+ userData.id || '',
+ userData.email || '',
+ userData.otac?.name || '',
+ userData.started?.toISOString() || '',
+ userData.progresses?.length || 0,
+ userData.uniqueScenarios || 0,
+ userData.quizResults.map((result) => result.title).join('; '),
+ userData.quizResults
+ .map((result) =>
+ result.score === undefined ? '-' : `${result.score}/100`,
+ )
+ .join('; '),
+ userData.quizResultLabel,
+ userData.status || '',
+ ]);
+ const userCSV = [header, ...rows]
+ .map((row) => row.map((value) => this.escapeCSV(value)).join(','))
+ .join('\n');
const filename = this.selectedEvent.event_name + '_users.csv';
const element = document.createElement('a');
element.setAttribute(
@@ -72,40 +125,90 @@ export class UsersDashboardComponent implements OnInit, OnChanges {
}
getList(): void {
+ if (!this.selectedEvent) {
+ return;
+ }
+
this.loading = true;
combineLatest([
this.userService.list(), // List of users
this.progressService.listByScheduledEvent(this.selectedEvent.id, true), // List of progresses
this.scheduledEventService.listOtacs(this.selectedEvent.id), // List of OTACs
this.courseService.list(),
+ this.quizService.list(),
+ this.quizService.listEvaluations(),
])
.pipe(
- switchMap(([users, progresses, otacs, courses]) => {
- const eventScenarioIds = new Set(
- this.selectedEvent.scenarios,
- );
- const categories: string[] = [];
+ switchMap(
+ ([users, progresses, otacs, courses, quizzes, quizEvaluations]) => {
+ const eventScenarioIds = new Set(
+ this.selectedEvent.scenarios,
+ );
+ const categories: string[] = [];
- // Add scenarios from courses
- courses.forEach((course) => {
- if (this.selectedEvent.courses?.includes(course.id)) {
- course.scenarios.forEach((s) => eventScenarioIds.add(s.id));
- categories.push(...(course.categories || []));
- }
- });
+ // Add scenarios from courses
+ courses.forEach((course) => {
+ if (this.selectedEvent.courses?.includes(course.id)) {
+ course.scenarios.forEach((s) => eventScenarioIds.add(s.id));
+ categories.push(...(course.categories || []));
+ }
+ });
- // Fetch dynamic scenarios if categories exist
- return forkJoin({
- users: of(users),
- progresses: of(progresses),
- otacs: of(otacs),
- dynamicScenarios: this.listDynamicScenarios(categories),
- eventScenarioIds: of(eventScenarioIds),
- });
- }),
+ // Fetch dynamic scenarios if categories exist
+ return forkJoin({
+ users: of(users),
+ progresses: of(progresses),
+ otacs: of(otacs),
+ dynamicScenarios: this.listDynamicScenarios(categories),
+ eventScenarioIds: of(eventScenarioIds),
+ quizzes: of(quizzes),
+ quizEvaluations: of(quizEvaluations),
+ });
+ },
+ ),
+ switchMap(
+ ({
+ users,
+ progresses,
+ otacs,
+ dynamicScenarios,
+ eventScenarioIds,
+ quizzes,
+ quizEvaluations,
+ }) => {
+ dynamicScenarios?.forEach((scenarioId) => {
+ eventScenarioIds.add(scenarioId.toString());
+ });
+
+ const scenarioRequests = [...eventScenarioIds].map((scenarioId) =>
+ this.scenarioService.get(scenarioId),
+ );
+
+ return forkJoin({
+ users: of(users),
+ progresses: of(progresses),
+ otacs: of(otacs),
+ eventScenarioIds: of(eventScenarioIds),
+ scenarios:
+ scenarioRequests.length > 0
+ ? forkJoin(scenarioRequests)
+ : of([] as Scenario[]),
+ quizzes: of(quizzes),
+ quizEvaluations: of(quizEvaluations),
+ });
+ },
+ ),
)
.subscribe(
- ({ users, progresses, otacs, dynamicScenarios, eventScenarioIds }) => {
+ ({
+ users,
+ progresses,
+ otacs,
+ eventScenarioIds,
+ scenarios,
+ quizzes,
+ quizEvaluations,
+ }) => {
const safeOtacs = otacs || []; // Default to empty array if null
// Map OTACs to users
const otacMap = new Map(
@@ -126,11 +229,11 @@ export class UsersDashboardComponent implements OnInit, OnChanges {
// List of users with OTACs (those linked to OTACs)
const usersWithOtacs = new Set(safeOtacs.map((otac) => otac.user));
- if (dynamicScenarios) {
- dynamicScenarios.forEach((sc) => {
- eventScenarioIds.add(sc.toString());
- });
- }
+ const eventQuizzes = this.getEventQuizzes(
+ eventScenarioIds,
+ scenarios,
+ quizzes,
+ );
// Filter and enrich users
this.dashboardUsers = users
@@ -163,6 +266,15 @@ export class UsersDashboardComponent implements OnInit, OnChanges {
const uniqueScenarios = new Set(
userProgresses.map((p) => p.scenario),
).size;
+ const quizResults = this.getQuizResults(
+ user.id,
+ eventQuizzes,
+ quizEvaluations,
+ );
+ const passedQuizzes = quizResults.filter(
+ (result) => result.passed,
+ ).length;
+ const quizResultStatus = this.getQuizResultStatus(quizResults);
return {
...user, // Spread original user properties
@@ -175,6 +287,15 @@ export class UsersDashboardComponent implements OnInit, OnChanges {
otacMap.get(user.id) || null,
eventScenarioIds,
),
+ quizResults,
+ passedQuizzes,
+ totalQuizzes: eventQuizzes.length,
+ quizResultStatus,
+ quizResultLabel: this.getQuizResultLabel(
+ quizResultStatus,
+ passedQuizzes,
+ eventQuizzes.length,
+ ),
} as dashboardUsers;
});
@@ -183,6 +304,127 @@ export class UsersDashboardComponent implements OnInit, OnChanges {
);
}
+ private getEventQuizzes(
+ eventScenarioIds: Set,
+ scenarios: Scenario[],
+ quizzes: Quiz[],
+ ): EventQuiz[] {
+ const quizzesById = new Map(
+ quizzes
+ .filter((quiz): quiz is Quiz & { id: string } => !!quiz.id)
+ .map((quiz) => [quiz.id, quiz]),
+ );
+ const eventQuizzes = new Map();
+
+ scenarios
+ .filter((scenario) => eventScenarioIds.has(scenario.id))
+ .forEach((scenario) => {
+ (scenario.steps || []).forEach((step) => {
+ if (!step.quiz) {
+ return;
+ }
+
+ const key = this.quizEvaluationKey(scenario.id, step.quiz);
+ if (!eventQuizzes.has(key)) {
+ const quiz = quizzesById.get(step.quiz);
+ eventQuizzes.set(key, {
+ key,
+ id: step.quiz,
+ title: quiz?.title || step.quiz,
+ scenarioId: scenario.id,
+ scenarioName: scenario.name,
+ maxAttempts: Math.max(quiz?.max_attempts || 1, 1),
+ });
+ }
+ });
+ });
+
+ return [...eventQuizzes.values()];
+ }
+
+ private getQuizResults(
+ userId: string,
+ eventQuizzes: EventQuiz[],
+ quizEvaluations: QuizEvaluation[],
+ ): QuizResult[] {
+ const evaluations = new Map(
+ quizEvaluations
+ .filter((evaluation) => evaluation.user === userId)
+ .map((evaluation) => [
+ this.quizEvaluationKey(evaluation.scenario, evaluation.quiz),
+ evaluation,
+ ]),
+ );
+
+ return eventQuizzes.map((eventQuiz) => {
+ const evaluation = evaluations.get(eventQuiz.key);
+ const completedAttempts = (evaluation?.attempts || []).filter(
+ (attempt) => !!attempt.timestamp,
+ );
+ const bestAttempt = completedAttempts.reduce<
+ QuizEvaluationAttempt | undefined
+ >(
+ (best, attempt) =>
+ !best || attempt.score > best.score ? attempt : best,
+ undefined,
+ );
+
+ const passed = completedAttempts.some((attempt) => attempt.pass);
+
+ return {
+ ...eventQuiz,
+ score: bestAttempt?.score,
+ passed,
+ started: !!evaluation,
+ attemptsExhausted:
+ !passed && completedAttempts.length >= eventQuiz.maxAttempts,
+ };
+ });
+ }
+
+ private getQuizResultStatus(quizResults: QuizResult[]): QuizResultStatus {
+ if (quizResults.length === 0) {
+ return 'not-applicable';
+ }
+ if (!quizResults.some((result) => result.started)) {
+ return 'not-started';
+ }
+ if (quizResults.some((result) => result.attemptsExhausted)) {
+ return 'failed';
+ }
+ if (quizResults.every((result) => result.passed)) {
+ return 'passed';
+ }
+ return 'in-progress';
+ }
+
+ private getQuizResultLabel(
+ status: QuizResultStatus,
+ passedQuizzes: number,
+ totalQuizzes: number,
+ ): string {
+ switch (status) {
+ case 'not-applicable':
+ return '-';
+ case 'not-started':
+ return 'Not Started';
+ case 'passed':
+ return 'Pass';
+ case 'failed':
+ return 'No Pass';
+ case 'in-progress':
+ return `${passedQuizzes}/${totalQuizzes}`;
+ }
+ }
+
+ private quizEvaluationKey(scenarioId: string, quizId: string): string {
+ return `${scenarioId}\u0000${quizId}`;
+ }
+
+ private escapeCSV(value: string | number): string {
+ return `"${String(value).replace(/"/g, '""')}"`;
+ }
+
getUsersStatus(
progresses: Progress[],
otac: OTAC | null,
diff --git a/src/app/data/quiz.service.ts b/src/app/data/quiz.service.ts
index d5b0e5bc..5990401d 100644
--- a/src/app/data/quiz.service.ts
+++ b/src/app/data/quiz.service.ts
@@ -40,6 +40,20 @@ export interface Quiz {
questions: QuizQuestion[];
}
+export interface QuizEvaluationAttempt {
+ timestamp?: string;
+ score: number;
+ pass: boolean;
+}
+
+export interface QuizEvaluation {
+ id: string;
+ quiz: string;
+ user: string;
+ scenario: string;
+ attempts: QuizEvaluationAttempt[];
+}
+
@Injectable({
providedIn: 'root',
})
@@ -75,6 +89,12 @@ export class QuizService {
return this.gargAdmin.get(`/${id}`).pipe(map(extractResponseContent));
}
+ listEvaluations(): Observable {
+ return this.gargAdmin
+ .get('/evaluation/list')
+ .pipe(map(extractResponseContent));
+ }
+
create(quiz: Quiz): Observable {
// backend returns the new quiz ID as plain text and a "created" message.
return this.gargAdmin.post('/create', quiz, { responseType: 'text' });
diff --git a/src/app/data/rbac.ts b/src/app/data/rbac.ts
index 2b821482..c0e877cf 100644
--- a/src/app/data/rbac.ts
+++ b/src/app/data/rbac.ts
@@ -26,6 +26,7 @@ export const resources: Resource[] = [
'scopes',
'settings',
'quizes',
+ 'quizevaluations',
];
export type Resource =
@@ -47,6 +48,7 @@ export type Resource =
| 'scopes'
| 'settings'
| 'quizes'
+ | 'quizevaluations'
| '*';
export const isResource = (x: unknown): x is Resource =>
typeof x === 'string' &&
diff --git a/src/app/data/scheduledevent.service.ts b/src/app/data/scheduledevent.service.ts
index f41f8264..6c9bf377 100644
--- a/src/app/data/scheduledevent.service.ts
+++ b/src/app/data/scheduledevent.service.ts
@@ -149,18 +149,18 @@ export class ScheduledeventService extends ListableResourceClient {
- const se = JSON.parse(atou(s.content));
- return of(se);
+ const otacs: OTAC[] | null = JSON.parse(atou(s.content));
+ return of(otacs ?? []);
}),
- map((otac: OTAC[]) => {
- otac.map((iOTAC: OTAC) => {
+ map((otacs: OTAC[]) => {
+ otacs.forEach((iOTAC: OTAC) => {
if (iOTAC.redeemed_timestamp) {
iOTAC.redeemed_timestamp = new Date(iOTAC.redeemed_timestamp);
} else {
iOTAC.redeemed_timestamp = undefined;
}
});
- return otac;
+ return otacs;
}),
);
}
diff --git a/src/app/unicode.ts b/src/app/unicode.ts
index 56924c89..45c625a2 100644
--- a/src/app/unicode.ts
+++ b/src/app/unicode.ts
@@ -1,12 +1,34 @@
export function atou(b64Str: string) {
- const text = atob(b64Str);
- const length = text.length;
- const bytes = new Uint8Array(length);
- for (let i = 0; i < length; i++) {
- bytes[i] = text.charCodeAt(i);
+ if (!b64Str) {
+ return b64Str;
+ }
+
+ const normalized = b64Str
+ .replace(/\s/g, '')
+ .replace(/-/g, '+')
+ .replace(/_/g, '/');
+
+ // Base64 can only have a remainder of 0, 2, or 3. Returning the original
+ // value also supports legacy/already-decoded text, including emojis.
+ if (
+ normalized.length % 4 === 1 ||
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)
+ ) {
+ return b64Str;
+ }
+
+ const padded = normalized.padEnd(
+ normalized.length + ((4 - (normalized.length % 4)) % 4),
+ '=',
+ );
+
+ try {
+ const text = atob(padded);
+ const bytes = Uint8Array.from(text, (character) => character.charCodeAt(0));
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
+ } catch {
+ return b64Str;
}
- const decoder = new TextDecoder(); // default is utf-8
- return decoder.decode(bytes);
}
export function utoa(data) {