feat: configure overview camera per curriculum week
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "instructor_curriculum_week_overrides"
|
||||||
|
ADD COLUMN "camera_view_url" TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN "camera_password" TEXT NOT NULL DEFAULT '';
|
||||||
@@ -365,6 +365,8 @@ model InstructorCurriculumSetting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model InstructorCurriculumWeekOverride {
|
model InstructorCurriculumWeekOverride {
|
||||||
|
cameraViewUrl String @default("") @map("camera_view_url")
|
||||||
|
cameraPassword String @default("") @map("camera_password")
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
organizationId String? @map("organization_id") @db.Uuid
|
organizationId String? @map("organization_id") @db.Uuid
|
||||||
organizationName String @map("organization_name")
|
organizationName String @map("organization_name")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { PlatformControlService } from '../platform-control/platform-control.ser
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { R2PublicFileService } from '../storage/r2-public-file.service';
|
import { R2PublicFileService } from '../storage/r2-public-file.service';
|
||||||
import { serializeCourseMaps } from './course-maps';
|
import { serializeCourseMaps } from './course-maps';
|
||||||
import { readPlatformCamera } from '../instructor/platform-camera';
|
import { readWeekCamera } from './week-camera';
|
||||||
|
|
||||||
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
|
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
|
||||||
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
|
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
|
||||||
@@ -542,6 +542,7 @@ export class CurriculumService {
|
|||||||
github: week.github,
|
github: week.github,
|
||||||
detail: {
|
detail: {
|
||||||
overview: override?.overview ?? week.overview,
|
overview: override?.overview ?? week.overview,
|
||||||
|
camera: readWeekCamera(override),
|
||||||
learningSteps,
|
learningSteps,
|
||||||
topicDetails,
|
topicDetails,
|
||||||
topicVideoUrls,
|
topicVideoUrls,
|
||||||
@@ -620,6 +621,7 @@ export class CurriculumService {
|
|||||||
github: '-',
|
github: '-',
|
||||||
detail: {
|
detail: {
|
||||||
overview: override.overview ?? '',
|
overview: override.overview ?? '',
|
||||||
|
camera: readWeekCamera(override),
|
||||||
learningSteps,
|
learningSteps,
|
||||||
topicDetails: learningSteps.map((step) => step.description),
|
topicDetails: learningSteps.map((step) => step.description),
|
||||||
topicVideoUrls: learningSteps.map((step) => step.videoUrl),
|
topicVideoUrls: learningSteps.map((step) => step.videoUrl),
|
||||||
@@ -1100,7 +1102,6 @@ export class CurriculumService {
|
|||||||
organizationName: platform.organizationName,
|
organizationName: platform.organizationName,
|
||||||
iconKey: platform.platform.iconKey,
|
iconKey: platform.platform.iconKey,
|
||||||
sourceSettings: platform.studioSettingsOverride ?? platform.platform.defaultStudioSettings,
|
sourceSettings: platform.studioSettingsOverride ?? platform.platform.defaultStudioSettings,
|
||||||
camera: readPlatformCamera(platform.studioSettingsOverride),
|
|
||||||
vehicleRuntimeProfile: this.toVehicleRuntimeProfile({
|
vehicleRuntimeProfile: this.toVehicleRuntimeProfile({
|
||||||
platformKey: platform.platform.platformKey,
|
platformKey: platform.platform.platformKey,
|
||||||
vehicleType: platform.vehicleType,
|
vehicleType: platform.vehicleType,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { normalizeCameraUrl, readPlatformCamera } from './platform-camera';
|
import { normalizeCameraUrl, readWeekCamera } from './week-camera';
|
||||||
|
|
||||||
describe('platform camera settings', () => {
|
describe('week camera settings', () => {
|
||||||
it('keeps only viewing parameters', () => {
|
it('keeps only viewing parameters', () => {
|
||||||
const url = new URL(normalizeCameraUrl('https://vdo.ninja/?room=track&view=camera&push=bad&director=bad&password=secret'));
|
const url = new URL(normalizeCameraUrl('https://vdo.ninja/?room=track&view=camera&push=bad&director=bad&password=secret'));
|
||||||
expect(url.searchParams.get('room')).toBe('track');
|
expect(url.searchParams.get('room')).toBe('track');
|
||||||
@@ -17,7 +17,7 @@ describe('platform camera settings', () => {
|
|||||||
|
|
||||||
it('supports unset camera settings and preserves password whitespace', () => {
|
it('supports unset camera settings and preserves password whitespace', () => {
|
||||||
expect(normalizeCameraUrl(' ')).toBe('');
|
expect(normalizeCameraUrl(' ')).toBe('');
|
||||||
expect(readPlatformCamera(null)).toEqual({ url: '', password: '' });
|
expect(readWeekCamera(null)).toEqual({ url: '', password: '' });
|
||||||
expect(readPlatformCamera({ cameraViewUrl: 'url', cameraPassword: ' a&b ' })).toEqual({ url: 'url', password: ' a&b ' });
|
expect(readWeekCamera({ cameraViewUrl: 'url', cameraPassword: ' a&b ' })).toEqual({ url: 'url', password: ' a&b ' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export function readPlatformCamera(settings: unknown): { url: string; password: string } {
|
export function readWeekCamera(settings: unknown): { url: string; password: string } {
|
||||||
const value = settings && typeof settings === 'object' ? settings as Record<string, unknown> : {};
|
const value = settings && typeof settings === 'object' ? settings as Record<string, unknown> : {};
|
||||||
return {
|
return {
|
||||||
url: typeof value.cameraViewUrl === 'string' ? value.cameraViewUrl : '',
|
url: typeof value.cameraViewUrl === 'string' ? value.cameraViewUrl : '',
|
||||||
@@ -10,7 +10,7 @@ export function normalizeCameraUrl(input: string): string {
|
|||||||
if (!input.trim()) return '';
|
if (!input.trim()) return '';
|
||||||
const url = new URL(input.trim());
|
const url = new URL(input.trim());
|
||||||
if (url.protocol !== 'https:' || url.hostname !== 'vdo.ninja' || url.port || url.username || url.password || url.pathname !== '/') {
|
if (url.protocol !== 'https:' || url.hostname !== 'vdo.ninja' || url.port || url.username || url.password || url.pathname !== '/') {
|
||||||
throw new Error('VDO.Ninja 시청 URL을 입력해주세요.');
|
throw new Error('올바른 오버뷰 시청 URL을 입력해주세요.');
|
||||||
}
|
}
|
||||||
if (!url.searchParams.get('room')?.trim() || !url.searchParams.get('view')?.trim()) {
|
if (!url.searchParams.get('room')?.trim() || !url.searchParams.get('view')?.trim()) {
|
||||||
throw new Error('시청 URL에 room과 view가 필요합니다.');
|
throw new Error('시청 URL에 room과 view가 필요합니다.');
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { IsArray, IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
import { IsArray, IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateInstructorCurriculumDto {
|
export class UpdateInstructorCurriculumDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cameraViewUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cameraPassword?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
courseId?: string | null;
|
courseId?: string | null;
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { IsArray, IsInt, IsNumber, IsObject, IsOptional, IsString, Max, Min } from 'class-validator';
|
import { IsArray, IsInt, IsNumber, IsObject, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateInstructorPlatformDto {
|
export class UpdateInstructorPlatformDto {
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
cameraViewUrl?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
cameraPassword?: string;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dt
|
|||||||
import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto';
|
import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto';
|
||||||
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
|
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
|
||||||
import { groupCourseScope } from './group-course-scope';
|
import { groupCourseScope } from './group-course-scope';
|
||||||
import { normalizeCameraUrl, readPlatformCamera } from './platform-camera';
|
import { normalizeCameraUrl, readWeekCamera } from '../curriculum/week-camera';
|
||||||
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
||||||
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
|
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
|
||||||
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
|
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
|
||||||
@@ -1338,7 +1338,6 @@ export class InstructorService {
|
|||||||
type: this.toPlatformType(platform.platform.displayName),
|
type: this.toPlatformType(platform.platform.displayName),
|
||||||
status: activeControl ? '활성' : '대기',
|
status: activeControl ? '활성' : '대기',
|
||||||
battery: platform.battery,
|
battery: platform.battery,
|
||||||
camera: readPlatformCamera(platform.studioSettingsOverride),
|
|
||||||
uptime: typeof agentConnection?.agentUptimeSeconds === 'number'
|
uptime: typeof agentConnection?.agentUptimeSeconds === 'number'
|
||||||
? this.formatUptimeSeconds(agentConnection.agentUptimeSeconds)
|
? this.formatUptimeSeconds(agentConnection.agentUptimeSeconds)
|
||||||
: this.formatUptime(platform.uptimeMinutes),
|
: this.formatUptime(platform.uptimeMinutes),
|
||||||
@@ -1473,6 +1472,7 @@ export class InstructorService {
|
|||||||
github: week.github,
|
github: week.github,
|
||||||
detail: {
|
detail: {
|
||||||
overview: override?.overview ?? week.overview,
|
overview: override?.overview ?? week.overview,
|
||||||
|
camera: readWeekCamera(override),
|
||||||
learningSteps,
|
learningSteps,
|
||||||
topicDetails,
|
topicDetails,
|
||||||
topicVideoUrls,
|
topicVideoUrls,
|
||||||
@@ -1528,6 +1528,7 @@ export class InstructorService {
|
|||||||
github: '-',
|
github: '-',
|
||||||
detail: {
|
detail: {
|
||||||
overview: override.overview ?? '',
|
overview: override.overview ?? '',
|
||||||
|
camera: readWeekCamera(override),
|
||||||
learningSteps,
|
learningSteps,
|
||||||
topicDetails: learningSteps.map((step) => step.description),
|
topicDetails: learningSteps.map((step) => step.description),
|
||||||
topicVideoUrls: learningSteps.map((step) => step.videoUrl),
|
topicVideoUrls: learningSteps.map((step) => step.videoUrl),
|
||||||
@@ -1596,6 +1597,17 @@ export class InstructorService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let cameraViewUrl: string | undefined;
|
||||||
|
try {
|
||||||
|
cameraViewUrl = input.cameraViewUrl === undefined ? undefined : normalizeCameraUrl(input.cameraViewUrl);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('오버뷰 시청 URL에 올바른 room과 view를 지정해주세요.');
|
||||||
|
}
|
||||||
|
const cameraSettings = {
|
||||||
|
cameraViewUrl: cameraViewUrl ?? existingOverride?.cameraViewUrl ?? '',
|
||||||
|
cameraPassword: cameraViewUrl === '' ? '' : input.cameraPassword ?? existingOverride?.cameraPassword ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
const existingLearningSteps = this.resolveLearningSteps(
|
const existingLearningSteps = this.resolveLearningSteps(
|
||||||
existingOverride?.learningSteps,
|
existingOverride?.learningSteps,
|
||||||
this.stringArray(existingOverride?.topics) ?? targetWeek?.topics.map((topic) => topic.title) ?? ['학습단계 1'],
|
this.stringArray(existingOverride?.topics) ?? targetWeek?.topics.map((topic) => topic.title) ?? ['학습단계 1'],
|
||||||
@@ -1764,7 +1776,7 @@ export class InstructorService {
|
|||||||
selectedTagLabel = tagOption.label;
|
selectedTagLabel = tagOption.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetWeek || input.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.isTeamProject !== undefined || input.learningSteps || input.topics || input.topicDetails || input.topicVideoUrls || input.practiceContents || input.disabledModuleIds) {
|
if (!targetWeek || input.cameraViewUrl !== undefined || input.cameraPassword !== undefined || input.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.isTeamProject !== undefined || input.learningSteps || input.topics || input.topicDetails || input.topicVideoUrls || input.practiceContents || input.disabledModuleIds) {
|
||||||
await this.prisma.instructorCurriculumWeekOverride.upsert({
|
await this.prisma.instructorCurriculumWeekOverride.upsert({
|
||||||
where: {
|
where: {
|
||||||
organizationName_courseId_week: {
|
organizationName_courseId_week: {
|
||||||
@@ -1776,6 +1788,7 @@ export class InstructorService {
|
|||||||
update: {
|
update: {
|
||||||
organizationId,
|
organizationId,
|
||||||
tagLabel: selectedTagLabel ?? existingOverride?.tagLabel ?? targetWeek?.tags[0] ?? '커리큘럼',
|
tagLabel: selectedTagLabel ?? existingOverride?.tagLabel ?? targetWeek?.tags[0] ?? '커리큘럼',
|
||||||
|
...cameraSettings,
|
||||||
title: this.optionalTrim(input.title) ?? existingOverride?.title ?? targetWeek?.title ?? `${input.activeWeek}주차 커리큘럼`,
|
title: this.optionalTrim(input.title) ?? existingOverride?.title ?? targetWeek?.title ?? `${input.activeWeek}주차 커리큘럼`,
|
||||||
duration: this.optionalTrim(input.duration) ?? existingOverride?.duration ?? targetWeek?.duration ?? '-',
|
duration: this.optionalTrim(input.duration) ?? existingOverride?.duration ?? targetWeek?.duration ?? '-',
|
||||||
overview: this.optionalTrim(input.overview) ?? existingOverride?.overview ?? targetWeek?.overview ?? '',
|
overview: this.optionalTrim(input.overview) ?? existingOverride?.overview ?? targetWeek?.overview ?? '',
|
||||||
@@ -1797,6 +1810,7 @@ export class InstructorService {
|
|||||||
courseId: course.id,
|
courseId: course.id,
|
||||||
week: input.activeWeek,
|
week: input.activeWeek,
|
||||||
tagLabel: selectedTagLabel ?? targetWeek?.tags[0] ?? '커리큘럼',
|
tagLabel: selectedTagLabel ?? targetWeek?.tags[0] ?? '커리큘럼',
|
||||||
|
...cameraSettings,
|
||||||
title: this.optionalTrim(input.title) ?? targetWeek?.title ?? `${input.activeWeek}주차 커리큘럼`,
|
title: this.optionalTrim(input.title) ?? targetWeek?.title ?? `${input.activeWeek}주차 커리큘럼`,
|
||||||
duration: this.optionalTrim(input.duration) ?? targetWeek?.duration ?? '-',
|
duration: this.optionalTrim(input.duration) ?? targetWeek?.duration ?? '-',
|
||||||
overview: this.optionalTrim(input.overview) ?? targetWeek?.overview ?? '',
|
overview: this.optionalTrim(input.overview) ?? targetWeek?.overview ?? '',
|
||||||
@@ -2227,12 +2241,6 @@ export class InstructorService {
|
|||||||
const maxSteeringAngleDegrees = this.toBoundedNumber(input.maxSteeringAngleDegrees, 30, 0, 90);
|
const maxSteeringAngleDegrees = this.toBoundedNumber(input.maxSteeringAngleDegrees, 30, 0, 90);
|
||||||
const sensors = this.normalizeNameDescriptionList(input.sensors, { withEnabled: true });
|
const sensors = this.normalizeNameDescriptionList(input.sensors, { withEnabled: true });
|
||||||
const algorithms = this.normalizeAlgorithms(input.algorithms);
|
const algorithms = this.normalizeAlgorithms(input.algorithms);
|
||||||
let cameraViewUrl: string | undefined;
|
|
||||||
try {
|
|
||||||
cameraViewUrl = input.cameraViewUrl === undefined ? undefined : normalizeCameraUrl(input.cameraViewUrl);
|
|
||||||
} catch {
|
|
||||||
throw new BadRequestException('VDO.Ninja 시청 URL에 올바른 room과 view를 지정해주세요.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const platform = await this.prisma.instructorPlatform.findFirst({
|
const platform = await this.prisma.instructorPlatform.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -2245,7 +2253,6 @@ export class InstructorService {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
displayName: true,
|
displayName: true,
|
||||||
studioSettingsOverride: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!platform) throw new NotFoundException('Platform not found');
|
if (!platform) throw new NotFoundException('Platform not found');
|
||||||
@@ -2264,14 +2271,6 @@ export class InstructorService {
|
|||||||
algorithms,
|
algorithms,
|
||||||
controlDurationMinutes,
|
controlDurationMinutes,
|
||||||
controlWarningMinutes,
|
controlWarningMinutes,
|
||||||
...((cameraViewUrl !== undefined || input.cameraPassword !== undefined) ? {
|
|
||||||
studioSettingsOverride: {
|
|
||||||
...(platform.studioSettingsOverride && typeof platform.studioSettingsOverride === 'object' && !Array.isArray(platform.studioSettingsOverride)
|
|
||||||
? platform.studioSettingsOverride : {}),
|
|
||||||
...(cameraViewUrl !== undefined ? { cameraViewUrl } : {}),
|
|
||||||
...(cameraViewUrl === '' ? { cameraPassword: '' } : input.cameraPassword !== undefined ? { cameraPassword: input.cameraPassword } : {}),
|
|
||||||
},
|
|
||||||
} : {}),
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.studentGroup.updateMany({
|
this.prisma.studentGroup.updateMany({
|
||||||
|
|||||||
Reference in New Issue
Block a user