From aa721c32f2a238f7c7132f4dbd9a753143a2e47e Mon Sep 17 00:00:00 2001 From: Benjamin Ramet Date: Tue, 26 Nov 2024 10:39:06 +0100 Subject: [PATCH 1/5] Add classroom tab in statistics dashboard --- server/controllers/classroom.ts | 21 +++++ .../dashboard-statistics/ClassroomStats.tsx | 86 ++++++++++++++++--- .../filters/ClassroomDropdown.tsx | 8 +- src/services/useClassrooms.ts | 50 +++++++++++ types/classroom.type.ts | 4 + 5 files changed, 153 insertions(+), 16 deletions(-) create mode 100644 src/services/useClassrooms.ts diff --git a/server/controllers/classroom.ts b/server/controllers/classroom.ts index e78a00193..403efb1de 100644 --- a/server/controllers/classroom.ts +++ b/server/controllers/classroom.ts @@ -19,6 +19,27 @@ export const classroomController = new Controller('/classrooms'); * @return {object} Route API JSON response */ +classroomController.get({ path: '', userType: UserType.ADMIN }, async (_req: Request, res: Response) => { + const villageId = _req.query.villageId as string; + try { + const classroomRepository = AppDataSource.getRepository(Classroom); + let classrooms; + if (villageId) { + const query = classroomRepository.createQueryBuilder('classroom'); + query.where('classroom.villageId = :villageId', { villageId }); + + classrooms = await query.getMany(); + //classrooms = await classroomRepository.find({ where: { villageId: +villageId } }); + } else { + classrooms = await classroomRepository.find(); + } + res.sendJSON(classrooms); + } catch (e) { + console.error(e); + res.status(500).sendJSON({ message: 'An error occurred while fetching classrooms' }); + } +}); + classroomController.get({ path: '/:id', userType: UserType.TEACHER }, async (req: Request, res: Response, next: NextFunction) => { const id = parseInt(req.params.id, 10) || 0; const classroom = await AppDataSource.getRepository(Classroom).findOne({ diff --git a/src/components/admin/dashboard-statistics/ClassroomStats.tsx b/src/components/admin/dashboard-statistics/ClassroomStats.tsx index 0932e7138..9fede9382 100644 --- a/src/components/admin/dashboard-statistics/ClassroomStats.tsx +++ b/src/components/admin/dashboard-statistics/ClassroomStats.tsx @@ -1,14 +1,24 @@ import React, { useState } from 'react'; +import { Box, Tab, Tabs } from '@mui/material'; + +import { OneVillageTable } from '../OneVillageTable'; +import TabPanel from './TabPanel'; +import StatsCard from './cards/StatsCard/StatsCard'; import ClassroomDropdown from './filters/ClassroomDropdown'; import CountriesDropdown from './filters/CountriesDropdown'; import PhaseDropdown from './filters/PhaseDropdown'; import VillageDropdown from './filters/VillageDropdown'; -import { mockClassroomsStats } from './mocks/mocks'; import { PelicoCard } from './pelico-card'; import styles from './styles/charts.module.css'; +import { createFamiliesWithoutAccountRows } from './utils/tableCreator'; +import { FamiliesWithoutAccountHeaders } from './utils/tableHeaders'; +import { useGetVillagesStats } from 'src/api/statistics/statistics.get'; +import { useClassrooms } from 'src/services/useClassrooms'; import { useCountries } from 'src/services/useCountries'; import { useVillages } from 'src/services/useVillages'; +import type { ClassroomFilter } from 'types/classroom.type'; +import type { OneVillageTableRow } from 'types/statistics.type'; import type { VillageFilter } from 'types/village.type'; const ClassroomStats = () => { @@ -16,12 +26,16 @@ const ClassroomStats = () => { const [selectedVillage, setSelectedVillage] = useState(''); const [selectedClassroom, setSelectedClassroom] = useState(); const [selectedPhase, setSelectedPhase] = useState('4'); - const [options, setOptions] = useState({ countryIsoCode: '' }); + const [villageFilter, setVillageFilter] = useState({ countryIsoCode: '' }); + const [classroomFilter, setClassroomFilter] = useState({ villageId: '' }); + const [value, setValue] = React.useState(0); const pelicoMessage = 'Merci de sélectionner une classe pour analyser ses statistiques '; const { countries } = useCountries(); - const { villages } = useVillages(options); + const { villages } = useVillages(villageFilter); + const villagesStats = useGetVillagesStats(+selectedVillage, +selectedPhase); + const { classrooms } = useClassrooms(classroomFilter); const handleCountryChange = (country: string) => { setSelectedCountry(country); @@ -30,21 +44,26 @@ const ClassroomStats = () => { }; React.useEffect(() => { - setOptions({ + setVillageFilter({ countryIsoCode: selectedCountry, }); - }, [selectedCountry, selectedPhase]); + setClassroomFilter({ + villageId: selectedVillage, + }); + }, [selectedCountry, selectedPhase, selectedVillage]); + + const [familiesWithoutAccountRows, setFamiliesWithoutAccountRows] = React.useState>([]); + React.useEffect(() => { + if (villagesStats.data?.familiesWithoutAccount) { + setFamiliesWithoutAccountRows(createFamiliesWithoutAccountRows(villagesStats.data?.familiesWithoutAccount)); + } + }, [villagesStats.data?.familiesWithoutAccount]); const handleVillageChange = (village: string) => { setSelectedVillage(village); setSelectedClassroom(''); }; - const classroomsMap = mockClassroomsStats - .filter((classroom) => classroom.villageName === selectedVillage) - .map((classroom) => classroom.classroomId); - const classrooms = [...new Set(classroomsMap)]; - const handleClassroomChange = (classroom: string) => { setSelectedClassroom(classroom); }; @@ -53,9 +72,15 @@ const ClassroomStats = () => { setSelectedPhase(phase); }; + const noDataFoundMessage = 'Pas de données pour le Village-Monde sélectionné'; + + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setValue(newValue); + }; + return ( <> -
+
@@ -68,8 +93,43 @@ const ClassroomStats = () => {
-
- {!selectedClassroom ? : null} + + + + + + +

Statistiques - En classe

+
+ + {!selectedClassroom ? ( + + ) : ( + <> + {noDataFoundMessage}

} + data={familiesWithoutAccountRows} + columns={FamiliesWithoutAccountHeaders} + titleContent={`À surveiller : comptes non créés (${familiesWithoutAccountRows.length})`} + /> + + Nombre de codes enfant créés + Nombre de familles connectées + + + )} +
); }; diff --git a/src/components/admin/dashboard-statistics/filters/ClassroomDropdown.tsx b/src/components/admin/dashboard-statistics/filters/ClassroomDropdown.tsx index a641ccdc4..60196d481 100644 --- a/src/components/admin/dashboard-statistics/filters/ClassroomDropdown.tsx +++ b/src/components/admin/dashboard-statistics/filters/ClassroomDropdown.tsx @@ -7,8 +7,10 @@ import MenuItem from '@mui/material/MenuItem'; import type { SelectChangeEvent } from '@mui/material/Select'; import Select from '@mui/material/Select'; +import type { Classroom } from 'types/classroom.type'; + interface ClassroomDropdownProps { - classrooms: number[]; + classrooms: Classroom[]; onClassroomChange: (classrooms: string) => void; } @@ -27,8 +29,8 @@ export default function ClassroomDropdown({ classrooms, onClassroomChange }: Cla Classe diff --git a/src/services/useClassrooms.ts b/src/services/useClassrooms.ts new file mode 100644 index 000000000..6f517015e --- /dev/null +++ b/src/services/useClassrooms.ts @@ -0,0 +1,50 @@ +import React from 'react'; +import type { QueryFunction } from 'react-query'; +import { useQueryClient, useQuery } from 'react-query'; + +import { axiosRequest } from 'src/utils/axiosRequest'; +import type { Classroom, ClassroomFilter } from 'types/classroom.type'; + +export const useClassrooms = (options?: ClassroomFilter): { classrooms: Classroom[]; setClassrooms(newClassrooms: Classroom[]): void } => { + const queryClient = useQueryClient(); + const buildUrl = () => { + if (!options) return '/classrooms'; + const { villageId } = options; + const queryParams = []; + + if (villageId) queryParams.push(`villageId=${villageId}`); + + return queryParams.length ? `/classrooms?${queryParams.join('&')}` : '/classrooms'; + }; + + const url = buildUrl(); + // The query function for fetching villages, memoized with useCallback + const getClassrooms: QueryFunction = React.useCallback(async () => { + const response = await axiosRequest({ + method: 'GET', + url, + }); + if (response.error) { + return []; + } + return response.data; + }, [url]); + + // Notice that we include `countryIsoCode` in the query key so that the query is refetched + const { data, isLoading, error } = useQuery( + ['classrooms', options], // Include countryIsoCode in the query key to trigger refetching + getClassrooms, + ); + + const setClassrooms = React.useCallback( + (newClassrooms: Classroom[]) => { + queryClient.setQueryData(['classrooms', options], newClassrooms); // Ensure that the query key includes countryIsoCode + }, + [queryClient, options], + ); + + return { + classrooms: isLoading || error ? [] : data || [], + setClassrooms, + }; +}; diff --git a/types/classroom.type.ts b/types/classroom.type.ts index 4c8e2f2cf..020a093be 100644 --- a/types/classroom.type.ts +++ b/types/classroom.type.ts @@ -36,3 +36,7 @@ export interface InitialStateOptionsProps { ownClass: StateOptions; ownClassTimeDelay: StateOptions; } + +export interface ClassroomFilter { + villageId: string; +} From 04a9d54b13ee0161a099947e7747dd4928222708 Mon Sep 17 00:00:00 2001 From: Benjamin Ramet Date: Wed, 27 Nov 2024 11:10:31 +0100 Subject: [PATCH 2/5] Create queries for classroom statistics in admin dashboard --- server/controllers/statistics.ts | 13 ++++ server/stats/classroomStats.ts | 78 +++++++++++++++++++ server/stats/villageStats.ts | 5 +- src/api/statistics/statistics.get.ts | 12 +-- .../dashboard-statistics/ClassroomStats.tsx | 16 ++-- types/statistics.type.ts | 2 + 6 files changed, 110 insertions(+), 16 deletions(-) diff --git a/server/controllers/statistics.ts b/server/controllers/statistics.ts index 500a2b748..3530aafcd 100644 --- a/server/controllers/statistics.ts +++ b/server/controllers/statistics.ts @@ -5,6 +5,9 @@ import { getConnectedClassroomsCount, getContributedClassroomsCount, getRegisteredClassroomsCount, + getChildrenCodesCount as getChildrenCodesCountForClassroom, + getConnectedFamiliesCount as getConnectedFamiliesCountForClassroom, + getFamiliesWithoutAccount as getFamiliesWithoutAccountForClassroom, } from '../stats/classroomStats'; import { getAverageConnections, @@ -72,3 +75,13 @@ statisticsController.get({ path: '/villages/:villageId' }, async (_req, res) => floatingAccounts: await getFloatingAccounts(villageId), }); }); + +statisticsController.get({ path: '/classrooms/:classroomId' }, async (_req, res) => { + const classroomId = parseInt(_req.params.classroomId); + const phase = _req.query.phase as unknown as number; + res.sendJSON({ + childrenCodesCount: await getChildrenCodesCountForClassroom(classroomId, phase), + connectedFamiliesCount: await getConnectedFamiliesCountForClassroom(classroomId, phase), + familiesWithoutAccount: await getFamiliesWithoutAccountForClassroom(classroomId), + }); +}); diff --git a/server/stats/classroomStats.ts b/server/stats/classroomStats.ts index 7eca0bec4..67c11e0a6 100644 --- a/server/stats/classroomStats.ts +++ b/server/stats/classroomStats.ts @@ -1,9 +1,14 @@ import { UserType } from '../../types/user.type'; import { Activity } from '../entities/activity'; import { Classroom } from '../entities/classroom'; +import { Student } from '../entities/student'; +import { Village } from '../entities/village'; import { AppDataSource } from '../utils/data-source'; +import { getPhasePeriod, phaseWasSelected } from './villageStats'; const classroomRepository = AppDataSource.getRepository(Classroom); +const studentRepository = AppDataSource.getRepository(Student); +const villageRepository = AppDataSource.getRepository(Village); const teacherType = UserType.TEACHER; @@ -86,3 +91,76 @@ export const getContributedClassroomsCount = async (phase: number | null) => { // return parseInt(result.contributedUsersCount); return 10; }; + +export const getChildrenCodesCount = async (classroomId?: number, phase?: number) => { + const classroom = await classroomRepository + .createQueryBuilder('classroom') + .innerJoin('classroom.village', 'village') + .where('classroom.id = :classroomId', { classroomId }) + .getOne(); + const villageId = classroom?.villageId; + const query = studentRepository.createQueryBuilder('student').innerJoin('student.classroom', 'classroom').innerJoin('classroom.village', 'village'); + if (classroomId) { + query.andWhere('classroom.id = :classroomId', { classroomId }); + if (phaseWasSelected(phase) && villageId) { + const village = await villageRepository.findOne({ where: { id: villageId } }); + const phaseValue = phase as number; + const { debut, end } = await getPhasePeriod(villageId, phaseValue); + query.andWhere('student.createdAt >= :debut', { debut }); + if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); + } + } + const childrenCodeCount = await query.getCount(); + return childrenCodeCount; +}; + +export const getConnectedFamiliesCount = async (classroomId?: number, phase?: number) => { + const classroom = await classroomRepository + .createQueryBuilder('classroom') + .innerJoin('classroom.village', 'village') + .where('classroom.id = :classroomId', { classroomId }) + .getOne(); + const villageId = classroom?.villageId; + const village = await villageRepository.findOne({ where: { id: villageId } }); + const query = studentRepository + .createQueryBuilder('student') + .innerJoin('classroom', 'classroom', 'classroom.id = student.classroomId') + .andWhere('classroom.id = :classroomId', { classroomId }) + .andWhere('student.numLinkedAccount >= 1'); + if (villageId) { + query.andWhere('classroom.villageId = :villageId', { villageId }); + if (phaseWasSelected(phase)) { + const phaseValue = phase as number; + const { debut, end } = await getPhasePeriod(villageId, phaseValue); + query.andWhere('student.createdAt >= :debut', { debut }); + if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); + } + } + + const connectedFamiliesCount = await query.getCount(); + + return connectedFamiliesCount; +}; + +export const getFamiliesWithoutAccount = async (classroomId?: number) => { + const query = studentRepository + .createQueryBuilder('student') + .innerJoin('student.classroom', 'classroom') + .innerJoin('classroom.user', 'user') + .innerJoin('user.village', 'village') + .where('student.numLinkedAccount < 1'); + if (classroomId) query.andWhere('classroom.id = :classroomId', { classroomId }); + + query.select([ + 'classroom.name AS classroom_name', + 'classroom.countryCode as classroom_country', + 'student.firstname AS student_firstname', + 'student.lastname AS student_lastname', + 'student.id AS student_id', + 'student.createdAt as student_creation_date', + 'village.name AS village_name', + ]); + + const familiesWithoutAccount = query.getRawMany(); + return familiesWithoutAccount; +}; diff --git a/server/stats/villageStats.ts b/server/stats/villageStats.ts index 4a41b73d9..1ed202ab3 100644 --- a/server/stats/villageStats.ts +++ b/server/stats/villageStats.ts @@ -9,7 +9,7 @@ const studentRepository = AppDataSource.getRepository(Student); const phaseHistoryRepository = AppDataSource.getRepository(PhaseHistory); const villageRepository = AppDataSource.getRepository(Village); -const getPhasePeriod = async (villageId: number, phase: number): Promise<{ debut: Date | undefined; end: Date | undefined }> => { +export const getPhasePeriod = async (villageId: number, phase: number): Promise<{ debut: Date | undefined; end: Date | undefined }> => { // Getting the debut and end dates for the given phase const query = phaseHistoryRepository .createQueryBuilder('phaseHistory') @@ -26,10 +26,9 @@ const getPhasePeriod = async (villageId: number, phase: number): Promise<{ debut }; }; -const phaseWasSelected = (phase: number | undefined): boolean => { +export const phaseWasSelected = (phase: number | undefined): boolean => { return phase !== undefined && Object.values(VillagePhase).includes(+phase); }; - export const getChildrenCodesCount = async (villageId?: number, phase?: number) => { const query = studentRepository.createQueryBuilder('student').innerJoin('student.classroom', 'classroom').innerJoin('classroom.village', 'village'); const village = await villageRepository.findOne({ where: { id: villageId } }); diff --git a/src/api/statistics/statistics.get.ts b/src/api/statistics/statistics.get.ts index 037934e8b..2dc640028 100644 --- a/src/api/statistics/statistics.get.ts +++ b/src/api/statistics/statistics.get.ts @@ -1,7 +1,7 @@ import { useQuery } from 'react-query'; import { axiosRequest } from 'src/utils/axiosRequest'; -import type { ClassroomsStats, SessionsStats, VillageStats } from 'types/statistics.type'; +import type { ClassroomStats, SessionsStats, VillageStats } from 'types/statistics.type'; async function getSessionsStats(phase: number | null): Promise { return ( @@ -46,16 +46,18 @@ export const useGetVillagesStats = (villageId: number | null, phase: number) => }); }; -async function getClassroomsStats(): Promise { +async function getClassroomsStats(classroomId: number | null, phase: number): Promise { return ( await axiosRequest({ method: 'GET', baseURL: '/api', - url: '/statistics/classrooms', + url: phase ? `/statistics/classrooms/${classroomId}?phase=${phase}` : `/statistics/classrooms/${classroomId}`, }) ).data; } -export const useGetClassroomsStats = () => { - return useQuery(['classrooms-stats'], () => getClassroomsStats()); +export const useGetClassroomsStats = (classroomId: number | null, phase: number) => { + return useQuery(['classrooms-stats', classroomId, phase], () => getClassroomsStats(classroomId, phase), { + enabled: classroomId !== null, + }); }; diff --git a/src/components/admin/dashboard-statistics/ClassroomStats.tsx b/src/components/admin/dashboard-statistics/ClassroomStats.tsx index 9fede9382..42fda598a 100644 --- a/src/components/admin/dashboard-statistics/ClassroomStats.tsx +++ b/src/components/admin/dashboard-statistics/ClassroomStats.tsx @@ -13,7 +13,7 @@ import { PelicoCard } from './pelico-card'; import styles from './styles/charts.module.css'; import { createFamiliesWithoutAccountRows } from './utils/tableCreator'; import { FamiliesWithoutAccountHeaders } from './utils/tableHeaders'; -import { useGetVillagesStats } from 'src/api/statistics/statistics.get'; +import { useGetClassroomsStats } from 'src/api/statistics/statistics.get'; import { useClassrooms } from 'src/services/useClassrooms'; import { useCountries } from 'src/services/useCountries'; import { useVillages } from 'src/services/useVillages'; @@ -24,7 +24,7 @@ import type { VillageFilter } from 'types/village.type'; const ClassroomStats = () => { const [selectedCountry, setSelectedCountry] = useState(''); const [selectedVillage, setSelectedVillage] = useState(''); - const [selectedClassroom, setSelectedClassroom] = useState(); + const [selectedClassroom, setSelectedClassroom] = useState(''); const [selectedPhase, setSelectedPhase] = useState('4'); const [villageFilter, setVillageFilter] = useState({ countryIsoCode: '' }); const [classroomFilter, setClassroomFilter] = useState({ villageId: '' }); @@ -34,7 +34,7 @@ const ClassroomStats = () => { const { countries } = useCountries(); const { villages } = useVillages(villageFilter); - const villagesStats = useGetVillagesStats(+selectedVillage, +selectedPhase); + const classroomsStats = useGetClassroomsStats(+selectedClassroom, +selectedPhase); const { classrooms } = useClassrooms(classroomFilter); const handleCountryChange = (country: string) => { @@ -54,10 +54,10 @@ const ClassroomStats = () => { const [familiesWithoutAccountRows, setFamiliesWithoutAccountRows] = React.useState>([]); React.useEffect(() => { - if (villagesStats.data?.familiesWithoutAccount) { - setFamiliesWithoutAccountRows(createFamiliesWithoutAccountRows(villagesStats.data?.familiesWithoutAccount)); + if (classroomsStats.data?.familiesWithoutAccount) { + setFamiliesWithoutAccountRows(createFamiliesWithoutAccountRows(classroomsStats.data?.familiesWithoutAccount)); } - }, [villagesStats.data?.familiesWithoutAccount]); + }, [classroomsStats.data?.familiesWithoutAccount]); const handleVillageChange = (village: string) => { setSelectedVillage(village); @@ -124,8 +124,8 @@ const ClassroomStats = () => { gap: 2, }} > - Nombre de codes enfant créés - Nombre de familles connectées + Nombre de codes enfant créés + Nombre de familles connectées )} diff --git a/types/statistics.type.ts b/types/statistics.type.ts index 51a142ad8..cc74dec8a 100644 --- a/types/statistics.type.ts +++ b/types/statistics.type.ts @@ -34,6 +34,8 @@ export interface VillageStats { floatingAccounts: FloatingAccount[]; } +export type ClassroomStats = Omit; + export interface FamiliesWithoutAccount { student_id: number; student_firstname: string; From e8207c7d5f734fb1321e33ac46796c602dba634e Mon Sep 17 00:00:00 2001 From: Benjamin Ramet Date: Fri, 29 Nov 2024 08:47:49 +0100 Subject: [PATCH 3/5] Add display name in classroom dropdown --- server/controllers/classroom.ts | 3 +-- .../filters/ClassroomDropdown.tsx | 27 ++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/server/controllers/classroom.ts b/server/controllers/classroom.ts index 403efb1de..76c904aaa 100644 --- a/server/controllers/classroom.ts +++ b/server/controllers/classroom.ts @@ -25,11 +25,10 @@ classroomController.get({ path: '', userType: UserType.ADMIN }, async (_req: Req const classroomRepository = AppDataSource.getRepository(Classroom); let classrooms; if (villageId) { - const query = classroomRepository.createQueryBuilder('classroom'); + const query = classroomRepository.createQueryBuilder('classroom').leftJoinAndSelect('classroom.user', 'user'); query.where('classroom.villageId = :villageId', { villageId }); classrooms = await query.getMany(); - //classrooms = await classroomRepository.find({ where: { villageId: +villageId } }); } else { classrooms = await classroomRepository.find(); } diff --git a/src/components/admin/dashboard-statistics/filters/ClassroomDropdown.tsx b/src/components/admin/dashboard-statistics/filters/ClassroomDropdown.tsx index 60196d481..48d56e779 100644 --- a/src/components/admin/dashboard-statistics/filters/ClassroomDropdown.tsx +++ b/src/components/admin/dashboard-statistics/filters/ClassroomDropdown.tsx @@ -7,6 +7,7 @@ import MenuItem from '@mui/material/MenuItem'; import type { SelectChangeEvent } from '@mui/material/Select'; import Select from '@mui/material/Select'; +import { getUserDisplayName } from 'src/utils'; import type { Classroom } from 'types/classroom.type'; interface ClassroomDropdownProps { @@ -24,17 +25,19 @@ export default function ClassroomDropdown({ classrooms, onClassroomChange }: Cla }; return ( - - - Classe - - - + classrooms && ( + + + Classe + + + + ) ); } From 80f7071d15ffaef3fb5976293811b068c3ce2388 Mon Sep 17 00:00:00 2001 From: Benjamin Ramet Date: Mon, 2 Dec 2024 08:43:40 +0100 Subject: [PATCH 4/5] Redefine style to avoid glitch between village and classroom tabs --- .../admin/dashboard-statistics/ClassroomStats.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/admin/dashboard-statistics/ClassroomStats.tsx b/src/components/admin/dashboard-statistics/ClassroomStats.tsx index 42fda598a..2c0e14ce9 100644 --- a/src/components/admin/dashboard-statistics/ClassroomStats.tsx +++ b/src/components/admin/dashboard-statistics/ClassroomStats.tsx @@ -72,7 +72,7 @@ const ClassroomStats = () => { setSelectedPhase(phase); }; - const noDataFoundMessage = 'Pas de données pour le Village-Monde sélectionné'; + const noDataFoundMessage = 'Pas de données pour la classe sélectionnée'; const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { setValue(newValue); @@ -80,7 +80,17 @@ const ClassroomStats = () => { return ( <> - +
From 6918008e2799dcb8b69610080c59ed0c34492e73 Mon Sep 17 00:00:00 2001 From: Benjamin Ramet Date: Mon, 2 Dec 2024 16:16:40 +0100 Subject: [PATCH 5/5] Refactor stats controller --- server/controllers/index.ts | 2 + server/controllers/statistics.ts | 43 +++++---- server/stats/classroomStats.ts | 77 ++++----------- server/stats/globalStats.ts | 31 ++++++ server/stats/queryStatsByFilter.ts | 149 +++++++++++++++++++++++++++++ server/stats/villageStats.ts | 143 ++++++--------------------- types/statistics.type.ts | 8 ++ 7 files changed, 261 insertions(+), 192 deletions(-) create mode 100644 server/stats/globalStats.ts create mode 100644 server/stats/queryStatsByFilter.ts diff --git a/server/controllers/index.ts b/server/controllers/index.ts index 0aa408ca1..d4c30efe5 100644 --- a/server/controllers/index.ts +++ b/server/controllers/index.ts @@ -14,6 +14,7 @@ import { languageController } from './languages'; import { mediathequeController } from './mediatheque'; import { notificationsController } from './notifications'; import { pelicoController } from './pelicoPresentation'; +import { phaseHistoryController } from './phaseHistory'; import { statisticsController } from './statistics'; import { storyController } from './story'; import { studentController } from './student'; @@ -51,6 +52,7 @@ const controllers = [ pelicoController, mediathequeController, notificationsController, + phaseHistoryController, ]; for (let i = 0, n = controllers.length; i < n; i++) { diff --git a/server/controllers/statistics.ts b/server/controllers/statistics.ts index 3530aafcd..2324f8c55 100644 --- a/server/controllers/statistics.ts +++ b/server/controllers/statistics.ts @@ -5,10 +5,17 @@ import { getConnectedClassroomsCount, getContributedClassroomsCount, getRegisteredClassroomsCount, - getChildrenCodesCount as getChildrenCodesCountForClassroom, - getConnectedFamiliesCount as getConnectedFamiliesCountForClassroom, - getFamiliesWithoutAccount as getFamiliesWithoutAccountForClassroom, + getChildrenCodesCountForClassroom, + getConnectedFamiliesCountForClassroom, + getFamiliesWithoutAccountForClassroom, } from '../stats/classroomStats'; +import { + getChildrenCodesCountForGlobal, + getConnectedFamiliesCountForGlobal, + getFamiliesWithoutAccountForGlobal, + getFamilyAccountsCountForGlobal, + getFloatingAccountsForGlobal, +} from '../stats/globalStats'; import { getAverageConnections, getAverageDuration, @@ -20,11 +27,11 @@ import { getMinDuration, } from '../stats/sessionStats'; import { - getChildrenCodesCount, - getFamilyAccountsCount, - getConnectedFamiliesCount, - getFamiliesWithoutAccount, - getFloatingAccounts, + getChildrenCodesCountForVillage, + getConnectedFamiliesCountForVillage, + getFamiliesWithoutAccountForVillage, + getFloatingAccountsForVillage, + getFamilyAccountsCountForVillage, } from '../stats/villageStats'; import { Controller } from './controller'; @@ -56,11 +63,11 @@ statisticsController.get({ path: '/classrooms' }, async (_req, res) => { statisticsController.get({ path: '/onevillage' }, async (_req, res) => { res.sendJSON({ - familyAccountsCount: await getFamilyAccountsCount(), - childrenCodesCount: await getChildrenCodesCount(), - connectedFamiliesCount: await getConnectedFamiliesCount(), - familiesWithoutAccount: await getFamiliesWithoutAccount(), - floatingAccounts: await getFloatingAccounts(), + familyAccountsCount: await getFamilyAccountsCountForGlobal(), + childrenCodesCount: await getChildrenCodesCountForGlobal(), + connectedFamiliesCount: await getConnectedFamiliesCountForGlobal(), + familiesWithoutAccount: await getFamiliesWithoutAccountForGlobal(), + floatingAccounts: await getFloatingAccountsForGlobal(), }); }); @@ -68,11 +75,11 @@ statisticsController.get({ path: '/villages/:villageId' }, async (_req, res) => const villageId = parseInt(_req.params.villageId); const phase = _req.query.phase as unknown as number; res.sendJSON({ - familyAccountsCount: await getFamilyAccountsCount(villageId, phase), - childrenCodesCount: await getChildrenCodesCount(villageId, phase), - connectedFamiliesCount: await getConnectedFamiliesCount(villageId, phase), - familiesWithoutAccount: await getFamiliesWithoutAccount(villageId), - floatingAccounts: await getFloatingAccounts(villageId), + familyAccountsCount: await getFamilyAccountsCountForVillage(villageId, phase), + childrenCodesCount: await getChildrenCodesCountForVillage(villageId, phase), + connectedFamiliesCount: await getConnectedFamiliesCountForVillage(villageId, phase), + familiesWithoutAccount: await getFamiliesWithoutAccountForVillage(villageId), + floatingAccounts: await getFloatingAccountsForVillage(villageId), }); }); diff --git a/server/stats/classroomStats.ts b/server/stats/classroomStats.ts index 67c11e0a6..3e0645924 100644 --- a/server/stats/classroomStats.ts +++ b/server/stats/classroomStats.ts @@ -1,14 +1,11 @@ import { UserType } from '../../types/user.type'; import { Activity } from '../entities/activity'; import { Classroom } from '../entities/classroom'; -import { Student } from '../entities/student'; -import { Village } from '../entities/village'; +import type { VillagePhase } from '../entities/village'; import { AppDataSource } from '../utils/data-source'; -import { getPhasePeriod, phaseWasSelected } from './villageStats'; +import { generateEmptyFilterParams, getChildrenCodesCount, getConnectedFamiliesCount, getFamiliesWithoutAccount } from './queryStatsByFilter'; const classroomRepository = AppDataSource.getRepository(Classroom); -const studentRepository = AppDataSource.getRepository(Student); -const villageRepository = AppDataSource.getRepository(Village); const teacherType = UserType.TEACHER; @@ -92,75 +89,35 @@ export const getContributedClassroomsCount = async (phase: number | null) => { return 10; }; -export const getChildrenCodesCount = async (classroomId?: number, phase?: number) => { +export const getChildrenCodesCountForClassroom = async (classroomId: number, phase: VillagePhase) => { const classroom = await classroomRepository .createQueryBuilder('classroom') .innerJoin('classroom.village', 'village') .where('classroom.id = :classroomId', { classroomId }) .getOne(); + const villageId = classroom?.villageId; - const query = studentRepository.createQueryBuilder('student').innerJoin('student.classroom', 'classroom').innerJoin('classroom.village', 'village'); - if (classroomId) { - query.andWhere('classroom.id = :classroomId', { classroomId }); - if (phaseWasSelected(phase) && villageId) { - const village = await villageRepository.findOne({ where: { id: villageId } }); - const phaseValue = phase as number; - const { debut, end } = await getPhasePeriod(villageId, phaseValue); - query.andWhere('student.createdAt >= :debut', { debut }); - if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); - } - } - const childrenCodeCount = await query.getCount(); - return childrenCodeCount; + + if (!classroomId || !villageId) return 0; + let filterParams = generateEmptyFilterParams(); + filterParams = { ...filterParams, villageId, classroomId, phase }; + const whereClause = { clause: 'classroom.id = :classroomId', value: { classroomId } }; + return await getChildrenCodesCount(filterParams, whereClause); }; -export const getConnectedFamiliesCount = async (classroomId?: number, phase?: number) => { +export const getConnectedFamiliesCountForClassroom = async (classroomId: number, phase: VillagePhase) => { const classroom = await classroomRepository .createQueryBuilder('classroom') .innerJoin('classroom.village', 'village') .where('classroom.id = :classroomId', { classroomId }) .getOne(); - const villageId = classroom?.villageId; - const village = await villageRepository.findOne({ where: { id: villageId } }); - const query = studentRepository - .createQueryBuilder('student') - .innerJoin('classroom', 'classroom', 'classroom.id = student.classroomId') - .andWhere('classroom.id = :classroomId', { classroomId }) - .andWhere('student.numLinkedAccount >= 1'); - if (villageId) { - query.andWhere('classroom.villageId = :villageId', { villageId }); - if (phaseWasSelected(phase)) { - const phaseValue = phase as number; - const { debut, end } = await getPhasePeriod(villageId, phaseValue); - query.andWhere('student.createdAt >= :debut', { debut }); - if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); - } - } - - const connectedFamiliesCount = await query.getCount(); - return connectedFamiliesCount; + const villageId = classroom?.villageId; + let filterParams = generateEmptyFilterParams(); + filterParams = { ...filterParams, villageId, classroomId, phase }; + return await getConnectedFamiliesCount(filterParams); }; -export const getFamiliesWithoutAccount = async (classroomId?: number) => { - const query = studentRepository - .createQueryBuilder('student') - .innerJoin('student.classroom', 'classroom') - .innerJoin('classroom.user', 'user') - .innerJoin('user.village', 'village') - .where('student.numLinkedAccount < 1'); - if (classroomId) query.andWhere('classroom.id = :classroomId', { classroomId }); - - query.select([ - 'classroom.name AS classroom_name', - 'classroom.countryCode as classroom_country', - 'student.firstname AS student_firstname', - 'student.lastname AS student_lastname', - 'student.id AS student_id', - 'student.createdAt as student_creation_date', - 'village.name AS village_name', - ]); - - const familiesWithoutAccount = query.getRawMany(); - return familiesWithoutAccount; +export const getFamiliesWithoutAccountForClassroom = async (classroomId: number) => { + return getFamiliesWithoutAccount('classroom.id = :classroomId', { classroomId }); }; diff --git a/server/stats/globalStats.ts b/server/stats/globalStats.ts new file mode 100644 index 000000000..98a381754 --- /dev/null +++ b/server/stats/globalStats.ts @@ -0,0 +1,31 @@ +import { + generateEmptyFilterParams, + getChildrenCodesCount, + getConnectedFamiliesCount, + getFamiliesWithoutAccount, + getFamilyAccountsCount, + getFloatingAccounts, +} from './queryStatsByFilter'; + +export const getFamiliesWithoutAccountForGlobal = async () => { + return getFamiliesWithoutAccount(); +}; + +export const getConnectedFamiliesCountForGlobal = async () => { + const filterParams = generateEmptyFilterParams(); + return getConnectedFamiliesCount(filterParams); +}; + +export const getChildrenCodesCountForGlobal = async () => { + const filterParams = generateEmptyFilterParams(); + return await getChildrenCodesCount(filterParams); +}; +export const getFloatingAccountsForGlobal = async () => { + const filterParams = generateEmptyFilterParams(); + return await getFloatingAccounts(filterParams); +}; + +export const getFamilyAccountsCountForGlobal = async () => { + const filterParams = generateEmptyFilterParams(); + return await getFamilyAccountsCount(filterParams); +}; diff --git a/server/stats/queryStatsByFilter.ts b/server/stats/queryStatsByFilter.ts new file mode 100644 index 000000000..8e97d32b9 --- /dev/null +++ b/server/stats/queryStatsByFilter.ts @@ -0,0 +1,149 @@ +import type { StatsFilterParams } from '../../types/statistics.type'; +import { PhaseHistory } from '../entities/phaseHistory'; +import { Student } from '../entities/student'; +import { User } from '../entities/user'; +import { VillagePhase, Village } from '../entities/village'; +import { AppDataSource } from '../utils/data-source'; + +const studentRepository = AppDataSource.getRepository(Student); +const villageRepository = AppDataSource.getRepository(Village); +const userRepository = AppDataSource.getRepository(User); +const phaseHistoryRepository = AppDataSource.getRepository(PhaseHistory); + +export const getFamiliesWithoutAccount = async (condition?: string, conditionValue?: object) => { + const query = studentRepository + .createQueryBuilder('student') + .innerJoin('student.classroom', 'classroom') + .innerJoin('classroom.user', 'user') + .innerJoin('user.village', 'village') + .where('student.numLinkedAccount < 1'); + + if (condition && conditionValue) { + query.andWhere(condition, conditionValue); + } + + query.select([ + 'classroom.name AS classroom_name', + 'classroom.countryCode as classroom_country', + 'student.firstname AS student_firstname', + 'student.lastname AS student_lastname', + 'student.id AS student_id', + 'student.createdAt as student_creation_date', + 'village.name AS village_name', + ]); + + return query.getRawMany(); +}; + +export const getConnectedFamiliesCount = async (filterParams: StatsFilterParams) => { + const { villageId, classroomId, phase } = filterParams; + const query = studentRepository + .createQueryBuilder('student') + .innerJoin('classroom', 'classroom', 'classroom.id = student.classroomId') + .andWhere('student.numLinkedAccount >= 1'); + + if (classroomId) { + query.andWhere('classroom.id = :classroomId', { classroomId }); + } + + if (villageId) { + query.andWhere('classroom.villageId = :villageId', { villageId }); + + if (phaseWasSelected(phase)) { + const phaseValue = phase as number; + const { debut, end } = await getPhasePeriod(villageId, phaseValue); + query.andWhere('student.createdAt >= :debut', { debut }); + if (phaseValue != (await villageRepository.findOne({ where: { id: villageId } }))?.activePhase) { + query.andWhere('student.createdAt <= :end', { end }); + } + } + } + + return await query.getCount(); +}; + +export const getFloatingAccounts = async (filterParams: StatsFilterParams) => { + const { villageId } = filterParams; + const query = userRepository.createQueryBuilder('user').where('user.hasStudentLinked = 0').andWhere('user.type = 4'); + + if (villageId) query.andWhere('user.villageId = :villageId', { villageId }); + + query.select(['user.id', 'user.firstname', 'user.lastname', 'user.language', 'user.email', 'user.createdAt']); + const floatingAccounts = await query.getMany(); + return floatingAccounts; +}; + +export const getFamilyAccountsCount = async (filterParams: { villageId: number | undefined; phase: VillagePhase | undefined }) => { + const { villageId, phase } = filterParams; + const village = await villageRepository.findOne({ where: { id: villageId } }); + const query = userRepository + .createQueryBuilder('user') + .innerJoin('user.village', 'village') + .innerJoin('classroom', 'classroom', 'classroom.villageId = village.id') + .innerJoin('student', 'student', 'student.classroomId = classroom.id') + .where('user.type = 3'); + + if (villageId) { + query.andWhere('classroom.villageId = :villageId', { villageId }); + if (phaseWasSelected(phase)) { + const phaseValue = phase as number; + const { debut, end } = await getPhasePeriod(villageId, phaseValue); + query.andWhere('user.createdAt >= :debut', { debut }); + if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); + } + } + + query.groupBy('user.id'); + const familyAccountsCount = await query.getCount(); + return familyAccountsCount; +}; + +export const generateEmptyFilterParams = (): StatsFilterParams => { + const filterParams: { [K in keyof StatsFilterParams]: StatsFilterParams[K] } = { + villageId: undefined, + classroomId: undefined, + phase: undefined, + }; + + return filterParams; +}; +export type WhereClause = { + clause: string; + value: object; +}; +export const getChildrenCodesCount = async (filterParams: StatsFilterParams, whereClause?: WhereClause) => { + const { villageId, phase } = filterParams; + const village = await villageRepository.findOne({ where: { id: villageId } }); + const query = studentRepository.createQueryBuilder('student').innerJoin('student.classroom', 'classroom').innerJoin('classroom.village', 'village'); + if (whereClause) query.where(whereClause.clause, whereClause.value); + + if (phaseWasSelected(phase) && villageId) { + const phaseValue = phase as number; + const { debut, end } = await getPhasePeriod(villageId, phaseValue); + query.andWhere('student.createdAt >= :debut', { debut }); + if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); + } + + return await query.getCount(); +}; + +export const getPhasePeriod = async (villageId: number, phase: number): Promise<{ debut: Date | undefined; end: Date | undefined }> => { + // Getting the debut and end dates for the given phase + const query = phaseHistoryRepository + .createQueryBuilder('phaseHistory') + .withDeleted() + .where('phaseHistory.villageId = :villageId', { villageId }) + .andWhere('phaseHistory.phase = :phase', { phase }); + query.select(['phaseHistory.startingOn', 'phaseHistory.endingOn']); + const result = await query.getOne(); + const debut = result?.startingOn; + const end = result?.endingOn; + return { + debut, + end, + }; +}; + +export const phaseWasSelected = (phase: number | undefined): boolean => { + return phase !== undefined && Object.values(VillagePhase).includes(+phase); +}; diff --git a/server/stats/villageStats.ts b/server/stats/villageStats.ts index 1ed202ab3..41c0ea372 100644 --- a/server/stats/villageStats.ts +++ b/server/stats/villageStats.ts @@ -1,123 +1,38 @@ -import { PhaseHistory } from '../entities/phaseHistory'; -import { Student } from '../entities/student'; -import { User } from '../entities/user'; -import { Village, VillagePhase } from '../entities/village'; -import { AppDataSource } from '../utils/data-source'; - -const userRepository = AppDataSource.getRepository(User); -const studentRepository = AppDataSource.getRepository(Student); -const phaseHistoryRepository = AppDataSource.getRepository(PhaseHistory); -const villageRepository = AppDataSource.getRepository(Village); - -export const getPhasePeriod = async (villageId: number, phase: number): Promise<{ debut: Date | undefined; end: Date | undefined }> => { - // Getting the debut and end dates for the given phase - const query = phaseHistoryRepository - .createQueryBuilder('phaseHistory') - .withDeleted() - .where('phaseHistory.villageId = :villageId', { villageId }) - .andWhere('phaseHistory.phase = :phase', { phase }); - query.select(['phaseHistory.startingOn', 'phaseHistory.endingOn']); - const result = await query.getOne(); - const debut = result?.startingOn; - const end = result?.endingOn; - return { - debut, - end, - }; +import type { VillagePhase } from '../entities/village'; +import { + generateEmptyFilterParams, + getChildrenCodesCount, + getConnectedFamiliesCount, + getFamiliesWithoutAccount, + getFamilyAccountsCount, + getFloatingAccounts, +} from './queryStatsByFilter'; + +export const getChildrenCodesCountForVillage = async (villageId: number, phase: VillagePhase) => { + let filterParams = generateEmptyFilterParams(); + filterParams = { ...filterParams, villageId, phase }; + const whereClause = { clause: 'classroom.villageId = :villageId', value: { villageId } }; + return await getChildrenCodesCount(filterParams, whereClause); }; -export const phaseWasSelected = (phase: number | undefined): boolean => { - return phase !== undefined && Object.values(VillagePhase).includes(+phase); +export const getFamilyAccountsCountForVillage = async (villageId: number, phase: number) => { + let filterParams = generateEmptyFilterParams(); + filterParams = { ...filterParams, villageId, phase }; + return await getFamilyAccountsCount(filterParams); }; -export const getChildrenCodesCount = async (villageId?: number, phase?: number) => { - const query = studentRepository.createQueryBuilder('student').innerJoin('student.classroom', 'classroom').innerJoin('classroom.village', 'village'); - const village = await villageRepository.findOne({ where: { id: villageId } }); - if (villageId) { - query.andWhere('classroom.villageId = :villageId', { villageId }); - if (phaseWasSelected(phase)) { - const phaseValue = phase as number; - const { debut, end } = await getPhasePeriod(villageId, phaseValue); - query.andWhere('student.createdAt >= :debut', { debut }); - if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); - } - } - const childrenCodeCount = await query.getCount(); - return childrenCodeCount; -}; - -export const getFamilyAccountsCount = async (villageId?: number, phase?: number) => { - const village = await villageRepository.findOne({ where: { id: villageId } }); - const query = userRepository - .createQueryBuilder('user') - .innerJoin('user.village', 'village') - .innerJoin('classroom', 'classroom', 'classroom.villageId = village.id') - .innerJoin('student', 'student', 'student.classroomId = classroom.id'); - if (villageId) { - query.andWhere('classroom.villageId = :villageId', { villageId }); - if (phaseWasSelected(phase)) { - const phaseValue = phase as number; - const { debut, end } = await getPhasePeriod(villageId, phaseValue); - query.andWhere('user.createdAt >= :debut', { debut }); - if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); - } - } - - query.groupBy('user.id'); - const familyAccountsCount = await query.getCount(); - return familyAccountsCount; +export const getConnectedFamiliesCountForVillage = async (villageId: number, phase: number) => { + let filterParams = generateEmptyFilterParams(); + filterParams = { ...filterParams, villageId, phase }; + return await getConnectedFamiliesCount(filterParams); }; -export const getConnectedFamiliesCount = async (villageId?: number, phase?: number) => { - const village = await villageRepository.findOne({ where: { id: villageId } }); - const query = studentRepository - .createQueryBuilder('student') - .innerJoin('classroom', 'classroom', 'classroom.id = student.classroomId') - .andWhere('student.numLinkedAccount >= 1'); - if (villageId) { - query.andWhere('classroom.villageId = :villageId', { villageId }); - if (phaseWasSelected(phase)) { - const phaseValue = phase as number; - const { debut, end } = await getPhasePeriod(villageId, phaseValue); - query.andWhere('student.createdAt >= :debut', { debut }); - if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); - } - } - - const connectedFamiliesCount = await query.getCount(); - - return connectedFamiliesCount; +export const getFamiliesWithoutAccountForVillage = async (villageId: number) => { + return getFamiliesWithoutAccount('classroom.villageId = :villageId', { villageId }); }; -export const getFamiliesWithoutAccount = async (villageId?: number) => { - const query = studentRepository - .createQueryBuilder('student') - .innerJoin('student.classroom', 'classroom') - .innerJoin('classroom.user', 'user') - .innerJoin('user.village', 'village') - .where('student.numLinkedAccount < 1'); - if (villageId) query.andWhere('classroom.villageId = :villageId', { villageId }); - - query.select([ - 'classroom.name AS classroom_name', - 'classroom.countryCode as classroom_country', - 'student.firstname AS student_firstname', - 'student.lastname AS student_lastname', - 'student.id AS student_id', - 'student.createdAt as student_creation_date', - 'village.name AS village_name', - ]); - - const familiesWithoutAccount = query.getRawMany(); - return familiesWithoutAccount; -}; - -export const getFloatingAccounts = async (villageId?: number) => { - const query = userRepository.createQueryBuilder('user').where('user.hasStudentLinked = 0').andWhere('user.type = 4'); - - if (villageId) query.andWhere('user.villageId = :villageId', { villageId }); - - query.select(['user.id', 'user.firstname', 'user.lastname', 'user.language', 'user.email', 'user.createdAt']); - const floatingAccounts = query.getMany(); - return floatingAccounts; +export const getFloatingAccountsForVillage = async (villageId: number) => { + let filterParams = generateEmptyFilterParams(); + filterParams = { ...filterParams, villageId }; + return await getFloatingAccounts(filterParams); }; diff --git a/types/statistics.type.ts b/types/statistics.type.ts index cc74dec8a..44b069ced 100644 --- a/types/statistics.type.ts +++ b/types/statistics.type.ts @@ -1,3 +1,5 @@ +import type { VillagePhase } from './village.type'; + export interface ClassroomsStats { classroomId: number; classroomCountryCode: string; @@ -59,3 +61,9 @@ export interface OneVillageTableRow { id: string | number; [key: string]: string | boolean | number | React.ReactNode; } + +export type StatsFilterParams = { + villageId: number | undefined; + classroomId: number | undefined; + phase: VillagePhase | undefined; +};