diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 44309cf91b..276729bb32 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -3905,6 +3905,91 @@ export interface MentorStudentDto { */ 'repoUrl': string | null; } +/** + * + * @export + * @interface MentorStudentSummaryDto + */ +export interface MentorStudentSummaryDto { + /** + * + * @type {number} + * @memberof MentorStudentSummaryDto + */ + 'id': number; + /** + * + * @type {string} + * @memberof MentorStudentSummaryDto + */ + 'githubId': string; + /** + * + * @type {string} + * @memberof MentorStudentSummaryDto + */ + 'name': string; + /** + * + * @type {boolean} + * @memberof MentorStudentSummaryDto + */ + 'isActive': boolean; + /** + * + * @type {string} + * @memberof MentorStudentSummaryDto + */ + 'cityName': string; + /** + * + * @type {string} + * @memberof MentorStudentSummaryDto + */ + 'countryName': string; + /** + * + * @type {Array} + * @memberof MentorStudentSummaryDto + */ + 'students': Array; + /** + * + * @type {string} + * @memberof MentorStudentSummaryDto + */ + 'contactsEmail': string | null; + /** + * + * @type {string} + * @memberof MentorStudentSummaryDto + */ + 'contactsPhone': string | null; + /** + * + * @type {string} + * @memberof MentorStudentSummaryDto + */ + 'contactsSkype': string | null; + /** + * + * @type {string} + * @memberof MentorStudentSummaryDto + */ + 'contactsTelegram': string | null; + /** + * + * @type {string} + * @memberof MentorStudentSummaryDto + */ + 'contactsNotes': string | null; + /** + * + * @type {string} + * @memberof MentorStudentSummaryDto + */ + 'contactsWhatsApp': string | null; +} /** * * @export @@ -4449,6 +4534,25 @@ export interface PutInterviewFeedbackDto { */ 'score'?: number; } +/** + * + * @export + * @interface ResultDto + */ +export interface ResultDto { + /** + * + * @type {number} + * @memberof ResultDto + */ + 'score'?: number; + /** + * + * @type {number} + * @memberof ResultDto + */ + 'courseTaskId'?: number; +} /** * * @export @@ -5230,6 +5334,49 @@ export interface StudentId { */ 'id': number; } +/** + * + * @export + * @interface StudentSummaryDto + */ +export interface StudentSummaryDto { + /** + * + * @type {number} + * @memberof StudentSummaryDto + */ + 'totalScore': number; + /** + * + * @type {Array} + * @memberof StudentSummaryDto + */ + 'results': Array; + /** + * + * @type {boolean} + * @memberof StudentSummaryDto + */ + 'isActive': boolean; + /** + * + * @type {MentorStudentSummaryDto} + * @memberof StudentSummaryDto + */ + 'mentor': MentorStudentSummaryDto | null; + /** + * + * @type {number} + * @memberof StudentSummaryDto + */ + 'rank': number; + /** + * + * @type {string} + * @memberof StudentSummaryDto + */ + 'repository': string | null; +} /** * * @export @@ -15915,6 +16062,43 @@ export const StudentsApiAxiosParamCreator = function (configuration?: Configurat + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {number} courseId + * @param {string} githubId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getStudentSummary: async (courseId: number, githubId: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'courseId' is not null or undefined + assertParamExists('getStudentSummary', 'courseId', courseId) + // verify required parameter 'githubId' is not null or undefined + assertParamExists('getStudentSummary', 'githubId', githubId) + const localVarPath = `/courses/{courseId}/students/{githubId}/summary` + .replace(`{${"courseId"}}`, encodeURIComponent(String(courseId))) + .replace(`{${"githubId"}}`, encodeURIComponent(String(githubId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -16012,6 +16196,17 @@ export const StudentsApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getStudent(studentId, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {number} courseId + * @param {string} githubId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getStudentSummary(courseId: number, githubId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getStudentSummary(courseId, githubId, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} current @@ -16047,6 +16242,16 @@ export const StudentsApiFactory = function (configuration?: Configuration, baseP getStudent(studentId: number, options?: any): AxiosPromise { return localVarFp.getStudent(studentId, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {number} courseId + * @param {string} githubId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getStudentSummary(courseId: number, githubId: string, options?: any): AxiosPromise { + return localVarFp.getStudentSummary(courseId, githubId, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} current @@ -16083,6 +16288,18 @@ export class StudentsApi extends BaseAPI { return StudentsApiFp(this.configuration).getStudent(studentId, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {number} courseId + * @param {string} githubId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof StudentsApi + */ + public getStudentSummary(courseId: number, githubId: string, options?: AxiosRequestConfig) { + return StudentsApiFp(this.configuration).getStudentSummary(courseId, githubId, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {string} current diff --git a/client/src/modules/Home/components/HomeSummary/index.tsx b/client/src/modules/Home/components/HomeSummary/index.tsx index 7c615287ab..c2117ccce7 100644 --- a/client/src/modules/Home/components/HomeSummary/index.tsx +++ b/client/src/modules/Home/components/HomeSummary/index.tsx @@ -1,10 +1,10 @@ import { Card, Col, Row, Statistic, Typography } from 'antd'; +import { StudentSummaryDto } from 'api'; import { GithubUserLink } from 'components/GithubUserLink'; import * as React from 'react'; -import { StudentSummary } from 'services/course'; type Props = { - summary: StudentSummary; + summary: StudentSummaryDto; courseTasks: { id: number }[]; }; @@ -19,7 +19,7 @@ export function HomeSummary({ summary, courseTasks }: Props) { contactsNotes, contactsWhatsApp, } = summary.mentor ?? {}; - const tasksCount = summary.results.filter(r => r.score > 0).length; + const tasksCount = summary.results.filter(r => Number(r.score) > 0).length; const totalTaskCount = courseTasks.length; const contacts = [ diff --git a/client/src/modules/Home/data/loadHomeData.ts b/client/src/modules/Home/data/loadHomeData.ts index 93816df9ec..8dfc8dff16 100644 --- a/client/src/modules/Home/data/loadHomeData.ts +++ b/client/src/modules/Home/data/loadHomeData.ts @@ -1,9 +1,9 @@ import { CoursesTasksApi } from 'api'; import { CourseService } from 'services/course'; -export async function loadHomeData(courseId: number) { +export async function loadHomeData(courseId: number, githubId: string) { const [studentSummary, { data: courseTasks }] = await Promise.all([ - new CourseService(courseId).getStudentSummary('me'), + new CourseService(courseId).getStudentSummary(githubId), new CoursesTasksApi().getCourseTasks(courseId), ]); return { diff --git a/client/src/modules/Home/hooks/useStudentSummary.tsx b/client/src/modules/Home/hooks/useStudentSummary.tsx index 428e58375c..47ab4e909b 100644 --- a/client/src/modules/Home/hooks/useStudentSummary.tsx +++ b/client/src/modules/Home/hooks/useStudentSummary.tsx @@ -1,18 +1,18 @@ +import { StudentSummaryDto } from 'api'; import { Session } from 'components/withSession'; import { isStudent } from 'domain/user'; import { loadHomeData } from 'modules/Home/data/loadHomeData'; import { useState } from 'react'; import { useAsync } from 'react-use'; -import { StudentSummary } from 'services/course'; import { Course } from 'services/models'; export function useStudentSummary(session: Session, course: Course | null) { - const [studentSummary, setStudentSummary] = useState(null); + const [studentSummary, setStudentSummary] = useState(null); const [courseTasks, setCourseTasks] = useState<{ id: number }[]>([]); useAsync(async () => { const showData = course && isStudent(session, course.id); - const data = showData ? await loadHomeData(course.id) : null; + const data = showData ? await loadHomeData(course.id, session.githubId) : null; setStudentSummary(data?.studentSummary ?? null); setCourseTasks(data?.courseTasks ?? []); }, [course]); diff --git a/client/src/modules/StudentDashboard/components/MentorCard/MentorCard.tsx b/client/src/modules/StudentDashboard/components/MentorCard/MentorCard.tsx index 2f00b09416..614070401d 100644 --- a/client/src/modules/StudentDashboard/components/MentorCard/MentorCard.tsx +++ b/client/src/modules/StudentDashboard/components/MentorCard/MentorCard.tsx @@ -1,11 +1,11 @@ import { Col, Typography, Row } from 'antd'; -import { MentorBasic } from 'common/models'; import CommonCard from '../CommonDashboardCard'; -import { MentorContact, MentorInfo } from '../MentorInfo'; +import { MentorInfo } from '../MentorInfo'; import { SubmitTaskSolution } from '../SubmitTaskSolution'; +import { MentorStudentSummaryDto } from 'api'; export type MentorCardProps = { - mentor?: (MentorBasic & MentorContact) | null; + mentor?: MentorStudentSummaryDto | null; courseId: number; }; diff --git a/client/src/modules/StudentDashboard/components/MentorInfo/MentorInfo.tsx b/client/src/modules/StudentDashboard/components/MentorInfo/MentorInfo.tsx index 991b0e77a8..6678c1eabd 100644 --- a/client/src/modules/StudentDashboard/components/MentorInfo/MentorInfo.tsx +++ b/client/src/modules/StudentDashboard/components/MentorInfo/MentorInfo.tsx @@ -1,9 +1,9 @@ import { Col, Row, Space, Typography } from 'antd'; import React from 'react'; -import { MentorBasic } from 'common/models'; import GithubFilled from '@ant-design/icons/GithubFilled'; import EnvironmentFilled from '@ant-design/icons/EnvironmentFilled'; import { GithubAvatar } from 'components/GithubAvatar'; +import { MentorStudentSummaryDto } from 'api'; const { Text, Link } = Typography; @@ -15,10 +15,8 @@ export interface MentorContact { contactsNotes?: string; } -type Contact = { name: string; value: string | undefined }; - interface Props { - mentor: MentorBasic & MentorContact; + mentor: MentorStudentSummaryDto; } function MentorInfo({ mentor }: Props) { @@ -42,7 +40,7 @@ function MentorInfo({ mentor }: Props) { { name: 'Notes', value: contactsNotes }, ]; - const filledContacts = contacts.filter(({ value }: Contact) => value); + const filledContacts = contacts.filter(({ value }) => value); return (
diff --git a/client/src/pages/course/student/dashboard.tsx b/client/src/pages/course/student/dashboard.tsx index 1e117f6dd7..6e8867848a 100644 --- a/client/src/pages/course/student/dashboard.tsx +++ b/client/src/pages/course/student/dashboard.tsx @@ -8,7 +8,7 @@ import omitBy from 'lodash/omitBy'; import { LoadingScreen } from 'components/LoadingScreen'; import { PageLayout } from 'components/PageLayout'; -import { CourseService, StudentSummary } from 'services/course'; +import { CourseService } from 'services/course'; import { UserService } from 'services/user'; import { MainStatsCard, @@ -29,6 +29,7 @@ import { CourseScheduleItemDtoStatusEnum, AvailableReviewStatsDto, CourseScheduleItemDtoTypeEnum, + StudentSummaryDto, } from 'api'; import { ActiveCourseProvider, SessionContext, SessionProvider, useActiveCourseContext } from 'modules/Course/contexts'; @@ -44,7 +45,7 @@ function Page() { const courseService = useMemo(() => new CourseService(course.id), [course.id]); const userService = useMemo(() => new UserService(), []); - const [studentSummary, setStudentSummary] = useState({} as StudentSummary); + const [studentSummary, setStudentSummary] = useState(); const [repositoryUrl, setRepositoryUrl] = useState(''); const [courseTasks, setCourseTasks] = useState([]); const [nextEvents, setNextEvent] = useState([] as CourseScheduleItemDto[]); diff --git a/client/src/services/course.ts b/client/src/services/course.ts index c605a9aae6..986e1c6fbd 100644 --- a/client/src/services/course.ts +++ b/client/src/services/course.ts @@ -16,6 +16,8 @@ import { CriteriaDto, CrossCheckMessageDto, CrossCheckCriteriaDataDto, + StudentsApi, + StudentSummaryDto, } from 'api'; import { optionalQueryString } from 'utils/optionalQueryString'; import { Decision } from 'data/interviews/technical-screening'; @@ -140,6 +142,7 @@ export type SearchStudent = UserBasic & { mentor: UserBasic | null }; const courseTasksApi = new CoursesTasksApi(); const courseEventsApi = new CoursesEventsApi(); const studentsScoreApi = new StudentsScoreApi(); +const studentsApi = new StudentsApi(); export class CourseService { private axios: AxiosInstance; @@ -526,9 +529,9 @@ export class CourseService { return result.data; } - async getStudentSummary(githubId: string | 'me') { - const result = await this.axios.get(`/student/${githubId}/summary`); - return result.data.data as StudentSummary; + async getStudentSummary(githubId: string) { + const result = await studentsApi.getStudentSummary(this.courseId, githubId); + return result.data as StudentSummaryDto; } async getStudentScore(githubId: string) { @@ -658,7 +661,6 @@ export interface StudentSummary { totalScore: number; results: { score: number; courseTaskId: number }[]; isActive: boolean; - discord: string; mentor: | (MentorBasic & { contactsEmail?: string; diff --git a/nestjs/src/courses/course-schedule/course-schedule.service.ts b/nestjs/src/courses/course-schedule/course-schedule.service.ts index 316d250146..bedd106d29 100644 --- a/nestjs/src/courses/course-schedule/course-schedule.service.ts +++ b/nestjs/src/courses/course-schedule/course-schedule.service.ts @@ -304,7 +304,11 @@ export class CourseScheduleService { ...(technicalScreeningResults .find(task => task.courseTaskId === courseTaskId) ?.stageInterviewFeedbacks.map(feedback => JSON.parse(feedback.json)) - .map((json: any) => json?.resume?.score ?? 0) ?? []), + .map((json: any) => { + const resumeScore = json?.resume?.score; + const decisionScore = json?.steps?.decision?.values?.finalScore; + return resumeScore ?? decisionScore ?? 0; + }) ?? []), ); const currentScore = isFinite(scoreRaw) ? scoreRaw : null; return currentScore; diff --git a/nestjs/src/courses/course-students/course-students.controller.ts b/nestjs/src/courses/course-students/course-students.controller.ts new file mode 100644 index 0000000000..3d117d57f2 --- /dev/null +++ b/nestjs/src/courses/course-students/course-students.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get, NotFoundException, Param, UseGuards } from '@nestjs/common'; +import { ApiBadRequestResponse, ApiForbiddenResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { DefaultGuard } from '../../auth'; +import { StudentSummaryDto } from './dto/student-summary.dto'; +import { CourseStudentsService } from './course-students.service'; + +@Controller('courses/:courseId/students') +@ApiTags('students') +@UseGuards(DefaultGuard) +export class CourseStudentsController { + constructor(private courseStudentService: CourseStudentsService) {} + + @Get(':githubId/summary') + @ApiForbiddenResponse() + @ApiBadRequestResponse() + @ApiOkResponse({ + type: StudentSummaryDto, + }) + @ApiOperation({ operationId: 'getStudentSummary' }) + public async getStudentSummary(@Param('courseId') courseId: number, @Param('githubId') githubId: string) { + const student = await this.courseStudentService.getStudentByGithubId(courseId, githubId); + + if (student === null) { + throw new NotFoundException(`Student with GitHub id ${githubId} not found`); + } + const [score, mentor] = await Promise.all([ + this.courseStudentService.getStudentScore(student?.id), + student?.mentorId ? await this.courseStudentService.getMentorWithContacts(student.mentorId) : null, + ]); + + return new StudentSummaryDto({ + totalScore: score?.totalScore, + results: score?.results, + rank: score?.rank, + isActive: !student?.isExpelled && !student?.isFailed, + mentor, + repository: student?.repository ?? null, + }); + } +} diff --git a/nestjs/src/courses/course-students/course-students.service.ts b/nestjs/src/courses/course-students/course-students.service.ts new file mode 100644 index 0000000000..fa19361952 --- /dev/null +++ b/nestjs/src/courses/course-students/course-students.service.ts @@ -0,0 +1,179 @@ +import { User } from '@entities/user'; +import { StageInterview, StageInterviewFeedback, Mentor, Student } from '@entities/index'; + +import { TaskInterviewResult } from '@entities/taskInterviewResult'; +import { TaskResult } from '@entities/taskResult'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Mentor as MentorWithContacts } from './dto/mentor-student-summary.dto'; +import { MentorBasic, StageInterviewFeedbackJson } from '@common/models'; + +@Injectable() +export class CourseStudentsService { + constructor( + @InjectRepository(Student) + readonly studentRepository: Repository, + + @InjectRepository(Mentor) + readonly mentorRepository: Repository, + ) {} + + async getStudentByGithubId(courseId: number, githubId: string): Promise { + const record = await this.studentRepository.findOne({ + where: { + courseId, + user: { githubId }, + }, + relations: ['user'], + }); + + if (record == null) { + return null; + } + return record; + } + + public async getStudentScore(studentId: number) { + const student = await this.studentRepository + .createQueryBuilder('student') + .leftJoinAndSelect('student.taskResults', 'taskResults') + .leftJoin('taskResults.courseTask', 'courseTask') + .addSelect(['courseTask.disabled', 'courseTask.id']) + .leftJoinAndSelect('student.taskInterviewResults', 'taskInterviewResults') + .leftJoin('student.stageInterviews', 'si') + .leftJoin('si.stageInterviewFeedbacks', 'sif') + .addSelect([ + 'sif.stageInterviewId', + 'sif.json', + 'si.isCompleted', + 'si.id', + 'si.courseTaskId', + 'si.score', + 'sif.version', + ]) + .where('student.id = :studentId', { studentId }) + .getOne(); + + if (!student) return null; + + const { taskResults, taskInterviewResults, stageInterviews } = student; + + const toTaskScore = ({ courseTaskId, score = 0 }: TaskResult | TaskInterviewResult) => ({ courseTaskId, score }); + + const results = []; + + if (taskResults?.length) { + results.push(...(taskResults.filter(taskResult => !taskResult.courseTask.disabled).map(toTaskScore) ?? [])); + } + + if (taskInterviewResults?.length) { + results.push(...taskInterviewResults.map(toTaskScore)); + } + + // we have a case when technical screening score are set as task result. + if (stageInterviews?.length && !results.find(tr => tr.courseTaskId === stageInterviews[0]?.courseTaskId)) { + const feedbackVersion = stageInterviews[0]?.stageInterviewFeedbacks[0]?.version; + const score = !feedbackVersion + ? Math.floor(getStageInterviewRating(stageInterviews) ?? 0) + : stageInterviews[0]?.score; + + results.push({ + score, + courseTaskId: stageInterviews[0]?.courseTaskId, + }); + } + + return { + totalScore: student.totalScore, + rank: student.rank ?? 999999, + results, + }; + } + + async getMentorWithContacts(mentorId: number): Promise { + const record = await this.mentorRepository.findOne({ + relations: ['user'], + where: { + id: mentorId, + }, + }); + + if (!record) { + throw new NotFoundException(`Mentor not found ${mentorId}`); + } + + const mentor = convertToMentorBasic(record); + const user = record.user as User; + const mentorWithContacts: MentorWithContacts = { + ...mentor, + contactsEmail: user.contactsEmail, + contactsSkype: user.contactsSkype, + contactsWhatsApp: user.contactsWhatsApp, + contactsTelegram: user.contactsTelegram, + contactsNotes: user.contactsNotes, + contactsPhone: null, + }; + return mentorWithContacts; + } +} + +const getStageInterviewRating = (stageInterviews: StageInterview[]) => { + const [lastInterview] = stageInterviews + .filter((interview: StageInterview) => interview.isCompleted) + .map(({ stageInterviewFeedbacks, score }: StageInterview) => + stageInterviewFeedbacks.map((feedback: StageInterviewFeedback) => ({ + date: feedback.updatedDate, + // interviews in new template should have precalculated score + rating: score ?? getInterviewRatings(JSON.parse(feedback.json) as StageInterviewFeedbackJson).rating, + })), + ) + .reduce((acc, cur) => acc.concat(cur), []) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + return lastInterview?.rating ?? null; +}; + +export function getInterviewRatings({ skills, programmingTask, resume }: StageInterviewFeedbackJson) { + const commonSkills = Object.values(skills?.common ?? {}).filter(Boolean) as number[]; + const dataStructuresSkills = Object.values(skills?.dataStructures ?? {}).filter(Boolean) as number[]; + + const htmlCss = skills?.htmlCss.level; + const common = commonSkills.reduce((acc, cur) => acc + cur, 0) / commonSkills.length; + const dataStructures = dataStructuresSkills.reduce((acc, cur) => acc + cur, 0) / dataStructuresSkills.length; + + if (resume?.score !== undefined) { + const rating = resume.score; + return { rating, htmlCss, common, dataStructures }; + } + + const ratingsCount = 4; + const ratings = [htmlCss, common, dataStructures, programmingTask.codeWritingLevel].filter(Boolean) as number[]; + const rating = (ratings.length === ratingsCount ? ratings.reduce((sum, num) => sum + num) / ratingsCount : 0) * 10; + + return { rating, htmlCss, common, dataStructures }; +} + +export function convertToMentorBasic(mentor: Mentor): MentorBasic { + const { user, isExpelled, id, students } = mentor; + return { + isActive: !isExpelled, + name: createName(user), + id: id, + githubId: user.githubId, + students: students ? students.filter(s => !s.isExpelled && !s.isFailed).map(s => ({ id: s.id })) : [], + cityName: user.cityName ?? '', + countryName: user.countryName ?? '', + }; +} + +export function createName({ firstName, lastName }: { firstName: string; lastName: string }) { + const result = []; + if (firstName) { + result.push(firstName.trim()); + } + if (lastName) { + result.push(lastName.trim()); + } + return result.join(' '); +} diff --git a/nestjs/src/courses/course-students/dto/mentor-student-summary.dto.ts b/nestjs/src/courses/course-students/dto/mentor-student-summary.dto.ts new file mode 100644 index 0000000000..4c19d089b1 --- /dev/null +++ b/nestjs/src/courses/course-students/dto/mentor-student-summary.dto.ts @@ -0,0 +1,68 @@ +import { MentorBasic, StudentBasic } from '@common/models'; +import { ApiProperty } from '@nestjs/swagger'; + +export type Mentor = MentorBasic & { + contactsEmail: string | null; + contactsPhone: string | null; + contactsSkype: string | null; + contactsTelegram: string | null; + contactsNotes: string | null; + contactsWhatsApp: string | null; +}; + +export class MentorStudentSummaryDto { + constructor(mentor: Mentor) { + this.id = mentor.id; + this.githubId = mentor.githubId; + this.name = mentor.name; + this.isActive = mentor.isActive; + this.cityName = mentor.cityName; + this.countryName = mentor.countryName; + this.students = mentor.students; + this.contactsEmail = mentor.contactsEmail; + this.contactsPhone = mentor.contactsPhone; + this.contactsSkype = mentor.contactsSkype; + this.contactsTelegram = mentor.contactsTelegram; + this.contactsNotes = mentor.contactsNotes; + this.contactsWhatsApp = mentor.contactsWhatsApp; + } + + @ApiProperty({ type: Number }) + id: number; + + @ApiProperty({ type: String }) + githubId: string; + + @ApiProperty({ type: String }) + name: string; + + @ApiProperty({ type: Boolean }) + isActive: boolean; + + @ApiProperty({ type: String }) + cityName: string; + + @ApiProperty({ type: String }) + countryName: string; + + @ApiProperty({ type: Array }) + students: (StudentBasic | { id: number })[]; + + @ApiProperty({ type: String, nullable: true }) + contactsEmail: string | null; + + @ApiProperty({ type: String, nullable: true }) + contactsPhone: string | null; + + @ApiProperty({ type: String, nullable: true }) + contactsSkype: string | null; + + @ApiProperty({ type: String, nullable: true }) + contactsTelegram: string | null; + + @ApiProperty({ type: String, nullable: true }) + contactsNotes: string | null; + + @ApiProperty({ type: String, nullable: true }) + contactsWhatsApp: string | null; +} diff --git a/nestjs/src/courses/course-students/dto/result.dto.ts b/nestjs/src/courses/course-students/dto/result.dto.ts new file mode 100644 index 0000000000..14754112d4 --- /dev/null +++ b/nestjs/src/courses/course-students/dto/result.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ResultDto { + @ApiProperty({ type: Number, required: false }) + score?: number; + + @ApiProperty({ type: Number, required: false }) + courseTaskId?: number; +} diff --git a/nestjs/src/courses/course-students/dto/student-summary.dto.ts b/nestjs/src/courses/course-students/dto/student-summary.dto.ts new file mode 100644 index 0000000000..63c96e9c9b --- /dev/null +++ b/nestjs/src/courses/course-students/dto/student-summary.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Mentor, MentorStudentSummaryDto } from './mentor-student-summary.dto'; +import { ResultDto } from './result.dto'; + +export interface StudentSummary { + totalScore?: number; + results?: Array; + isActive: boolean; + mentor: Mentor | null; + rank?: number; + repository: string | null; +} + +export class StudentSummaryDto { + constructor(studentSummary: StudentSummary) { + this.totalScore = studentSummary.totalScore ?? 0; + this.results = studentSummary.results ?? []; + this.isActive = studentSummary.isActive; + this.mentor = studentSummary.mentor; + this.rank = studentSummary.rank ?? 999999; + this.repository = studentSummary.repository; + } + + @ApiProperty() + totalScore: number; + + @ApiProperty({ type: ResultDto, isArray: true }) + results: Array; + + @ApiProperty() + isActive: boolean; + + @ApiProperty({ type: MentorStudentSummaryDto, nullable: true }) + mentor: MentorStudentSummaryDto | null; + + @ApiProperty({ type: Number }) + rank: number; + + @ApiProperty({ type: String, nullable: true }) + repository: string | null; +} diff --git a/nestjs/src/courses/courses.module.ts b/nestjs/src/courses/courses.module.ts index a87d00a62d..f7a9152347 100644 --- a/nestjs/src/courses/courses.module.ts +++ b/nestjs/src/courses/courses.module.ts @@ -65,6 +65,8 @@ import { CourseUsersService } from './course-users/course-users.service'; import { CloudApiModule } from 'src/cloud-api/cloud-api.module'; import { SelfEducationService } from './task-verifications/self-education.service'; import { CourseMentorsController, CourseMentorsService } from './course-mentors'; +import { CourseStudentsController } from './course-students/course-students.controller'; +import { CourseStudentsService } from './course-students/course-students.service'; import { MentorReviewsController, MentorReviewsService } from './mentor-reviews'; @Module({ @@ -119,6 +121,7 @@ import { MentorReviewsController, MentorReviewsService } from './mentor-reviews' TaskVerificationsController, CourseUsersController, CourseMentorsController, + CourseStudentsController, MentorReviewsController, ], providers: [ @@ -147,6 +150,7 @@ import { MentorReviewsController, MentorReviewsService } from './mentor-reviews' SelfEducationService, TaskVerificationsService, CourseMentorsService, + CourseStudentsService, MentorReviewsService, ], exports: [CourseTasksService, CourseUsersService, CoursesService, StudentsService], diff --git a/nestjs/src/spec.json b/nestjs/src/spec.json index 198af6371a..0b6434e55f 100644 --- a/nestjs/src/spec.json +++ b/nestjs/src/spec.json @@ -1461,6 +1461,25 @@ "tags": ["course mentors"] } }, + "/courses/{courseId}/students/{githubId}/summary": { + "get": { + "operationId": "getStudentSummary", + "summary": "", + "parameters": [ + { "name": "courseId", "required": true, "in": "path", "schema": { "type": "number" } }, + { "name": "githubId", "required": true, "in": "path", "schema": { "type": "string" } } + ], + "responses": { + "200": { + "description": "", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StudentSummaryDto" } } } + }, + "400": { "description": "" }, + "403": { "description": "" } + }, + "tags": ["students"] + } + }, "/course/{courseId}/mentor-reviews": { "get": { "operationId": "getMentorReviews", @@ -3953,6 +3972,55 @@ "properties": { "id": { "type": "number" }, "githubId": { "type": "string" }, "name": { "type": "string" } }, "required": ["id", "githubId", "name"] }, + "ResultDto": { + "type": "object", + "properties": { "score": { "type": "number" }, "courseTaskId": { "type": "number" } } + }, + "MentorStudentSummaryDto": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "githubId": { "type": "string" }, + "name": { "type": "string" }, + "isActive": { "type": "boolean" }, + "cityName": { "type": "string" }, + "countryName": { "type": "string" }, + "students": { "type": "array", "items": { "type": "string" } }, + "contactsEmail": { "type": "string", "nullable": true }, + "contactsPhone": { "type": "string", "nullable": true }, + "contactsSkype": { "type": "string", "nullable": true }, + "contactsTelegram": { "type": "string", "nullable": true }, + "contactsNotes": { "type": "string", "nullable": true }, + "contactsWhatsApp": { "type": "string", "nullable": true } + }, + "required": [ + "id", + "githubId", + "name", + "isActive", + "cityName", + "countryName", + "students", + "contactsEmail", + "contactsPhone", + "contactsSkype", + "contactsTelegram", + "contactsNotes", + "contactsWhatsApp" + ] + }, + "StudentSummaryDto": { + "type": "object", + "properties": { + "totalScore": { "type": "number" }, + "results": { "type": "array", "items": { "$ref": "#/components/schemas/ResultDto" } }, + "isActive": { "type": "boolean" }, + "mentor": { "nullable": true, "allOf": [{ "$ref": "#/components/schemas/MentorStudentSummaryDto" }] }, + "rank": { "type": "number" }, + "repository": { "type": "string", "nullable": true } + }, + "required": ["totalScore", "results", "isActive", "mentor", "rank", "repository"] + }, "MentorReviewDto": { "type": "object", "properties": { diff --git a/server/src/routes/course/index.ts b/server/src/routes/course/index.ts index c86fed7ba9..5253dce91d 100644 --- a/server/src/routes/course/index.ts +++ b/server/src/routes/course/index.ts @@ -59,7 +59,6 @@ import { createInterviewResult, getCrossMentors, getStudent, - getStudentSummary, postFeedback, selfUpdateStudentStatus, updateMentoringAvailability, @@ -205,7 +204,6 @@ function addStudentApi(router: Router, logger: ILogger) { interviews.createInterviewStudent(logger), ); - router.get('/student/:githubId/summary', courseGuard, ...validators, getStudentSummary(logger)); router.post('/student/:githubId/availability', courseManagerGuard, updateMentoringAvailability(logger)); router.get('/student/:githubId/tasks/cross-mentors', courseGuard, ...validators, getCrossMentors(logger)); router.get('/student/:githubId/tasks/verifications', courseGuard, ...validators, getStudentTaskVerifications(logger)); diff --git a/server/src/routes/course/student.ts b/server/src/routes/course/student.ts index 6999d373ad..f5c0c2d77e 100644 --- a/server/src/routes/course/student.ts +++ b/server/src/routes/course/student.ts @@ -76,28 +76,6 @@ export const postFeedback = (_: ILogger) => async (ctx: Router.RouterContext) => return; }; -export const getStudentSummary = (_: ILogger) => async (ctx: Router.RouterContext) => { - const { courseId, githubId } = ctx.params; - - const student = await courseService.getStudentByGithubId(courseId, githubId); - if (student == null) { - setResponse(ctx, OK, null); - return; - } - - const [score, mentor] = await Promise.all([ - courseService.getStudentScore(student.id), - student.mentorId ? await courseService.getMentorWithContacts(student.mentorId) : null, - ]); - - setResponse(ctx, OK, { - ...score, - isActive: !student.isExpelled && !student.isFailed, - mentor, - repository: student.repository, - }); -}; - export const updateStudent = (_: ILogger) => async (ctx: Router.RouterContext) => { const { courseId, githubId } = ctx.params; const student = await courseService.queryStudentByGithubId(courseId, githubId);