From 716d2ed28e499298117e72d1ca85301ca3d40be1 Mon Sep 17 00:00:00 2001 From: Valery <57412523+valerydluski@users.noreply.github.com> Date: Fri, 28 Jun 2024 10:39:00 +0200 Subject: [PATCH] feat: add certificates countries widget (#2498) --- client/src/api/api.ts | 63 +++++++++++++++++++ .../StudentsCertificatesCountriesCard.tsx | 29 +++++++++ .../index.tsx | 1 + .../hooks/useCourseStats/useCourseStats.tsx | 12 +++- .../pages/CourseStatistics.tsx | 11 ++++ .../courses/stats/course-stats.controller.ts | 14 +++++ .../src/courses/stats/course-stats.service.ts | 21 +++++++ nestjs/src/spec.json | 15 +++++ 8 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 client/src/modules/CourseStatistics/components/StudentsCertificatesCountriesCard/StudentsCertificatesCountriesCard.tsx create mode 100644 client/src/modules/CourseStatistics/components/StudentsCertificatesCountriesCard/index.tsx diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 164392fdf..b37162259 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -8337,6 +8337,39 @@ export const CourseStatsApiAxiosParamCreator = function (configuration?: Configu + 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 {*} [options] Override http request option. + * @throws {RequiredError} + */ + getCourseStudentCertificatesCountries: async (courseId: number, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'courseId' is not null or undefined + assertParamExists('getCourseStudentCertificatesCountries', 'courseId', courseId) + const localVarPath = `/courses/{courseId}/stats/students/certificates/countries` + .replace(`{${"courseId"}}`, encodeURIComponent(String(courseId))); + // 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}; @@ -8456,6 +8489,16 @@ export const CourseStatsApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getCourseStats(courseId, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {number} courseId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getCourseStudentCertificatesCountries(courseId: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getCourseStudentCertificatesCountries(courseId, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {number} courseId @@ -8514,6 +8557,15 @@ export const CourseStatsApiFactory = function (configuration?: Configuration, ba getCourseStats(courseId: number, options?: any): AxiosPromise { return localVarFp.getCourseStats(courseId, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {number} courseId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getCourseStudentCertificatesCountries(courseId: number, options?: any): AxiosPromise { + return localVarFp.getCourseStudentCertificatesCountries(courseId, options).then((request) => request(axios, basePath)); + }, /** * * @param {number} courseId @@ -8576,6 +8628,17 @@ export class CourseStatsApi extends BaseAPI { return CourseStatsApiFp(this.configuration).getCourseStats(courseId, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {number} courseId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CourseStatsApi + */ + public getCourseStudentCertificatesCountries(courseId: number, options?: AxiosRequestConfig) { + return CourseStatsApiFp(this.configuration).getCourseStudentCertificatesCountries(courseId, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {number} courseId diff --git a/client/src/modules/CourseStatistics/components/StudentsCertificatesCountriesCard/StudentsCertificatesCountriesCard.tsx b/client/src/modules/CourseStatistics/components/StudentsCertificatesCountriesCard/StudentsCertificatesCountriesCard.tsx new file mode 100644 index 000000000..00b9c0c5a --- /dev/null +++ b/client/src/modules/CourseStatistics/components/StudentsCertificatesCountriesCard/StudentsCertificatesCountriesCard.tsx @@ -0,0 +1,29 @@ +import { Card } from 'antd'; +import { CountriesStatsDto } from 'api'; +import { Colors } from 'modules/CourseStatistics/data'; +import dynamic from 'next/dynamic'; + +type Props = { + studentsCertificatesCountriesStats: CountriesStatsDto; + certificatesCount: number; +}; + +const CountriesChart = dynamic(() => import('../CountriesChart/CountriesChart'), { + ssr: false, +}); + +export const StudentsCertificatesCountriesCard = ({ studentsCertificatesCountriesStats, certificatesCount }: Props) => { + const { countries } = studentsCertificatesCountriesStats; + return ( + +
+ +
+
+ ); +}; diff --git a/client/src/modules/CourseStatistics/components/StudentsCertificatesCountriesCard/index.tsx b/client/src/modules/CourseStatistics/components/StudentsCertificatesCountriesCard/index.tsx new file mode 100644 index 000000000..9c58c9a29 --- /dev/null +++ b/client/src/modules/CourseStatistics/components/StudentsCertificatesCountriesCard/index.tsx @@ -0,0 +1 @@ +export { StudentsCertificatesCountriesCard } from './StudentsCertificatesCountriesCard'; diff --git a/client/src/modules/CourseStatistics/hooks/useCourseStats/useCourseStats.tsx b/client/src/modules/CourseStatistics/hooks/useCourseStats/useCourseStats.tsx index 202e8af20..bbd1865f8 100644 --- a/client/src/modules/CourseStatistics/hooks/useCourseStats/useCourseStats.tsx +++ b/client/src/modules/CourseStatistics/hooks/useCourseStats/useCourseStats.tsx @@ -8,19 +8,29 @@ const coursesTasksApi = new CoursesTasksApi(); export function useCourseStats(courseId: number) { return useAsync(async () => { try { - const [studentsCountries, studentsStats, mentorsCountries, mentorsStats, courseTasks] = await Promise.all([ + const [ + studentsCountries, + studentsStats, + mentorsCountries, + mentorsStats, + courseTasks, + studentsCertificatesCountries, + ] = await Promise.all([ courseStatsApi.getCourseStudentCountries(courseId), courseStatsApi.getCourseStats(courseId), courseStatsApi.getCourseMentorCountries(courseId), courseStatsApi.getCourseMentors(courseId), coursesTasksApi.getCourseTasks(courseId), + courseStatsApi.getCourseStudentCertificatesCountries(courseId), ]); + return { studentsCountries: studentsCountries.data, studentsStats: studentsStats.data, mentorsCountries: mentorsCountries.data, mentorsStats: mentorsStats.data, courseTasks: courseTasks.data, + studentsCertificatesCountries: studentsCertificatesCountries.data, }; } catch (error) { message.error('Something went wrong, please try to reload the page later'); diff --git a/client/src/modules/CourseStatistics/pages/CourseStatistics.tsx b/client/src/modules/CourseStatistics/pages/CourseStatistics.tsx index bec7c3e06..00c5a12d0 100644 --- a/client/src/modules/CourseStatistics/pages/CourseStatistics.tsx +++ b/client/src/modules/CourseStatistics/pages/CourseStatistics.tsx @@ -11,6 +11,7 @@ import { StudentsWithMentorsCard } from '../components/StudentsWithMentorsCard'; import { StudentsWithCertificateCard } from '../components/StudentsWithCertificateCard'; import { StudentsEligibleForCertificationCard } from '../components/StudentsEligibleForCertificationCard'; import { TaskPerformanceCard } from '../components/TaskPerformanceCard'; +import { StudentsCertificatesCountriesCard } from '../components/StudentsCertificatesCountriesCard'; const gapSize = 24; @@ -70,6 +71,16 @@ function CourseStatistic() { title: 'taskPerformanceCard', component: , }, + stats?.studentsCertificatesCountries && + stats.studentsStats.certifiedStudentsCount && { + title: 'studentsCertificatesCountriesCard', + component: ( + + ), + }, ].filter(Boolean); return ( diff --git a/nestjs/src/courses/stats/course-stats.controller.ts b/nestjs/src/courses/stats/course-stats.controller.ts index d672da2ae..8d84cd7eb 100644 --- a/nestjs/src/courses/stats/course-stats.controller.ts +++ b/nestjs/src/courses/stats/course-stats.controller.ts @@ -74,6 +74,20 @@ export class CourseStatsController { return data; } + @Get('/students/certificates/countries') + @CacheTTL(ONE_HOUR_CACHE_TTL) + @UseInterceptors(CacheInterceptor) + @UseGuards(DefaultGuard, CourseGuard) + @ApiOperation({ operationId: 'getCourseStudentCertificatesCountries' }) + @ApiOkResponse({ type: CountriesStatsDto }) + @ApiBadRequestResponse() + public async getStudentsWithCertificatesCountries( + @Param('courseId', ParseIntPipe) courseId: number, + ): Promise { + const data = await this.courseStatsService.getStudentsWithCertificatesCountries(courseId); + return data; + } + @Get('/task/:taskId/performance') @CacheTTL(ONE_HOUR_CACHE_TTL) @UseInterceptors(CacheInterceptor) diff --git a/nestjs/src/courses/stats/course-stats.service.ts b/nestjs/src/courses/stats/course-stats.service.ts index 8f6ef03ed..0fce7e061 100644 --- a/nestjs/src/courses/stats/course-stats.service.ts +++ b/nestjs/src/courses/stats/course-stats.service.ts @@ -109,6 +109,27 @@ export class CourseStatsService { return this.getCountries(courseId, this.studentRepository); } + public async getStudentsWithCertificatesCountries(courseId: number): Promise<{ countries: CountryStatDto[] }> { + const countries = await this.studentRepository + .createQueryBuilder('student') + .leftJoin('student.user', 'user') + .leftJoin(Certificate, 'certificate', 'certificate.studentId = student.id') + .select('user.countryName', 'countryName') + .addSelect('COUNT(DISTINCT student.id)', 'count') + .where('student.courseId = :courseId', { courseId }) + .andWhere('certificate.publicId IS NOT NULL') + .groupBy('user.countryName') + .orderBy('COUNT(DISTINCT student.id)', 'DESC') + .getRawMany(); + + return { + countries: countries.map(country => ({ + countryName: country.countryName, + count: Number(country.count), + })), + }; + } + private async getCountries( courseId: number, repository: Repository, diff --git a/nestjs/src/spec.json b/nestjs/src/spec.json index 7b99ca12c..30ff738a5 100644 --- a/nestjs/src/spec.json +++ b/nestjs/src/spec.json @@ -743,6 +743,21 @@ "tags": ["course stats"] } }, + "/courses/{courseId}/stats/students/certificates/countries": { + "get": { + "operationId": "getCourseStudentCertificatesCountries", + "summary": "", + "parameters": [{ "name": "courseId", "required": true, "in": "path", "schema": { "type": "number" } }], + "responses": { + "200": { + "description": "", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CountriesStatsDto" } } } + }, + "400": { "description": "" } + }, + "tags": ["course stats"] + } + }, "/courses/{courseId}/stats/task/{taskId}/performance": { "get": { "operationId": "getTaskPerformance",