diff --git a/backups/lms-server-curriculum-20260804-102757.sql b/backups/lms-server-curriculum-20260804-102757.sql new file mode 100644 index 0000000..e69de29 diff --git a/prisma/migrations/20260803143000_align_group_enrollment_timestamps/migration.sql b/prisma/migrations/20260803143000_align_group_enrollment_timestamps/migration.sql new file mode 100644 index 0000000..37d2843 --- /dev/null +++ b/prisma/migrations/20260803143000_align_group_enrollment_timestamps/migration.sql @@ -0,0 +1,31 @@ +DO $$ +DECLARE + target record; +BEGIN + FOR target IN + SELECT * FROM (VALUES + ('student_enrollments', 'created_at'), + ('student_enrollments', 'updated_at'), + ('student_enrollments', 'removed_at'), + ('student_groups', 'created_at'), + ('student_groups', 'updated_at'), + ('student_group_members', 'created_at') + ) AS targets(table_name, column_name) + LOOP + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = target.table_name + AND column_name = target.column_name + AND data_type = 'timestamp without time zone' + ) THEN + EXECUTE format( + 'ALTER TABLE %I ALTER COLUMN %I TYPE TIMESTAMPTZ(3) USING %I AT TIME ZONE ''UTC''', + target.table_name, + target.column_name, + target.column_name + ); + END IF; + END LOOP; +END $$; diff --git a/prisma/migrations/20260804103000_add_student_custom_modules/migration.sql b/prisma/migrations/20260804103000_add_student_custom_modules/migration.sql new file mode 100644 index 0000000..d1284c2 --- /dev/null +++ b/prisma/migrations/20260804103000_add_student_custom_modules/migration.sql @@ -0,0 +1,31 @@ +CREATE TABLE "student_custom_modules" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "course_id" UUID NOT NULL, + "week" INTEGER NOT NULL, + "sort_order" INTEGER NOT NULL, + "category" TEXT NOT NULL, + "name" TEXT NOT NULL, + "folder_name" TEXT NOT NULL, + "docker_image" TEXT, + "package_name" TEXT NOT NULL, + "launch_file" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "student_custom_modules_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "student_custom_modules_user_id_course_id_week_folder_name_key" +ON "student_custom_modules"("user_id", "course_id", "week", "folder_name"); + +CREATE INDEX "student_custom_modules_user_id_course_id_week_sort_order_idx" +ON "student_custom_modules"("user_id", "course_id", "week", "sort_order"); + +ALTER TABLE "student_custom_modules" +ADD CONSTRAINT "student_custom_modules_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "student_custom_modules" +ADD CONSTRAINT "student_custom_modules_course_id_fkey" +FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260804113000_normalize_student_custom_module_folders/migration.sql b/prisma/migrations/20260804113000_normalize_student_custom_module_folders/migration.sql new file mode 100644 index 0000000..7b39911 --- /dev/null +++ b/prisma/migrations/20260804113000_normalize_student_custom_module_folders/migration.sql @@ -0,0 +1,3 @@ +UPDATE "student_custom_modules" +SET "folder_name" = '[lms-nav2-custom]' || "package_name" || '_v1.0.0_ws' +WHERE "folder_name" <> '[lms-nav2-custom]' || "package_name" || '_v1.0.0_ws'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9d84ceb..1325fbf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,6 +32,7 @@ model User { platformControlQueueEntries PlatformControlQueueEntry[] studioRepositories StudioRepository[] savedMaps StudentSavedMap[] + customModules StudentCustomModule[] submissionScores SubmissionScore[] submissionTotalScores SubmissionTotalScore[] @@ -160,6 +161,7 @@ model Course { maps CourseMap[] weekMaps CourseWeekMap[] savedMaps StudentSavedMap[] + customModules StudentCustomModule[] submissionScores SubmissionScore[] submissionTotalScores SubmissionTotalScore[] @@ -277,6 +279,28 @@ model StudentSavedMap { @@map("student_saved_maps") } +model StudentCustomModule { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + courseId String @map("course_id") @db.Uuid + week Int + sortOrder Int @map("sort_order") + category String + name String + folderName String @map("folder_name") + dockerImage String? @map("docker_image") + packageName String @map("package_name") + launchFile String @map("launch_file") + 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, folderName]) + @@index([userId, courseId, week, sortOrder]) + @@map("student_custom_modules") +} + model InstructorCurriculumSetting { id String @id @default(uuid()) @db.Uuid organizationId String? @map("organization_id") @db.Uuid diff --git a/scripts/reseed-base-nav2-curriculum.sql b/scripts/reseed-base-nav2-curriculum.sql new file mode 100644 index 0000000..adac18a --- /dev/null +++ b/scripts/reseed-base-nav2-curriculum.sql @@ -0,0 +1,253 @@ +-- Rebuild the shared base course as a two-week NAV2 curriculum. +-- Target: courses.slug = 'futuredrive-autonomy-basic' + +BEGIN; + +CREATE TEMP TABLE target_course AS +SELECT id +FROM courses +WHERE slug = 'futuredrive-autonomy-basic'; + +DO $$ +BEGIN + IF (SELECT count(*) FROM target_course) <> 1 THEN + RAISE EXCEPTION 'Expected exactly one futuredrive-autonomy-basic course'; + END IF; +END $$; + +-- Existing module IDs are replaced, so their student repository/score rows cascade. +DELETE FROM course_student_week_progress +WHERE course_id IN (SELECT id FROM target_course); + +DELETE FROM instructor_curriculum_week_overrides +WHERE course_id IN (SELECT id FROM target_course); + +DELETE FROM course_environment_profiles +WHERE course_id IN (SELECT id FROM target_course); + +DELETE FROM course_week_visualization_platforms +WHERE course_id IN (SELECT id FROM target_course); + +DELETE FROM course_week_maps +WHERE course_id IN (SELECT id FROM target_course); + +DELETE FROM course_week_modules +WHERE week_id IN (SELECT id FROM course_weeks WHERE course_id IN (SELECT id FROM target_course)); + +DELETE FROM course_week_topics +WHERE week_id IN (SELECT id FROM course_weeks WHERE course_id IN (SELECT id FROM target_course)); + +DELETE FROM course_week_practices +WHERE week_id IN (SELECT id FROM course_weeks WHERE course_id IN (SELECT id FROM target_course)); + +DELETE FROM course_week_references +WHERE week_id IN (SELECT id FROM course_weeks WHERE course_id IN (SELECT id FROM target_course)); + +DELETE FROM course_weeks +WHERE course_id IN (SELECT id FROM target_course) + AND week > 2; + +UPDATE courses +SET + title = 'NAV2 기반 자율주행 교육 과정', + subtitle = 'ROS2, 시뮬레이션, SLAM부터 경로 계획과 제어까지', + description = 'ROS2와 NAV2 기반 시뮬레이션 및 플랫폼 실습을 통해 자율주행 시스템의 핵심 흐름을 학습하고 팀 프로젝트로 검증합니다.' +WHERE id IN (SELECT id FROM target_course); + +UPDATE course_weeks +SET + title = 'ROS2 & Simulation', + tags = ARRAY['MK-mini', '진행중'], + progress = 0, + state = 'current', + duration = '1주', + github = 'https://github.com/ros-navigation/navigation2', + overview = 'ROS2 통신 구조와 Gazebo 시뮬레이션 환경을 구성하고, LiDAR SLAM·위치추정·경로 계획·제어까지 NAV2 자율주행 파이프라인을 실습합니다.', + deliverable = 'NAV2 모듈 소스 변경 내역, SLAM 결과 맵, 목표 지점 주행 검증 화면 및 실행 로그를 제출합니다.', + requires_source_submission = true +WHERE course_id IN (SELECT id FROM target_course) AND week = 1; + +UPDATE course_weeks +SET + title = 'Autonomy Studio 팀 프로젝트', + tags = ARRAY['MK-mini', '진행예정'], + progress = 0, + state = 'locked', + duration = '1주', + github = 'https://github.com/ros-navigation/navigation2', + overview = '3~4인 팀으로 자율주행 알고리즘이 필요한 문제 상황을 정의하고, 센서 구성과 NAV2 모듈을 조합해 시뮬레이션과 플랫폼에서 검증합니다.', + deliverable = '팀 프로젝트 소스, 시스템 구성도, 시뮬레이션 및 플랫폼 주행 검증 결과, 발표 자료를 제출합니다.', + requires_source_submission = true +WHERE course_id IN (SELECT id FROM target_course) AND week = 2; + +INSERT INTO course_weeks (course_id, week, title, tags, progress, state, duration, github, overview, deliverable, requires_source_submission) +SELECT id, 1, 'ROS2 & Simulation', ARRAY['MK-mini', '진행중'], 0, 'current', '1주', + 'https://github.com/ros-navigation/navigation2', + 'ROS2 통신 구조와 Gazebo 시뮬레이션 환경을 구성하고, LiDAR SLAM·위치추정·경로 계획·제어까지 NAV2 자율주행 파이프라인을 실습합니다.', + 'NAV2 모듈 소스 변경 내역, SLAM 결과 맵, 목표 지점 주행 검증 화면 및 실행 로그를 제출합니다.', true +FROM target_course +WHERE NOT EXISTS (SELECT 1 FROM course_weeks WHERE course_id = target_course.id AND week = 1); + +INSERT INTO course_weeks (course_id, week, title, tags, progress, state, duration, github, overview, deliverable, requires_source_submission) +SELECT id, 2, 'Autonomy Studio 팀 프로젝트', ARRAY['MK-mini', '진행예정'], 0, 'locked', '1주', + 'https://github.com/ros-navigation/navigation2', + '3~4인 팀으로 자율주행 알고리즘이 필요한 문제 상황을 정의하고, 센서 구성과 NAV2 모듈을 조합해 시뮬레이션과 플랫폼에서 검증합니다.', + '팀 프로젝트 소스, 시스템 구성도, 시뮬레이션 및 플랫폼 주행 검증 결과, 발표 자료를 제출합니다.', true +FROM target_course +WHERE NOT EXISTS (SELECT 1 FROM course_weeks WHERE course_id = target_course.id AND week = 2); + +INSERT INTO course_week_topics (week_id, title, detail, sort_order) +SELECT w.id, topic.title, topic.detail, topic.sort_order +FROM course_weeks w +CROSS JOIN ( + VALUES + (1, 'ROS2 기초', 'ROS Topic, Service, Action 통신과 TF 정의, ROS2 패키지 및 tree 구조를 실습합니다.'), + (2, 'Sensor Processing', 'LiDAR와 카메라 등 센서 데이터 처리 흐름과 Calibration 기초를 확인합니다.'), + (3, 'Simulation 환경 구축 Part 1', 'Gazebo 월드, 로봇 모델, 센서 토픽을 구성해 시뮬레이션 실습 환경을 만듭니다.'), + (4, 'Simulation 환경 구축 Part 2', '시뮬레이션에서 활용할 수 있는 모빌리티 플랫폼과 실행 모듈 구성을 점검합니다.'), + (5, 'LiDAR SLAM', '센서 데이터와 SLAM 알고리즘을 활용해 자율주행용 2D Map을 구축합니다.'), + (6, 'LiDAR Localization', '구축한 Map과 실시간 LiDAR 데이터를 이용해 현재 위치를 추정합니다.'), + (7, 'Parameter Tuning', '모듈 파라미터를 변경하고 결과를 비교해 알고리즘을 튜닝합니다.'), + (8, '[심화] Camera based SLAM', '카메라 기반 Map 구축과 위치인식의 입력·출력 구조를 분석합니다.'), + (9, 'Occupancy Map', '장애물 인식을 위한 Occupancy Map과 costmap 반영 과정을 실습합니다.'), + (10, 'Planning', '장애물 충돌 없이 목표 지점으로 이동하기 위한 전역·지역 경로 계획을 실습합니다.'), + (11, 'Control', '생성한 경로를 추종하도록 속도 명령을 만들고 주행 결과를 검증합니다.'), + (12, '[심화] 딥러닝 기반 객체 인식', '딥러닝 기반 객체 인식 결과를 자율주행 데이터 흐름에 반영하는 방법을 학습합니다.') +) AS topic(sort_order, title, detail) +WHERE w.course_id IN (SELECT id FROM target_course) AND w.week = 1; + +INSERT INTO course_week_topics (week_id, title, detail, sort_order) +SELECT w.id, topic.title, topic.detail, topic.sort_order +FROM course_weeks w +CROSS JOIN ( + VALUES + (1, '프로젝트 주제와 역할 분담', '팀별 자율주행 문제 상황과 목표, 역할, 검증 기준을 정의합니다.'), + (2, '센서·모듈 구성 설계', '센서 입력과 NAV2 모듈 조합, ROS 토픽 및 실행 순서를 설계합니다.'), + (3, '알고리즘 구현과 튜닝', 'SLAM, Localization, Planning, Control 파라미터를 수정하고 성능을 비교합니다.'), + (4, '시뮬레이션·플랫폼 검증', 'Gazebo 시뮬레이션과 실제 플랫폼에서 주행 시나리오를 검증합니다.'), + (5, '결과 제출과 발표', '소스, 실행 로그, 검증 결과를 정리해 팀 프로젝트 결과를 제출하고 발표합니다.') +) AS topic(sort_order, title, detail) +WHERE w.course_id IN (SELECT id FROM target_course) AND w.week = 2; + +INSERT INTO course_week_practices (week_id, title, description, sort_order) +SELECT w.id, practice.title, practice.description, practice.sort_order +FROM course_weeks w +CROSS JOIN ( + VALUES + (1, 'ROS2와 Gazebo 실행 환경', 'Topic, Service, Action, TF를 확인하고 TurtleBot3 Gazebo 환경을 실행합니다.'), + (2, 'LiDAR SLAM과 Localization', '맵을 생성·저장하고 저장된 맵에서 AMCL 기반 위치추정을 검증합니다.'), + (3, '경로 계획과 제어', '목표 지점을 설정하고 장애물 회피, 경로 계획, 제어 명령을 비교합니다.') +) AS practice(sort_order, title, description) +WHERE w.course_id IN (SELECT id FROM target_course) AND w.week = 1; + +INSERT INTO course_week_practices (week_id, title, description, sort_order) +SELECT w.id, practice.title, practice.description, practice.sort_order +FROM course_weeks w +CROSS JOIN ( + VALUES + (1, '팀 자율주행 시나리오 구현', '팀별 시나리오에 맞춰 NAV2 모듈을 구성하고 소스를 수정합니다.'), + (2, '통합 주행 검증', '시뮬레이션과 플랫폼에서 재현 가능한 주행 결과를 확보합니다.') +) AS practice(sort_order, title, description) +WHERE w.course_id IN (SELECT id FROM target_course) AND w.week = 2; + +WITH module_definitions(category, name, folder_name, template_repository_url, prebuilt_artifacts, default_parameters, docker_image) AS ( + VALUES + ('Simulator', 'NAV2-Gazebo', '[lms-nav2-gazebo-simulator]nav2-simulator_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-simulator.git', + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-simulator/1.0.0/prebuilt/latest/lms-nav2-gazebo-simulator-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-simulator-build-install.tar.zst', 'required', false, 'extractPath', '.')), jsonb_build_array('gui:=false'), 'future-nav2-gazebo:humble'), + ('Vehicle', 'NAV2-TurtleBot3Burger', '[lms-nav2-gazebo-vehicle]nav2-vehicle_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-vehicle.git', + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-vehicle/1.0.0/prebuilt/latest/lms-nav2-gazebo-vehicle-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-vehicle-build-install.tar.zst', 'required', false, 'extractPath', '.')), jsonb_build_array('sim_mode:=true', 'platform:=turtlebot3', 'level:=2d'), 'future-nav2-gazebo:humble'), + ('Map', 'NAV2-Map', '[lms-nav2-gazebo-map]nav2-map_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-map.git', + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-map/1.0.0/prebuilt/latest/lms-nav2-gazebo-map-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-map-build-install.tar.zst', 'required', false, 'extractPath', '.')), '[]'::jsonb, 'future-nav2-gazebo:humble'), + ('Localization', 'NAV2-SLAM', '[lms-nav2-gazebo-slam]nav2-slam_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-slam.git', + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-slam/1.0.0/prebuilt/latest/lms-nav2-gazebo-slam-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-slam-build-install.tar.zst', 'required', false, 'extractPath', '.')), '[]'::jsonb, 'future-nav2-gazebo:humble'), + ('Localization', 'NAV2-Localization', '[lms-nav2-gazebo-localization]nav2-localization_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-localization.git', + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-localization/1.0.0/prebuilt/latest/lms-nav2-gazebo-localization-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-localization-build-install.tar.zst', 'required', false, 'extractPath', '.')), jsonb_build_array('localization_mode:=slam'), 'future-nav2-gazebo:humble'), + ('Perception', 'NAV2-Perception', '[lms-nav2-gazebo-perception]nav2-perception_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-perception.git', + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-perception/1.0.0/prebuilt/latest/lms-nav2-gazebo-perception-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-perception-build-install.tar.zst', 'required', false, 'extractPath', '.')), jsonb_build_array('sim_mode:=true'), 'future-nav2-gazebo:humble'), + ('Planning', 'NAV2-Planning', '[lms-nav2-gazebo-planning]nav2-planning_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-planning.git', + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-planning/1.0.0/prebuilt/latest/lms-nav2-gazebo-planning-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-planning-build-install.tar.zst', 'required', false, 'extractPath', '.')), '[]'::jsonb, 'future-nav2-gazebo:humble'), + ('Control', 'NAV2-Control', '[lms-nav2-gazebo-control]nav2-control_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-control.git', + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-control/1.0.0/prebuilt/latest/lms-nav2-gazebo-control-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-control-build-install.tar.zst', 'required', false, 'extractPath', '.')), jsonb_build_array('sim_mode:=true', 'robot:=turtlebot3', 'level:=2d'), 'future-nav2-gazebo:humble'), + ('Vehicle', 'NAV2-MKMini', '[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-mkmini.git', + '[]'::jsonb, jsonb_build_array('sim_mode:=false', 'platform:=mkmini', 'robot:=mkmini', 'level:=2d'), 'future-nav2:humble-jetson_orin') +) +INSERT INTO course_week_modules (week_id, sort_order, category, name, folder_name, template_repository_url, source_type, prebuilt_artifacts, default_parameters, docker_image) +SELECT w.id, row_number() OVER (PARTITION BY w.id ORDER BY module_definitions.folder_name), category, name, folder_name, template_repository_url, 'GIT', prebuilt_artifacts, default_parameters, docker_image +FROM course_weeks w +CROSS JOIN module_definitions +WHERE w.course_id IN (SELECT id FROM target_course) AND w.week IN (1, 2); + +INSERT INTO course_week_visualization_platforms (course_id, week, platform_key, enabled, created_at, updated_at) +SELECT target_course.id, weeks.week, platform.platform_key, true, now(), now() +FROM target_course +CROSS JOIN (VALUES (1), (2)) AS weeks(week) +JOIN visualization_platforms platform ON platform.platform_key IN ('NAV2', 'REAL_SLAM', 'REAL_NAVIGATION'); + +INSERT INTO course_environment_profiles (course_id, week, platform_key, profile_type, name, config, enabled, created_at, updated_at) +SELECT target_course.id, weeks.week, platform.platform_key, platform.type, + CASE WHEN platform.platform_key = 'NAV2' THEN '로컬' ELSE platform.display_name END, + platform.default_config, true, now(), now() +FROM target_course +CROSS JOIN (VALUES (1), (2)) AS weeks(week) +JOIN visualization_platforms platform ON platform.platform_key IN ('NAV2', 'REAL_SLAM', 'REAL_NAVIGATION'); + +-- Local simulation gets all Gazebo modules. Platform profiles only receive modules applicable to their workflow. +UPDATE course_environment_profiles profile +SET config = profile.config::jsonb || jsonb_build_object('moduleSet', jsonb_build_object( + 'target', CASE WHEN profile.platform_key = 'NAV2' THEN 'local' ELSE 'platform' END, + 'includeUnlisted', false, + 'modules', COALESCE(( + SELECT jsonb_agg(jsonb_build_object('id', module.id) ORDER BY module.sort_order) + FROM course_weeks week + JOIN course_week_modules module ON module.week_id = week.id + WHERE week.course_id = profile.course_id + AND week.week = profile.week + AND ( + (profile.platform_key = 'NAV2' AND module.folder_name LIKE '[lms-nav2-gazebo-%') + OR (profile.platform_key IN ('REAL_SLAM', 'REAL_NAVIGATION') AND module.folder_name IN ( + '[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', + '[lms-nav2-gazebo-map]nav2-map_v1.0.0_ws', + '[lms-nav2-gazebo-perception]nav2-perception_v1.0.0_ws', + '[lms-nav2-gazebo-localization]nav2-localization_v1.0.0_ws', + '[lms-nav2-gazebo-planning]nav2-planning_v1.0.0_ws', + '[lms-nav2-gazebo-control]nav2-control_v1.0.0_ws' + )) + ) + ), '[]'::jsonb) +)) +WHERE profile.course_id IN (SELECT id FROM target_course) + AND profile.platform_key IN ('NAV2', 'REAL_SLAM', 'REAL_NAVIGATION'); + +-- Control runs with TurtleBot3/Gazebo defaults locally, but uses MK-mini and the ARM64 image on a platform. +UPDATE course_environment_profiles profile +SET config = jsonb_set(profile.config::jsonb, '{moduleSet,modules}', ( + SELECT jsonb_agg( + CASE WHEN item->>'id' = control_module.id::text THEN item || jsonb_build_object( + 'defaultParameters', jsonb_build_array('sim_mode:=false', 'robot:=mkmini', 'level:=2d'), + 'dockerImage', 'future-nav2:humble-jetson_orin' + ) ELSE item END + ) + FROM jsonb_array_elements(profile.config->'moduleSet'->'modules') AS item + CROSS JOIN LATERAL ( + SELECT module.id + FROM course_weeks week + JOIN course_week_modules module ON module.week_id = week.id + WHERE week.course_id = profile.course_id + AND week.week = profile.week + AND module.name = 'NAV2-Control' + ) AS control_module +), true) +WHERE profile.course_id IN (SELECT id FROM target_course) + AND profile.platform_key IN ('REAL_SLAM', 'REAL_NAVIGATION'); + +INSERT INTO course_week_maps (id, course_id, week, map_key, sort_order, created_at, updated_at) +SELECT gen_random_uuid(), target_course.id, weeks.week, map.map_key, map.sort_order, now(), now() +FROM target_course +CROSS JOIN (VALUES (1), (2)) AS weeks(week) +CROSS JOIN (VALUES ('nav2-turtlebot3-world', 1), ('nav2-turtlebot3-house', 2)) AS map(map_key, sort_order); + +UPDATE instructor_curriculum_settings +SET active_week = 1, active_learning_step = 'ROS2 기초' +WHERE course_id IN (SELECT id FROM target_course); + +COMMIT; diff --git a/src/curriculum/curriculum.controller.ts b/src/curriculum/curriculum.controller.ts index 2d0168b..2a594e2 100644 --- a/src/curriculum/curriculum.controller.ts +++ b/src/curriculum/curriculum.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Query, Req, Res } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Query, Req, Res } from '@nestjs/common'; import { Request, Response } from 'express'; import { CurriculumService } from './curriculum.service'; @@ -15,6 +15,31 @@ export class CurriculumController { ); } + @Post('custom-modules') + createCustomModule( + @Req() request: Request, + @Body() body: { + courseId?: string; + week?: number; + name?: string; + category?: string; + dockerImage?: string; + packageName?: string; + launchFile?: string; + }, + ) { + return this.curriculumService.createCustomModule(this.getBearerToken(request), body); + } + + @Delete('custom-modules/:moduleId') + deleteCustomModule( + @Req() request: Request, + @Param('moduleId') moduleId: string, + @Query('courseId') courseId?: string, + ) { + return this.curriculumService.deleteCustomModule(this.getBearerToken(request), moduleId, courseId); + } + @Get('courses') listCourses(@Req() request: Request) { return this.curriculumService.listCourses(this.getBearerToken(request)); diff --git a/src/curriculum/curriculum.service.ts b/src/curriculum/curriculum.service.ts index 35c6a6c..9caa811 100644 --- a/src/curriculum/curriculum.service.ts +++ b/src/curriculum/curriculum.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { join } from 'node:path'; @@ -10,6 +10,9 @@ 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'; +const CUSTOM_MODULE_CATEGORIES = new Set([ + 'Vehicle', 'Sensing', 'Map', 'Perception', 'Localization', 'Planning', 'Control', 'System', 'Simulator', 'Gui', +]); @Injectable() export class CurriculumService { @@ -181,6 +184,19 @@ export class CurriculumService { return emptyCurriculumResponse(); } + const customModules = lmsUser + ? await this.prisma.studentCustomModule.findMany({ + where: { userId: lmsUser.id, courseId: course.id }, + orderBy: [{ week: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }], + }) + : []; + const customModulesByWeek = new Map(); + customModules.forEach((module) => { + const items = customModulesByWeek.get(module.week) ?? []; + items.push(module); + customModulesByWeek.set(module.week, items); + }); + const visualizationSelections = await this.prisma.courseWeekVisualizationPlatform.findMany({ where: { courseId: course.id, @@ -421,7 +437,8 @@ export class CurriculumService { sourceSubmitted: Boolean(weekProgress?.submittedAt), sourceSubmittedAt: weekProgress?.submittedAt?.toISOString() ?? null, }, - modules: week.modules + modules: [ + ...week.modules .filter((module) => !disabledModuleIdSet.has(module.id)) .map((module) => { const moduleWithArtifacts = module as typeof module & { @@ -442,6 +459,21 @@ export class CurriculumService { dockerImage: module.dockerImage, }; }), + ...(customModulesByWeek.get(week.week) ?? []).map((module) => ({ + id: module.id, + sortOrder: module.sortOrder, + category: module.category, + name: module.name, + folderName: module.folderName, + templateRepositoryUrl: null, + sourceType: 'CUSTOM', + prebuiltArtifacts: [], + defaultParameters: [], + dockerImage: module.dockerImage, + packageName: module.packageName, + launchFile: module.launchFile, + })), + ], environmentProfiles: visualizationProfilesByWeek.get(week.week) ?? [], maps: serializeCourseMaps(mapKeysByWeek.get(week.week) ?? [], course.maps), }; @@ -547,6 +579,110 @@ export class CurriculumService { }; } + async createCustomModule( + accessToken: string | undefined, + input: { + courseId?: string; + week?: number; + name?: string; + category?: string; + dockerImage?: string; + packageName?: string; + launchFile?: string; + }, + ) { + const { user: authUser } = await this.authService.me(accessToken); + if (!authUser?.email) throw new UnauthorizedException('Invalid user'); + const user = await this.prisma.user.findUnique({ where: { email: authUser.email } }); + if (!user) throw new UnauthorizedException('LMS user not found'); + + const courseId = input.courseId?.trim() ?? ''; + const week = Number(input.week); + const name = input.name?.trim() ?? ''; + const category = input.category?.trim() ?? ''; + const dockerImage = input.dockerImage?.trim() || null; + const packageName = input.packageName?.trim() ?? ''; + const launchFile = input.launchFile?.trim() ?? ''; + if (!courseId || !Number.isInteger(week) || week < 1 || !name || !packageName || !launchFile) { + throw new BadRequestException('모듈 정보가 올바르지 않습니다.'); + } + if (name.length > 80 || !CUSTOM_MODULE_CATEGORIES.has(category)) { + throw new BadRequestException('모듈 이름 또는 카테고리가 올바르지 않습니다.'); + } + if (!/^[a-z][a-z0-9_]*$/.test(packageName) || !/^[a-zA-Z0-9_.-]+\.launch\.py$/.test(launchFile)) { + throw new BadRequestException('ROS2 패키지명 또는 Launch 파일명이 올바르지 않습니다.'); + } + if (dockerImage && (!/^[a-zA-Z0-9_./:@-]+$/.test(dockerImage) || dockerImage.length > 180)) { + throw new BadRequestException('실행 이미지명이 올바르지 않습니다.'); + } + + const enrollment = await this.prisma.studentEnrollment.findFirst({ + where: { userId: user.id, courseId, status: 'ACTIVE' }, + select: { id: true }, + }); + const courseWeek = await this.prisma.courseWeek.findFirst({ + where: { courseId, week }, + select: { id: true }, + }); + if (!enrollment || !courseWeek) throw new NotFoundException('수강 중인 강좌 주차를 찾을 수 없습니다.'); + + const folderName = `[lms-nav2-custom]${packageName}_v1.0.0_ws`; + const duplicateName = await this.prisma.studentCustomModule.findFirst({ + where: { userId: user.id, courseId, week, name }, + select: { id: true }, + }); + if (duplicateName) throw new ConflictException('같은 이름의 커스텀 모듈이 이미 있습니다.'); + const count = await this.prisma.studentCustomModule.count({ where: { userId: user.id, courseId, week } }); + try { + const module = await this.prisma.studentCustomModule.create({ + data: { + userId: user.id, + courseId, + week, + sortOrder: 10_000 + count, + category, + name, + folderName, + dockerImage, + packageName, + launchFile, + }, + }); + return { + id: module.id, + sortOrder: module.sortOrder, + category: module.category, + name: module.name, + folderName: module.folderName, + dockerImage: module.dockerImage, + packageName: module.packageName, + launchFile: module.launchFile, + }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + throw new ConflictException('같은 패키지명의 커스텀 모듈이 이미 있습니다.'); + } + throw error; + } + } + + async deleteCustomModule(accessToken: string | undefined, moduleId: string, courseId?: string) { + const { user: authUser } = await this.authService.me(accessToken); + if (!authUser?.email) throw new UnauthorizedException('Invalid user'); + const user = await this.prisma.user.findUnique({ where: { email: authUser.email } }); + if (!user) throw new UnauthorizedException('LMS user not found'); + + const deleted = await this.prisma.studentCustomModule.deleteMany({ + where: { + id: moduleId, + userId: user.id, + ...(courseId?.trim() ? { courseId: courseId.trim() } : {}), + }, + }); + if (!deleted.count) throw new NotFoundException('커스텀 모듈을 찾을 수 없습니다.'); + return { ok: true }; + } + private withControlState( platform: { id: string;