feat: enable hybrid local and remote module execution
This commit is contained in:
@@ -48,7 +48,7 @@ export class UpdateInstructorCurriculumDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresSourceSubmission?: boolean;
|
||||
isTeamProject?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
|
||||
@@ -31,6 +31,21 @@ export class InstructorController {
|
||||
return this.instructorService.listSubmissions(this.getBearerToken(request));
|
||||
}
|
||||
|
||||
@Get('submissions/file')
|
||||
getSubmissionFile(
|
||||
@Req() request: Request,
|
||||
@Query('owner') owner?: string,
|
||||
@Query('repo') repoName?: string,
|
||||
@Query('path') path?: string,
|
||||
@Query('baseRef') baseRef?: string,
|
||||
@Query('headRef') headRef?: string,
|
||||
) {
|
||||
return this.instructorService.getSubmissionFile(
|
||||
this.getBearerToken(request),
|
||||
{ owner, repoName, path, baseRef, headRef },
|
||||
);
|
||||
}
|
||||
|
||||
@Put('submissions/score')
|
||||
saveSubmissionScore(@Req() request: Request, @Body() body: SaveSubmissionScoreDto) {
|
||||
return this.instructorService.saveSubmissionScore(this.getBearerToken(request), body);
|
||||
|
||||
@@ -36,6 +36,8 @@ type SubmissionCommitFile = {
|
||||
path: string;
|
||||
status: string;
|
||||
patch: string;
|
||||
originalRef?: string | null;
|
||||
modifiedRef?: string | null;
|
||||
};
|
||||
|
||||
type SubmissionCommit = {
|
||||
@@ -245,7 +247,7 @@ export class InstructorService {
|
||||
const isSubmitted = Boolean(weekProgressByUserWeek.get(`${user.id}:${week.week}`)?.submittedAt);
|
||||
const isPastByProgress = (progress?.completedWeeks ?? 0) >= week.week;
|
||||
const isCompletedByGroup = completedWeekNumbers.includes(week.week);
|
||||
return week.requiresSourceSubmission && !isSubmitted && (isPastByProgress || isCompletedByGroup);
|
||||
return !isSubmitted && (isPastByProgress || isCompletedByGroup);
|
||||
})
|
||||
.forEach((week) => {
|
||||
if (issues.some((issue) => issue.week === week.week && issue.type === 'COMPLETED_WITHOUT_SUBMISSION')) return;
|
||||
@@ -419,6 +421,50 @@ export class InstructorService {
|
||||
}
|
||||
const weekIds = courses.flatMap((course) => course.weeks.map((week) => week.id));
|
||||
const moduleIds = courses.flatMap((course) => course.weeks.flatMap((week) => week.modules.map((module) => module.id)));
|
||||
const courseIds = courses.map((course) => course.id);
|
||||
const teamWeekOverrides = organizationName && courseIds.length
|
||||
? await this.prisma.instructorCurriculumWeekOverride.findMany({
|
||||
where: {
|
||||
organizationName,
|
||||
courseId: { in: courseIds },
|
||||
isTeamProject: true,
|
||||
},
|
||||
select: {
|
||||
courseId: true,
|
||||
week: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const teamWeekKeys = new Set(teamWeekOverrides.map((override) => `${override.courseId}:${override.week}`));
|
||||
const isTeamProjectWeek = (courseId: string, week: number) => teamWeekKeys.has(`${courseId}:${week}`);
|
||||
const studentGroupByStudentId = new Map(
|
||||
enrollments
|
||||
.map((enrollment) => [enrollment.user.id, enrollment.user.groupMemberships[0]?.group ?? null] as const)
|
||||
.filter(([, group]) => group),
|
||||
);
|
||||
const teamGroupsById = new Map<string, {
|
||||
id: string;
|
||||
name: string;
|
||||
members: Array<{ id: string; name: string; email: string }>;
|
||||
}>();
|
||||
students.forEach((student) => {
|
||||
const group = studentGroupByStudentId.get(student.id);
|
||||
if (!group) return;
|
||||
const current = teamGroupsById.get(group.id) ?? { id: group.id, name: group.name, members: [] };
|
||||
if (!current.members.some((member) => member.id === student.id)) {
|
||||
current.members.push({ id: student.id, name: student.name, email: student.email });
|
||||
}
|
||||
teamGroupsById.set(group.id, current);
|
||||
});
|
||||
const teamGroups = [...teamGroupsById.values()];
|
||||
const teamRepositories = teamGroups.length && moduleIds.length
|
||||
? await this.prisma.teamStudioRepository.findMany({
|
||||
where: {
|
||||
groupId: { in: teamGroups.map((group) => group.id) },
|
||||
moduleId: { in: moduleIds },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const repositories = studentIds.length && moduleIds.length
|
||||
? await this.prisma.studioRepository.findMany({
|
||||
@@ -427,9 +473,31 @@ export class InstructorService {
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const customRepositories = studentIds.length && courseIds.length
|
||||
? await this.prisma.studentCustomModuleRepository.findMany({
|
||||
where: {
|
||||
userId: { in: studentIds },
|
||||
customModule: { courseId: { in: courseIds } },
|
||||
},
|
||||
include: { customModule: true },
|
||||
})
|
||||
: [];
|
||||
const teamCustomRepositories = teamGroups.length && courseIds.length
|
||||
? await this.prisma.teamCustomModuleRepository.findMany({
|
||||
where: {
|
||||
groupId: { in: teamGroups.map((group) => group.id) },
|
||||
customModule: { courseId: { in: courseIds } },
|
||||
},
|
||||
include: { customModule: true },
|
||||
})
|
||||
: [];
|
||||
const repositoryByStudentModule = new Map(repositories.map((repository) => [`${repository.userId}:${repository.moduleId}`, repository]));
|
||||
const repositoryByStudentRepoName = new Map(repositories.map((repository) => [`${repository.userId}:${repository.repoName}`, repository]));
|
||||
const teamRepositoryByGroupModule = new Map(teamRepositories.map((repository) => [`${repository.groupId}:${repository.moduleId}`, repository]));
|
||||
const progressByRepositoryId = new Map<string, SubmissionProgress>();
|
||||
const progressByTeamRepositoryId = new Map<string, SubmissionProgress>();
|
||||
const progressByCustomRepositoryId = new Map<string, SubmissionProgress>();
|
||||
const progressByTeamCustomRepositoryId = new Map<string, SubmissionProgress>();
|
||||
const scores = studentIds.length && weekIds.length
|
||||
? await this.prisma.submissionTotalScore.findMany({
|
||||
where: {
|
||||
@@ -452,8 +520,37 @@ export class InstructorService {
|
||||
}));
|
||||
progressByRepositoryId.set(repository.id, progress);
|
||||
}));
|
||||
await Promise.all(teamRepositories.map(async (repository) => {
|
||||
const memberEmails = new Set(
|
||||
(teamGroupsById.get(repository.groupId)?.members ?? []).map((member) => member.email.trim().toLowerCase()),
|
||||
);
|
||||
const progress = await this.readTeamSubmissionProgress(repository.repoOwner, repository.repoName, memberEmails).catch(() => ({
|
||||
commits: [],
|
||||
finalFiles: [],
|
||||
}));
|
||||
progressByTeamRepositoryId.set(repository.id, progress);
|
||||
}));
|
||||
await Promise.all(customRepositories.map(async (repository) => {
|
||||
const progress = await this.readSubmissionProgress(repository.repoOwner, repository.repoName).catch(() => ({
|
||||
commits: [],
|
||||
finalFiles: [],
|
||||
}));
|
||||
progressByCustomRepositoryId.set(repository.id, progress);
|
||||
}));
|
||||
await Promise.all(teamCustomRepositories.map(async (repository) => {
|
||||
const memberEmails = new Set(
|
||||
(teamGroupsById.get(repository.groupId)?.members ?? []).map((member) => member.email.trim().toLowerCase()),
|
||||
);
|
||||
const progress = await this.readTeamSubmissionProgress(repository.repoOwner, repository.repoName, memberEmails).catch(() => ({
|
||||
commits: [],
|
||||
finalFiles: [],
|
||||
}));
|
||||
progressByTeamCustomRepositoryId.set(repository.id, progress);
|
||||
}));
|
||||
|
||||
const items = courses.flatMap((course) => course.weeks.flatMap((week) => week.modules.flatMap((module) => students.map((student) => {
|
||||
const items = courses.flatMap((course) => course.weeks.flatMap((week) => {
|
||||
if (isTeamProjectWeek(course.id, week.week)) return [];
|
||||
return week.modules.flatMap((module) => students.map((student) => {
|
||||
const expectedRepoName = this.buildStudioRepoName(student.email, week.week, module.name);
|
||||
const repository = repositoryByStudentModule.get(`${student.id}:${module.id}`)
|
||||
?? repositoryByStudentRepoName.get(`${student.id}:${expectedRepoName}`);
|
||||
@@ -479,6 +576,7 @@ export class InstructorService {
|
||||
id: week.id,
|
||||
week: week.week,
|
||||
title: week.title,
|
||||
isTeamProject: false,
|
||||
},
|
||||
module: {
|
||||
id: module.id,
|
||||
@@ -498,7 +596,152 @@ export class InstructorService {
|
||||
commits,
|
||||
finalFiles: progress?.finalFiles ?? [],
|
||||
};
|
||||
}))));
|
||||
}));
|
||||
}));
|
||||
const customItems = customRepositories.flatMap((repository) => {
|
||||
const module = repository.customModule;
|
||||
const course = courses.find((candidate) => candidate.id === module.courseId);
|
||||
const week = course?.weeks.find((candidate) => candidate.week === module.week);
|
||||
const student = students.find((candidate) => candidate.id === repository.userId);
|
||||
if (!course || !week || !student || isTeamProjectWeek(course.id, week.week)) return [];
|
||||
const progress = progressByCustomRepositoryId.get(repository.id);
|
||||
const commits = progress?.commits ?? [];
|
||||
const status = commits.length > 0 ? 'SUBMITTED' : 'PENDING';
|
||||
return [{
|
||||
id: `custom:${repository.userId}:${module.id}`,
|
||||
status,
|
||||
submitted: status === 'SUBMITTED',
|
||||
submittedAt: commits[0]?.date ?? null,
|
||||
commitCount: commits.length,
|
||||
score: scoreByStudentWeek.get(`${student.id}:${week.id}`) ?? 0,
|
||||
course: { id: course.id, title: course.title, slug: course.slug },
|
||||
week: { id: week.id, week: week.week, title: week.title, isTeamProject: false },
|
||||
module: {
|
||||
id: module.id,
|
||||
name: module.name,
|
||||
folderName: module.folderName,
|
||||
sortOrder: module.sortOrder,
|
||||
sourceType: 'CUSTOM',
|
||||
},
|
||||
student,
|
||||
repository: {
|
||||
id: repository.id,
|
||||
owner: repository.repoOwner,
|
||||
name: repository.repoName,
|
||||
url: repository.repositoryUrl,
|
||||
},
|
||||
commits,
|
||||
finalFiles: progress?.finalFiles ?? [],
|
||||
}];
|
||||
});
|
||||
const teamItems = courses.flatMap((course) => course.weeks.flatMap((week) => {
|
||||
if (!isTeamProjectWeek(course.id, week.week)) return [];
|
||||
return week.modules.flatMap((module) => teamGroups.map((group) => {
|
||||
const repository = teamRepositoryByGroupModule.get(`${group.id}:${module.id}`);
|
||||
const progress = repository ? progressByTeamRepositoryId.get(repository.id) : null;
|
||||
const commits = progress?.commits ?? [];
|
||||
const submittedAt = commits[0]?.date ?? null;
|
||||
const status = !repository ? 'NOT_INSTALLED' : commits.length > 0 ? 'SUBMITTED' : 'PENDING';
|
||||
const members = group.members.map((member) => {
|
||||
const memberCommits = commits.filter((commit) => commit.authorEmail.trim().toLowerCase() === member.email.trim().toLowerCase());
|
||||
return {
|
||||
...member,
|
||||
commitCount: memberCommits.length,
|
||||
lastCommittedAt: memberCommits[0]?.date ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: `team:${group.id}:${module.id}`,
|
||||
status,
|
||||
submitted: status === 'SUBMITTED',
|
||||
submittedAt,
|
||||
commitCount: commits.length,
|
||||
course: {
|
||||
id: course.id,
|
||||
title: course.title,
|
||||
slug: course.slug,
|
||||
},
|
||||
week: {
|
||||
id: week.id,
|
||||
week: week.week,
|
||||
title: week.title,
|
||||
isTeamProject: true,
|
||||
},
|
||||
module: {
|
||||
id: module.id,
|
||||
name: module.name,
|
||||
folderName: module.folderName,
|
||||
sortOrder: module.sortOrder,
|
||||
},
|
||||
team: {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
members,
|
||||
},
|
||||
student: {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
email: '',
|
||||
group: group.name,
|
||||
},
|
||||
repository: repository
|
||||
? {
|
||||
id: repository.id,
|
||||
owner: repository.repoOwner,
|
||||
name: repository.repoName,
|
||||
url: repository.repositoryUrl,
|
||||
}
|
||||
: null,
|
||||
commits,
|
||||
finalFiles: progress?.finalFiles ?? [],
|
||||
};
|
||||
}));
|
||||
}));
|
||||
const teamCustomItems = teamCustomRepositories.flatMap((repository) => {
|
||||
const module = repository.customModule;
|
||||
const course = courses.find((candidate) => candidate.id === module.courseId);
|
||||
const week = course?.weeks.find((candidate) => candidate.week === module.week);
|
||||
const group = teamGroupsById.get(repository.groupId);
|
||||
if (!course || !week || !group || !isTeamProjectWeek(course.id, week.week)) return [];
|
||||
const progress = progressByTeamCustomRepositoryId.get(repository.id);
|
||||
const commits = progress?.commits ?? [];
|
||||
const members = group.members.map((member) => {
|
||||
const memberCommits = commits.filter((commit) => commit.authorEmail.trim().toLowerCase() === member.email.trim().toLowerCase());
|
||||
return {
|
||||
...member,
|
||||
commitCount: memberCommits.length,
|
||||
lastCommittedAt: memberCommits[0]?.date ?? null,
|
||||
};
|
||||
});
|
||||
return [{
|
||||
id: `team-custom:${group.id}:${module.id}`,
|
||||
status: commits.length > 0 ? 'SUBMITTED' : 'PENDING',
|
||||
submitted: commits.length > 0,
|
||||
submittedAt: commits[0]?.date ?? null,
|
||||
commitCount: commits.length,
|
||||
course: { id: course.id, title: course.title, slug: course.slug },
|
||||
week: { id: week.id, week: week.week, title: week.title, isTeamProject: true },
|
||||
module: {
|
||||
id: module.id,
|
||||
name: module.name,
|
||||
folderName: module.folderName,
|
||||
sortOrder: module.sortOrder,
|
||||
sourceType: 'CUSTOM',
|
||||
},
|
||||
team: { id: group.id, name: group.name, members },
|
||||
student: { id: group.id, name: group.name, email: '', group: group.name },
|
||||
repository: {
|
||||
id: repository.id,
|
||||
owner: repository.repoOwner,
|
||||
name: repository.repoName,
|
||||
url: repository.repositoryUrl,
|
||||
},
|
||||
commits,
|
||||
finalFiles: progress?.finalFiles ?? [],
|
||||
}];
|
||||
});
|
||||
const allItems = [...items, ...customItems, ...teamItems, ...teamCustomItems];
|
||||
|
||||
return {
|
||||
organization: organizationName
|
||||
@@ -508,20 +751,21 @@ export class InstructorService {
|
||||
}
|
||||
: null,
|
||||
summary: {
|
||||
totalItems: items.length,
|
||||
submittedItems: items.filter((item) => item.status === 'SUBMITTED').length,
|
||||
pendingItems: items.filter((item) => item.status === 'PENDING').length,
|
||||
notInstalledItems: items.filter((item) => item.status === 'NOT_INSTALLED').length,
|
||||
totalCommits: items.reduce((count, item) => count + item.commitCount, 0),
|
||||
totalItems: allItems.length,
|
||||
submittedItems: allItems.filter((item) => item.status === 'SUBMITTED').length,
|
||||
pendingItems: allItems.filter((item) => item.status === 'PENDING').length,
|
||||
notInstalledItems: allItems.filter((item) => item.status === 'NOT_INSTALLED').length,
|
||||
totalCommits: allItems.reduce((count, item) => count + item.commitCount, 0),
|
||||
},
|
||||
courses: courses.map((course) => ({
|
||||
id: course.id,
|
||||
title: course.title,
|
||||
slug: course.slug,
|
||||
weeks: course.weeks.map((week) => ({
|
||||
id: week.id,
|
||||
week: week.week,
|
||||
title: week.title,
|
||||
id: course.id,
|
||||
title: course.title,
|
||||
slug: course.slug,
|
||||
weeks: course.weeks.map((week) => ({
|
||||
id: week.id,
|
||||
week: week.week,
|
||||
title: week.title,
|
||||
isTeamProject: isTeamProjectWeek(course.id, week.week),
|
||||
modules: week.modules.map((module) => ({
|
||||
id: module.id,
|
||||
name: module.name,
|
||||
@@ -531,7 +775,48 @@ export class InstructorService {
|
||||
})),
|
||||
})),
|
||||
students,
|
||||
items,
|
||||
items: [...items, ...customItems],
|
||||
teamItems: [...teamItems, ...teamCustomItems],
|
||||
};
|
||||
}
|
||||
|
||||
async getSubmissionFile(
|
||||
accessToken: string | undefined,
|
||||
input: {
|
||||
owner?: string;
|
||||
repoName?: string;
|
||||
path?: string;
|
||||
baseRef?: string;
|
||||
headRef?: string;
|
||||
},
|
||||
) {
|
||||
const { authUser } = await this.getInstructor(accessToken);
|
||||
const organizationId = authUser.primaryAffiliation?.organizationId;
|
||||
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
||||
const owner = input.owner?.trim();
|
||||
const repoName = input.repoName?.trim();
|
||||
const path = input.path?.trim();
|
||||
const baseRef = this.normalizeGitRef(input.baseRef);
|
||||
const headRef = this.normalizeGitRef(input.headRef);
|
||||
|
||||
if (!owner || !repoName || !path || (!baseRef && !headRef)) {
|
||||
throw new BadRequestException('저장소와 파일 정보를 확인할 수 없습니다.');
|
||||
}
|
||||
if (path.split('/').some((part) => !part || part === '.' || part === '..')) {
|
||||
throw new BadRequestException('파일 경로가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
await this.assertInstructorRepositoryAccess(organizationId, organizationName, owner, repoName);
|
||||
const [original, modified] = await Promise.all([
|
||||
baseRef ? this.readGiteaFileAtRef(owner, repoName, path, baseRef) : null,
|
||||
headRef ? this.readGiteaFileAtRef(owner, repoName, path, headRef) : null,
|
||||
]);
|
||||
|
||||
return {
|
||||
original: original ?? '',
|
||||
modified: modified ?? '',
|
||||
originalAvailable: original !== null,
|
||||
modifiedAvailable: modified !== null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1009,6 +1294,16 @@ export class InstructorService {
|
||||
const agents = boundAgentValues.length
|
||||
? await this.platformGatewayClient.listAgents(authUser.email).catch(() => [])
|
||||
: [];
|
||||
const agentConnectionByPlatform = new Map(await Promise.all(platforms.map(async (platform) => {
|
||||
const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname');
|
||||
const agentOwnerEmail = this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail');
|
||||
const gatewayPlatformId = this.readStringSetting(platform.studioSettingsOverride, 'gatewayPlatformId')
|
||||
?? agentHostname;
|
||||
const connection = gatewayPlatformId
|
||||
? await this.platformGatewayClient.getPlatformStatus(gatewayPlatformId, agentOwnerEmail ?? authUser.email).catch(() => null)
|
||||
: null;
|
||||
return [platform.id, connection] as const;
|
||||
})));
|
||||
|
||||
const responsePlatforms = platforms.map((platform) => {
|
||||
const assignedGroup = groupByPlatform.get(platform.id);
|
||||
@@ -1021,6 +1316,7 @@ export class InstructorService {
|
||||
hostname: agentHostname,
|
||||
ownerEmail: agentOwnerEmail,
|
||||
});
|
||||
const agentConnection = agentConnectionByPlatform.get(platform.id);
|
||||
return {
|
||||
id: platform.id,
|
||||
masterPlatformId: platform.platformId,
|
||||
@@ -1028,7 +1324,9 @@ export class InstructorService {
|
||||
type: this.toPlatformType(platform.platform.displayName),
|
||||
status: activeControl ? '활성' : '대기',
|
||||
battery: platform.battery,
|
||||
uptime: this.formatUptime(platform.uptimeMinutes),
|
||||
uptime: typeof agentConnection?.agentUptimeSeconds === 'number'
|
||||
? this.formatUptimeSeconds(agentConnection.agentUptimeSeconds)
|
||||
: this.formatUptime(platform.uptimeMinutes),
|
||||
speed: `${Number(platform.speedMeters).toFixed(1)} m/s`,
|
||||
errors: platform.errorCount,
|
||||
group: assignedGroup?.name ?? null,
|
||||
@@ -1146,9 +1444,6 @@ export class InstructorService {
|
||||
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) => {
|
||||
@@ -1188,8 +1483,8 @@ export class InstructorService {
|
||||
references: week.references.filter((reference) => reference.kind === 'REFERENCE').map((reference) => this.serializeWeekReference(reference, week.week)),
|
||||
exampleCodes: week.references.filter((reference) => reference.kind === 'EXAMPLE_CODE').map((reference) => this.serializeWeekReference(reference, week.week)),
|
||||
deliverable: override?.deliverable ?? week.deliverable,
|
||||
requiresSourceSubmission: override?.requiresSourceSubmission ?? week.requiresSourceSubmission,
|
||||
},
|
||||
isTeamProject: override?.isTeamProject ?? false,
|
||||
modules: week.modules.map((module) => ({
|
||||
id: module.id,
|
||||
sortOrder: module.sortOrder,
|
||||
@@ -1201,16 +1496,15 @@ export class InstructorService {
|
||||
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,
|
||||
};
|
||||
}),
|
||||
environmentProfiles: environmentProfiles
|
||||
.filter((profile) => profile.week === week.week)
|
||||
.map((profile) => ({
|
||||
id: profile.id,
|
||||
platformKey: profile.platformKey,
|
||||
profileType: profile.profileType,
|
||||
name: profile.name,
|
||||
config: profile.config,
|
||||
})),
|
||||
visualizationPlatforms: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => ({
|
||||
key: selection.platform.key,
|
||||
type: selection.platform.type,
|
||||
@@ -1249,8 +1543,8 @@ export class InstructorService {
|
||||
practiceContents: this.practiceContentsArray(override.practiceContents) ?? [],
|
||||
references: [],
|
||||
deliverable: override.deliverable ?? '',
|
||||
requiresSourceSubmission: override.requiresSourceSubmission ?? true,
|
||||
},
|
||||
isTeamProject: override.isTeamProject ?? false,
|
||||
modules: [],
|
||||
visualizationPlatforms: (visualizationSelectionsByWeek.get(override.week) ?? []).map((selection) => ({
|
||||
key: selection.platform.key,
|
||||
@@ -1421,32 +1715,55 @@ export class InstructorService {
|
||||
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 logicalEnvironment = item.platformKey.toLowerCase();
|
||||
const isLogicalEnvironment = logicalEnvironment === 'local' || logicalEnvironment === 'platform';
|
||||
const isPlatformProfile = (profile: { profileType: string; config: Prisma.JsonValue | null }) => (
|
||||
profile.profileType.toUpperCase() === 'PLATFORM'
|
||||
|| (profile.config && typeof profile.config === 'object' && !Array.isArray(profile.config)
|
||||
&& (profile.config as Prisma.JsonObject).target === 'remote-jetson')
|
||||
);
|
||||
const wantsPlatform = logicalEnvironment === 'platform';
|
||||
const selection = isLogicalEnvironment
|
||||
? selections.find((candidate) => wantsPlatform === isPlatformProfile({
|
||||
profileType: candidate.platform.type,
|
||||
config: candidate.platform.defaultConfig,
|
||||
}))
|
||||
: selectionsByPlatformKey.get(item.platformKey);
|
||||
const existingProfile = isLogicalEnvironment
|
||||
? profiles.find((profile) => wantsPlatform === isPlatformProfile(profile))
|
||||
: profilesByPlatformKey.get(item.platformKey);
|
||||
if (!isLogicalEnvironment && !selection && !existingProfile) {
|
||||
throw new BadRequestException('선택한 환경 프로필을 찾을 수 없습니다.');
|
||||
}
|
||||
const resolvedPlatformKey = existingProfile?.platformKey
|
||||
?? selection?.platformKey
|
||||
?? (wantsPlatform ? 'futuredrive-platform' : 'futuredrive-local-pc');
|
||||
const profileType = existingProfile?.profileType
|
||||
?? selection?.platform.type
|
||||
?? (wantsPlatform ? 'PLATFORM' : 'LOCAL');
|
||||
const profileName = existingProfile?.name
|
||||
?? selection?.platform.displayName
|
||||
?? (wantsPlatform ? '플랫폼' : '로컬');
|
||||
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)
|
||||
const defaultConfig = selection?.platform.defaultConfig && typeof selection.platform.defaultConfig === 'object' && !Array.isArray(selection.platform.defaultConfig)
|
||||
? selection.platform.defaultConfig as Prisma.JsonObject
|
||||
: {};
|
||||
: wantsPlatform ? { target: 'remote-jetson' } : { target: 'local' };
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
...existingConfig,
|
||||
moduleSet: {
|
||||
target: defaultConfig.target === 'remote-jetson' ? 'platform' : 'local',
|
||||
target: wantsPlatform ? 'platform' : 'local',
|
||||
includeUnlisted: false,
|
||||
modules: item.modules.map((module) => ({
|
||||
id: module.id,
|
||||
@@ -1460,21 +1777,21 @@ export class InstructorService {
|
||||
courseId_week_platformKey: {
|
||||
courseId: course.id,
|
||||
week: input.activeWeek,
|
||||
platformKey: item.platformKey,
|
||||
platformKey: resolvedPlatformKey,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
courseId: course.id,
|
||||
week: input.activeWeek,
|
||||
platformKey: item.platformKey,
|
||||
profileType: selection.platform.type,
|
||||
name: selection.platform.displayName,
|
||||
platformKey: resolvedPlatformKey,
|
||||
profileType,
|
||||
name: profileName,
|
||||
config,
|
||||
enabled: true,
|
||||
},
|
||||
update: {
|
||||
profileType: selection.platform.type,
|
||||
name: selection.platform.displayName,
|
||||
profileType,
|
||||
name: profileName,
|
||||
config,
|
||||
enabled: true,
|
||||
},
|
||||
@@ -1490,7 +1807,7 @@ export class InstructorService {
|
||||
selectedTagLabel = tagOption.label;
|
||||
}
|
||||
|
||||
if (!targetWeek || input.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.requiresSourceSubmission !== undefined || input.learningSteps || input.topics || input.topicDetails || input.topicVideoUrls || input.practiceContents || input.disabledModuleIds) {
|
||||
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) {
|
||||
await this.prisma.instructorCurriculumWeekOverride.upsert({
|
||||
where: {
|
||||
organizationName_courseId_week: {
|
||||
@@ -1506,7 +1823,7 @@ export class InstructorService {
|
||||
duration: this.optionalTrim(input.duration) ?? existingOverride?.duration ?? targetWeek?.duration ?? '-',
|
||||
overview: this.optionalTrim(input.overview) ?? existingOverride?.overview ?? targetWeek?.overview ?? '',
|
||||
deliverable: this.optionalTrim(input.deliverable) ?? existingOverride?.deliverable ?? targetWeek?.deliverable ?? '',
|
||||
requiresSourceSubmission: input.requiresSourceSubmission ?? existingOverride?.requiresSourceSubmission ?? targetWeek?.requiresSourceSubmission ?? true,
|
||||
isTeamProject: input.isTeamProject ?? existingOverride?.isTeamProject ?? false,
|
||||
learningSteps,
|
||||
topics: topicTitles,
|
||||
topicDetails: learningSteps.map((step) => step.description),
|
||||
@@ -1527,7 +1844,7 @@ export class InstructorService {
|
||||
duration: this.optionalTrim(input.duration) ?? targetWeek?.duration ?? '-',
|
||||
overview: this.optionalTrim(input.overview) ?? targetWeek?.overview ?? '',
|
||||
deliverable: this.optionalTrim(input.deliverable) ?? targetWeek?.deliverable ?? '',
|
||||
requiresSourceSubmission: input.requiresSourceSubmission ?? targetWeek?.requiresSourceSubmission ?? true,
|
||||
isTeamProject: input.isTeamProject ?? false,
|
||||
learningSteps,
|
||||
topics: topicTitles,
|
||||
topicDetails: learningSteps.map((step) => step.description),
|
||||
@@ -3320,6 +3637,10 @@ export class InstructorService {
|
||||
return rest > 0 ? `${hours}시간 ${rest}분` : `${hours}시간`;
|
||||
}
|
||||
|
||||
private formatUptimeSeconds(seconds: number) {
|
||||
return this.formatUptime(Math.floor(Math.max(0, seconds) / 60));
|
||||
}
|
||||
|
||||
private toBoundedInt(
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
@@ -3415,6 +3736,20 @@ export class InstructorService {
|
||||
};
|
||||
}
|
||||
|
||||
private async readTeamSubmissionProgress(
|
||||
owner: string,
|
||||
repoName: string,
|
||||
memberEmails: Set<string>,
|
||||
): Promise<SubmissionProgress> {
|
||||
const commits = await this.readSubmissionCommits(owner, repoName, (commit) => (
|
||||
memberEmails.has(commit.authorEmail.trim().toLowerCase()) || commit.message.includes('팀 반영')
|
||||
));
|
||||
return {
|
||||
commits,
|
||||
finalFiles: await this.readFinalSubmissionFiles(owner, repoName, commits),
|
||||
};
|
||||
}
|
||||
|
||||
private buildStudioRepoName(studentEmail: string, week: number, moduleName: string) {
|
||||
return `${this.slug(studentEmail.split('@')[0] || studentEmail)}-week-${week}-${this.slug(moduleName)}`;
|
||||
}
|
||||
@@ -3427,7 +3762,11 @@ export class InstructorService {
|
||||
.replace(/^-+|-+$/g, '') || 'workspace';
|
||||
}
|
||||
|
||||
private async readSubmissionCommits(owner: string, repoName: string): Promise<SubmissionCommit[]> {
|
||||
private async readSubmissionCommits(
|
||||
owner: string,
|
||||
repoName: string,
|
||||
includeCommit: (commit: Omit<SubmissionCommit, 'files'>) => boolean = (commit) => this.isSubmissionCommit(commit),
|
||||
): Promise<SubmissionCommit[]> {
|
||||
const commits = await this.fetchGiteaJson<unknown[]>(
|
||||
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits?limit=50`,
|
||||
).catch(() => []);
|
||||
@@ -3435,7 +3774,7 @@ export class InstructorService {
|
||||
const submissionCommits = commits
|
||||
.map((commit) => this.normalizeGiteaCommit(commit))
|
||||
.filter((commit): commit is Omit<SubmissionCommit, 'files'> => Boolean(commit))
|
||||
.filter((commit) => this.isSubmissionCommit(commit))
|
||||
.filter(includeCommit)
|
||||
.slice(0, 20);
|
||||
|
||||
return Promise.all(submissionCommits.map(async (commit) => ({
|
||||
@@ -3456,7 +3795,13 @@ export class InstructorService {
|
||||
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/compare/${encodeURIComponent(baseHash)}...${encodeURIComponent(latest.hash)}.diff`,
|
||||
`/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/compare/${encodeURIComponent(baseHash)}...${encodeURIComponent(latest.hash)}.diff`,
|
||||
]).catch(() => '');
|
||||
const diffFiles = this.parseDiffFiles(diffText).filter((file) => !this.isIgnoredSubmissionFile(file.path));
|
||||
const diffFiles = this.parseDiffFiles(diffText)
|
||||
.filter((file) => !this.isIgnoredSubmissionFile(file.path))
|
||||
.map((file) => ({
|
||||
...file,
|
||||
originalRef: baseHash,
|
||||
modifiedRef: latest.hash,
|
||||
}));
|
||||
if (diffFiles.length) return diffFiles;
|
||||
}
|
||||
|
||||
@@ -3488,6 +3833,10 @@ export class InstructorService {
|
||||
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}`,
|
||||
]).catch(() => null);
|
||||
const files = Array.isArray(detail?.files) ? detail.files : [];
|
||||
const parent = Array.isArray(detail?.parents) ? detail.parents[0] : null;
|
||||
const parentHash = parent && typeof parent === 'object'
|
||||
? String((parent as Record<string, unknown>).sha ?? (parent as Record<string, unknown>).id ?? '') || null
|
||||
: null;
|
||||
const diffText = await this.fetchGiteaText([
|
||||
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/git/commits/${encodeURIComponent(hash)}.diff`,
|
||||
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}.diff`,
|
||||
@@ -3506,6 +3855,8 @@ export class InstructorService {
|
||||
path,
|
||||
status: String(item.status ?? diffFile?.status ?? 'modified'),
|
||||
patch: String(item.patch ?? '') || diffFile?.patch || '',
|
||||
originalRef: parentHash,
|
||||
modifiedRef: hash,
|
||||
};
|
||||
}).filter((file) => file.path && !this.isIgnoredSubmissionFile(file.path));
|
||||
}
|
||||
@@ -3570,6 +3921,74 @@ export class InstructorService {
|
||||
return '';
|
||||
}
|
||||
|
||||
private normalizeGitRef(value: string | undefined): string | null {
|
||||
const ref = value?.trim() ?? '';
|
||||
return /^[a-f0-9]{7,64}$/i.test(ref) ? ref : null;
|
||||
}
|
||||
|
||||
private async assertInstructorRepositoryAccess(
|
||||
organizationId: string | undefined,
|
||||
organizationName: string | undefined,
|
||||
repoOwner: string,
|
||||
repoName: string,
|
||||
) {
|
||||
if (!organizationId && !organizationName) {
|
||||
throw new ForbiddenException('소속 기관 정보를 확인할 수 없습니다.');
|
||||
}
|
||||
|
||||
const organizationFilter = [
|
||||
organizationId ? { organizationId } : undefined,
|
||||
organizationName ? { organizationName } : undefined,
|
||||
].filter(Boolean) as Prisma.StudentEnrollmentWhereInput[];
|
||||
const groupFilter = [
|
||||
organizationId ? { organizationId } : undefined,
|
||||
organizationName ? { organizationName } : undefined,
|
||||
].filter(Boolean) as Prisma.StudentGroupWhereInput[];
|
||||
const repositoryFilter = { repoOwner, repoName };
|
||||
const [studentRepository, studentCustomRepository, teamRepository, teamCustomRepository] = await Promise.all([
|
||||
this.prisma.studioRepository.findFirst({
|
||||
where: {
|
||||
...repositoryFilter,
|
||||
user: { enrollments: { some: { OR: organizationFilter } } },
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
this.prisma.studentCustomModuleRepository.findFirst({
|
||||
where: {
|
||||
...repositoryFilter,
|
||||
user: { enrollments: { some: { OR: organizationFilter } } },
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
this.prisma.teamStudioRepository.findFirst({
|
||||
where: {
|
||||
...repositoryFilter,
|
||||
group: { OR: groupFilter },
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
this.prisma.teamCustomModuleRepository.findFirst({
|
||||
where: {
|
||||
...repositoryFilter,
|
||||
group: { OR: groupFilter },
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!studentRepository && !studentCustomRepository && !teamRepository && !teamCustomRepository) {
|
||||
throw new ForbiddenException('제출 저장소에 접근할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
private async readGiteaFileAtRef(owner: string, repoName: string, path: string, ref: string): Promise<string | null> {
|
||||
const encodedPath = path.split('/').map((part) => encodeURIComponent(part)).join('/');
|
||||
const response = await this.fetchGitea(
|
||||
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/raw/${encodedPath}?ref=${encodeURIComponent(ref)}`,
|
||||
).catch(() => null);
|
||||
return response ? response.text() : null;
|
||||
}
|
||||
|
||||
private async fetchGitea(path: string): Promise<Response> {
|
||||
const giteaBaseUrl = this.configService
|
||||
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
|
||||
|
||||
Reference in New Issue
Block a user