From b7e910dcf790b45b88a504ee9b4db6987ea61563 Mon Sep 17 00:00:00 2001 From: chyoo Date: Mon, 3 Aug 2026 14:03:00 +0900 Subject: [PATCH] patch.. --- .../migration.sql | 4 + .../migration.sql | 45 ++ .../migration.sql | 27 ++ .../migration.sql | 27 ++ .../migration.sql | 12 + .../migration.sql | 2 + .../migration.sql | 18 + .../migration.sql | 2 + .../migration.sql | 2 + prisma/schema.prisma | 63 +++ src/curriculum/course-maps.ts | 64 +++ src/curriculum/curriculum.service.ts | 22 +- .../dto/create-instructor-course-map.dto.ts | 16 + .../dto/update-instructor-curriculum.dto.ts | 24 + src/instructor/instructor.controller.ts | 21 + src/instructor/instructor.service.ts | 433 +++++++++++++++--- .../platform-control.controller.ts | 31 ++ .../platform-control.service.ts | 207 +++++++-- .../platform-gateway.client.ts | 49 +- src/studio/studio.controller.ts | 20 +- src/studio/studio.service.ts | 60 +++ .../619a4b0f-7b49-4f9d-96d9-5ab7effa3224.py | 13 + .../7d288277-c62d-4769-8be8-c36e4f0c8232.py | 29 ++ .../7eebe9b7-6275-444a-95da-7999f135d8a4.pdf | 1 + 24 files changed, 1081 insertions(+), 111 deletions(-) create mode 100644 prisma/migrations/20260716000000_add_course_week_module_default_parameters/migration.sql create mode 100644 prisma/migrations/20260720090000_add_course_week_maps/migration.sql create mode 100644 prisma/migrations/20260720100000_add_student_saved_maps/migration.sql create mode 100644 prisma/migrations/20260722000000_add_real_vehicle_visualization_platforms/migration.sql create mode 100644 prisma/migrations/20260722001000_rename_real_visualization_platforms/migration.sql create mode 100644 prisma/migrations/20260722002000_add_platform_control_session_extension/migration.sql create mode 100644 prisma/migrations/20260729000000_add_real_slam_3d_visualization_platform/migration.sql create mode 100644 prisma/migrations/20260731000000_add_student_saved_map_database_path/migration.sql create mode 100644 prisma/migrations/20260731163000_add_student_saved_map_preview_pgm/migration.sql create mode 100644 src/curriculum/course-maps.ts create mode 100644 src/instructor/dto/create-instructor-course-map.dto.ts create mode 100644 uploads/course-references/619a4b0f-7b49-4f9d-96d9-5ab7effa3224.py create mode 100644 uploads/course-references/7d288277-c62d-4769-8be8-c36e4f0c8232.py create mode 100644 uploads/course-references/7eebe9b7-6275-444a-95da-7999f135d8a4.pdf diff --git a/prisma/migrations/20260716000000_add_course_week_module_default_parameters/migration.sql b/prisma/migrations/20260716000000_add_course_week_module_default_parameters/migration.sql new file mode 100644 index 0000000..2d541cd --- /dev/null +++ b/prisma/migrations/20260716000000_add_course_week_module_default_parameters/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "course_week_modules" + ADD COLUMN IF NOT EXISTS "default_parameters" JSONB NOT NULL DEFAULT '[]'; + +COMMENT ON COLUMN "course_week_modules"."default_parameters" IS '주차 모듈 실행 시 적용하는 기본 ROS 파라미터 배열.'; diff --git a/prisma/migrations/20260720090000_add_course_week_maps/migration.sql b/prisma/migrations/20260720090000_add_course_week_maps/migration.sql new file mode 100644 index 0000000..7e67588 --- /dev/null +++ b/prisma/migrations/20260720090000_add_course_week_maps/migration.sql @@ -0,0 +1,45 @@ +CREATE TABLE "course_maps" ( + "id" UUID NOT NULL, + "course_id" UUID NOT NULL, + "key" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "platform_key" TEXT NOT NULL DEFAULT 'NAV2', + "world_file" TEXT NOT NULL DEFAULT 'empty_world.world', + "viewer_model" TEXT NOT NULL DEFAULT 'turtlebot3_world', + "layout" JSONB NOT NULL DEFAULT '{"version":1,"objects":[]}', + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + CONSTRAINT "course_maps_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "course_week_maps" ( + "id" UUID NOT NULL, + "course_id" UUID NOT NULL, + "week" INTEGER NOT NULL, + "map_key" TEXT NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + CONSTRAINT "course_week_maps_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "course_maps_course_id_key_key" ON "course_maps"("course_id", "key"); +CREATE INDEX "course_maps_course_id_platform_key_idx" ON "course_maps"("course_id", "platform_key"); +CREATE UNIQUE INDEX "course_week_maps_course_id_week_map_key_key" ON "course_week_maps"("course_id", "week", "map_key"); +CREATE INDEX "course_week_maps_course_id_week_idx" ON "course_week_maps"("course_id", "week"); + +ALTER TABLE "course_maps" ADD CONSTRAINT "course_maps_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "course_week_maps" ADD CONSTRAINT "course_week_maps_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +INSERT INTO "course_week_maps" ("id", "course_id", "week", "map_key", "sort_order", "updated_at") +SELECT gen_random_uuid(), "course_id", "week", 'nav2-turtlebot3-world', 0, CURRENT_TIMESTAMP +FROM "course_week_visualization_platforms" +WHERE "platform_key" = 'NAV2' AND "enabled" = true +ON CONFLICT ("course_id", "week", "map_key") DO NOTHING; + +INSERT INTO "course_week_maps" ("id", "course_id", "week", "map_key", "sort_order", "updated_at") +SELECT gen_random_uuid(), "course_id", "week", 'nav2-turtlebot3-house', 1, CURRENT_TIMESTAMP +FROM "course_week_visualization_platforms" +WHERE "platform_key" = 'NAV2' AND "enabled" = true +ON CONFLICT ("course_id", "week", "map_key") DO NOTHING; diff --git a/prisma/migrations/20260720100000_add_student_saved_maps/migration.sql b/prisma/migrations/20260720100000_add_student_saved_maps/migration.sql new file mode 100644 index 0000000..6e2ad8d --- /dev/null +++ b/prisma/migrations/20260720100000_add_student_saved_maps/migration.sql @@ -0,0 +1,27 @@ +CREATE TABLE "student_saved_maps" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "course_id" UUID NOT NULL, + "week" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "source_map_key" TEXT NOT NULL, + "world_file" TEXT NOT NULL, + "viewer_model" TEXT NOT NULL, + "yaml_path" TEXT NOT NULL, + "pgm_path" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + CONSTRAINT "student_saved_maps_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "student_saved_maps_user_id_course_id_week_yaml_path_key" + ON "student_saved_maps"("user_id", "course_id", "week", "yaml_path"); +CREATE INDEX "student_saved_maps_user_id_course_id_week_created_at_idx" + ON "student_saved_maps"("user_id", "course_id", "week", "created_at"); + +ALTER TABLE "student_saved_maps" + ADD CONSTRAINT "student_saved_maps_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "student_saved_maps" + ADD CONSTRAINT "student_saved_maps_course_id_fkey" + FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260722000000_add_real_vehicle_visualization_platforms/migration.sql b/prisma/migrations/20260722000000_add_real_vehicle_visualization_platforms/migration.sql new file mode 100644 index 0000000..71ef005 --- /dev/null +++ b/prisma/migrations/20260722000000_add_real_vehicle_visualization_platforms/migration.sql @@ -0,0 +1,27 @@ +INSERT INTO "visualization_platforms" ( + "platform_key", "type", "display_name", "description", "default_config", "sort_order", "enabled", "created_at", "updated_at" +) VALUES +( + 'REAL_SLAM', + 'REAL_SLAM', + '실차 SLAM', + '원격 플랫폼의 실제 센서로 지도를 생성합니다.', + '{"type":"REAL_SLAM","target":"remote-jetson","runtimeKind":"real","viewer":{"stack":"nav2","mode":"slam"},"bridge":{"name":"nav2-ros-bridge","port":8765,"image":"slam-ros-bridge:latest","env":{"ROBOT_STACK":"nav2"}},"runtimeImages":[{"url":"https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge/latest/slam-ros-bridge-latest.tar.zst","image":"slam-ros-bridge:latest","fileName":"slam-ros-bridge-latest.tar.zst","required":true}]}'::jsonb, + 30, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +), +( + 'REAL_NAVIGATION', + 'REAL_NAVIGATION', + '실차 자율주행', + '실차 SLAM 결과 맵에서 초기 위치와 목표점을 지정해 주행합니다.', + '{"type":"REAL_NAVIGATION","target":"remote-jetson","runtimeKind":"real","viewer":{"stack":"nav2","mode":"navigation"},"bridge":{"name":"nav2-ros-bridge","port":8765,"image":"slam-ros-bridge:latest","env":{"ROBOT_STACK":"nav2"}},"runtimeImages":[{"url":"https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge/latest/slam-ros-bridge-latest.tar.zst","image":"slam-ros-bridge:latest","fileName":"slam-ros-bridge-latest.tar.zst","required":true}]}'::jsonb, + 31, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +) +ON CONFLICT ("platform_key") DO UPDATE SET + "type" = EXCLUDED."type", + "display_name" = EXCLUDED."display_name", + "description" = EXCLUDED."description", + "default_config" = EXCLUDED."default_config", + "sort_order" = EXCLUDED."sort_order", + "enabled" = EXCLUDED."enabled", + "updated_at" = CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260722001000_rename_real_visualization_platforms/migration.sql b/prisma/migrations/20260722001000_rename_real_visualization_platforms/migration.sql new file mode 100644 index 0000000..9bd6480 --- /dev/null +++ b/prisma/migrations/20260722001000_rename_real_visualization_platforms/migration.sql @@ -0,0 +1,12 @@ +UPDATE "visualization_platforms" +SET + "display_name" = CASE "platform_key" + WHEN 'REAL_SLAM' THEN '플랫폼 SLAM' + WHEN 'REAL_NAVIGATION' THEN '플랫폼 자율주행' + END, + "description" = CASE "platform_key" + WHEN 'REAL_SLAM' THEN '원격 플랫폼의 센서로 지도를 생성합니다.' + WHEN 'REAL_NAVIGATION' THEN '플랫폼 SLAM 결과 맵에서 초기 위치와 목표점을 지정해 주행합니다.' + END, + "updated_at" = CURRENT_TIMESTAMP +WHERE "platform_key" IN ('REAL_SLAM', 'REAL_NAVIGATION'); diff --git a/prisma/migrations/20260722002000_add_platform_control_session_extension/migration.sql b/prisma/migrations/20260722002000_add_platform_control_session_extension/migration.sql new file mode 100644 index 0000000..43bc546 --- /dev/null +++ b/prisma/migrations/20260722002000_add_platform_control_session_extension/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "platform_control_sessions" +ADD COLUMN "extension_count" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/migrations/20260729000000_add_real_slam_3d_visualization_platform/migration.sql b/prisma/migrations/20260729000000_add_real_slam_3d_visualization_platform/migration.sql new file mode 100644 index 0000000..41799f1 --- /dev/null +++ b/prisma/migrations/20260729000000_add_real_slam_3d_visualization_platform/migration.sql @@ -0,0 +1,18 @@ +INSERT INTO "visualization_platforms" ( + "platform_key", "type", "display_name", "description", "default_config", "sort_order", "enabled", "created_at", "updated_at" +) VALUES ( + 'REAL_SLAM_3D', + 'REAL_SLAM_3D', + '플랫폼 3D SLAM', + '플랫폼의 3D LiDAR 포인트클라우드와 ICP 위치를 읽기 전용으로 표시합니다.', + '{"type":"REAL_SLAM_3D","target":"remote-jetson","runtimeKind":"real","viewer":{"stack":"nav2","mode":"slam3d"},"bridge":{"name":"nav2-ros-bridge","port":8765,"image":"slam-ros-bridge:jetson_orin-3d","env":{"ROBOT_STACK":"nav2","READ_ONLY":"true","ODOM_TOPIC":"/odom","ICP_ODOM_TOPIC":"/icp_odom","SCAN_TOPIC":"/scan","POINT_CLOUD_TOPIC":"/cloud_map","FALLBACK_POINT_CLOUD_TOPIC":"/lidar_filtered"}},"runtimeImages":[{"url":"https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge/jetson-orin-3d/slam-ros-bridge-jetson-orin-3d.tar.zst","image":"slam-ros-bridge:jetson_orin-3d","fileName":"slam-ros-bridge-jetson-orin-3d.tar.zst","required":true}]}'::jsonb, + 32, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +) +ON CONFLICT ("platform_key") DO UPDATE SET + "type" = EXCLUDED."type", + "display_name" = EXCLUDED."display_name", + "description" = EXCLUDED."description", + "default_config" = EXCLUDED."default_config", + "sort_order" = EXCLUDED."sort_order", + "enabled" = EXCLUDED."enabled", + "updated_at" = CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260731000000_add_student_saved_map_database_path/migration.sql b/prisma/migrations/20260731000000_add_student_saved_map_database_path/migration.sql new file mode 100644 index 0000000..ea2f341 --- /dev/null +++ b/prisma/migrations/20260731000000_add_student_saved_map_database_path/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "student_saved_maps" + ADD COLUMN "database_path" TEXT; diff --git a/prisma/migrations/20260731163000_add_student_saved_map_preview_pgm/migration.sql b/prisma/migrations/20260731163000_add_student_saved_map_preview_pgm/migration.sql new file mode 100644 index 0000000..e8680b4 --- /dev/null +++ b/prisma/migrations/20260731163000_add_student_saved_map_preview_pgm/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "student_saved_maps" + ADD COLUMN "preview_pgm" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6e14f33..9d84ceb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,7 @@ model User { platformControlSessions PlatformControlSession[] platformControlQueueEntries PlatformControlQueueEntry[] studioRepositories StudioRepository[] + savedMaps StudentSavedMap[] submissionScores SubmissionScore[] submissionTotalScores SubmissionTotalScore[] @@ -156,6 +157,9 @@ model Course { instructorCurriculumWeekOverrides InstructorCurriculumWeekOverride[] environmentProfiles CourseEnvironmentProfile[] visualizationSelections CourseWeekVisualizationPlatform[] + maps CourseMap[] + weekMaps CourseWeekMap[] + savedMaps StudentSavedMap[] submissionScores SubmissionScore[] submissionTotalScores SubmissionTotalScore[] @@ -216,6 +220,63 @@ model CourseWeekVisualizationPlatform { @@map("course_week_visualization_platforms") } +model CourseMap { + id String @id @default(uuid()) @db.Uuid + courseId String @map("course_id") @db.Uuid + key String + name String + description String? + platformKey String @default("NAV2") @map("platform_key") + worldFile String @default("empty_world.world") @map("world_file") + viewerModel String @default("turtlebot3_world") @map("viewer_model") + layout Json @default("{\"version\":1,\"objects\":[]}") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + + @@unique([courseId, key]) + @@index([courseId, platformKey]) + @@map("course_maps") +} + +model CourseWeekMap { + id String @id @default(uuid()) @db.Uuid + courseId String @map("course_id") @db.Uuid + week Int + mapKey String @map("map_key") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + + @@unique([courseId, week, mapKey]) + @@index([courseId, week]) + @@map("course_week_maps") +} + +model StudentSavedMap { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + courseId String @map("course_id") @db.Uuid + week Int + name String + sourceMapKey String @map("source_map_key") + worldFile String @map("world_file") + viewerModel String @map("viewer_model") + yamlPath String @map("yaml_path") + pgmPath String @map("pgm_path") + previewPgm String? @map("preview_pgm") + databasePath String? @map("database_path") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + + @@unique([userId, courseId, week, yamlPath]) + @@index([userId, courseId, week, createdAt]) + @@map("student_saved_maps") +} + model InstructorCurriculumSetting { id String @id @default(uuid()) @db.Uuid organizationId String? @map("organization_id") @db.Uuid @@ -328,6 +389,7 @@ model PlatformControlSession { endsAt DateTime @map("ends_at") @db.Timestamptz(3) warningAt DateTime @map("warning_at") @db.Timestamptz(3) warningSentAt DateTime? @map("warning_sent_at") @db.Timestamptz(3) + extensionCount Int @default(0) @map("extension_count") releasedAt DateTime? @map("released_at") @db.Timestamptz(3) endReason String? @map("end_reason") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) @@ -448,6 +510,7 @@ model CourseWeekModule { templateRepositoryUrl String? @map("template_repository_url") sourceType String @default("GIT") @map("source_type") prebuiltArtifacts Json @default("[]") @map("prebuilt_artifacts") + defaultParameters Json @default("[]") @map("default_parameters") dockerImage String? @map("docker_image") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) diff --git a/src/curriculum/course-maps.ts b/src/curriculum/course-maps.ts new file mode 100644 index 0000000..f604622 --- /dev/null +++ b/src/curriculum/course-maps.ts @@ -0,0 +1,64 @@ +import { Prisma } from '@prisma/client'; + +export type CourseMapPayload = { + key: string; + platformKey: 'NAV2'; + name: string; + description: string; + worldFile: string; + viewerModel: 'turtlebot3_world' | 'turtlebot3_house'; + custom?: boolean; + layout?: Prisma.JsonValue; +}; + +export const NAV2_SYSTEM_MAPS: CourseMapPayload[] = [ + { + key: 'nav2-turtlebot3-world', + platformKey: 'NAV2', + name: '기본 월드', + description: 'TurtleBot3 기본 장애물 환경', + worldFile: 'turtlebot3_world.world', + viewerModel: 'turtlebot3_world', + }, + { + key: 'nav2-turtlebot3-house', + platformKey: 'NAV2', + name: '실내 환경', + description: '방과 복도로 구성된 TurtleBot3 House', + worldFile: 'turtlebot3_house.world', + viewerModel: 'turtlebot3_house', + }, +]; + +export function serializeCourseMaps( + selectedKeys: string[], + customMaps: Array<{ + key: string; + name: string; + description: string | null; + platformKey: string; + worldFile: string; + viewerModel: string; + layout: Prisma.JsonValue; + }>, +): CourseMapPayload[] { + const systemByKey = new Map(NAV2_SYSTEM_MAPS.map((map) => [map.key, map])); + const customByKey = new Map(customMaps.map((map) => [map.key, map])); + + return selectedKeys.flatMap((key) => { + const systemMap = systemByKey.get(key); + if (systemMap) return [systemMap]; + const customMap = customByKey.get(key); + if (!customMap || customMap.platformKey.toUpperCase() !== 'NAV2') return []; + return [{ + key: customMap.key, + platformKey: 'NAV2' as const, + name: customMap.name, + description: customMap.description ?? '강사가 구성한 학습 맵', + worldFile: customMap.worldFile, + viewerModel: customMap.viewerModel === 'turtlebot3_house' ? 'turtlebot3_house' as const : 'turtlebot3_world' as const, + custom: true, + layout: customMap.layout, + }]; + }); +} diff --git a/src/curriculum/curriculum.service.ts b/src/curriculum/curriculum.service.ts index 2c2dd5f..35c6a6c 100644 --- a/src/curriculum/curriculum.service.ts +++ b/src/curriculum/curriculum.service.ts @@ -6,6 +6,7 @@ import { AuthService } from '../auth/auth.service'; import { PlatformControlService } from '../platform-control/platform-control.service'; import { PrismaService } from '../prisma/prisma.service'; import { R2PublicFileService } from '../storage/r2-public-file.service'; +import { serializeCourseMaps } from './course-maps'; const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references'); const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'; @@ -159,6 +160,12 @@ export class CurriculumService { environmentProfiles: { where: { enabled: true }, }, + maps: { + orderBy: { createdAt: 'asc' }, + }, + weekMaps: { + orderBy: { sortOrder: 'asc' }, + }, studentProgress: { where: { user: { @@ -183,6 +190,12 @@ export class CurriculumService { platform: true, }, }); + const mapKeysByWeek = new Map(); + course.weekMaps.forEach((selection) => { + const items = mapKeysByWeek.get(selection.week) ?? []; + items.push(selection.mapKey); + mapKeysByWeek.set(selection.week, items); + }); const environmentProfileByWeekType = new Map(course.environmentProfiles.map((profile) => [`${profile.week}:${profile.profileType.toUpperCase()}`, profile])); const visualizationProfilesByWeek = new Map 0 ? `${hours}h ${minutes}m` : `${hours}h`; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; } } diff --git a/src/instructor/dto/create-instructor-course-map.dto.ts b/src/instructor/dto/create-instructor-course-map.dto.ts new file mode 100644 index 0000000..5ca83a0 --- /dev/null +++ b/src/instructor/dto/create-instructor-course-map.dto.ts @@ -0,0 +1,16 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateInstructorCourseMapDto { + @IsOptional() + @IsString() + courseId?: string; + + @IsString() + @MaxLength(80) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(240) + description?: string; +} diff --git a/src/instructor/dto/update-instructor-curriculum.dto.ts b/src/instructor/dto/update-instructor-curriculum.dto.ts index 591246c..33f98e6 100644 --- a/src/instructor/dto/update-instructor-curriculum.dto.ts +++ b/src/instructor/dto/update-instructor-curriculum.dto.ts @@ -87,8 +87,32 @@ export class UpdateInstructorCurriculumDto { @IsString({ each: true }) disabledModuleIds?: string[]; + @IsOptional() + @IsArray() + moduleDefaultParameters?: Array<{ + moduleId?: string; + parameters?: string[]; + }>; + + @IsOptional() + @IsArray() + environmentModuleSets?: Array<{ + platformKey?: string; + moduleIds?: string[]; + modules?: Array<{ + id?: string; + defaultParameters?: string[]; + dockerImage?: string | null; + }>; + }>; + @IsOptional() @IsArray() @IsString({ each: true }) visualizationPlatformKeys?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + mapKeys?: string[]; } diff --git a/src/instructor/instructor.controller.ts b/src/instructor/instructor.controller.ts index 32b744b..3f9d74d 100644 --- a/src/instructor/instructor.controller.ts +++ b/src/instructor/instructor.controller.ts @@ -5,6 +5,7 @@ import { Request } from 'express'; import { InstructorService } from './instructor.service'; import { AssignInstructorPlatformDto } from './dto/assign-instructor-platform.dto'; import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto'; +import { CreateInstructorCourseMapDto } from './dto/create-instructor-course-map.dto'; import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto'; import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto'; import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto'; @@ -85,6 +86,17 @@ export class InstructorController { ); } + @Post('curriculum/maps') + createCurriculumMap( + @Req() request: Request, + @Body() body: CreateInstructorCourseMapDto, + ) { + return this.instructorService.createCurriculumMap( + this.getBearerToken(request), + body, + ); + } + @Delete('curriculum/weeks/:week') deleteCurriculumWeek( @Req() request: Request, @@ -152,6 +164,15 @@ export class InstructorController { ); } + @Post('platform-registration-code') + issuePlatformRegistrationCode( + @Req() request: Request, + ) { + return this.instructorService.issuePlatformRegistrationCode( + this.getBearerToken(request), + ); + } + @Put('platforms/:platformId') updatePlatform( @Req() request: Request, diff --git a/src/instructor/instructor.service.ts b/src/instructor/instructor.service.ts index 1a2f6d3..b54d925 100644 --- a/src/instructor/instructor.service.ts +++ b/src/instructor/instructor.service.ts @@ -4,6 +4,7 @@ import { ConflictException, ForbiddenException, Injectable, + Logger, NotFoundException, UnauthorizedException, } from '@nestjs/common'; @@ -24,6 +25,8 @@ import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto'; import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto'; import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto'; import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto'; +import { CreateInstructorCourseMapDto } from './dto/create-instructor-course-map.dto'; +import { NAV2_SYSTEM_MAPS } from '../curriculum/course-maps'; const INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']); const DEFAULT_SUBMISSION_BOT_EMAIL = 'lms-bot@futuredrive.net'; @@ -59,6 +62,8 @@ type StudentIssueItem = { @Injectable() export class InstructorService { + private readonly logger = new Logger(InstructorService.name); + constructor( private readonly authService: AuthService, private readonly chatGateway: ChatGateway, @@ -734,7 +739,7 @@ export class InstructorService { } if (group && organizationName) { - await this.syncPlatformAccessPolicy(organizationId, organizationName); + this.queuePlatformAccessPolicy(organizationId, organizationName); } return this.listStudents(accessToken, course.id); } @@ -789,7 +794,7 @@ export class InstructorService { ]); if (organizationName) { - await this.syncPlatformAccessPolicy(organizationId, organizationName); + this.queuePlatformAccessPolicy(organizationId, organizationName); } return this.listStudents(accessToken); } @@ -1123,6 +1128,27 @@ export class InstructorService { items.push(selection); visualizationSelectionsByWeek.set(selection.week, items); }); + const courseMaps = await this.prisma.courseMap.findMany({ + where: { courseId: course.id }, + orderBy: { createdAt: 'asc' }, + }); + const weekMapSelections = await this.prisma.courseWeekMap.findMany({ + where: { courseId: course.id }, + orderBy: { sortOrder: 'asc' }, + }); + const mapKeysByWeek = new Map(); + weekMapSelections.forEach((selection) => { + const items = mapKeysByWeek.get(selection.week) ?? []; + items.push(selection.mapKey); + mapKeysByWeek.set(selection.week, items); + }); + const environmentProfiles = await this.prisma.courseEnvironmentProfile.findMany({ + where: { courseId: course.id, enabled: true }, + orderBy: [{ platformKey: 'asc' }, { profileType: 'asc' }], + }); + const environmentProfileByWeekPlatformKey = new Map( + environmentProfiles.map((profile) => [`${profile.week}:${profile.platformKey}`, profile]), + ); const overrideByWeek = new Map(overrides.map((override) => [override.week, override])); const baseWeekNumbers = new Set(course.weeks.map((week) => week.week)); const baseWeeks = course.weeks.map((week) => { @@ -1172,8 +1198,19 @@ export class InstructorService { folderName: module.folderName, templateRepositoryUrl: module.templateRepositoryUrl, dockerImage: module.dockerImage, + defaultParameters: this.stringArray(module.defaultParameters) ?? [], enabled: !disabledModuleIdSet.has(module.id), })), + environmentProfiles: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => { + const profile = environmentProfileByWeekPlatformKey.get(`${week.week}:${selection.platformKey}`); + return { + id: profile?.id ?? `${week.week}:${selection.platformKey}`, + platformKey: selection.platformKey, + profileType: profile?.profileType ?? selection.platform.type, + name: profile?.name ?? selection.platform.displayName, + config: profile?.config ?? selection.platform.defaultConfig, + }; + }), visualizationPlatforms: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => ({ key: selection.platform.key, type: selection.platform.type, @@ -1181,6 +1218,7 @@ export class InstructorService { description: selection.platform.description, config: selection.platform.defaultConfig, })), + mapKeys: mapKeysByWeek.get(week.week) ?? [], }; }); const addedWeeks = overrides @@ -1221,6 +1259,7 @@ export class InstructorService { description: selection.platform.description, config: selection.platform.defaultConfig, })), + mapKeys: mapKeysByWeek.get(override.week) ?? [], }; }); @@ -1241,6 +1280,19 @@ export class InstructorService { description: platform.description, config: platform.defaultConfig, })), + mapOptions: [ + ...NAV2_SYSTEM_MAPS, + ...courseMaps.map((map) => ({ + key: map.key, + platformKey: 'NAV2' as const, + name: map.name, + description: map.description ?? '강사가 구성한 학습 맵', + worldFile: map.worldFile, + viewerModel: map.viewerModel === 'turtlebot3_house' ? 'turtlebot3_house' as const : 'turtlebot3_world' as const, + custom: true, + layout: map.layout, + })), + ], setting: { groupId: setting?.groupId ?? null, activeWeek: setting?.activeWeek ?? course.weeks.find((week) => week.state === 'current')?.week ?? course.weeks[0]?.week ?? 1, @@ -1300,6 +1352,135 @@ export class InstructorService { const practiceContents = this.normalizePracticeContents(input.practiceContents); const disabledModuleIds = input.disabledModuleIds?.map((moduleId) => moduleId.trim()).filter(Boolean); + const moduleDefaultParameters = input.moduleDefaultParameters === undefined + ? undefined + : input.moduleDefaultParameters.reduce>((items, entry) => { + const moduleId = entry?.moduleId?.trim(); + if (!moduleId) return items; + items.push({ + moduleId, + parameters: Array.isArray(entry.parameters) + ? entry.parameters.map((parameter) => parameter.trim()).filter(Boolean) + : [], + }); + return items; + }, []); + + if (moduleDefaultParameters !== undefined) { + const moduleIdSet = new Set(targetWeek?.modules.map((module) => module.id) ?? []); + const invalidModule = moduleDefaultParameters.find((entry) => !moduleIdSet.has(entry.moduleId)); + if (invalidModule) throw new BadRequestException('선택한 주차에 없는 모듈입니다.'); + + await this.prisma.$transaction(moduleDefaultParameters.map((entry) => ( + this.prisma.courseWeekModule.update({ + where: { id: entry.moduleId }, + data: { defaultParameters: entry.parameters }, + }) + ))); + } + + if (input.environmentModuleSets !== undefined) { + const moduleIdSet = new Set(targetWeek?.modules.map((module) => module.id) ?? []); + const requestedSets = input.environmentModuleSets.reduce; + }>>((items, entry) => { + const platformKey = entry?.platformKey?.trim(); + if (!platformKey) return items; + const moduleMap = new Map(); + if (Array.isArray(entry.modules)) { + entry.modules.forEach((module) => { + const id = module?.id?.trim(); + if (!id) return; + moduleMap.set(id, { + defaultParameters: Array.isArray(module.defaultParameters) + ? module.defaultParameters.map((parameter) => parameter.trim()).filter(Boolean) + : [], + dockerImage: typeof module.dockerImage === 'string' + ? module.dockerImage.trim() || null + : module.dockerImage === null ? null : undefined, + }); + }); + } else { + (entry.moduleIds ?? []).forEach((moduleId) => { + const id = moduleId.trim(); + if (id) moduleMap.set(id, { defaultParameters: [] }); + }); + } + if ([...moduleMap.keys()].some((moduleId) => !moduleIdSet.has(moduleId))) { + throw new BadRequestException('선택한 주차에 없는 모듈입니다.'); + } + items.push({ + platformKey, + modules: [...moduleMap.entries()].map(([id, value]) => ({ id, ...value })), + }); + return items; + }, []); + const selections = await this.prisma.courseWeekVisualizationPlatform.findMany({ + where: { + courseId: course.id, + week: input.activeWeek, + enabled: true, + platformKey: { in: requestedSets.map((item) => item.platformKey) }, + }, + include: { platform: true }, + }); + const selectionsByPlatformKey = new Map(selections.map((selection) => [selection.platformKey, selection])); + if (requestedSets.some((item) => !selectionsByPlatformKey.has(item.platformKey))) { + throw new BadRequestException('선택한 환경 프로필을 찾을 수 없습니다.'); + } + const profiles = await this.prisma.courseEnvironmentProfile.findMany({ + where: { courseId: course.id, week: input.activeWeek }, + }); + const profilesByPlatformKey = new Map(profiles.map((profile) => [profile.platformKey, profile])); + await this.prisma.$transaction(requestedSets.map((item) => { + const selection = selectionsByPlatformKey.get(item.platformKey)!; + const existingProfile = profilesByPlatformKey.get(item.platformKey); + const existingConfig = existingProfile?.config && typeof existingProfile.config === 'object' && !Array.isArray(existingProfile.config) + ? existingProfile.config as Prisma.JsonObject + : {}; + const defaultConfig = selection.platform.defaultConfig && typeof selection.platform.defaultConfig === 'object' && !Array.isArray(selection.platform.defaultConfig) + ? selection.platform.defaultConfig as Prisma.JsonObject + : {}; + const config = { + ...defaultConfig, + ...existingConfig, + moduleSet: { + target: defaultConfig.target === 'remote-jetson' ? 'platform' : 'local', + includeUnlisted: false, + modules: item.modules.map((module) => ({ + id: module.id, + ...(module.defaultParameters.length ? { defaultParameters: module.defaultParameters } : {}), + ...(module.dockerImage !== undefined ? { dockerImage: module.dockerImage } : {}), + })), + }, + }; + return this.prisma.courseEnvironmentProfile.upsert({ + where: { + courseId_week_platformKey: { + courseId: course.id, + week: input.activeWeek, + platformKey: item.platformKey, + }, + }, + create: { + courseId: course.id, + week: input.activeWeek, + platformKey: item.platformKey, + profileType: selection.platform.type, + name: selection.platform.displayName, + config, + enabled: true, + }, + update: { + profileType: selection.platform.type, + name: selection.platform.displayName, + config, + enabled: true, + }, + }); + })); + } let selectedTagLabel: string | undefined; if (input.tagLabel !== undefined) { const tagLabel = input.tagLabel.trim(); @@ -1357,7 +1538,7 @@ export class InstructorService { }); } - if (input.visualizationPlatformKeys) { + if (input.visualizationPlatformKeys !== undefined) { const selectedKeys = Array.from(new Set(input.visualizationPlatformKeys.map((key) => key.trim().toUpperCase()).filter(Boolean))); const validPlatforms = selectedKeys.length ? await this.prisma.visualizationPlatform.findMany({ @@ -1400,6 +1581,34 @@ export class InstructorService { ))); } + if (input.mapKeys !== undefined) { + const selectedKeys = Array.from(new Set(input.mapKeys.map((key) => key.trim()).filter(Boolean))); + const customMaps = selectedKeys.length + ? await this.prisma.courseMap.findMany({ + where: { courseId: course.id, key: { in: selectedKeys }, platformKey: 'NAV2' }, + select: { key: true }, + }) + : []; + const validKeys = new Set([ + ...NAV2_SYSTEM_MAPS.map((map) => map.key), + ...customMaps.map((map) => map.key), + ]); + const mapKeys = selectedKeys.filter((key) => validKeys.has(key)); + + await this.prisma.courseWeekMap.deleteMany({ + where: { + courseId: course.id, + week: input.activeWeek, + mapKey: { notIn: mapKeys }, + }, + }); + await Promise.all(mapKeys.map((mapKey, sortOrder) => this.prisma.courseWeekMap.upsert({ + where: { courseId_week_mapKey: { courseId: course.id, week: input.activeWeek, mapKey } }, + create: { courseId: course.id, week: input.activeWeek, mapKey, sortOrder }, + update: { sortOrder }, + }))); + } + if (applyToStudents) { const targetGroup = selectedGroup; if (!targetGroup) throw new BadRequestException('강의 진행을 적용하려면 그룹을 선택해 주세요.'); @@ -1449,6 +1658,28 @@ export class InstructorService { return this.getCurriculum(accessToken, selectedGroup?.id ?? input.groupId ?? null, course.id); } + async createCurriculumMap(accessToken: string | undefined, input: CreateInstructorCourseMapDto) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) throw new BadRequestException('Instructor organization is required'); + const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId); + if (!course) throw new NotFoundException('Course not found'); + + const name = input.name.trim(); + if (!name) throw new BadRequestException('맵 이름을 입력해 주세요.'); + const slug = name.toLowerCase().replace(/[^a-z0-9가-힣]+/g, '-').replace(/^-+|-+$/g, '') || 'map'; + const key = `nav2-custom-${slug}-${randomUUID().slice(0, 8)}`; + return this.prisma.courseMap.create({ + data: { + courseId: course.id, + key, + name, + description: this.optionalTrim(input.description) ?? '강사가 구성한 학습 맵', + layout: { version: 1, objects: [] }, + }, + }); + } + async deleteCurriculumWeek(accessToken: string | undefined, week: number) { const { authUser, instructor } = await this.getInstructor(accessToken); const organizationId = authUser.primaryAffiliation?.organizationId; @@ -1708,6 +1939,40 @@ export class InstructorService { return this.listPlatforms(accessToken); } + async issuePlatformRegistrationCode(accessToken: string | undefined) { + if (!accessToken) throw new UnauthorizedException('Bearer token required'); + const launcherApiBaseUrl = this.launcherApiBaseUrl(); + let response: Response; + try { + response = await fetch(`${launcherApiBaseUrl}/provision/device-authorizations`, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + usageType: 'class', + }), + }); + } catch { + throw new BadGatewayException('Platform registration code request failed'); + } + + const payload = await response.json().catch((): Record => ({})); + if (!response.ok || typeof payload.code !== 'string') { + throw new BadGatewayException( + typeof payload.message === 'string' + ? payload.message + : 'Platform registration code request failed', + ); + } + + return { + code: payload.code, + expiresAt: typeof payload.expiresAt === 'string' ? payload.expiresAt : null, + }; + } + async updatePlatform( accessToken: string | undefined, platformId: string, @@ -1812,7 +2077,8 @@ export class InstructorService { }); if (!platform) throw new NotFoundException('Platform not found'); - const group = await this.prisma.studentGroup.findFirst({ + const unassign = groupId === 'unassigned'; + const group = unassign ? null : await this.prisma.studentGroup.findFirst({ where: { id: groupId, ...this.organizationWhere( @@ -1826,8 +2092,8 @@ export class InstructorService { instructorPlatformId: true, }, }); - if (!group) throw new NotFoundException('Group not found'); - if (group.instructorPlatformId && group.instructorPlatformId !== platform.id) { + if (!unassign && !group) throw new NotFoundException('Group not found'); + if (group?.instructorPlatformId && group.instructorPlatformId !== platform.id) { throw new BadRequestException('이미 플랫폼이 배정된 그룹입니다.'); } @@ -1871,20 +2137,22 @@ export class InstructorService { organizationName, ), instructorPlatformId: platform.id, - NOT: { id: group.id }, + ...(group ? { NOT: { id: group.id } } : {}), }, data: { instructorPlatformId: null, platformName: null, }, }); - await tx.studentGroup.update({ - where: { id: group.id }, - data: { - instructorPlatformId: platform.id, - platformName: platform.displayName, - }, - }); + if (group) { + await tx.studentGroup.update({ + where: { id: group.id }, + data: { + instructorPlatformId: platform.id, + platformName: platform.displayName, + }, + }); + } if (activeSession && force) { await tx.platformControlSession.updateMany({ where: { @@ -1916,12 +2184,14 @@ export class InstructorService { sessionId: activeSession.id, platformId: null, instructorPlatformId: platform.id, - reason: 'GROUP_ASSIGNMENT_CHANGED', - message: `${platform.displayName} 플랫폼 배정이 변경되어 사용이 종료되었습니다.`, + reason: group ? 'GROUP_ASSIGNMENT_CHANGED' : 'GROUP_ASSIGNMENT_REMOVED', + message: group + ? `${platform.displayName} 플랫폼 배정이 변경되어 사용이 종료되었습니다.` + : `${platform.displayName} 플랫폼 배정이 해제되어 사용이 종료되었습니다.`, endedAt: now.toISOString(), }); } - await this.syncPlatformAccessPolicy(organizationId, organizationName); + this.queuePlatformAccessPolicy(organizationId, organizationName); return this.listPlatforms(accessToken); } @@ -2040,7 +2310,7 @@ export class InstructorService { }), ]); - await this.syncPlatformAccessPolicy(organizationId, organizationName); + this.queuePlatformAccessPolicy(organizationId, organizationName); if (terminatedSessionNotice) { this.chatGateway.notifyUser(terminatedSessionNotice.userId, { type: 'platform.control.terminated', @@ -2116,7 +2386,7 @@ export class InstructorService { throw error; } - await this.syncPlatformAccessPolicy(organizationId, organizationName); + this.queuePlatformAccessPolicy(organizationId, organizationName); return this.listGroups(accessToken, input.courseId); } @@ -2221,7 +2491,7 @@ export class InstructorService { throw error; } - await this.syncPlatformAccessPolicy(organizationId, organizationName); + this.queuePlatformAccessPolicy(organizationId, organizationName); this.notifyPlatformTerminatedSessions(termination.notices, termination.endedAt, termination.reason); return this.listGroups(accessToken, input.courseId); } @@ -2281,7 +2551,7 @@ export class InstructorService { }); }); - await this.syncPlatformAccessPolicy(organizationId, organizationName); + this.queuePlatformAccessPolicy(organizationId, organizationName); this.notifyPlatformTerminatedSessions(termination.notices, termination.endedAt, termination.reason); return this.listGroups(accessToken, courseId); } @@ -2408,57 +2678,75 @@ export class InstructorService { } } + private queuePlatformAccessPolicy( + organizationId: string | undefined, + organizationName: string, + ) { + void this.syncPlatformAccessPolicy(organizationId, organizationName).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Platform access sync failed in background: ${message}`); + }); + } + private async syncPlatformAccessPolicy( organizationId: string | undefined, organizationName: string, ) { - const groups = await this.prisma.studentGroup.findMany({ - where: { - ...this.organizationWhere( + const [platforms, groups] = await Promise.all([ + this.prisma.instructorPlatform.findMany({ + where: this.organizationWhere( organizationId, organizationName, ), - instructorPlatformId: { not: null }, - }, - include: { - instructorPlatform: { - select: { - id: true, - displayName: true, - studioSettingsOverride: true, - }, + select: { + id: true, + displayName: true, + studioSettingsOverride: true, }, - members: { - include: { - user: { - select: { - email: true, + }), + this.prisma.studentGroup.findMany({ + where: { + ...this.organizationWhere( + organizationId, + organizationName, + ), + instructorPlatformId: { not: null }, + }, + select: { + instructorPlatformId: true, + members: { + select: { + user: { + select: { + email: true, + }, }, }, }, }, - }, - }); + }), + ]); - const assignments = groups - .map((group): GatewayAccessAssignment | null => { - const platform = group.instructorPlatform; - if (!platform) return null; - const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname'); - const gatewayPlatformId = this.readStringSetting(platform.studioSettingsOverride, 'gatewayPlatformId') ?? agentHostname ?? platform.id; - const studentEmails = group.members - .map((member) => member.user.email) - .filter(Boolean); - if (!studentEmails.length) return null; - return { - platformId: gatewayPlatformId, - platformName: platform.displayName, - agentHostname, - agentOwnerEmail: this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail'), - studentEmails, - }; - }) - .filter((assignment): assignment is GatewayAccessAssignment => Boolean(assignment)); + const studentEmailsByPlatform = new Map(); + for (const group of groups) { + if (!group.instructorPlatformId) continue; + studentEmailsByPlatform.set( + group.instructorPlatformId, + group.members.map((member) => member.user.email).filter(Boolean), + ); + } + + // Include unassigned platforms with no students so the agent removes stale SSH keys. + const assignments: GatewayAccessAssignment[] = platforms.map((platform) => { + const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname'); + return { + platformId: this.readStringSetting(platform.studioSettingsOverride, 'gatewayPlatformId') ?? agentHostname ?? platform.id, + platformName: platform.displayName, + agentHostname, + agentOwnerEmail: this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail'), + studentEmails: studentEmailsByPlatform.get(platform.id) ?? [], + }; + }); await this.platformGatewayClient.syncAccess(assignments); } @@ -2654,20 +2942,18 @@ export class InstructorService { agents: GatewayAgent[], binding: { nodeId?: string | null; hostname?: string | null; ownerEmail?: string | null }, ) { - const bindingTokens = [binding.nodeId, binding.hostname, binding.ownerEmail] + // An owner can register multiple teaching devices. Bind an instructor + // platform to the saved node or hostname, never to the owner alone. + const bindingTokens = [binding.nodeId, binding.hostname] .map((value) => this.normalizeAgentToken(value)) .filter(Boolean); if (!bindingTokens.length) return null; return agents.find((agent) => { - const agentTokens = [agent.id, agent.hostname, agent.name, agent.ip, agent.user] + const agentTokens = [agent.id, agent.hostname, agent.name, agent.ip] .map((value) => this.normalizeAgentToken(value)) .filter(Boolean); - return bindingTokens.some((bindingToken) => agentTokens.some((agentToken) => ( - agentToken === bindingToken || - agentToken.includes(bindingToken) || - bindingToken.includes(agentToken) - ))); + return bindingTokens.some((bindingToken) => agentTokens.includes(bindingToken)); }) ?? null; } @@ -2714,6 +3000,10 @@ export class InstructorService { platform: true, }, }, + environmentProfiles: { + where: { enabled: true }, + orderBy: [{ platformKey: 'asc' }, { profileType: 'asc' }], + }, }, }); } @@ -3323,9 +3613,7 @@ export class InstructorService { } private async grantLmsAppPermission(accessToken: string, authUserId: string) { - const launcherApiBaseUrl = this.configService - .get('LAUNCHER_API_BASE_URL', 'http://localhost:4200/api') - .replace(/\/$/, ''); + const launcherApiBaseUrl = this.launcherApiBaseUrl(); const response = await fetch( `${launcherApiBaseUrl}/apps/${encodeURIComponent('LMS')}/users/${encodeURIComponent(authUserId)}/permission`, { @@ -3351,6 +3639,13 @@ export class InstructorService { } } + private launcherApiBaseUrl() { + return this.configService + .get('LAUNCHER_API_BASE_URL', 'http://localhost:4200/api') + .replace(/\/$/, ''); + } + + private toSummary(students: Array<{ progress: number; online: boolean; issues: number }>) { const total = students.length; const averageProgress = total diff --git a/src/platform-control/platform-control.controller.ts b/src/platform-control/platform-control.controller.ts index 00fadb8..4510754 100644 --- a/src/platform-control/platform-control.controller.ts +++ b/src/platform-control/platform-control.controller.ts @@ -42,6 +42,14 @@ export class PlatformControlController { ); } + @Post('platforms/:platformId/extend') + extendControl(@Req() request: Request, @Param('platformId') platformId: string) { + return this.platformControlService.extendControl( + this.getBearerToken(request), + platformId, + ); + } + @Post('platforms/:platformId/connect') connectPlatform(@Req() request: Request, @Param('platformId') platformId: string) { return this.platformControlService.connectPlatform( @@ -84,6 +92,29 @@ export class PlatformControlController { ); } + @Post('platforms/:platformId/visualization/status') + getRemoteVisualizationStatus(@Req() request: Request, @Param('platformId') platformId: string) { + return this.platformControlService.getRemoteVisualizationStatus(this.getBearerToken(request), platformId); + } + + @Post('platforms/:platformId/visualization/start') + startRemoteVisualization( + @Req() request: Request, + @Param('platformId') platformId: string, + @Body() body: { stack?: string; rosDomainId?: string; profile?: unknown }, + ) { + return this.platformControlService.startRemoteVisualization(this.getBearerToken(request), platformId, body); + } + + @Post('platforms/:platformId/visualization/stop') + stopRemoteVisualization( + @Req() request: Request, + @Param('platformId') platformId: string, + @Body() body: { stack?: string; rosDomainId?: string; profile?: unknown }, + ) { + return this.platformControlService.stopRemoteVisualization(this.getBearerToken(request), platformId, body); + } + private getBearerToken(request: Request): string | undefined { const [type, token] = request.headers.authorization?.split(' ') ?? []; return type === 'Bearer' ? token : undefined; diff --git a/src/platform-control/platform-control.service.ts b/src/platform-control/platform-control.service.ts index e7bdd3d..2ab319c 100644 --- a/src/platform-control/platform-control.service.ts +++ b/src/platform-control/platform-control.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, OnModuleDestroy, OnModuleInit, + ServiceUnavailableException, UnauthorizedException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; @@ -205,7 +206,7 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy { ? '사용 가능' : this.toControlStatusLabel(control.status), action: control.status === 'AVAILABLE' - ? '제어권 요청' + ? '제어요청' : control.action, state: control.state, control, @@ -233,7 +234,7 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy { const group = await this.findStudentGroup(user.id, context); const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null); - return this.prisma.$transaction(async (tx) => { + const result = await this.prisma.$transaction(async (tx) => { const active = await tx.platformControlSession.findFirst({ where: { ...this.resourceWhere(resource), @@ -258,25 +259,100 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy { const next = await this.grantNextQueued(tx, resource, context); if (next) this.notifyGranted(next); - const gatewayPlatformId = await this.resolveGatewayPlatformId(resource); - if (gatewayPlatformId) { - await this.platformGatewayClient.release({ - platformId: gatewayPlatformId, - user: { - id: user.id, - email: user.email, - name: user.name, - role: user.authRole, - }, - }); - } - return { result: 'RELEASED', platformId: platformOptionId, nextSession: next ? this.serializeSession(next) : null, }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + + // The gateway call can wait for a remote agent. Keep it outside the + // interactive transaction so its timeout cannot invalidate the DB commit. + const gatewayPlatformId = await this.resolveGatewayPlatformId(resource); + if (gatewayPlatformId) { + await this.platformGatewayClient.release({ + platformId: gatewayPlatformId, + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.authRole, + }, + }); + } + + return result; + } + + async extendControl(accessToken: string | undefined, platformOptionId: string) { + const { authUser, user } = await this.getStudent(accessToken); + const context = this.organizationContext(authUser); + const group = await this.findStudentGroup(user.id, context); + const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null); + + await this.applyExpiredSessions(context); + + const session = await this.prisma.$transaction(async (tx) => { + const active = await tx.platformControlSession.findFirst({ + where: { + ...this.resourceWhere(resource), + holderUserId: user.id, + status: 'ACTIVE', + }, + }); + if (!active) throw new NotFoundException('Active control session not found'); + + const now = new Date(); + if (now < active.warningAt) { + throw new BadRequestException('제어 시간 경고 구간에서만 연장할 수 있습니다.'); + } + if (active.extensionCount >= 1) { + throw new BadRequestException('제어 시간은 한 번만 연장할 수 있습니다.'); + } + + const waitingCount = await tx.platformControlQueueEntry.count({ + where: { + ...this.resourceWhere(resource), + status: 'WAITING', + }, + }); + if (waitingCount > 0) { + throw new BadRequestException('대기 중인 예약이 있어 제어 시간을 연장할 수 없습니다.'); + } + + const endsAt = new Date(active.endsAt.getTime() + resource.durationMinutes * 60_000); + const warningAt = new Date(endsAt.getTime() - resource.warningMinutes * 60_000); + return tx.platformControlSession.update({ + where: { id: active.id }, + data: { + endsAt, + warningAt, + warningSentAt: null, + extensionCount: { increment: 1 }, + }, + }); + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + + const gatewayPlatformId = await this.resolveGatewayPlatformId(resource); + if (gatewayPlatformId) { + const ttlSeconds = Math.max(60, Math.floor((session.endsAt.getTime() - Date.now()) / 1000)); + await this.platformGatewayClient.extendSession({ + platformId: gatewayPlatformId, + ttlSeconds, + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.authRole, + }, + }); + } + + return { + result: 'EXTENDED', + platformId: platformOptionId, + session: this.serializeSession(session), + }; } async connectPlatform(accessToken: string | undefined, platformOptionId: string) { @@ -304,11 +380,15 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy { } const agentOwnerEmail = await this.resolveAgentOwnerEmail(resource); + const assignedStudentEmails = group?.members + .map((member) => member.user.email) + .filter((email): email is string => Boolean(email)) + ?? (authUser.email ? [authUser.email] : []); await this.platformGatewayClient.syncAccess([{ platformId: gatewayPlatformId, platformName: resource.title, agentOwnerEmail, - studentEmails: authUser.email ? [authUser.email] : [], + studentEmails: assignedStudentEmails, }]); const ttlSeconds = Math.max(60, Math.floor((active.endsAt.getTime() - Date.now()) / 1000)); @@ -323,30 +403,76 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy { role: user.authRole || undefined, }, }) - .catch(() => null); + .catch((error: unknown) => { + const detail = error instanceof Error ? error.message : '원격 플랫폼 Gateway에 연결하지 못했습니다.'; + throw new ServiceUnavailableException(`원격 플랫폼 연결에 실패했습니다. ${detail}`); + }); + + if (!gateway?.connection) { + throw new ServiceUnavailableException('원격 플랫폼 연결 정보를 받지 못했습니다.'); + } return { - mode: gateway?.connection ? 'remote' : 'gateway-unavailable', + mode: 'remote', gateway, - environmentConfigOverride: gateway?.connection - ? { - target: 'remote-jetson', - agent: { - url: gateway.connection.agentUrl, - sshUser: gateway.connection.sshUser ?? null, - workspaceRoot: gateway.connection.workspaceRoot ?? null, - }, - bridge: { - env: { - BRIDGE_HTTP_URL: gateway.connection.bridgeHttpUrl, - BRIDGE_WS_URL: gateway.connection.bridgeWsUrl, - }, - }, - } - : null, + environmentConfigOverride: { + target: 'remote-jetson', + agent: { + url: gateway.connection.agentUrl, + sshUser: gateway.connection.sshUser ?? null, + workspaceRoot: gateway.connection.workspaceRoot ?? null, + }, + bridge: { + env: { + BRIDGE_HTTP_URL: gateway.connection.bridgeHttpUrl, + BRIDGE_WS_URL: gateway.connection.bridgeWsUrl, + }, + }, + }, }; } + async getRemoteVisualizationStatus(accessToken: string | undefined, platformOptionId: string) { + const { authUser, user } = await this.getStudent(accessToken); + const context = this.organizationContext(authUser); + const group = await this.findStudentGroup(user.id, context); + const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null); + await this.assertActiveControl(resource, user.id); + const gatewayPlatformId = await this.resolveGatewayPlatformId(resource); + if (!gatewayPlatformId) throw new BadRequestException('원격 플랫폼이 아닙니다.'); + return this.platformGatewayClient.getVisualizationStatus(gatewayPlatformId); + } + + async startRemoteVisualization( + accessToken: string | undefined, + platformOptionId: string, + input: { stack?: string; rosDomainId?: string; profile?: unknown }, + ) { + const { authUser, user } = await this.getStudent(accessToken); + const context = this.organizationContext(authUser); + const group = await this.findStudentGroup(user.id, context); + const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null); + await this.assertActiveControl(resource, user.id); + const gatewayPlatformId = await this.resolveGatewayPlatformId(resource); + if (!gatewayPlatformId) throw new BadRequestException('원격 플랫폼이 아닙니다.'); + return this.platformGatewayClient.startVisualization(gatewayPlatformId, input); + } + + async stopRemoteVisualization( + accessToken: string | undefined, + platformOptionId: string, + input: { stack?: string; rosDomainId?: string; profile?: unknown }, + ) { + const { authUser, user } = await this.getStudent(accessToken); + const context = this.organizationContext(authUser); + const group = await this.findStudentGroup(user.id, context); + const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null); + await this.assertActiveControl(resource, user.id); + const gatewayPlatformId = await this.resolveGatewayPlatformId(resource); + if (!gatewayPlatformId) throw new BadRequestException('원격 플랫폼이 아닙니다.'); + return this.platformGatewayClient.stopVisualization(gatewayPlatformId, input); + } + async listRemoteModules(accessToken: string | undefined, platformOptionId: string) { const { authUser, user } = await this.getStudent(accessToken); const context = this.organizationContext(authUser); @@ -782,6 +908,15 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy { some: { userId }, }, }, + include: { + members: { + include: { + user: { + select: { email: true }, + }, + }, + }, + }, orderBy: { updatedAt: 'desc' }, }); } @@ -832,7 +967,7 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy { private emptyState(status: 'AVAILABLE'): PlatformControlState { return { status, - action: '제어권 요청', + action: '제어요청', state: 'ready', session: null, queue: null, @@ -853,6 +988,7 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy { startedAt: Date; endsAt: Date; warningAt: Date; + extensionCount?: number; holderUser?: { name: string | null; email: string } | null; }) { return { @@ -862,6 +998,7 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy { startedAt: session.startedAt.toISOString(), endsAt: session.endsAt.toISOString(), warningAt: session.warningAt.toISOString(), + extensionCount: session.extensionCount ?? 0, }; } diff --git a/src/platform-control/platform-gateway.client.ts b/src/platform-control/platform-gateway.client.ts index 43dbaba..20d979c 100644 --- a/src/platform-control/platform-gateway.client.ts +++ b/src/platform-control/platform-gateway.client.ts @@ -66,9 +66,27 @@ export class PlatformGatewayClient { ttlSeconds: number; }): Promise { if (!this.enabled) return null; + const body = { + platformId: input.platformId, + user: input.user, + ttlSeconds: input.ttlSeconds, + ...(input.agentOwnerEmail ? { agentOwnerEmail: input.agentOwnerEmail } : {}), + }; return this.request('/api/lms/platform-sessions', { method: 'POST', - body: JSON.stringify(input), + body: JSON.stringify(body), + }); + } + + async extendSession(input: { + platformId: string; + user: { id: string; email?: string; name?: string | null; role?: string | null }; + ttlSeconds: number; + }): Promise { + if (!this.enabled) return; + await this.request(`/api/lms/platform-sessions/${encodeURIComponent(input.platformId)}/extend`, { + method: 'POST', + body: JSON.stringify({ user: input.user, ttlSeconds: input.ttlSeconds }), }); } @@ -135,6 +153,29 @@ export class PlatformGatewayClient { }); } + async getVisualizationStatus(platformId: string): Promise { + if (!this.enabled) return null; + return this.request(`/api/lms/platforms/${encodeURIComponent(platformId)}/visualization/status`, { + method: 'GET', + }); + } + + async startVisualization(platformId: string, input: { stack?: string | null; rosDomainId?: string | null; profile?: unknown }): Promise { + if (!this.enabled) return null; + return this.request(`/api/lms/platforms/${encodeURIComponent(platformId)}/visualization/start`, { + method: 'POST', + body: JSON.stringify(input), + }); + } + + async stopVisualization(platformId: string, input: { stack?: string | null; rosDomainId?: string | null; profile?: unknown } = {}): Promise { + if (!this.enabled) return null; + return this.request(`/api/lms/platforms/${encodeURIComponent(platformId)}/visualization/stop`, { + method: 'POST', + body: JSON.stringify(input), + }); + } + async syncAccess(assignments: GatewayAccessAssignment[]): Promise { if (!this.enabled) return; await this.request('/api/lms/access-sync', { @@ -157,8 +198,10 @@ export class PlatformGatewayClient { const payload = await response.json().catch(() => ({})); if (!response.ok) { const message = String(payload?.error ?? payload?.message ?? `platform_gateway_failed:${response.status}`); - this.logger.warn(`Platform gateway request failed. path=${path} status=${response.status} message=${message}`); - throw new Error(message); + const issues = payload?.issues ? ` issues=${JSON.stringify(payload.issues)}` : ''; + const detail = payload?.result?.error ? ` detail=${String(payload.result.error)}` : ''; + this.logger.warn(`Platform gateway request failed. path=${path} status=${response.status} message=${message}${issues}${detail}`); + throw new Error(`${message}${issues}${detail}`); } return payload as T; } diff --git a/src/studio/studio.controller.ts b/src/studio/studio.controller.ts index 7337831..8a7b749 100644 --- a/src/studio/studio.controller.ts +++ b/src/studio/studio.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Post, Req } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Query, Req } from '@nestjs/common'; import { Request } from 'express'; import { StudioService } from './studio.service'; @@ -65,6 +65,24 @@ export class StudioController { return this.studioService.recordLearningTime(this.getBearerToken(request), body); } + @Get('maps') + listSavedMaps(@Req() request: Request, @Query('courseId') courseId?: string, @Query('week') week?: string) { + return this.studioService.listSavedMaps(this.getBearerToken(request), courseId, Number(week)); + } + + @Post('maps') + recordSavedMap( + @Req() request: Request, + @Body() body: { courseId?: string; week?: number; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string }, + ) { + return this.studioService.recordSavedMap(this.getBearerToken(request), body); + } + + @Delete('maps/:id') + deleteSavedMap(@Req() request: Request, @Param('id') id: string) { + return this.studioService.deleteSavedMap(this.getBearerToken(request), id); + } + private getBearerToken(request: Request): string | undefined { const [type, token] = request.headers.authorization?.split(' ') ?? []; return type === 'Bearer' ? token : undefined; diff --git a/src/studio/studio.service.ts b/src/studio/studio.service.ts index e6a5dc4..b9a74da 100644 --- a/src/studio/studio.service.ts +++ b/src/studio/studio.service.ts @@ -412,6 +412,66 @@ export class StudioService { }; } + async listSavedMaps(accessToken: string | undefined, courseId: string | undefined, week: number) { + const { user: authUser } = await this.authService.me(accessToken); + if (!authUser?.email) throw new UnauthorizedException('Invalid user'); + if (!courseId?.trim()) throw new BadRequestException('courseId is required'); + if (!Number.isInteger(week) || week < 1) throw new BadRequestException('week is required'); + const lmsUser = await this.prisma.user.findUnique({ where: { email: authUser.email } }); + if (!lmsUser) throw new NotFoundException('LMS user not found'); + return this.prisma.studentSavedMap.findMany({ + where: { userId: lmsUser.id, courseId: courseId.trim(), week }, + orderBy: { createdAt: 'desc' }, + }); + } + + async recordSavedMap( + accessToken: string | undefined, + input: { courseId?: string; week?: number; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string }, + ) { + const { user: authUser } = await this.authService.me(accessToken); + if (!authUser?.email) throw new UnauthorizedException('Invalid user'); + const courseId = input.courseId?.trim(); + const yamlPath = input.yamlPath?.trim(); + const pgmPath = input.pgmPath?.trim(); + const previewPgm = input.previewPgm?.trim() || null; + const databasePath = input.databasePath?.trim() || null; + const sourceMapKey = input.sourceMapKey?.trim(); + // Platform SLAM maps do not originate from a Gazebo world file. + const worldFile = input.worldFile?.trim() ?? ''; + const viewerModel = input.viewerModel?.trim(); + if (!courseId) throw new BadRequestException('courseId is required'); + if (!Number.isInteger(input.week) || input.week! < 1) throw new BadRequestException('week is required'); + if (!yamlPath?.startsWith('/workspace/maps/') || !yamlPath.endsWith('.yaml')) throw new BadRequestException('yamlPath is invalid'); + if (!pgmPath?.startsWith('/workspace/maps/') || !pgmPath.endsWith('.pgm')) throw new BadRequestException('pgmPath is invalid'); + if (previewPgm && (!/^[A-Za-z0-9+/=]+$/.test(previewPgm) || previewPgm.length > 70_000)) throw new BadRequestException('previewPgm is invalid'); + if (databasePath && (!databasePath.startsWith('/workspace/maps/') || !databasePath.endsWith('.db'))) throw new BadRequestException('databasePath is invalid'); + if (!sourceMapKey || !viewerModel) throw new BadRequestException('source map is required'); + const lmsUser = await this.prisma.user.findUnique({ where: { email: authUser.email } }); + if (!lmsUser) throw new NotFoundException('LMS user not found'); + const enrollment = await this.prisma.studentEnrollment.findFirst({ + where: { userId: lmsUser.id, courseId, status: 'ACTIVE' }, + }); + if (!enrollment) throw new NotFoundException('Student enrollment not found'); + return this.prisma.studentSavedMap.upsert({ + where: { userId_courseId_week_yamlPath: { userId: lmsUser.id, courseId, week: input.week!, yamlPath } }, + update: { name: input.name?.trim() || 'SLAM 결과 맵', pgmPath, previewPgm, databasePath, sourceMapKey, worldFile, viewerModel }, + create: { userId: lmsUser.id, courseId, week: input.week!, name: input.name?.trim() || 'SLAM 결과 맵', sourceMapKey, worldFile, viewerModel, yamlPath, pgmPath, previewPgm, databasePath }, + }); + } + + async deleteSavedMap(accessToken: string | undefined, id: string) { + const { user: authUser } = await this.authService.me(accessToken); + if (!authUser?.email) throw new UnauthorizedException('Invalid user'); + const lmsUser = await this.prisma.user.findUnique({ where: { email: authUser.email } }); + if (!lmsUser) throw new NotFoundException('LMS user not found'); + const result = await this.prisma.studentSavedMap.deleteMany({ + where: { id, userId: lmsUser.id }, + }); + if (!result.count) throw new NotFoundException('Saved map not found'); + return { ok: true }; + } + private async ensureGiteaRepository(repoName: string, description: string) { const existingResponse = await fetch(`${this.giteaBaseUrl}/api/v1/repos/${encodeURIComponent(this.studentOwner)}/${encodeURIComponent(repoName)}`, { headers: this.giteaHeaders(), diff --git a/uploads/course-references/619a4b0f-7b49-4f9d-96d9-5ab7effa3224.py b/uploads/course-references/619a4b0f-7b49-4f9d-96d9-5ab7effa3224.py new file mode 100644 index 0000000..22251e4 --- /dev/null +++ b/uploads/course-references/619a4b0f-7b49-4f9d-96d9-5ab7effa3224.py @@ -0,0 +1,13 @@ +# launch 파일 예제 +from launch import LaunchDescription +from launch_ros.actions import Node + +def generate_launch_description(): + return LaunchDescription([ + Node( + package='slam_pkg', + executable='slam_node', + name='slam', + output='screen' + ) + ]) diff --git a/uploads/course-references/7d288277-c62d-4769-8be8-c36e4f0c8232.py b/uploads/course-references/7d288277-c62d-4769-8be8-c36e4f0c8232.py new file mode 100644 index 0000000..951cb03 --- /dev/null +++ b/uploads/course-references/7d288277-c62d-4769-8be8-c36e4f0c8232.py @@ -0,0 +1,29 @@ +import rclpy +from rclpy.node import Node +from sensor_msgs.msg import LaserScan + +class SlamNode(Node): + def __init__(self): + super().__init__('slam_node') + self.subscription = \ + self.create_subscription( + LaserScan, + '/scan', + self.listener_callback, + 10) + + def listener_callback(self, msg): + self.get_logger().info( + 'Received scan data' + ) + # SLAM 알고리즘 구현 + pass + +def main(args=None): + rclpy.init(args=args) + node = SlamNode() + rclpy.spin(node) + rclpy.shutdown() + +if __name__ == '__main__': + main() diff --git a/uploads/course-references/7eebe9b7-6275-444a-95da-7999f135d8a4.pdf b/uploads/course-references/7eebe9b7-6275-444a-95da-7999f135d8a4.pdf new file mode 100644 index 0000000..6257582 --- /dev/null +++ b/uploads/course-references/7eebe9b7-6275-444a-95da-7999f135d8a4.pdf @@ -0,0 +1 @@ +Autonomy Studio LMS demo file