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) {