fix: confirm module shutdown before disconnect or release
This commit is contained in:
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
|
||||
@@ -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<number, typeof customModules>();
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user