diff --git a/api-dto/files/testtakers-validation.dto.ts b/api-dto/files/testtakers-validation.dto.ts new file mode 100644 index 000000000..36bae5007 --- /dev/null +++ b/api-dto/files/testtakers-validation.dto.ts @@ -0,0 +1,21 @@ +export interface TestTakerLoginDto { + group: string; + login: string; + mode: string; + bookletCodes: string[]; +} + +export interface MissingPersonDto { + group: string; + login: string; + code: string; + reason: string; +} + +export interface TestTakersValidationDto { + testTakersFound: boolean; + totalGroups: number; + totalLogins: number; + totalBookletCodes: number; + missingPersons: MissingPersonDto[]; +} diff --git a/api-dto/files/variable-validation.dto.ts b/api-dto/files/variable-validation.dto.ts new file mode 100644 index 000000000..3fa2de2e8 --- /dev/null +++ b/api-dto/files/variable-validation.dto.ts @@ -0,0 +1,13 @@ +export interface InvalidVariableDto { + fileName: string; + variableId: string; + value: string; + responseId?: number; + expectedType?: string; + errorReason?: string; +} + +export interface VariableValidationDto { + checkedFiles: number; + invalidVariables: InvalidVariableDto[]; +} diff --git a/apps/backend/src/app/admin/workspace/workspace-files.controller.ts b/apps/backend/src/app/admin/workspace/workspace-files.controller.ts index 854c866ea..43bdac1c5 100644 --- a/apps/backend/src/app/admin/workspace/workspace-files.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-files.controller.ts @@ -17,6 +17,8 @@ import { WorkspaceGuard } from './workspace.guard'; import { FileDownloadDto } from '../../../../../../api-dto/files/file-download.dto'; import { FileValidationResultDto } from '../../../../../../api-dto/files/file-validation-result.dto'; import { WorkspaceFilesService } from '../../database/services/workspace-files.service'; +import { InvalidVariableDto } from '../../../../../../api-dto/files/variable-validation.dto'; +import { TestTakersValidationDto } from '../../../../../../api-dto/files/testtakers-validation.dto'; @ApiTags('Admin Workspace Files') @Controller('admin/workspace') @@ -305,10 +307,6 @@ export class WorkspaceFilesController { try { const codingSchemeFile = await this.workspaceFilesService.getCodingSchemeByRef(workspace_id, coding_scheme_ref); - if (!codingSchemeFile) { - throw new NotFoundException(`Coding scheme file '${coding_scheme_ref}' not found in workspace ${workspace_id}`); - } - return codingSchemeFile; } catch (error) { if (error.status === 404) { @@ -317,4 +315,201 @@ export class WorkspaceFilesController { throw new InternalServerErrorException(`Error retrieving coding scheme file: ${error.message}`); } } + + @Get(':workspace_id/files/validate-testtakers') + @ApiTags('test files validation') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiOperation({ summary: 'Validate TestTakers', description: 'Validates TestTakers XML files and checks if each person from the persons table is found' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiOkResponse({ + description: 'TestTakers validation result' + }) + async validateTestTakers( + @Param('workspace_id') workspace_id: number): Promise { + return this.workspaceFilesService.validateTestTakers(workspace_id); + } + + @Get(':workspace_id/files/validate-group-responses') + @ApiTags('test files validation') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiOperation({ summary: 'Validate group responses', description: 'Validates if there\'s at least one response for each group found in TestTakers XML files' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'Group responses validation result', + schema: { + type: 'object', + properties: { + testTakersFound: { type: 'boolean' }, + groupsWithResponses: { + type: 'array', + items: { + type: 'object', + properties: { + group: { type: 'string' }, + hasResponse: { type: 'boolean' } + } + } + }, + allGroupsHaveResponses: { type: 'boolean' }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + async validateGroupResponses( + @Param('workspace_id') workspace_id: number, + @Query('page') page: number = 1, + @Query('limit') limit: number = 10 + ): Promise<{ + testTakersFound: boolean; + groupsWithResponses: { group: string; hasResponse: boolean }[]; + allGroupsHaveResponses: boolean; + total: number; + page: number; + limit: number; + }> { + return this.workspaceFilesService.validateGroupResponses(workspace_id, page, limit); + } + + @Get(':workspace_id/files/validate-response-status') + @ApiTags('test files validation') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiOperation({ summary: 'Validate response status', description: 'Validates if response status is one of the valid values (VALUE_CHANGED, NOT_REACHED, DISPLAYED, UNSET, PARTLY_DISPLAYED)' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'Response status validation result', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/InvalidVariableDto' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + async validateResponseStatus( + @Param('workspace_id') workspace_id: number, + @Query('page') page: number = 1, + @Query('limit') limit: number = 10 + ): Promise<{ data: InvalidVariableDto[]; total: number; page: number; limit: number }> { + return this.workspaceFilesService.validateResponseStatus(workspace_id, page, limit); + } + + @Get(':workspace_id/files/validate-variables') + @ApiTags('test files validation') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiOperation({ summary: 'Validate variables', description: 'Validates if variables in responses are defined in Unit-XMLs' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'Variables validation result', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/InvalidVariableDto' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + async validateVariables( + @Param('workspace_id') workspace_id: number, + @Query('page') page: number = 1, + @Query('limit') limit: number = 10 + ): Promise<{ data: InvalidVariableDto[]; total: number; page: number; limit: number }> { + return this.workspaceFilesService.validateVariables(workspace_id, page, limit); + } + + @Get(':workspace_id/files/validate-variable-types') + @ApiTags('test files validation') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiOperation({ summary: 'Validate variable types', description: 'Validates if variable values match their defined types in Unit-XMLs' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'Variable types validation result', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/InvalidVariableDto' } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } + }) + + async validateVariableTypes( + @Param('workspace_id') workspace_id: number, + @Query('page') page: number = 1, + @Query('limit') limit: number = 10 + ): Promise<{ data: InvalidVariableDto[]; total: number; page: number; limit: number }> { + return this.workspaceFilesService.validateVariableTypes(workspace_id, page, limit); + } + + @Delete(':workspace_id/files/invalid-responses') + @ApiTags('test files validation') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiOperation({ summary: 'Delete invalid responses', description: 'Deletes invalid responses from the database' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiQuery({ name: 'responseIds', type: String, description: 'Comma-separated list of response IDs to delete' }) + @ApiOkResponse({ + description: 'Number of deleted responses', + type: Number + }) + async deleteInvalidResponses( + @Param('workspace_id') workspace_id: number, + @Query('responseIds') responseIds: string): Promise { + const ids = responseIds.split(',').map(id => parseInt(id, 10)); + return this.workspaceFilesService.deleteInvalidResponses(workspace_id, ids); + } } diff --git a/apps/backend/src/app/database/services/workspace-files.service.ts b/apps/backend/src/app/database/services/workspace-files.service.ts index c62d3f756..87a8c27d2 100644 --- a/apps/backend/src/app/database/services/workspace-files.service.ts +++ b/apps/backend/src/app/database/services/workspace-files.service.ts @@ -6,12 +6,22 @@ import AdmZip = require('adm-zip'); import * as fs from 'fs'; import * as path from 'path'; import * as libxmljs from 'libxmljs2'; +import { parseStringPromise } from 'xml2js'; import FileUpload from '../entities/file_upload.entity'; import { FilesDto } from '../../../../../../api-dto/files/files.dto'; import { FileIo } from '../../admin/workspace/file-io.interface'; import { FileDownloadDto } from '../../../../../../api-dto/files/file-download.dto'; import { FileValidationResultDto } from '../../../../../../api-dto/files/file-validation-result.dto'; import { ResponseDto } from '../../../../../../api-dto/responses/response-dto'; +import { InvalidVariableDto } from '../../../../../../api-dto/files/variable-validation.dto'; +import { Unit } from '../entities/unit.entity'; +import { ResponseEntity } from '../entities/response.entity'; +import { + MissingPersonDto, + TestTakerLoginDto, + TestTakersValidationDto +} from '../../../../../../api-dto/files/testtakers-validation.dto'; +import Persons from '../entities/persons.entity'; function sanitizePath(filePath: string): string { const normalizedPath = path.normalize(filePath); @@ -62,7 +72,16 @@ export class WorkspaceFilesService { constructor( @InjectRepository(FileUpload) - private fileUploadRepository: Repository + private fileUploadRepository: Repository, + @InjectRepository(ResponseEntity) + private responseRepository: Repository, + @InjectRepository(Unit) + private unitRepository: Repository, + @InjectRepository(FileUpload) + private filesRepository: Repository, + @InjectRepository(Persons) + private personsRepository: Repository + ) {} async findAllFileTypes(workspaceId: number): Promise { @@ -960,4 +979,704 @@ export class WorkspaceFilesService { return null; } } + + async validateVariables(workspaceId: number, page: number = 1, limit: number = 10): Promise<{ data: InvalidVariableDto[]; total: number; page: number; limit: number }> { + const unitFiles = await this.filesRepository.find({ + where: { workspace_id: workspaceId, file_type: 'Unit' } + }); + const unitVariables = new Map>(); + for (const unitFile of unitFiles) { + try { + const xmlContent = unitFile.data.toString(); + const parsedXml = await parseStringPromise(xmlContent, { explicitArray: false }); + if (parsedXml.Unit && parsedXml.Unit.Metadata && parsedXml.Unit.Metadata.Id) { + const unitName = parsedXml.Unit.Metadata.Id; + const variables = new Set(); + if (parsedXml.Unit.BaseVariables && parsedXml.Unit.BaseVariables.Variable) { + const baseVariables = Array.isArray(parsedXml.Unit.BaseVariables.Variable) ? + parsedXml.Unit.BaseVariables.Variable : + [parsedXml.Unit.BaseVariables.Variable]; + for (const variable of baseVariables) { + if (variable.$.alias) { + variables.add(variable.$.alias); + } + } + } + unitVariables.set(unitName, variables); + } + } catch (e) { + console.error(`Could not parse Unit file ${unitFile.filename}: ${e.message}`); + } + } + + const invalidVariables: InvalidVariableDto[] = []; + + // Find all persons with the given workspace_id + const persons = await this.personsRepository.find({ + where: { workspace_id: workspaceId } + }); + + if (persons.length === 0) { + this.logger.warn(`No persons found for workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + + // Get all person IDs + const personIds = persons.map(person => person.id); + + // Find all units that belong to booklets that belong to these persons + const units = await this.unitRepository.createQueryBuilder('unit') + .innerJoin('unit.booklet', 'booklet') + .where('booklet.personid IN (:...personIds)', { personIds }) + .getMany(); + + if (units.length === 0) { + this.logger.warn(`No units found for persons in workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + + const unitIds = units.map(unit => unit.id); + + // Find all responses that belong to these units + const responses = await this.responseRepository.find({ + where: { unitid: In(unitIds) }, + relations: ['unit'] // Include unit relation to access unit.name + }); + + // Check each response + for (const response of responses) { + const unit = response.unit; + if (!unit) { + this.logger.warn(`Response ${response.id} has no associated unit`); + continue; + } + + const unitName = unit.name; + const variableId = response.variableid; + + // Check if the unit name exists in unitVariables + if (!unitVariables.has(unitName)) { + invalidVariables.push({ + fileName: `Unit ${unitName}`, + variableId: variableId, + value: response.value || '', + responseId: response.id, + errorReason: 'Unit not found' + }); + continue; + } + + // Check if the variable ID exists in the unit's variables + const unitVars = unitVariables.get(unitName); + if (!unitVars || !unitVars.has(variableId)) { + invalidVariables.push({ + fileName: `Unit ${unitName}`, + variableId: variableId, + value: response.value || '', + responseId: response.id, + errorReason: 'Variable not defined in unit' + }); + } + } + + // Apply pagination + const validPage = Math.max(1, page); + const validLimit = Math.min(Math.max(1, limit), 1000); + const startIndex = (validPage - 1) * validLimit; + const endIndex = startIndex + validLimit; + const paginatedData = invalidVariables.slice(startIndex, endIndex); + + return { + data: paginatedData, + total: invalidVariables.length, + page: validPage, + limit: validLimit + }; + } + + /** + * Validates if variable values match their defined types + * @param workspaceId The ID of the workspace + * @param page Page number for pagination + * @param limit Number of items per page + * @returns Paginated validation result with invalid variables + */ + async validateVariableTypes(workspaceId: number, page: number = 1, limit: number = 10): Promise<{ data: InvalidVariableDto[]; total: number; page: number; limit: number }> { + const unitFiles = await this.filesRepository.find({ + where: { workspace_id: workspaceId, file_type: 'Unit' } + }); + + // Map to store unit variables with their types + // Key: unitName, Value: Map of variableId to type + const unitVariableTypes = new Map>(); + + for (const unitFile of unitFiles) { + try { + const xmlContent = unitFile.data.toString(); + const parsedXml = await parseStringPromise(xmlContent, { explicitArray: false }); + if (parsedXml.Unit && parsedXml.Unit.Metadata && parsedXml.Unit.Metadata.Id) { + const unitName = parsedXml.Unit.Metadata.Id; + const variableTypes = new Map(); + + if (parsedXml.Unit.BaseVariables && parsedXml.Unit.BaseVariables.Variable) { + const baseVariables = Array.isArray(parsedXml.Unit.BaseVariables.Variable) ? + parsedXml.Unit.BaseVariables.Variable : + [parsedXml.Unit.BaseVariables.Variable]; + + for (const variable of baseVariables) { + if (variable.$.alias && variable.$.type) { + variableTypes.set(variable.$.alias, variable.$.type); + } + } + } + + unitVariableTypes.set(unitName, variableTypes); + } + } catch (e) { + console.error(`Could not parse Unit file ${unitFile.filename}: ${e.message}`); + } + } + + const invalidVariables: InvalidVariableDto[] = []; + + const persons = await this.personsRepository.find({ + where: { workspace_id: workspaceId } + }); + + if (persons.length === 0) { + this.logger.warn(`No persons found for workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + + const personIds = persons.map(person => person.id); + + const units = await this.unitRepository.createQueryBuilder('unit') + .innerJoin('unit.booklet', 'booklet') + .where('booklet.personid IN (:...personIds)', { personIds }) + .getMany(); + + if (units.length === 0) { + this.logger.warn(`No units found for persons in workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + + const unitIds = units.map(unit => unit.id); + + const responses = await this.responseRepository.find({ + where: { unitid: In(unitIds) }, + relations: ['unit'] // Include unit relation to access unit.name + }); + + for (const response of responses) { + const unit = response.unit; + if (!unit) { + this.logger.warn(`Response ${response.id} has no associated unit`); + continue; + } + + const unitName = unit.name; + const variableId = response.variableid; + const value = response.value || ''; + + if (!unitVariableTypes.has(unitName)) { + continue; + } + + const variableTypes = unitVariableTypes.get(unitName); + if (!variableTypes || !variableTypes.has(variableId)) { + continue; + } + + const expectedType = variableTypes.get(variableId); + + if (!this.isValidValueForType(value, expectedType)) { + invalidVariables.push({ + fileName: `Unit ${unitName}`, + variableId: variableId, + value: value, + responseId: response.id, + expectedType: expectedType, + errorReason: `Value does not match expected type: ${expectedType}` + }); + } + } + + // Apply pagination + const validPage = Math.max(1, page); + const validLimit = Math.min(Math.max(1, limit), 1000); + const startIndex = (validPage - 1) * validLimit; + const endIndex = startIndex + validLimit; + const paginatedData = invalidVariables.slice(startIndex, endIndex); + + return { + data: paginatedData, + total: invalidVariables.length, + page: validPage, + limit: validLimit + }; + } + + /** + * Checks if a value is valid for a given type + * @param value The value to check + * @param type The expected type (string, integer, number, boolean, json) + * @returns True if the value is valid for the type, false otherwise + */ + private isValidValueForType(value: string, type: string): boolean { + if (!value && type !== 'string') { + return false; + } + + switch (type.toLowerCase()) { + case 'string': + return true; // All values are valid strings + + case 'integer': + // Check if the value is an integer + return /^-?\d+$/.test(value); + + case 'number': + // Check if the value is a number (integer or decimal) + return !Number.isNaN(Number(value)) && Number.isFinite(Number(value)); + + case 'boolean': { + // Check if the value is a boolean (true/false, 0/1, yes/no) + const lowerValue = value.toLowerCase(); + return ['true', 'false', '0', '1', 'yes', 'no'].includes(lowerValue); + } + + case 'json': + // Check if the value is valid JSON + try { + JSON.parse(value); + return true; + } catch (e) { + return false; + } + + default: + return true; // For unknown types, assume the value is valid + } + } + + /** + * Validates if response status is one of the valid values + * @param workspaceId The ID of the workspace + * @param page Page number for pagination + * @param limit Number of items per page + * @returns Paginated validation result with invalid responses + */ + /** + * Validates TestTakers XML files and checks if each person from the persons table is found + * @param workspaceId The ID of the workspace + * @returns Validation results + */ + async validateTestTakers(workspaceId: number): Promise { + try { + // Find TestTakers files in the workspace + const testTakers = await this.fileUploadRepository.find({ + where: { workspace_id: workspaceId, file_type: In(['TestTakers', 'Testtakers']) } + }); + + if (!testTakers || testTakers.length === 0) { + this.logger.warn(`No TestTakers found in workspace with ID ${workspaceId}.`); + return { + testTakersFound: false, + totalGroups: 0, + totalLogins: 0, + totalBookletCodes: 0, + missingPersons: [] + }; + } + + // Parse XML to extract Groups, Logins, and Booklet codes + const testTakerLogins: TestTakerLoginDto[] = []; + let totalGroups = 0; + let totalLogins = 0; + let totalBookletCodes = 0; + + // Process all test takers + for (const testTaker of testTakers) { + const xmlDocument = cheerio.load(testTaker.data, { xml: true }); + const groupElements = xmlDocument('Group'); + + if (groupElements.length === 0) { + this.logger.warn(`No elements found in TestTakers file ${testTaker.file_id}.`); + continue; + } + + totalGroups += groupElements.length; + + // Extract data from each group + for (let i = 0; i < groupElements.length; i += 1) { + const groupElement = groupElements[i]; + const groupId = xmlDocument(groupElement).attr('id'); + const loginElements = xmlDocument(groupElement).find('Login'); + + // Extract data from each login + for (let j = 0; j < loginElements.length; j += 1) { + const loginElement = loginElements[j]; + const loginName = xmlDocument(loginElement).attr('name'); + const loginMode = xmlDocument(loginElement).attr('mode'); + + // Only include logins with mode "run-hot-return" or "run-hot-restart" + if (loginMode === 'run-hot-return' || loginMode === 'run-hot-restart') { + totalLogins += 1; + + const bookletElements = xmlDocument(loginElement).find('Booklet'); + const bookletCodes: string[] = []; + + // Extract data from each booklet + for (let k = 0; k < bookletElements.length; k += 1) { + const bookletElement = bookletElements[k]; + const codes = xmlDocument(bookletElement).attr('codes'); + if (codes) { + bookletCodes.push(codes); + totalBookletCodes += 1; + } + } + + testTakerLogins.push({ + group: groupId || '', + login: loginName || '', + mode: loginMode || '', + bookletCodes + }); + } + } + } + } + + // Find all persons in the workspace + const persons = await this.personsRepository.find({ + where: { workspace_id: workspaceId } + }); + + // Check if each person from the persons table is found in the extracted data + const missingPersons: MissingPersonDto[] = []; + + for (const person of persons) { + const found = testTakerLogins.some(login => login.group === person.group && login.login === person.login); + + if (!found) { + missingPersons.push({ + group: person.group, + login: person.login, + code: person.code, + reason: 'Person not found in TestTakers XML' + }); + } + } + + return { + testTakersFound: true, + totalGroups, + totalLogins, + totalBookletCodes, + missingPersons + }; + } catch (error) { + this.logger.error(`Error validating TestTakers for workspace ${workspaceId}: ${error.message}`, error.stack); + throw error; + } + } + + async validateResponseStatus(workspaceId: number, page: number = 1, limit: number = 10): Promise<{ data: InvalidVariableDto[]; total: number; page: number; limit: number }> { + const validStatusValues = ['VALUE_CHANGED', 'NOT_REACHED', 'DISPLAYED', 'UNSET', 'PARTLY_DISPLAYED']; + + const persons = await this.personsRepository.find({ + where: { workspace_id: workspaceId } + }); + + if (persons.length === 0) { + this.logger.warn(`No persons found for workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + + const personIds = persons.map(person => person.id); + + const units = await this.unitRepository.createQueryBuilder('unit') + .innerJoin('unit.booklet', 'booklet') + .where('booklet.personid IN (:...personIds)', { personIds }) + .getMany(); + + if (units.length === 0) { + this.logger.warn(`No units found for persons in workspace ${workspaceId}`); + return { + data: [], + total: 0, + page, + limit + }; + } + + const unitIds = units.map(unit => unit.id); + + const responses = await this.responseRepository.find({ + where: { unitid: In(unitIds) }, + relations: ['unit'] // Include unit relation to access unit.name + }); + + const invalidVariables: InvalidVariableDto[] = []; + + for (const response of responses) { + const unit = response.unit; + if (!unit) { + this.logger.warn(`Response ${response.id} has no associated unit`); + continue; + } + + const unitName = unit.name; + const variableId = response.variableid; + const status = response.status; + + // Check if the response status is one of the valid values + if (!validStatusValues.includes(status)) { + invalidVariables.push({ + fileName: `Unit ${unitName}`, + variableId: variableId, + value: response.value || '', + responseId: response.id, + errorReason: `Invalid response status: ${status}. Valid values are: ${validStatusValues.join(', ')}` + }); + } + } + + // Apply pagination + const validPage = Math.max(1, page); + const validLimit = Math.min(Math.max(1, limit), 1000); + const startIndex = (validPage - 1) * validLimit; + const endIndex = startIndex + validLimit; + const paginatedData = invalidVariables.slice(startIndex, endIndex); + + return { + data: paginatedData, + total: invalidVariables.length, + page: validPage, + limit: validLimit + }; + } + + async validateGroupResponses( + workspaceId: number, + page: number = 1, + limit: number = 10 + ): Promise<{ + testTakersFound: boolean; + groupsWithResponses: { group: string; hasResponse: boolean }[]; + allGroupsHaveResponses: boolean; + total: number; + page: number; + limit: number; + }> { + try { + // Find TestTakers files in the workspace + const testTakers = await this.fileUploadRepository.find({ + where: { workspace_id: workspaceId, file_type: In(['TestTakers', 'Testtakers']) } + }); + + if (!testTakers || testTakers.length === 0) { + this.logger.warn(`No TestTakers found in workspace with ID ${workspaceId}.`); + return { + testTakersFound: false, + groupsWithResponses: [], + allGroupsHaveResponses: false, + total: 0, + page, + limit + }; + } + + // Extract groups from TestTakers XML files + const groups: Set = new Set(); + + // Process all test takers + for (const testTaker of testTakers) { + const xmlDocument = cheerio.load(testTaker.data, { xml: true }); + const groupElements = xmlDocument('Group'); + + if (groupElements.length === 0) { + this.logger.warn(`No elements found in TestTakers file ${testTaker.file_id}.`); + continue; + } + + // Extract data from each group + for (let i = 0; i < groupElements.length; i += 1) { + const groupElement = groupElements[i]; + const groupId = xmlDocument(groupElement).attr('id'); + const loginElements = xmlDocument(groupElement).find('Login'); + + // Check if there's at least one login with mode "run-hot-return" or "run-hot-restart" + let hasValidLogin = false; + for (let j = 0; j < loginElements.length; j += 1) { + const loginElement = loginElements[j]; + const loginMode = xmlDocument(loginElement).attr('mode'); + + if (loginMode === 'run-hot-return' || loginMode === 'run-hot-restart') { + hasValidLogin = true; + break; + } + } + + // Only add groups with valid logins + if (hasValidLogin && groupId) { + groups.add(groupId); + } + } + } + + if (groups.size === 0) { + this.logger.warn(`No valid groups found in TestTakers files for workspace ${workspaceId}.`); + return { + testTakersFound: true, + groupsWithResponses: [], + allGroupsHaveResponses: false, + total: 0, + page, + limit + }; + } + + // Check if each group has at least one response + const groupsWithResponses: { group: string; hasResponse: boolean }[] = []; + let allGroupsHaveResponses = true; + + for (const group of groups) { + // Find persons with this group ID + const persons = await this.personsRepository.find({ + where: { workspace_id: workspaceId, group } + }); + + if (persons.length === 0) { + // No persons found for this group + groupsWithResponses.push({ group, hasResponse: false }); + allGroupsHaveResponses = false; + continue; + } + + // Get all person IDs + const personIds = persons.map(person => person.id); + + // Find all units that belong to booklets that belong to these persons + const units = await this.unitRepository.createQueryBuilder('unit') + .innerJoin('unit.booklet', 'booklet') + .where('booklet.personid IN (:...personIds)', { personIds }) + .getMany(); + + if (units.length === 0) { + // No units found for persons in this group + groupsWithResponses.push({ group, hasResponse: false }); + allGroupsHaveResponses = false; + continue; + } + + // Get all unit IDs + const unitIds = units.map(unit => unit.id); + + // Check if there's at least one response for these units + const responseCount = await this.responseRepository.count({ + where: { unitid: In(unitIds) } + }); + + const hasResponse = responseCount > 0; + groupsWithResponses.push({ group, hasResponse }); + + if (!hasResponse) { + allGroupsHaveResponses = false; + } + } + + // Apply pagination + const validPage = Math.max(1, page); + const validLimit = Math.max(1, limit); + const startIndex = (validPage - 1) * validLimit; + const endIndex = startIndex + validLimit; + const paginatedGroupsWithResponses = groupsWithResponses.slice(startIndex, endIndex); + + return { + testTakersFound: true, + groupsWithResponses: paginatedGroupsWithResponses, + allGroupsHaveResponses, + total: groupsWithResponses.length, + page: validPage, + limit: validLimit + }; + } catch (error) { + this.logger.error(`Error validating group responses for workspace ${workspaceId}: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Deletes invalid responses from the database + * @param workspaceId The ID of the workspace + * @param responseIds Array of response IDs to delete + * @returns Number of deleted responses + */ + async deleteInvalidResponses(workspaceId: number, responseIds: number[]): Promise { + try { + this.logger.log(`Deleting invalid responses for workspace ${workspaceId}: ${responseIds.join(', ')}`); + + // Verify that the responses belong to units that belong to persons in the workspace + const persons = await this.personsRepository.find({ + where: { workspace_id: workspaceId } + }); + + if (persons.length === 0) { + this.logger.warn(`No persons found for workspace ${workspaceId}`); + return 0; + } + + const personIds = persons.map(person => person.id); + + const units = await this.unitRepository.createQueryBuilder('unit') + .innerJoin('unit.booklet', 'booklet') + .where('booklet.personid IN (:...personIds)', { personIds }) + .getMany(); + + if (units.length === 0) { + this.logger.warn(`No units found for persons in workspace ${workspaceId}`); + return 0; + } + + const unitIds = units.map(unit => unit.id); + + // Delete responses that match the given IDs and belong to the units in the workspace + const deleteResult = await this.responseRepository.delete({ + id: In(responseIds), + unitid: In(unitIds) + }); + + this.logger.log(`Deleted ${deleteResult.affected} invalid responses`); + return deleteResult.affected || 0; + } catch (error) { + this.logger.error(`Error deleting invalid responses: ${error.message}`, error.stack); + throw error; + } + } } diff --git a/apps/frontend/src/app/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index c7927dae0..2cc60bea4 100755 --- a/apps/frontend/src/app/components/home/home.component.html +++ b/apps/frontend/src/app/components/home/home.component.html @@ -9,7 +9,7 @@ [appTitle]="'Web application for coding'" [introHtml]="'appService.appConfig.introHtml'" [appName]="'IQB-Kodierbox'" - [appVersion]="'0.8.2'" + [appVersion]="'0.8.3'" [userName]="authData.userName" [userLongName]="appService.userProfile.firstName + ' ' + appService.userProfile.lastName" [isUserLoggedIn]="Number(authData.userId) > 0" diff --git a/apps/frontend/src/app/interceptors/auth.interceptor.spec.ts b/apps/frontend/src/app/interceptors/auth.interceptor.spec.ts deleted file mode 100644 index edff0c5a4..000000000 --- a/apps/frontend/src/app/interceptors/auth.interceptor.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { AuthInterceptor } from './auth.interceptor'; - -describe('AuthInterceptor', () => { - beforeEach(() => TestBed.configureTestingModule({ - providers: [ - { - provide: 'APP_VERSION', - useValue: '0.0.0' - } - ] - })); - - it('should be created', () => { - const interceptor: AuthInterceptor = TestBed.inject(AuthInterceptor); - expect(interceptor).toBeTruthy(); - }); -}); diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 51aba487f..15f6b0487 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -34,6 +34,8 @@ import { CreateUnitNoteDto } from '../../../../../api-dto/unit-notes/create-unit import { UpdateUnitNoteDto } from '../../../../../api-dto/unit-notes/update-unit-note.dto'; import { ResourcePackageDto } from '../../../../../api-dto/resource-package/resource-package-dto'; import { PaginatedWorkspaceUserDto } from '../../../../../api-dto/workspaces/paginated-workspace-user-dto'; +import { InvalidVariableDto } from '../../../../../api-dto/files/variable-validation.dto'; +import { TestTakersValidationDto } from '../../../../../api-dto/files/testtakers-validation.dto'; interface PaginatedResponse { data: T[]; @@ -1021,4 +1023,117 @@ export class BackendService { }) ); } + + validateVariables(workspaceId: number, page: number = 1, limit: number = 10): Observable> { + const params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()); + + return this.http.get>( + `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-variables`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => of({ + data: [], + total: 0, + page, + limit + })) + ); + } + + validateVariableTypes(workspaceId: number, page: number = 1, limit: number = 10): Observable> { + const params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()); + + return this.http.get>( + `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-variable-types`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => of({ + data: [], + total: 0, + page, + limit + })) + ); + } + + validateResponseStatus(workspaceId: number, page: number = 1, limit: number = 10): Observable> { + const params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()); + + return this.http.get>( + `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-response-status`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => of({ + data: [], + total: 0, + page, + limit + })) + ); + } + + validateTestTakers(workspaceId: number): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-testtakers`, + { headers: this.authHeader } + ).pipe( + catchError(() => of({ + testTakersFound: false, + totalGroups: 0, + totalLogins: 0, + totalBookletCodes: 0, + missingPersons: [] + })) + ); + } + + validateGroupResponses(workspaceId: number, page: number = 1, limit: number = 10): Observable<{ + testTakersFound: boolean; + groupsWithResponses: { group: string; hasResponse: boolean }[]; + allGroupsHaveResponses: boolean; + total: number; + page: number; + limit: number; + }> { + const params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()); + + return this.http.get<{ + testTakersFound: boolean; + groupsWithResponses: { group: string; hasResponse: boolean }[]; + allGroupsHaveResponses: boolean; + total: number; + page: number; + limit: number; + }>( + `${this.serverUrl}admin/workspace/${workspaceId}/files/validate-group-responses`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => of({ + testTakersFound: false, + groupsWithResponses: [], + allGroupsHaveResponses: false, + total: 0, + page, + limit + })) + ); + } + + deleteInvalidResponses(workspaceId: number, responseIds: number[]): Observable { + const params = new HttpParams().set('responseIds', responseIds.join(',')); + return this.http.delete( + `${this.serverUrl}admin/workspace/${workspaceId}/files/invalid-responses`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => of(0)) + ); + } } diff --git a/apps/frontend/src/app/shared/dialogs/content-dialog/content-dialog.component.ts b/apps/frontend/src/app/shared/dialogs/content-dialog/content-dialog.component.ts index c02186f00..f7936beb9 100644 --- a/apps/frontend/src/app/shared/dialogs/content-dialog/content-dialog.component.ts +++ b/apps/frontend/src/app/shared/dialogs/content-dialog/content-dialog.component.ts @@ -11,6 +11,7 @@ export interface DialogData { content: string; isJson?: boolean; isXml?: boolean; + showDeleteButton?: boolean; } @Component({ @@ -40,6 +41,9 @@ export interface DialogData { + @if (data.showDeleteButton) { + + } `, styles: [` @@ -75,8 +79,8 @@ export class ContentDialogComponent { @Inject(MAT_DIALOG_DATA) public data: DialogData ) {} - close(): void { - this.dialogRef.close(); + close(deleteFromDb: boolean = false): void { + this.dialogRef.close(deleteFromDb); } formatJson(jsonString: string): string { diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html index 2e48a92e1..84b5c2e93 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html @@ -25,6 +25,10 @@ search Suchen + + rule + Validieren + = new Map(); unitNotes: UnitNoteDto[] = []; unitNotesMap: Map = new Map(); + isVariableValidationRunning: boolean = false; + variableValidationResult: VariableValidationDto | null = null; readonly SHORT_PROCESSING_TIME_THRESHOLD_MS: number = 60000; @ViewChild(MatPaginator) paginator!: MatPaginator; @@ -1036,4 +1040,17 @@ export class TestResultsComponent implements OnInit, OnDestroy { } }); } + + openValidationDialog(): void { + const dialogRef = this.dialog.open(ValidationDialogComponent, { + width: '800px' + }); + + dialogRef.afterClosed().subscribe(result => { + if (result && result.variableValidationResult) { + this.variableValidationResult = result.variableValidationResult; + this.isVariableValidationRunning = false; + } + }); + } } diff --git a/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.html b/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.html new file mode 100644 index 000000000..c70e63294 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.html @@ -0,0 +1,424 @@ +

Antworten validieren

+ + + +

Prüft, ob für jede Testperson in der Datenbank ein entsprechender Eintrag in den TestTakers XML-Dateien existiert.

+ + @if (isTestTakersValidationRunning) { +
+ +

TestTakers werden validiert...

+
+ } + @if (testTakersValidationResult) { +
+ @if (testTakersValidationResult.testTakersFound) { +

TestTakers gefunden: {{ testTakersValidationResult.totalGroups }} Gruppen, {{ testTakersValidationResult.totalLogins }} Logins, {{ testTakersValidationResult.totalBookletCodes }} Booklet-Codes.

+ } @else { +
+ error + Prüfung fehlgeschlagen: Keine TestTakers gefunden. +
+ } + @if (testTakersValidationResult.missingPersons.length > 0) { +
+ error + Prüfung fehlgeschlagen: {{ testTakersValidationResult.missingPersons.length }} Testpersonen wurden nicht in den TestTakers XML-Dateien gefunden. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
Gruppe{{ element.group }}Login{{ element.login }}Code{{ element.code }}Grund{{ element.reason }}
+
+ } @else { +
+ check_circle + Prüfung bestanden: Alle Testpersonen wurden in den TestTakers XML-Dateien gefunden. +
+ } +
+ } +
+ +
+
+ +

Prüft, ob die Variable in der Unit.xml definiert ist.

+ + @if (isVariableValidationRunning) { +
+ +

Variablen werden validiert...

+
+ } + @if (invalidVariables.length > 0 || totalInvalidVariables > 0) { +
+ error + Prüfung fehlgeschlagen: {{ totalInvalidVariables }} ungültige Variablen gefunden. +
+ @if (invalidVariables.length > 0) { +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
Auswählen + + Dateiname + {{ element.fileName }} + Variablen-ID{{ element.variableId }}Wert{{ element.value }}
+ + +
+ } + } @else if (!isVariableValidationRunning && invalidVariables.length === 0 && totalInvalidVariables === 0 && validateVariablesWasRun) { +
+ check_circle + Prüfung bestanden: Keine ungültigen Variablen gefunden. +
+ } +
+ +
+
+ +

Prüft, ob der Wert der Variable dem definierten Typ entspricht (string, integer, number, boolean, json).

+ + @if (isVariableTypeValidationRunning) { +
+ +

Variablentypen werden validiert...

+
+ } + @if (invalidTypeVariables.length > 0 || totalInvalidTypeVariables > 0) { +
+ error + Prüfung fehlgeschlagen: {{ totalInvalidTypeVariables }} ungültige Variablenwerte gefunden. +
+ @if (invalidTypeVariables.length > 0) { +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Auswählen + + Dateiname + {{ element.fileName }} + Variablen-ID{{ element.variableId }}Wert{{ element.value }}Erwarteter Typ{{ element.expectedType }}Fehlergrund{{ element.errorReason }}
+ + +
+ } + } @else if (!isVariableTypeValidationRunning && invalidTypeVariables.length === 0 && totalInvalidTypeVariables === 0 && validateVariableTypesWasRun) { +
+ check_circle + Prüfung bestanden: Keine ungültigen Variablenwerte gefunden. +
+ } +
+ +
+
+ +

Prüft, ob der Status der Antwort gültig ist (gemäß Unit.xml).

+ + @if (isResponseStatusValidationRunning) { +
+ +

Antwortstatus wird validiert...

+
+ } + @if (invalidStatusVariables.length > 0 || totalInvalidStatusVariables > 0) { +
+ error + Prüfung fehlgeschlagen: {{ totalInvalidStatusVariables }} ungültige Antwortstatus gefunden. +
+ @if (invalidStatusVariables.length > 0) { +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Auswählen + + Dateiname + {{ element.fileName }} + Variablen-ID{{ element.variableId }}Wert{{ element.value }}Fehlergrund{{ element.errorReason }}
+ + +
+ } + } @else if (!isResponseStatusValidationRunning && invalidStatusVariables.length === 0 && totalInvalidStatusVariables === 0 && validateResponseStatusWasRun) { +
+ check_circle + Prüfung bestanden: Keine ungültigen Antwortstatus gefunden. +
+ } +
+ +
+
+ +

Prüft, ob die Antwort für die Kombination Testperson x Booklet x Unit vorgesehen ist.

+
+ + +
+
+ +

Prüft, ob Antworten für alle Testperson-Gruppen vorliegen.

+ + @if (isGroupResponsesValidationRunning) { +
+ +

Gruppenantworten werden validiert...

+
+ } + @if (groupResponsesResult) { +
+ @if (groupResponsesResult.testTakersFound) { +

TestTakers gefunden und analysiert.

+ } @else { +
+ error + Prüfung fehlgeschlagen: Keine TestTakers gefunden. +
+ } + @if (groupResponsesResult.groupsWithResponses.length > 0) { + @if (groupResponsesResult.allGroupsHaveResponses) { +
+ check_circle + Prüfung bestanden: Alle Gruppen haben mindestens eine Antwort. +
+ } @else { +
+ error + Prüfung fehlgeschlagen: Einige Gruppen haben keine Antworten. +
+ } +
+ +
+ + + + + + + + + + + + + +
Gruppe{{ element.group }}Hat Antwort + @if (element.hasResponse) { + check_circle + } @else { + error + } +
+ + +
+ } @else { +
+ error + Prüfung fehlgeschlagen: Keine gültigen Gruppen gefunden. +
+ } +
+ } +
+ +
+
+
+
+ + + + diff --git a/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts b/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts new file mode 100644 index 000000000..51c7e2cb7 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts @@ -0,0 +1,663 @@ +import { + Component, Inject, inject, ViewChild, AfterViewInit, OnInit +} from '@angular/core'; +import { + MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef +} from '@angular/material/dialog'; +import { MatStepperModule } from '@angular/material/stepper'; +import { MatButtonModule } from '@angular/material/button'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTableModule, MatTableDataSource } from '@angular/material/table'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatIconModule } from '@angular/material/icon'; +import { BackendService } from '../../../services/backend.service'; +import { AppService } from '../../../services/app.service'; +import { InvalidVariableDto } from '../../../../../../../api-dto/files/variable-validation.dto'; +import { TestTakersValidationDto, MissingPersonDto } from '../../../../../../../api-dto/files/testtakers-validation.dto'; +import { ContentDialogComponent } from '../../../shared/dialogs/content-dialog/content-dialog.component'; + +@Component({ + selector: 'coding-box-validation-dialog', + templateUrl: './validation-dialog.component.html', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatStepperModule, + MatButtonModule, + FormsModule, + ReactiveFormsModule, + MatProgressSpinnerModule, + MatTableModule, + MatExpansionModule, + MatSnackBarModule, + MatPaginatorModule, + MatIconModule + ], + styles: [` + .actions-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; + } + + .mat-expansion-panel { + margin-bottom: 16px; + } + + .mat-spinner { + display: inline-block; + margin-right: 8px; + vertical-align: middle; + } + + table { + width: 100%; + } + + .validation-result { + display: flex; + align-items: center; + margin: 10px 0; + padding: 8px 16px; + border-radius: 4px; + font-weight: 500; + } + + .validation-success { + background-color: rgba(76, 175, 80, 0.1); + color: #4CAF50; + border: 1px solid #4CAF50; + } + + .validation-error { + background-color: rgba(244, 67, 54, 0.1); + color: #F44336; + border: 1px solid #F44336; + } + + .validation-result mat-icon { + margin-right: 8px; + } + `] +}) +export class ValidationDialogComponent implements AfterViewInit, OnInit { + @ViewChild('variablePaginator') variablePaginator!: MatPaginator; + @ViewChild('variableTypePaginator') variableTypePaginator!: MatPaginator; + @ViewChild('statusVariablePaginator') statusVariablePaginator!: MatPaginator; + @ViewChild('groupResponsesPaginator') groupResponsesPaginator!: MatPaginator; + + firstStepCompleted = true; + backendService = inject(BackendService); + appService = inject(AppService); + + // Variable validation properties + invalidVariables: InvalidVariableDto[] = []; + totalInvalidVariables: number = 0; + currentVariablePage: number = 1; + variablePageSize: number = 10; + + // Variable type validation properties + invalidTypeVariables: InvalidVariableDto[] = []; + totalInvalidTypeVariables: number = 0; + currentTypeVariablePage: number = 1; + typeVariablePageSize: number = 10; + + // Response status validation properties + invalidStatusVariables: InvalidVariableDto[] = []; + totalInvalidStatusVariables: number = 0; + currentStatusVariablePage: number = 1; + statusVariablePageSize: number = 10; + + // Group responses validation properties + groupResponsesResult: { + testTakersFound: boolean; + groupsWithResponses: { group: string; hasResponse: boolean }[]; + allGroupsHaveResponses: boolean; + } | null = null; + + isGroupResponsesValidationRunning: boolean = false; + + groupResponsesValidationWasRun: boolean = false; + + expandedGroupResponsesPanel: boolean = false; + + paginatedGroupResponses = new MatTableDataSource<{ group: string; hasResponse: boolean }>([]); + + // Group responses pagination properties + currentGroupResponsesPage: number = 1; + groupResponsesPageSize: number = 10; + totalGroupResponses: number = 0; + + // TestTakers validation properties + testTakersValidationResult: TestTakersValidationDto | null = null; + isTestTakersValidationRunning: boolean = false; + testTakersValidationWasRun: boolean = false; + expandedMissingPersonsPanel: boolean = false; + paginatedMissingPersons = new MatTableDataSource([]); + + // Validation running flags + isVariableValidationRunning: boolean = false; + isVariableTypeValidationRunning: boolean = false; + isResponseStatusValidationRunning: boolean = false; + + // Validation was run flags + validateVariablesWasRun: boolean = false; + validateVariableTypesWasRun: boolean = false; + validateResponseStatusWasRun: boolean = false; + isDeletingResponses: boolean = false; + expandedPanel: boolean = false; + expandedTypePanel: boolean = false; + expandedStatusPanel: boolean = false; + selectedResponses: Set = new Set(); + selectedTypeResponses: Set = new Set(); + selectedStatusResponses: Set = new Set(); + + // Pagination properties + pageSizeOptions = [5, 10, 25, 50]; + + // Paginated data + paginatedVariables = new MatTableDataSource([]); + paginatedTypeVariables = new MatTableDataSource([]); + paginatedStatusVariables = new MatTableDataSource([]); + + constructor( + @Inject(MAT_DIALOG_DATA) public data: unknown, + private dialogRef: MatDialogRef, + private snackBar: MatSnackBar, + private dialog: MatDialog + ) {} + + ngOnInit(): void { + // Initialize component + } + + ngAfterViewInit(): void { + // Set up paginators after view is initialized + this.paginatedVariables.paginator = this.variablePaginator; + this.paginatedTypeVariables.paginator = this.variableTypePaginator; + this.paginatedStatusVariables.paginator = this.statusVariablePaginator; + this.paginatedGroupResponses.paginator = this.groupResponsesPaginator; + } + + updatePaginatedVariables(): void { + this.paginatedVariables.data = this.invalidVariables; + } + + updatePaginatedMissingPersons(): void { + if (this.testTakersValidationResult) { + this.paginatedMissingPersons.data = this.testTakersValidationResult.missingPersons; + } + } + + updatePaginatedGroupResponses(): void { + if (this.groupResponsesResult) { + this.paginatedGroupResponses.data = this.groupResponsesResult.groupsWithResponses; + // totalGroupResponses is now set from the server response + } + } + + onGroupResponsesPageChange(event: PageEvent): void { + this.currentGroupResponsesPage = event.pageIndex + 1; + this.groupResponsesPageSize = event.pageSize; + + // Reload data from server with new pagination parameters + this.isGroupResponsesValidationRunning = true; + this.backendService.validateGroupResponses( + this.appService.selectedWorkspaceId, + this.currentGroupResponsesPage, + this.groupResponsesPageSize + ).subscribe(result => { + this.groupResponsesResult = result; + this.totalGroupResponses = result.total; + this.updatePaginatedGroupResponses(); + this.isGroupResponsesValidationRunning = false; + }); + } + + validateTestTakers(): void { + this.isTestTakersValidationRunning = true; + this.testTakersValidationResult = null; + this.testTakersValidationWasRun = false; + this.backendService.validateTestTakers(this.appService.selectedWorkspaceId) + .subscribe(result => { + this.testTakersValidationResult = result; + this.updatePaginatedMissingPersons(); + this.isTestTakersValidationRunning = false; + this.testTakersValidationWasRun = true; + }); + } + + toggleMissingPersonsExpansion(): void { + this.expandedMissingPersonsPanel = !this.expandedMissingPersonsPanel; + } + + toggleGroupResponsesExpansion(): void { + this.expandedGroupResponsesPanel = !this.expandedGroupResponsesPanel; + } + + validateGroupResponses(): void { + this.isGroupResponsesValidationRunning = true; + this.groupResponsesResult = null; + this.groupResponsesValidationWasRun = false; + this.currentGroupResponsesPage = 1; + this.backendService.validateGroupResponses( + this.appService.selectedWorkspaceId, + this.currentGroupResponsesPage, + this.groupResponsesPageSize + ).subscribe(result => { + this.groupResponsesResult = result; + this.totalGroupResponses = result.total; + this.updatePaginatedGroupResponses(); + this.isGroupResponsesValidationRunning = false; + this.groupResponsesValidationWasRun = true; + }); + } + + updatePaginatedTypeVariables(): void { + this.paginatedTypeVariables.data = this.invalidTypeVariables; + } + + updatePaginatedStatusVariables(): void { + this.paginatedStatusVariables.data = this.invalidStatusVariables; + } + + validateVariables(): void { + this.isVariableValidationRunning = true; + this.invalidVariables = []; + this.totalInvalidVariables = 0; + this.validateVariablesWasRun = false; + this.selectedResponses.clear(); + this.backendService.validateVariables( + this.appService.selectedWorkspaceId, + this.currentVariablePage, + this.variablePageSize + ).subscribe(result => { + this.invalidVariables = result.data; + this.totalInvalidVariables = result.total; + this.currentVariablePage = result.page; + this.variablePageSize = result.limit; + this.updatePaginatedVariables(); + this.isVariableValidationRunning = false; + this.validateVariablesWasRun = true; + }); + } + + onVariablePageChange(event: PageEvent): void { + this.currentVariablePage = event.pageIndex + 1; + this.variablePageSize = event.pageSize; + this.validateVariables(); + } + + toggleResponseSelection(responseId: number | undefined): void { + if (responseId === undefined) return; + + if (this.selectedResponses.has(responseId)) { + this.selectedResponses.delete(responseId); + } else { + this.selectedResponses.add(responseId); + } + } + + isResponseSelected(responseId: number | undefined): boolean { + return responseId !== undefined && this.selectedResponses.has(responseId); + } + + selectAllResponses(): void { + this.invalidVariables.forEach(variable => { + if (variable.responseId !== undefined) { + this.selectedResponses.add(variable.responseId); + } + }); + } + + deselectAllResponses(): void { + this.selectedResponses.clear(); + } + + deleteSelectedResponses(): void { + if (this.selectedResponses.size === 0) { + this.snackBar.open('Keine Antworten ausgewählt', 'Schließen', { duration: 3000 }); + return; + } + + this.isDeletingResponses = true; + const responseIds = Array.from(this.selectedResponses); + + this.backendService.deleteInvalidResponses(this.appService.selectedWorkspaceId, responseIds) + .subscribe(deletedCount => { + this.isDeletingResponses = false; + this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); + + // Refresh the data after deletion + this.validateVariables(); + this.selectedResponses.clear(); + }); + } + + deleteAllResponses(): void { + if (this.invalidVariables.length === 0) { + this.snackBar.open('Keine ungültigen Variablen vorhanden', 'Schließen', { duration: 3000 }); + return; + } + + // Create confirmation dialog + const dialogRef = this.dialog.open(ContentDialogComponent, { + width: '400px', + data: { + title: 'Alle Einträge löschen', + content: `Wirklich alle ${this.totalInvalidVariables} ungültigen Variablen löschen?`, + isJson: false, + isXml: false, + showDeleteButton: true + } + }); + + dialogRef.afterClosed().subscribe(deleteFromDb => { + if (deleteFromDb) { + this.isDeletingResponses = true; + // Get all response IDs + const responseIds = this.invalidVariables + .filter(variable => variable.responseId !== undefined) + .map(variable => variable.responseId as number); + + this.backendService.deleteInvalidResponses(this.appService.selectedWorkspaceId, responseIds) + .subscribe(deletedCount => { + this.isDeletingResponses = false; + this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); + + // Refresh the data after deletion + this.validateVariables(); + this.selectedResponses.clear(); + }); + } + }); + } + + toggleExpansion(): void { + this.expandedPanel = !this.expandedPanel; + } + + validateVariableTypes(): void { + this.isVariableTypeValidationRunning = true; + this.invalidTypeVariables = []; + this.totalInvalidTypeVariables = 0; + this.validateVariableTypesWasRun = false; + this.selectedTypeResponses.clear(); + this.backendService.validateVariableTypes( + this.appService.selectedWorkspaceId, + this.currentTypeVariablePage, + this.typeVariablePageSize + ).subscribe(result => { + this.invalidTypeVariables = result.data; + this.totalInvalidTypeVariables = result.total; + this.currentTypeVariablePage = result.page; + this.typeVariablePageSize = result.limit; + this.updatePaginatedTypeVariables(); + this.isVariableTypeValidationRunning = false; + this.validateVariableTypesWasRun = true; + }); + } + + validateResponseStatus(): void { + this.isResponseStatusValidationRunning = true; + this.invalidStatusVariables = []; + this.totalInvalidStatusVariables = 0; + this.validateResponseStatusWasRun = false; + this.selectedStatusResponses.clear(); + this.backendService.validateResponseStatus( + this.appService.selectedWorkspaceId, + this.currentStatusVariablePage, + this.statusVariablePageSize + ).subscribe(result => { + this.invalidStatusVariables = result.data; + this.totalInvalidStatusVariables = result.total; + this.currentStatusVariablePage = result.page; + this.statusVariablePageSize = result.limit; + this.updatePaginatedStatusVariables(); + this.isResponseStatusValidationRunning = false; + this.validateResponseStatusWasRun = true; + }); + } + + onTypeVariablePageChange(event: PageEvent): void { + this.currentTypeVariablePage = event.pageIndex + 1; + this.typeVariablePageSize = event.pageSize; + this.validateVariableTypes(); + } + + onStatusVariablePageChange(event: PageEvent): void { + this.currentStatusVariablePage = event.pageIndex + 1; + this.statusVariablePageSize = event.pageSize; + this.validateResponseStatus(); + } + + toggleTypeResponseSelection(responseId: number | undefined): void { + if (responseId === undefined) return; + + if (this.selectedTypeResponses.has(responseId)) { + this.selectedTypeResponses.delete(responseId); + } else { + this.selectedTypeResponses.add(responseId); + } + } + + isTypeResponseSelected(responseId: number | undefined): boolean { + return responseId !== undefined && this.selectedTypeResponses.has(responseId); + } + + selectAllTypeResponses(): void { + this.invalidTypeVariables.forEach(variable => { + if (variable.responseId !== undefined) { + this.selectedTypeResponses.add(variable.responseId); + } + }); + } + + deselectAllTypeResponses(): void { + this.selectedTypeResponses.clear(); + } + + deleteSelectedTypeResponses(): void { + if (this.selectedTypeResponses.size === 0) { + this.snackBar.open('Keine Antworten ausgewählt', 'Schließen', { duration: 3000 }); + return; + } + + this.isDeletingResponses = true; + const responseIds = Array.from(this.selectedTypeResponses); + + this.backendService.deleteInvalidResponses(this.appService.selectedWorkspaceId, responseIds) + .subscribe(deletedCount => { + this.isDeletingResponses = false; + this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); + + // Refresh the data after deletion + this.validateVariableTypes(); + this.selectedTypeResponses.clear(); + }); + } + + deleteAllTypeResponses(): void { + if (this.invalidTypeVariables.length === 0) { + this.snackBar.open('Keine ungültigen Variablentypen vorhanden', 'Schließen', { duration: 3000 }); + return; + } + + // Create confirmation dialog + const dialogRef = this.dialog.open(ContentDialogComponent, { + width: '400px', + data: { + title: 'Alle Einträge löschen', + content: `Wirklich alle ${this.totalInvalidTypeVariables} ungültigen Variablentypen löschen?`, + isJson: false, + isXml: false, + showDeleteButton: true + } + }); + + dialogRef.afterClosed().subscribe(deleteFromDb => { + if (deleteFromDb) { + this.isDeletingResponses = true; + // Get all response IDs + const responseIds = this.invalidTypeVariables + .filter(variable => variable.responseId !== undefined) + .map(variable => variable.responseId as number); + + this.backendService.deleteInvalidResponses(this.appService.selectedWorkspaceId, responseIds) + .subscribe(deletedCount => { + this.isDeletingResponses = false; + this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); + + // Refresh the data after deletion + this.validateVariableTypes(); + this.selectedTypeResponses.clear(); + }); + } + }); + } + + toggleTypeExpansion(): void { + this.expandedTypePanel = !this.expandedTypePanel; + } + + toggleStatusResponseSelection(responseId: number | undefined): void { + if (responseId === undefined) return; + + if (this.selectedStatusResponses.has(responseId)) { + this.selectedStatusResponses.delete(responseId); + } else { + this.selectedStatusResponses.add(responseId); + } + } + + isStatusResponseSelected(responseId: number | undefined): boolean { + return responseId !== undefined && this.selectedStatusResponses.has(responseId); + } + + selectAllStatusResponses(): void { + this.invalidStatusVariables.forEach(variable => { + if (variable.responseId !== undefined) { + this.selectedStatusResponses.add(variable.responseId); + } + }); + } + + deselectAllStatusResponses(): void { + this.selectedStatusResponses.clear(); + } + + deleteSelectedStatusResponses(): void { + if (this.selectedStatusResponses.size === 0) { + this.snackBar.open('Keine Antworten ausgewählt', 'Schließen', { duration: 3000 }); + return; + } + + this.isDeletingResponses = true; + const responseIds = Array.from(this.selectedStatusResponses); + + this.backendService.deleteInvalidResponses(this.appService.selectedWorkspaceId, responseIds) + .subscribe(deletedCount => { + this.isDeletingResponses = false; + this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); + + // Refresh the data after deletion + this.validateResponseStatus(); + this.selectedStatusResponses.clear(); + }); + } + + deleteAllStatusResponses(): void { + if (this.invalidStatusVariables.length === 0) { + this.snackBar.open('Keine ungültigen Antwortstatus vorhanden', 'Schließen', { duration: 3000 }); + return; + } + + // Create confirmation dialog + const dialogRef = this.dialog.open(ContentDialogComponent, { + width: '400px', + data: { + title: 'Alle Einträge löschen', + content: `Wirklich alle ${this.totalInvalidStatusVariables} ungültigen Antwortstatus löschen?`, + isJson: false, + isXml: false, + showDeleteButton: true + } + }); + + dialogRef.afterClosed().subscribe(deleteFromDb => { + if (deleteFromDb) { + this.isDeletingResponses = true; + // Get all response IDs + const responseIds = this.invalidStatusVariables + .filter(variable => variable.responseId !== undefined) + .map(variable => variable.responseId as number); + + this.backendService.deleteInvalidResponses(this.appService.selectedWorkspaceId, responseIds) + .subscribe(deletedCount => { + this.isDeletingResponses = false; + this.snackBar.open(`${deletedCount} Antworten gelöscht`, 'Schließen', { duration: 3000 }); + + // Refresh the data after deletion + this.validateResponseStatus(); + this.selectedStatusResponses.clear(); + }); + } + }); + } + + toggleStatusExpansion(): void { + this.expandedStatusPanel = !this.expandedStatusPanel; + } + + closeWithResults(): void { + this.dialogRef.close({ + invalidVariables: this.invalidVariables, + totalInvalidVariables: this.totalInvalidVariables, + invalidTypeVariables: this.invalidTypeVariables, + totalInvalidTypeVariables: this.totalInvalidTypeVariables, + invalidStatusVariables: this.invalidStatusVariables, + totalInvalidStatusVariables: this.totalInvalidStatusVariables + }); + } + + /** + * Extracts the unit ID from the fileName + * @param fileName The fileName in the format "Unit unitName" + * @returns The unit name + */ + extractUnitName(fileName: string): string { + // The fileName is in the format "Unit unitName" + const match = fileName.match(/^Unit\s+(.+)$/); + return match ? match[1] : fileName; + } + + /** + * Shows the unit XML content in a dialog + * @param fileName The fileName in the format "Unit unitName" + */ + showUnitXml(fileName: string): void { + const unitName = this.extractUnitName(fileName); + + this.backendService.getUnitContentXml(this.appService.selectedWorkspaceId, Number(unitName)) + .subscribe(xmlContent => { + if (xmlContent) { + this.dialog.open(ContentDialogComponent, { + width: '80%', + data: { + title: `Unit XML: ${unitName}`, + content: xmlContent, + isXml: true + } + }); + } else { + this.snackBar.open(`Keine XML-Daten für Unit ${unitName} gefunden`, 'Schließen', { duration: 3000 }); + } + }); + } +} diff --git a/database/changelog/coding-box.changelog-0.8.2.sql b/database/changelog/coding-box.changelog-0.8.2.sql index 3ad150587..ec0020e64 100644 --- a/database/changelog/coding-box.changelog-0.8.2.sql +++ b/database/changelog/coding-box.changelog-0.8.2.sql @@ -17,7 +17,7 @@ CREATE INDEX IF NOT EXISTS "idx_persons_login_code_workspace" ON "public"."perso CREATE INDEX IF NOT EXISTS "idx_response_unitid_variableid" ON "public"."response" ("unitid", "variableid"); CREATE INDEX IF NOT EXISTS "idx_response_unitid_status" ON "public"."response" ("unitid", "status"); CREATE INDEX IF NOT EXISTS "idx_response_codedstatus" ON "public"."response" ("codedstatus"); -CREATE INDEX IF NOT EXISTS "idx_response_value" ON "public"."response" ("value"); +CREATE INDEX IF NOT EXISTS "idx_response_value" ON "public"."response" (substring("value", 1, 1000)); -- rollback DROP INDEX IF EXISTS "idx_response_unitid_variableid"; DROP INDEX IF EXISTS "idx_response_unitid_status"; DROP INDEX IF EXISTS "idx_response_codedstatus"; DROP INDEX IF EXISTS "idx_response_value"; -- changeset jurei733:4 diff --git a/package-lock.json b/package-lock.json index 1daebaf0f..8fa0d2359 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coding-box", - "version": "0.8.2", + "version": "0.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coding-box", - "version": "0.8.2", + "version": "0.8.3", "license": "MIT", "dependencies": { "@angular/animations": "20.0.3", diff --git a/package.json b/package.json index e2dd34ad0..b1d877a3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coding-box", - "version": "0.8.2", + "version": "0.8.3", "author": "IQB - Institut zur Qualitätsentwicklung im Bildungswesen", "license": "MIT", "scripts": {