diff --git a/assets/vue/components/attendance/AttendanceCalendarForm.vue b/assets/vue/components/attendance/AttendanceCalendarForm.vue new file mode 100644 index 0000000000..e6e0297c4b --- /dev/null +++ b/assets/vue/components/attendance/AttendanceCalendarForm.vue @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/vue/components/attendance/AttendanceForm.vue b/assets/vue/components/attendance/AttendanceForm.vue new file mode 100644 index 0000000000..3dc7a71e9d --- /dev/null +++ b/assets/vue/components/attendance/AttendanceForm.vue @@ -0,0 +1,205 @@ + + + + + + + + + + + + {{ t("Gradebook Options") }}: + + + + + + + + + + + + + + + + + + + + diff --git a/assets/vue/components/attendance/AttendanceTable.vue b/assets/vue/components/attendance/AttendanceTable.vue new file mode 100644 index 0000000000..f73593e5ee --- /dev/null +++ b/assets/vue/components/attendance/AttendanceTable.vue @@ -0,0 +1,133 @@ + + + + + + + {{ slotProps.data.title }} + + + + + + + + + + + + + + + {{ slotProps.data.results ? slotProps.data.results.length : 0 }} + + + + + + + + + + + + + + + + diff --git a/assets/vue/components/basecomponents/BaseIcon.vue b/assets/vue/components/basecomponents/BaseIcon.vue index 540c3ce23e..5a13b80ee0 100644 --- a/assets/vue/components/basecomponents/BaseIcon.vue +++ b/assets/vue/components/basecomponents/BaseIcon.vue @@ -2,6 +2,8 @@ diff --git a/assets/vue/components/basecomponents/ChamiloIcons.js b/assets/vue/components/basecomponents/ChamiloIcons.js index 718ee1dbaf..fbb7c5e937 100644 --- a/assets/vue/components/basecomponents/ChamiloIcons.js +++ b/assets/vue/components/basecomponents/ChamiloIcons.js @@ -41,7 +41,8 @@ export const chamiloIconToClass = { "dots-vertical": "mdi mdi-dots-vertical", "down": "mdi mdi-arrow-down-right", "download": "mdi mdi-download-box", - "drawing": "mdi mdi-drawing", + "comment": "mdi mdi-comment-text-outline", + "drawing": "mdi mdi-pencil-outline", "edit": "mdi mdi-pencil", "email-plus": "mdi mdi-email-plus-outline", "email-unread": "mdi mdi-email-mark-as-unread", @@ -127,5 +128,11 @@ export const chamiloIconToClass = { "next": "mdi mdi-arrow-right-bold-box", "crosshairs": "mdi mdi-crosshairs", "square": "mdi mdi-square", - "wheel": "mdi mdi-tire" + "wheel": "mdi mdi-tire", + "view-table": "mdi mdi-table-eye", + "eye-lock": "mdi mdi-lock", + "unlock": "mdi mdi-lock-open", + "lock": "mdi mdi-lock", + "account-check": "mdi mdi-account-check", + "account-cancel": "mdi mdi-account-cancel", } diff --git a/assets/vue/router/attendance.js b/assets/vue/router/attendance.js new file mode 100644 index 0000000000..2ea28edfdc --- /dev/null +++ b/assets/vue/router/attendance.js @@ -0,0 +1,44 @@ +export default { + path: "/resources/attendance/:node/", + meta: { requiresAuth: true, showBreadcrumb: true }, + name: "attendance", + component: () => import("../components/layout/SimpleRouterViewLayout.vue"), + redirect: { name: "AttendanceList" }, + children: [ + { + name: "AttendanceList", + path: "", + component: () => import("../views/attendance/AttendanceList.vue"), + }, + { + name: "CreateAttendance", + path: "create", + component: () => import("../views/attendance/AttendanceCreate.vue"), + }, + { + name: "EditAttendance", + path: "edit/:id", + component: () => import("../views/attendance/AttendanceEdit.vue"), + }, + { + name: "AttendanceSheetList", + path: ":id?/sheet-list", + component: () => import("../views/attendance/AttendanceSheetList.vue"), + }, + { + name: "CalendarList", + path: ":id?/calendar", + component: () => import("../views/attendance/AttendanceCalendarList.vue"), + }, + { + name: "AddCalendarEvent", + path: ":id?/calendar/create", + component: () => import("../views/attendance/AttendanceCalendarAdd.vue"), + }, + { + name: "ExportToPdf", + path: ":id?/export/pdf", + component: () => import("../views/attendance/AttendanceExport.vue"), + }, + ], +} diff --git a/assets/vue/router/index.js b/assets/vue/router/index.js index e772920745..4d0504368d 100644 --- a/assets/vue/router/index.js +++ b/assets/vue/router/index.js @@ -21,6 +21,7 @@ import documents from "./documents" import assignments from "./assignments" import links from "./links" import glossary from "./glossary" +import attendance from "./attendance" import catalogue from "./catalogue" import { useSecurityStore } from "../store/securityStore" import MyCourseList from "../views/user/courses/List.vue" @@ -234,6 +235,7 @@ const router = createRouter({ assignments, links, glossary, + attendance, accountRoutes, personalFileRoutes, messageRoutes, diff --git a/assets/vue/services/attendanceService.js b/assets/vue/services/attendanceService.js new file mode 100644 index 0000000000..71ea68b7d6 --- /dev/null +++ b/assets/vue/services/attendanceService.js @@ -0,0 +1,253 @@ +import { ENTRYPOINT } from "../config/entrypoint" +import axios from "axios" + +export const ATTENDANCE_STATES = { + ABSENT: { id: 0, label: "Absent", score: 0 }, + PRESENT: { id: 1, label: "Present", score: 1 }, + LATE_15: { id: 2, label: "Late < 15 min", score: 1 }, + LATE_15_PLUS: { id: 3, label: "Late > 15 min", score: 0.5 }, + ABSENT_JUSTIFIED: { id: 4, label: "Absent, justified", score: 0.25 }, +} + +export default { + /** + * Fetches all attendance lists for a specific course. + * @param {Object} params - Filters for the attendance lists. + * @returns {Promise} - Data of the attendance lists. + */ + getAttendances: async (params) => { + const response = await axios.get(`${ENTRYPOINT}attendances/`, { params }) + return response.data + }, + + /** + * Fetches a specific attendance list by its ID. + * @param {Number|String} attendanceId - ID of the attendance list. + * @returns {Promise} - Data of the specific attendance list. + */ + getAttendance: async (attendanceId) => { + const response = await axios.get(`${ENTRYPOINT}attendances/${attendanceId}/`) + return response.data + }, + + /** + * Fetches groups filtered by the parent node (course). + * @param {Number|String} parentNodeId - The ID of the parent resource node (course). + * @returns {Promise} - List of groups associated with the course. + */ + fetchGroups: async (parentNodeId) => { + try { + const response = await axios.get(`${ENTRYPOINT}groups`, { + params: { "resourceNode.parent": parentNodeId }, + }) + return response.data["hydra:member"].map((group) => ({ + label: group.title, + value: group.iid, + })) + } catch (error) { + console.error("Error fetching groups:", error) + throw error + } + }, + + /** + * Creates a new attendance list. + * @param {Object} data - Data for the new attendance list. + * @returns {Promise} - Data of the created attendance list. + */ + createAttendance: async (data) => { + const response = await axios.post(`${ENTRYPOINT}attendances`, data) + return response.data + }, + + /** + * Updates an existing attendance list. + * @param {Number|String} attendanceId - ID of the attendance list. + * @param {Object} data - Updated data for the attendance list. + * @returns {Promise} - Data of the updated attendance list. + */ + updateAttendance: async (attendanceId, data) => { + const response = await axios.put(`${ENTRYPOINT}attendances/${attendanceId}`, data) + return response.data + }, + + /** + * Deletes an attendance list. + * @param {Number|String} attendanceId - ID of the attendance list. + * @returns {Promise} - Result of the deletion. + */ + deleteAttendance: async (attendanceId) => { + const response = await axios.delete(`${ENTRYPOINT}attendances/${attendanceId}`) + return response.data + }, + + /** + * Toggles the visibility of an attendance list. + * @param {Number|String} attendanceId - ID of the attendance list. + * @returns {Promise} - Result of the toggle action. + */ + toggleVisibility: async (attendanceId) => { + const endpoint = `${ENTRYPOINT}attendances/${attendanceId}/toggle_visibility` + await axios.put(endpoint, {}, { headers: { "Content-Type": "application/json" } }) + }, + + /** + * Soft deletes an attendance list. + * @param {Number|String} attendanceId - ID of the attendance list. + * @returns {Promise} - Result of the soft delete action. + */ + softDelete: async (attendanceId) => { + const endpoint = `${ENTRYPOINT}attendances/${attendanceId}/soft_delete` + await axios.put(endpoint, {}, { headers: { "Content-Type": "application/json" } }) + }, + + /** + * Adds a new calendar event to an attendance list. + * @param {Number|String} attendanceId - ID of the attendance list. + * @param {Object} data - Data for the new calendar event. + * @returns {Promise} - Data of the created calendar event. + */ + addCalendarEvent: async (attendanceId, data) => { + const response = await axios.post(`${ENTRYPOINT}attendances/${attendanceId}/calendars`, data) + return response.data + }, + + /** + * Updates an existing calendar event. + * @param {Number|String} calendarId - ID of the calendar event. + * @param {Object} data - Updated data for the calendar event. + * @returns {Promise} - Data of the updated calendar event. + */ + updateCalendarEvent: async (calendarId, data) => { + const response = await axios.put(`${ENTRYPOINT}c_attendance_calendars/${calendarId}`, data) + return response.data + }, + + /** + * Deletes a specific calendar event. + * @param {Number|String} calendarId - ID of the calendar event. + * @returns {Promise} - Result of the deletion. + */ + deleteCalendarEvent: async (calendarId) => { + const response = await axios.delete(`${ENTRYPOINT}c_attendance_calendars/${calendarId}`) + return response.data + }, + + /** + * Adds a new calendar entry directly to an attendance list. + * @param {Number|String} attendanceId - ID of the attendance list. + * @param {Object} data - Calendar data, including repetition and groups. + * @returns {Promise} - Data of the created calendar entry. + */ + addAttendanceCalendar: async (attendanceId, data) => { + const endpoint = `${ENTRYPOINT}attendances/${attendanceId}/calendars` + const response = await axios.post(endpoint, data, { + headers: { "Content-Type": "application/json" }, + }) + return response.data + }, + + /** + * Fetches full attendance data (dates and attendance status). + * @param {Number|String} attendanceId - ID of the attendance list. + * @returns {Promise} - Full attendance data structured for Vue. + */ + getFullAttendanceData: async (attendanceId) => { + try { + const response = await axios.get(`/attendance/full-data`, { + params: { attendanceId }, + }) + return response.data + } catch (error) { + console.error("Error fetching full attendance data:", error) + throw error + } + }, + + /** + * Fetches users related to attendance based on course, session, or group. + * @param {Object} params - Object with courseId, sessionId, and/or groupId. + * @returns {Promise} - List of users. + */ + getAttendanceSheetUsers: async (params) => { + try { + const response = await axios.get(`/attendance/users/context`, { params }) + return response.data + } catch (error) { + console.error("Error fetching attendance sheet users:", error) + throw error + } + }, + + /** + * Updates an existing calendar entry for an attendance list. + * @param {Number|String} attendanceId - ID of the attendance list. + * @param {Number|String} calendarId - ID of the calendar entry to update. + * @param {Object} data - Updated calendar data. + * @returns {Promise} - Data of the updated calendar entry. + */ + updateAttendanceCalendar: async (attendanceId, calendarId, data) => { + const endpoint = `${ENTRYPOINT}attendances/${attendanceId}/calendars/${calendarId}` + const response = await axios.put(endpoint, data) + return response.data + }, + + /** + * Deletes a specific calendar entry for an attendance list. + * @param {Number|String} attendanceId - ID of the attendance list. + * @param {Number|String} calendarId - ID of the calendar entry to delete. + * @returns {Promise} - Result of the deletion. + */ + deleteAttendanceCalendar: async (attendanceId, calendarId) => { + const endpoint = `${ENTRYPOINT}attendances/${attendanceId}/calendars/${calendarId}` + const response = await axios.delete(endpoint) + return response.data + }, + + /** + * Deletes all calendar entries for a specific attendance list. + * @param {Number|String} attendanceId - ID of the attendance list. + * @returns {Promise} - Result of the deletion. + */ + deleteAllAttendanceCalendars: async (attendanceId) => { + const endpoint = `${ENTRYPOINT}attendances/${attendanceId}/calendars` + const response = await axios.delete(endpoint) + return response.data + }, + + /** + * Exports an attendance list to PDF format. + * @param {Number|String} attendanceId - ID of the attendance list. + * @returns {Promise} - PDF file of the attendance list. + */ + exportAttendanceToPdf: async (attendanceId) => { + const response = await axios.get(`${ENTRYPOINT}attendances/${attendanceId}/export/pdf`, { + responseType: "blob", + }) + return response.data + }, + + /** + * Exports an attendance list to XLS format. + * @param {Number|String} attendanceId - ID of the attendance list. + * @returns {Promise} - XLS file of the attendance list. + */ + exportAttendanceToXls: async (attendanceId) => { + const response = await axios.get(`${ENTRYPOINT}attendances/${attendanceId}/export/xls`, { + responseType: "blob", + }) + return response.data + }, + + saveAttendanceSheet: async (data) => { + try { + const response = await axios.post(`/attendance/sheet/save`, data, { + headers: { "Content-Type": "application/json" }, + }) + return response.data + } catch (error) { + console.error("Error saving attendance sheet:", error) + throw error + } + }, +} diff --git a/assets/vue/services/gradebookService.js b/assets/vue/services/gradebookService.js new file mode 100644 index 0000000000..5882bb8c3d --- /dev/null +++ b/assets/vue/services/gradebookService.js @@ -0,0 +1,24 @@ +import axios from "axios" + +const API_BASE = "/gradebook" + +export default { + /** + * Fetches gradebook categories for a specific course and session. + * @param {number} courseId The course ID. + * @param {number|null} sessionId The session ID (optional). + * @returns {Promise} The list of gradebook categories. + */ + async getCategories(courseId, sessionId = null) { + const params = { courseId } + if (sessionId) params.sessionId = sessionId + + try { + const response = await axios.get(`${API_BASE}/categories`, { params }) + return response.data + } catch (error) { + console.error("Error fetching gradebook categories:", error) + throw error + } + }, +} diff --git a/assets/vue/views/attendance/AttendanceCalendarAdd.vue b/assets/vue/views/attendance/AttendanceCalendarAdd.vue new file mode 100644 index 0000000000..089b716f06 --- /dev/null +++ b/assets/vue/views/attendance/AttendanceCalendarAdd.vue @@ -0,0 +1,32 @@ + + + + + {{ t("Add Attendance Calendar") }} + + + + + + + diff --git a/assets/vue/views/attendance/AttendanceCalendarList.vue b/assets/vue/views/attendance/AttendanceCalendarList.vue new file mode 100644 index 0000000000..284b11e572 --- /dev/null +++ b/assets/vue/views/attendance/AttendanceCalendarList.vue @@ -0,0 +1,262 @@ + + + + + + + + + + + + + + {{ + t( + "The attendance calendar allows you to register attendance lists (one per real session the students need to attend). Add new attendance lists here.", + ) + }} + + + + + + + + + {{ formatDateTime(event.dateTime) }} + + + + + + + + + + + + + + + + + + + + + + {{ t("Are you sure you want to delete this event?") }} + + + + + + + + + diff --git a/assets/vue/views/attendance/AttendanceCreate.vue b/assets/vue/views/attendance/AttendanceCreate.vue new file mode 100644 index 0000000000..727379574f --- /dev/null +++ b/assets/vue/views/attendance/AttendanceCreate.vue @@ -0,0 +1,32 @@ + + + + + {{ t("Add Attendance") }} + + + + + + + diff --git a/assets/vue/views/attendance/AttendanceEdit.vue b/assets/vue/views/attendance/AttendanceEdit.vue new file mode 100644 index 0000000000..06bff2f2a4 --- /dev/null +++ b/assets/vue/views/attendance/AttendanceEdit.vue @@ -0,0 +1,77 @@ + + + + + {{ t("Edit Attendance") }} + + + + + + + {{ t("No data available.") }} + + + + + diff --git a/assets/vue/views/attendance/AttendanceExport.vue b/assets/vue/views/attendance/AttendanceExport.vue new file mode 100644 index 0000000000..85af715314 --- /dev/null +++ b/assets/vue/views/attendance/AttendanceExport.vue @@ -0,0 +1,11 @@ + + + + + + + diff --git a/assets/vue/views/attendance/AttendanceList.vue b/assets/vue/views/attendance/AttendanceList.vue new file mode 100644 index 0000000000..25e14a1fe5 --- /dev/null +++ b/assets/vue/views/attendance/AttendanceList.vue @@ -0,0 +1,124 @@ + + + + + + + + + + + + diff --git a/assets/vue/views/attendance/AttendanceSheetList.vue b/assets/vue/views/attendance/AttendanceSheetList.vue new file mode 100644 index 0000000000..aa729f91ab --- /dev/null +++ b/assets/vue/views/attendance/AttendanceSheetList.vue @@ -0,0 +1,704 @@ + + + + + + + + {{ t("All") }} + {{ t("Today") }} + {{ t("All done") }} + {{ t("All not done") }} + + {{ date.label }} + + + + + + + + + {{ t("Loading attendance data...") }} + + + + + + + {{ + t( + "There is no class scheduled today, try picking another day or add your attendance entry yourself using the action icons.", + ) + }} + + + + + + {{ + t( + "The attendance calendar allows you to register attendance lists (one per real session the students need to attend).", + ) + }} + + + + + + + + + + + # + {{ t("Photo") }} + {{ t("Last Name") }} + {{ t("First Name") }} + {{ t("Not Attended") }} + + + + + {{ index + 1 }} + + + + + {{ user.lastName }} + + + {{ user.firstName }} + + + {{ user.notAttended }} + + + + + + + + + + + + + + {{ date.label }} + + + + + + + + + + + + + + + + + + + + + + + {{ state.label }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Clear + + + + + + + + + + + + diff --git a/config/services.yaml b/config/services.yaml index a63e3d7e97..5dcf374201 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -89,6 +89,15 @@ services: tags: - { name: 'api_platform.state_processor' } + Chamilo\CoreBundle\State\CAttendanceStateProcessor: + arguments: + $persistProcessor: '@api_platform.doctrine.orm.state.persist_processor' + $entityManager: '@doctrine.orm.entity_manager' + $calendarRepo: '@Chamilo\CourseBundle\Repository\CAttendanceCalendarRepository' + $requestStack: '@request_stack' + tags: + - { name: 'api_platform.state_processor' } + Chamilo\CoreBundle\State\ColorThemeStateProcessor: bind: $persistProcessor: '@api_platform.doctrine.orm.state.persist_processor' diff --git a/src/CoreBundle/Controller/AttendanceController.php b/src/CoreBundle/Controller/AttendanceController.php new file mode 100644 index 0000000000..22e51fef00 --- /dev/null +++ b/src/CoreBundle/Controller/AttendanceController.php @@ -0,0 +1,241 @@ +query->get('attendanceId', 0); + + if (!$attendanceId) { + return $this->json(['error' => 'Attendance ID is required'], 400); + } + + $data = $this->attendanceCalendarRepository->findAttendanceWithData($attendanceId); + + return $this->json($data, 200); + } + + #[Route('/users/context', name: 'chamilo_core_get_users_with_faults', methods: ['GET'])] + public function getUsersWithFaults( + Request $request, + UserRepository $userRepository, + CAttendanceCalendarRepository $calendarRepository, + CAttendanceSheetRepository $sheetRepository + ): JsonResponse { + $courseId = (int) $request->query->get('courseId', 0); + $sessionId = $request->query->get('sessionId') ? (int) $request->query->get('sessionId') : null; + $groupId = $request->query->get('groupId') ? (int) $request->query->get('groupId') : null; + + if (!$courseId) { + return $this->json(['error' => 'Course ID is required'], 400); + } + + try { + $users = $userRepository->findUsersByContext($courseId, $sessionId, $groupId); + + $totalCalendars = $calendarRepository->countByAttendanceAndGroup($courseId, $groupId); + + $formattedUsers = array_map(function ($user) use ($sheetRepository, $calendarRepository, $userRepository, $courseId, $groupId, $totalCalendars) { + $userScore = $sheetRepository->getUserScore($user->getId(), $courseId, $groupId); + + $faults = max(0, $totalCalendars - $userScore); + $faultsPercent = $totalCalendars > 0 ? round(($faults * 100) / $totalCalendars, 0) : 0; + + return [ + 'id' => $user->getId(), + 'firstname' => $user->getFirstname(), + 'lastname' => $user->getLastname(), + 'email' => $user->getEmail(), + 'username' => $user->getUsername(), + 'photo' => $userRepository->getUserPicture($user->getId()), + 'notAttended' => "$faults/$totalCalendars ({$faultsPercent}%)", + ]; + }, $users); + + return $this->json($formattedUsers, 200); + } catch (\Exception $e) { + return $this->json(['error' => 'An error occurred: ' . $e->getMessage()], 500); + } + } + + #[Route('/sheet/save', name: 'chamilo_core_attendance_sheet_save', methods: ['POST'])] + public function saveAttendanceSheet( + Request $request, + UserRepository $userRepository, + CAttendanceSheetRepository $sheetRepository + ): JsonResponse { + $data = json_decode($request->getContent(), true); + + if (empty($data['attendanceData']) || empty($data['courseId'])) { + return $this->json(['error' => 'Missing required parameters'], 400); + } + + $attendanceData = $data['attendanceData']; + $courseId = (int) $data['courseId']; + $sessionId = isset($data['sessionId']) ? (int) $data['sessionId'] : null; + $groupId = isset($data['groupId']) ? (int) $data['groupId'] : null; + + $usersInCourse = $userRepository->findUsersByContext($courseId, $sessionId, $groupId); + $userIdsInCourse = array_map(fn(User $user) => $user->getId(), $usersInCourse); + + $affectedRows = 0; + + try { + foreach ($attendanceData as $entry) { + $userId = (int) $entry['userId']; + $calendarId = (int) $entry['calendarId']; + $presence = isset($entry['presence']) ? (int)$entry['presence'] : null; + $signature = $entry['signature'] ?? null; + $comment = $entry['comment'] ?? null; + + $calendar = $this->attendanceCalendarRepository->find($calendarId); + if (!$calendar) { + return $this->json(['error' => "Attendance calendar with ID $calendarId not found"], 404); + } + + $user = $this->em->getRepository(User::class)->find($userId); + if (!$user) { + continue; + } + + $sheet = $sheetRepository->findOneBy([ + 'user' => $user, + 'attendanceCalendar' => $calendar, + ]) ?? new CAttendanceSheet(); + + $sheet->setUser($user) + ->setAttendanceCalendar($calendar) + ->setPresence($presence) + ->setSignature($signature); + + $this->em->persist($sheet); + + $this->em->flush(); + + if ($comment !== null) { + $existingComment = $this->em->getRepository(CAttendanceResultComment::class)->findOneBy([ + 'attendanceSheetId' => $sheet->getIid(), + 'userId' => $user->getId(), + ]); + + if (!$existingComment) { + $existingComment = new CAttendanceResultComment(); + $existingComment->setAttendanceSheetId($sheet->getIid()); + $existingComment->setUserId($user->getId()); + $existingComment->setAuthorUserId($this->getUser()->getId()); + } + + $existingComment->setComment($comment); + $existingComment->setUpdatedAt(new \DateTime()); + + $this->em->persist($existingComment); + } + } + + $calendarIds = array_unique(array_column($attendanceData, 'calendarId')); + foreach ($calendarIds as $calendarId) { + $calendar = $this->attendanceCalendarRepository->find($calendarId); + if ($calendar && !$calendar->getDoneAttendance()) { + $calendar->setDoneAttendance(true); + $this->em->persist($calendar); + } + } + + $calendars = $this->attendanceCalendarRepository->findBy(['iid' => $calendarIds]); + $attendance = $calendars[0]->getAttendance(); + $this->updateAttendanceResults($attendance); + + $lasteditType = $calendars[0]->getDoneAttendance() + ? 'UPDATED_ATTENDANCE_LOG_TYPE' + : 'DONE_ATTENDANCE_LOG_TYPE'; + + foreach ($calendars as $calendar) { + $this->saveAttendanceLog($attendance, $lasteditType, $calendar); + } + + $this->em->flush(); + + return $this->json([ + 'message' => 'Attendance data and comments saved successfully', + 'affectedRows' => $affectedRows, + ]); + + } catch (\Exception $e) { + return $this->json(['error' => 'An error occurred: ' . $e->getMessage()], 500); + } + } + + private function updateAttendanceResults(CAttendance $attendance): void + { + $sheets = $attendance->getCalendars()->map(fn ($calendar) => $calendar->getSheets())->toArray(); + $results = []; + + foreach ($sheets as $calendarSheets) { + foreach ($calendarSheets as $sheet) { + $userId = $sheet->getUser()->getId(); + $results[$userId] = ($results[$userId] ?? 0) + $sheet->getPresence(); + } + } + + foreach ($results as $userId => $score) { + $user = $this->em->getRepository(User::class)->find($userId); + if (!$user) { + continue; + } + + $result = $this->em->getRepository(CAttendanceResult::class)->findOneBy([ + 'user' => $user, + 'attendance' => $attendance, + ]); + + if (!$result) { + $result = new CAttendanceResult(); + $result->setUser($user); + $result->setAttendance($attendance); + } + + $result->setScore((int) $score); + $this->em->persist($result); + } + } + + private function saveAttendanceLog(CAttendance $attendance, string $lasteditType, CAttendanceCalendar $calendar): void + { + $log = new CAttendanceSheetLog(); + $log->setAttendance($attendance) + ->setLasteditDate(new \DateTime()) + ->setLasteditType($lasteditType) + ->setCalendarDateValue($calendar->getDateTime()) + ->setUser($this->getUser()); + + $this->em->persist($log); + } +} diff --git a/src/CoreBundle/Controller/GradebookController.php b/src/CoreBundle/Controller/GradebookController.php index d2a7f8b862..56a88eb359 100644 --- a/src/CoreBundle/Controller/GradebookController.php +++ b/src/CoreBundle/Controller/GradebookController.php @@ -5,16 +5,49 @@ declare(strict_types=1); namespace Chamilo\CoreBundle\Controller; use Chamilo\CoreBundle\Entity\GradebookCategory; +use Chamilo\CoreBundle\Repository\GradeBookCategoryRepository; use Chamilo\CourseBundle\Entity\CDocument; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route('/gradebook')] class GradebookController extends AbstractController { + public function __construct( + private readonly GradeBookCategoryRepository $gradeBookCategoryRepository, + ) {} + + #[Route('/categories', name: 'chamilo_core_gradebook_categories', methods: ['GET'])] + public function getCategories(Request $request): JsonResponse + { + // Extract parameters from the query string + $courseId = (int) $request->query->get('courseId'); + $sessionId = $request->query->get('sessionId') ? (int) $request->query->get('sessionId') : null; + + if (!$courseId) { + return new JsonResponse(['error' => 'courseId parameter is required'], Response::HTTP_BAD_REQUEST); + } + + // Ensure the default category exists + $this->gradeBookCategoryRepository->createDefaultCategory($courseId, $sessionId); + + // Fetch categories using the repository + $categories = $this->gradeBookCategoryRepository->getCategoriesForCourse($courseId, $sessionId); + + // Format the response + $formatted = array_map(fn($category) => [ + 'id' => $category->getId(), + 'title' => $category->getTitle(), + 'parentId' => $category->getParent()?->getId(), + ], $categories); + + return new JsonResponse($formatted); + } + // Sets the default certificate for a gradebook category #[Route('/set_default_certificate/{cid}/{certificateId}', name: 'chamilo_core_gradebook_set_default_certificate')] public function setDefaultCertificate(int $cid, int $certificateId, EntityManagerInterface $entityManager): Response diff --git a/src/CoreBundle/Entity/AbstractResource.php b/src/CoreBundle/Entity/AbstractResource.php index e1d08751ea..8ecc7c6a4f 100644 --- a/src/CoreBundle/Entity/AbstractResource.php +++ b/src/CoreBundle/Entity/AbstractResource.php @@ -70,6 +70,7 @@ abstract class AbstractResource 'illustration:read', 'message:read', 'c_tool_intro:read', + 'attendance:read', ])] #[ORM\OneToOne(targetEntity: ResourceNode::class, cascade: ['persist'])] #[ORM\JoinColumn(name: 'resource_node_id', referencedColumnName: 'id', onDelete: 'CASCADE')] @@ -96,7 +97,7 @@ abstract class AbstractResource */ public $parentResource; - #[Groups(['resource_node:read', 'document:read'])] + #[Groups(['resource_node:read', 'document:read', 'attendance:read'])] public ?array $resourceLinkListFromEntity = null; /** @@ -105,7 +106,7 @@ abstract class AbstractResource * * @var array> */ - #[Groups(['c_tool_intro:write', 'resource_node:write', 'c_student_publication:write', 'calendar_event:write'])] + #[Groups(['c_tool_intro:write', 'resource_node:write', 'c_student_publication:write', 'calendar_event:write', 'attendance:write'])] public array $resourceLinkList = []; /** diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20250126180000.php b/src/CoreBundle/Migrations/Schema/V200/Version20250126180000.php new file mode 100644 index 0000000000..45aee12545 --- /dev/null +++ b/src/CoreBundle/Migrations/Schema/V200/Version20250126180000.php @@ -0,0 +1,30 @@ +addSql('ALTER TABLE c_attendance_sheet MODIFY presence INT NULL'); + } + + public function down(Schema $schema): void + { + // Revert the column type back to TINYINT(1) + $this->addSql('ALTER TABLE c_attendance_sheet MODIFY presence TINYINT(1) NOT NULL'); + } +} diff --git a/src/CoreBundle/Repository/GradeBookCategoryRepository.php b/src/CoreBundle/Repository/GradeBookCategoryRepository.php index 38322d0f85..d4c9e2391a 100644 --- a/src/CoreBundle/Repository/GradeBookCategoryRepository.php +++ b/src/CoreBundle/Repository/GradeBookCategoryRepository.php @@ -6,14 +6,77 @@ declare(strict_types=1); namespace Chamilo\CoreBundle\Repository; +use Chamilo\CoreBundle\Entity\Course; use Chamilo\CoreBundle\Entity\GradebookCategory; +use Chamilo\CoreBundle\Entity\Session; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; class GradeBookCategoryRepository extends ServiceEntityRepository { - public function __construct(ManagerRegistry $registry) + private EntityManagerInterface $entityManager; + + public function __construct(ManagerRegistry $registry, EntityManagerInterface $entityManager) { parent::__construct($registry, GradebookCategory::class); + $this->entityManager = $entityManager; + } + + /** + * Retrieves gradebook categories for a specific course and optional session. + * + * @param int $courseId The ID of the course. + * @param int|null $sessionId The ID of the session (optional). + * @return GradebookCategory[] A list of gradebook categories. + */ + public function getCategoriesForCourse(int $courseId, ?int $sessionId = null): array + { + $qb = $this->createQueryBuilder('gc') + ->where('gc.course = :courseId') + ->setParameter('courseId', $courseId); + + if ($sessionId !== null) { + $qb->andWhere('gc.session = :sessionId') + ->setParameter('sessionId', $sessionId); + } else { + $qb->andWhere('gc.session IS NULL'); + } + + $qb->orderBy('gc.title', 'ASC'); + + return $qb->getQuery()->getResult(); + } + + /** + * Creates a default gradebook category for a course if it doesn't already exist. + * + * @param int $courseId The ID of the course. + * @param int|null $sessionId The ID of the session (optional). + * @return GradebookCategory The default category. + */ + public function createDefaultCategory(int $courseId, ?int $sessionId = null): GradebookCategory + { + $existingCategory = $this->findOneBy([ + 'course' => $courseId, + 'session' => $sessionId, + 'parent' => null, // Root category + ]); + + if ($existingCategory) { + return $existingCategory; // Return existing category + } + + $defaultCategory = new GradebookCategory(); + $defaultCategory->setTitle('Default'); + $defaultCategory->setCourse($this->entityManager->getReference(Course::class, $courseId)); + $defaultCategory->setSession($sessionId ? $this->entityManager->getReference(Session::class, $sessionId) : null); + $defaultCategory->setWeight(1.0); + $defaultCategory->setVisible(true); + + $this->entityManager->persist($defaultCategory); + $this->entityManager->flush(); + + return $defaultCategory; } } diff --git a/src/CoreBundle/Repository/Node/UserRepository.php b/src/CoreBundle/Repository/Node/UserRepository.php index 9e7d2cb04c..a42ddbc5cb 100644 --- a/src/CoreBundle/Repository/Node/UserRepository.php +++ b/src/CoreBundle/Repository/Node/UserRepository.php @@ -8,11 +8,13 @@ namespace Chamilo\CoreBundle\Repository\Node; use Chamilo\CoreBundle\Entity\AccessUrl; use Chamilo\CoreBundle\Entity\Course; +use Chamilo\CoreBundle\Entity\CourseRelUser; use Chamilo\CoreBundle\Entity\ExtraField; use Chamilo\CoreBundle\Entity\ExtraFieldValues; use Chamilo\CoreBundle\Entity\Message; use Chamilo\CoreBundle\Entity\ResourceNode; use Chamilo\CoreBundle\Entity\Session; +use Chamilo\CoreBundle\Entity\SessionRelCourseRelUser; use Chamilo\CoreBundle\Entity\Tag; use Chamilo\CoreBundle\Entity\TrackELogin; use Chamilo\CoreBundle\Entity\TrackEOnline; @@ -22,6 +24,7 @@ use Chamilo\CoreBundle\Entity\UsergroupRelUser; use Chamilo\CoreBundle\Entity\UserRelTag; use Chamilo\CoreBundle\Entity\UserRelUser; use Chamilo\CoreBundle\Repository\ResourceRepository; +use Chamilo\CourseBundle\Entity\CGroupRelUser; use Datetime; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; @@ -1123,4 +1126,50 @@ class UserRepository extends ResourceRepository implements PasswordUpgraderInter return $qb->getQuery()->getResult(); } + + public function findUsersByContext(int $courseId, ?int $sessionId = null, ?int $groupId = null): array + { + $course = $this->_em->getRepository(Course::class)->find($courseId); + if (!$course) { + throw new \InvalidArgumentException('Course not found.'); + } + + if ($sessionId !== null) { + $session = $this->_em->getRepository(Session::class)->find($sessionId); + if (!$session) { + throw new \InvalidArgumentException('Session not found.'); + } + + $list = $session->getSessionRelCourseRelUsersByStatus($course, Session::STUDENT); + $users = []; + + if ($list) { + foreach ($list as $sessionCourseUser) { + $users[$sessionCourseUser->getUser()->getId()] = $sessionCourseUser->getUser(); + } + } + + return array_values($users); + } + + if ($groupId !== null) { + $qb = $this->_em->createQueryBuilder(); + $qb->select('u') + ->from(CGroupRelUser::class, 'cgru') + ->innerJoin('cgru.user', 'u') + ->where('cgru.cId = :courseId') + ->andWhere('cgru.group = :groupId') + ->setParameters([ + 'courseId' => $courseId, + 'groupId' => $groupId, + ]) + ->orderBy('u.lastname', 'ASC') + ->addOrderBy('u.firstname', 'ASC'); + + return $qb->getQuery()->getResult(); + } + + $queryBuilder = $this->_em->getRepository(Course::class)->getSubscribedStudents($course); + return $queryBuilder->getQuery()->getResult(); + } } diff --git a/src/CoreBundle/State/CAttendanceStateProcessor.php b/src/CoreBundle/State/CAttendanceStateProcessor.php new file mode 100644 index 0000000000..1382ee2b73 --- /dev/null +++ b/src/CoreBundle/State/CAttendanceStateProcessor.php @@ -0,0 +1,133 @@ +getName(); + + error_log("Processing $operationName"); + + match ($operationName) { + 'toggle_visibility' => $this->handleToggleVisibility($data), + 'soft_delete' => $this->handleSoftDelete($data), + 'calendar_add' => $this->handleAddCalendar($data), + default => throw new BadRequestHttpException('Operation not supported.'), + }; + + $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + private function handleToggleVisibility(CAttendance $attendance): void + { + $attendance->setActive($attendance->getActive() === 1 ? 0 : 1); + $this->entityManager->persist($attendance); + } + + private function handleSoftDelete(CAttendance $attendance): void + { + $attendance->setActive(2); + $this->entityManager->persist($attendance); + } + + private function handleAddCalendar(CAttendance $attendance): void + { + $request = $this->requestStack->getCurrentRequest(); + $data = json_decode($request->getContent(), true); + + if (!$data) { + throw new BadRequestHttpException('Request data is required to create a calendar.'); + } + + $startDate = new \DateTime($data['startDate']); + $repeatDate = $data['repeatDate'] ?? false; + $repeatType = $data['repeatType'] ?? null; + $repeatDays = $data['repeatDays'] ?? null; + $endDate = $repeatDate ? new \DateTime($data['repeatEndDate']) : null; + $groupId = $data['group'] ?? 0; + + $this->saveCalendar($attendance, $startDate, $groupId); + + if ($repeatDate && $repeatType && $endDate) { + $interval = $this->getRepeatInterval($repeatType, $repeatDays); + $currentDate = clone $startDate; + + while ($currentDate < $endDate) { + $currentDate->add($interval); + $this->saveCalendar($attendance, $currentDate, $groupId); + } + } + } + + private function saveCalendar(CAttendance $attendance, \DateTime $date, ?int $groupId): void + { + $existingCalendar = $this->calendarRepo->findOneBy([ + 'attendance' => $attendance->getIid(), + 'dateTime' => $date, + ]); + + if ($existingCalendar) { + + return; + } + + $calendar = new CAttendanceCalendar(); + $calendar->setAttendance($attendance); + $calendar->setDateTime($date); + $calendar->setDoneAttendance(false); + $calendar->setBlocked(false); + + $this->entityManager->persist($calendar); + $this->entityManager->flush(); + + if (!empty($groupId)) { + $this->addAttendanceCalendarToGroup($calendar, $groupId); + } + } + + private function addAttendanceCalendarToGroup(CAttendanceCalendar $calendar, int $groupId): void + { + $repository = $this->entityManager->getRepository(CAttendanceCalendarRelGroup::class); + $repository->addGroupToCalendar($calendar->getIid(), $groupId); + } + + private function getRepeatInterval(string $repeatType, ?int $repeatDays = null): \DateInterval + { + return match ($repeatType) { + 'daily' => new \DateInterval('P1D'), + 'weekly' => new \DateInterval('P7D'), + 'bi-weekly' => new \DateInterval('P14D'), + 'every-x-days' => new \DateInterval("P{$repeatDays}D"), + 'monthly-by-date' => new \DateInterval('P1M'), + default => throw new BadRequestHttpException('Invalid repeat type.'), + }; + } +} diff --git a/src/CoreBundle/Tool/Attendance.php b/src/CoreBundle/Tool/Attendance.php index 560d6b14bf..da8337cf0b 100644 --- a/src/CoreBundle/Tool/Attendance.php +++ b/src/CoreBundle/Tool/Attendance.php @@ -17,7 +17,7 @@ class Attendance extends AbstractTool implements ToolInterface public function getLink(): string { - return '/main/attendance/index.php'; + return '/resources/attendance/:nodeId/'; } public function getIcon(): string diff --git a/src/CourseBundle/Entity/CAttendance.php b/src/CourseBundle/Entity/CAttendance.php index ec74510dba..a734290954 100644 --- a/src/CourseBundle/Entity/CAttendance.php +++ b/src/CourseBundle/Entity/CAttendance.php @@ -6,37 +6,110 @@ declare(strict_types=1); namespace Chamilo\CourseBundle\Entity; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; use Chamilo\CoreBundle\Entity\AbstractResource; use Chamilo\CoreBundle\Entity\ResourceInterface; +use Chamilo\CoreBundle\State\CAttendanceStateProcessor; use Chamilo\CourseBundle\Repository\CAttendanceRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Stringable; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; +#[ApiResource( + shortName: 'Attendances', + operations: [ + new Put( + uriTemplate: '/attendances/{iid}/toggle_visibility', + openapiContext: [ + 'summary' => 'Toggle visibility of the attendance\'s associated ResourceLink', + ], + security: "is_granted('EDIT', object.resourceNode)", + name: 'toggle_visibility', + processor: CAttendanceStateProcessor::class + ), + new Put( + uriTemplate: '/attendances/{iid}/soft_delete', + openapiContext: [ + 'summary' => 'Soft delete the attendance', + ], + security: "is_granted('EDIT', object.resourceNode)", + name: 'soft_delete', + processor: CAttendanceStateProcessor::class + ), + new Delete(security: "is_granted('ROLE_TEACHER')"), + new Post( + uriTemplate: '/attendances/{iid}/calendars', + openapiContext: ['summary' => 'Add a calendar to an attendance.'], + denormalizationContext: ['groups' => ['attendance:write']], + name: 'calendar_add', + processor: CAttendanceStateProcessor::class + ), + new GetCollection( + openapiContext: [ + 'parameters' => [ + [ + 'name' => 'resourceNode.parent', + 'in' => 'query', + 'required' => true, + 'description' => 'Resource node Parent', + 'schema' => ['type' => 'integer'], + ], + ], + ], + ), + new Get(security: "is_granted('VIEW', object.resourceNode)"), + new Post( + denormalizationContext: ['groups' => ['attendance:write']], + security: "is_granted('ROLE_TEACHER')", + validationContext: ['groups' => ['Default']] + ), + new Put( + denormalizationContext: ['groups' => ['attendance:write']], + security: "is_granted('ROLE_TEACHER')" + ), + ], + normalizationContext: ['groups' => ['attendance:read']], + denormalizationContext: ['groups' => ['attendance:write']], + paginationEnabled: true, +)] +#[ApiFilter(SearchFilter::class, properties: ['active' => 'exact', 'title' => 'partial', 'resourceNode.parent' => 'exact'])] #[ORM\Table(name: 'c_attendance')] -#[ORM\Index(name: 'active', columns: ['active'])] +#[ORM\Index(columns: ['active'], name: 'active')] #[ORM\Entity(repositoryClass: CAttendanceRepository::class)] class CAttendance extends AbstractResource implements ResourceInterface, Stringable { #[ORM\Column(name: 'iid', type: 'integer')] #[ORM\Id] #[ORM\GeneratedValue] + #[Groups(['attendance:read'])] protected ?int $iid = null; #[Assert\NotBlank] #[ORM\Column(name: 'title', type: 'text', nullable: false)] + #[Groups(['attendance:read', 'attendance:write'])] protected string $title; #[ORM\Column(name: 'description', type: 'text', nullable: true)] - protected ?string $description; + #[Groups(['attendance:read', 'attendance:write'])] + protected ?string $description = null; #[Assert\NotBlank] #[ORM\Column(name: 'active', type: 'integer', nullable: false)] - protected int $active; + #[Groups(['attendance:read', 'attendance:write'])] + protected int $active = 1; #[ORM\Column(name: 'attendance_qualify_title', type: 'string', length: 255, nullable: true)] + #[Groups(['attendance:read', 'attendance:write'])] protected ?string $attendanceQualifyTitle = null; #[Assert\NotNull] @@ -45,7 +118,8 @@ class CAttendance extends AbstractResource implements ResourceInterface, Stringa #[Assert\NotNull] #[ORM\Column(name: 'attendance_weight', type: 'float', precision: 6, scale: 2, nullable: false)] - protected float $attendanceWeight; + #[Groups(['attendance:read', 'attendance:write'])] + protected float $attendanceWeight = 0.0; #[Assert\NotNull] #[ORM\Column(name: 'locked', type: 'integer', nullable: false)] @@ -54,19 +128,20 @@ class CAttendance extends AbstractResource implements ResourceInterface, Stringa /** * @var Collection|CAttendanceCalendar[] */ - #[ORM\OneToMany(targetEntity: CAttendanceCalendar::class, mappedBy: 'attendance', cascade: ['persist', 'remove'])] + #[ORM\OneToMany(mappedBy: 'attendance', targetEntity: CAttendanceCalendar::class, cascade: ['persist', 'remove'])] + #[Groups(['attendance:read'])] protected Collection $calendars; /** * @var Collection|CAttendanceResult[] */ - #[ORM\OneToMany(targetEntity: CAttendanceResult::class, mappedBy: 'attendance', cascade: ['persist', 'remove'])] + #[ORM\OneToMany(mappedBy: 'attendance', targetEntity: CAttendanceResult::class, cascade: ['persist', 'remove'])] protected Collection $results; /** * @var Collection|CAttendanceSheetLog[] */ - #[ORM\OneToMany(targetEntity: CAttendanceSheetLog::class, mappedBy: 'attendance', cascade: ['persist', 'remove'])] + #[ORM\OneToMany(mappedBy: 'attendance', targetEntity: CAttendanceSheetLog::class, cascade: ['persist', 'remove'])] protected Collection $logs; public function __construct() @@ -145,7 +220,7 @@ class CAttendance extends AbstractResource implements ResourceInterface, Stringa * * @return int */ - public function getAttendanceQualifyMax() + public function getAttendanceQualifyMax(): int { return $this->attendanceQualifyMax; } @@ -162,7 +237,7 @@ class CAttendance extends AbstractResource implements ResourceInterface, Stringa * * @return float */ - public function getAttendanceWeight() + public function getAttendanceWeight(): float { return $this->attendanceWeight; } @@ -179,7 +254,7 @@ class CAttendance extends AbstractResource implements ResourceInterface, Stringa * * @return int */ - public function getLocked() + public function getLocked(): int { return $this->locked; } @@ -201,6 +276,16 @@ class CAttendance extends AbstractResource implements ResourceInterface, Stringa return $this; } + public function addCalendar(CAttendanceCalendar $calendar): self + { + if (!$this->calendars->contains($calendar)) { + $this->calendars->add($calendar); + $calendar->setAttendance($this); + } + + return $this; + } + /** * @return CAttendanceSheetLog[]|Collection */ diff --git a/src/CourseBundle/Entity/CAttendanceCalendar.php b/src/CourseBundle/Entity/CAttendanceCalendar.php index c1b948be33..082199560d 100644 --- a/src/CourseBundle/Entity/CAttendanceCalendar.php +++ b/src/CourseBundle/Entity/CAttendanceCalendar.php @@ -6,10 +6,18 @@ declare(strict_types=1); namespace Chamilo\CourseBundle\Entity; +use ApiPlatform\Metadata\ApiResource; use DateTime; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; - +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource( + normalizationContext: ['groups' => ['attendance_calendar:read']], + denormalizationContext: ['groups' => ['attendance_calendar:write']], + paginationEnabled: false, + security: "is_granted('ROLE_TEACHER')" +)] #[ORM\Table(name: 'c_attendance_calendar')] #[ORM\Index(columns: ['done_attendance'], name: 'done_attendance')] #[ORM\Entity] @@ -18,21 +26,29 @@ class CAttendanceCalendar #[ORM\Column(name: 'iid', type: 'integer')] #[ORM\Id] #[ORM\GeneratedValue] + #[Groups(['attendance:read', 'attendance_calendar:read'])] protected ?int $iid = null; - #[ORM\ManyToOne(targetEntity: CAttendance::class, cascade: ['remove'], inversedBy: 'calendars')] + #[ORM\ManyToOne(targetEntity: CAttendance::class, inversedBy: 'calendars')] #[ORM\JoinColumn(name: 'attendance_id', referencedColumnName: 'iid', onDelete: 'CASCADE')] + #[Groups(['attendance:write', 'attendance:read', 'attendance_calendar:read'])] protected CAttendance $attendance; #[ORM\Column(name: 'date_time', type: 'datetime', nullable: false)] + #[Groups(['attendance:read', 'attendance:write', 'attendance_calendar:read', 'attendance_calendar:write'])] protected DateTime $dateTime; #[ORM\Column(name: 'done_attendance', type: 'boolean', nullable: false)] + #[Groups(['attendance:read', 'attendance:write'])] protected bool $doneAttendance; #[ORM\Column(name: 'blocked', type: 'boolean', nullable: false)] + #[Groups(['attendance:read', 'attendance:write'])] protected bool $blocked; + #[ORM\Column(name: 'duration', type: 'integer', nullable: true)] + protected ?int $duration = null; + /** * @var Collection */ @@ -43,9 +59,6 @@ class CAttendanceCalendar )] protected Collection $sheets; - #[ORM\Column(name: 'duration', type: 'integer', nullable: true)] - protected ?int $duration = null; - public function getIid(): ?int { return $this->iid; diff --git a/src/CourseBundle/Entity/CAttendanceCalendarRelGroup.php b/src/CourseBundle/Entity/CAttendanceCalendarRelGroup.php index e6097995a7..3ca13f31da 100644 --- a/src/CourseBundle/Entity/CAttendanceCalendarRelGroup.php +++ b/src/CourseBundle/Entity/CAttendanceCalendarRelGroup.php @@ -6,13 +6,14 @@ declare(strict_types=1); namespace Chamilo\CourseBundle\Entity; +use Chamilo\CourseBundle\Repository\CAttendanceCalendarRelGroupRepository; use Doctrine\ORM\Mapping as ORM; /** * CAttendanceCalendarRelGroup. */ #[ORM\Table(name: 'c_attendance_calendar_rel_group')] -#[ORM\Entity] +#[ORM\Entity(repositoryClass: CAttendanceCalendarRelGroupRepository::class)] class CAttendanceCalendarRelGroup { #[ORM\Column(name: 'iid', type: 'integer')] diff --git a/src/CourseBundle/Entity/CAttendanceResult.php b/src/CourseBundle/Entity/CAttendanceResult.php index 6ba9969742..be182a95d5 100644 --- a/src/CourseBundle/Entity/CAttendanceResult.php +++ b/src/CourseBundle/Entity/CAttendanceResult.php @@ -21,6 +21,7 @@ class CAttendanceResult #[ORM\ManyToOne(targetEntity: User::class)] #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[Groups(['attendance:read'])] protected User $user; #[ORM\ManyToOne(targetEntity: CAttendance::class, inversedBy: 'results')] diff --git a/src/CourseBundle/Entity/CAttendanceSheet.php b/src/CourseBundle/Entity/CAttendanceSheet.php index 3d9fc32819..1554a39021 100644 --- a/src/CourseBundle/Entity/CAttendanceSheet.php +++ b/src/CourseBundle/Entity/CAttendanceSheet.php @@ -20,9 +20,19 @@ class CAttendanceSheet #[ORM\GeneratedValue] protected ?int $iid = null; + /** + * Attendance status for each user on a given date: + * - 0: Absent (Score: 0) + * - 1: Present (Score: 1) + * - 2: Late less than 15 minutes (Score: 1) + * - 3: Late more than 15 minutes (Score: 0.5) + * - 4: Absent but justified (Score: 0.25) + * + * Scores are tentative and can be used for gradebook calculations. + */ #[Assert\NotNull] - #[ORM\Column(name: 'presence', type: 'boolean', nullable: false)] - protected bool $presence; + #[ORM\Column(name: 'presence', type: 'integer', nullable: true)] + protected ?int $presence = null; #[ORM\ManyToOne(targetEntity: User::class)] #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')] @@ -33,28 +43,33 @@ class CAttendanceSheet protected CAttendanceCalendar $attendanceCalendar; #[ORM\Column(name: 'signature', type: 'text', nullable: true)] - protected string $signature; + protected ?string $signature; - public function setPresence(bool $presence): self + public function getIid(): ?int + { + return $this->iid; + } + + public function setPresence(?int $presence): self { $this->presence = $presence; return $this; } - public function getPresence(): bool + public function getPresence(): ?int { return $this->presence; } - public function setSignature(string $signature): static + public function setSignature(?string $signature): static { $this->signature = $signature; return $this; } - public function getSignature(): string + public function getSignature(): ?string { return $this->signature; } diff --git a/src/CourseBundle/Entity/CGroup.php b/src/CourseBundle/Entity/CGroup.php index c2f39ca605..6c4c079f43 100644 --- a/src/CourseBundle/Entity/CGroup.php +++ b/src/CourseBundle/Entity/CGroup.php @@ -6,23 +6,47 @@ declare(strict_types=1); namespace Chamilo\CourseBundle\Entity; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; -use Chamilo\CoreBundle\Entity\AbstractResource; -use Chamilo\CoreBundle\Entity\ResourceInterface; -use Chamilo\CoreBundle\Entity\User; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Get; use Chamilo\CourseBundle\Repository\CGroupRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Chamilo\CoreBundle\Entity\AbstractResource; +use Chamilo\CoreBundle\Entity\ResourceInterface; +use Chamilo\CoreBundle\Entity\User; use Stringable; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Uid\Uuid; use Symfony\Component\Validator\Constraints as Assert; -/** - * Course groups. - */ -#[ApiResource(normalizationContext: ['groups' => ['group:read']], security: "is_granted('ROLE_ADMIN')")] +#[ApiResource( + shortName: 'Groups', + operations: [ + new GetCollection( + uriTemplate: '/groups', + openapiContext: [ + 'parameters' => [ + [ + 'name' => 'resourceNode.parent', + 'in' => 'query', + 'required' => true, + 'description' => 'Filter groups by the parent resource node (course)', + 'schema' => ['type' => 'integer'], + ], + ], + ] + ), + new Get(security: "is_granted('VIEW', object.resourceNode)") + ], + normalizationContext: ['groups' => ['group:read']], + denormalizationContext: ['groups' => ['group:write']], + paginationEnabled: true +)] +#[ApiFilter(SearchFilter::class, properties: ['resourceNode.parent' => 'exact'])] #[ORM\Table(name: 'c_group_info')] #[ORM\Entity(repositoryClass: CGroupRepository::class)] class CGroup extends AbstractResource implements ResourceInterface, Stringable @@ -42,11 +66,13 @@ class CGroup extends AbstractResource implements ResourceInterface, Stringable protected string $title; #[Assert\NotNull] #[ORM\Column(name: 'status', type: 'boolean', nullable: false)] + #[Groups(['group:read', 'group:write'])] protected bool $status; #[ORM\ManyToOne(targetEntity: CGroupCategory::class, cascade: ['persist'])] #[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'iid', onDelete: 'CASCADE')] protected ?CGroupCategory $category = null; #[ORM\Column(name: 'description', type: 'text', nullable: true)] + #[Groups(['group:read', 'group:write'])] protected ?string $description = null; #[Assert\NotBlank] #[ORM\Column(name: 'max_student', type: 'integer')] diff --git a/src/CourseBundle/Repository/CAttendanceCalendarRelGroupRepository.php b/src/CourseBundle/Repository/CAttendanceCalendarRelGroupRepository.php new file mode 100644 index 0000000000..cfa2348f7a --- /dev/null +++ b/src/CourseBundle/Repository/CAttendanceCalendarRelGroupRepository.php @@ -0,0 +1,42 @@ +getEntityManager(); + $existingRelation = $this->findOneBy([ + 'attendanceCalendar' => $calendarId, + 'group' => $groupId, + ]); + + if (!$existingRelation) { + $relation = new CAttendanceCalendarRelGroup(); + $relation->setAttendanceCalendar($em->getReference(CAttendanceCalendar::class, $calendarId)); + $relation->setGroup($em->getReference(CGroup::class, $groupId)); + + $em->persist($relation); + $em->flush(); + } + } +} diff --git a/src/CourseBundle/Repository/CAttendanceCalendarRepository.php b/src/CourseBundle/Repository/CAttendanceCalendarRepository.php new file mode 100644 index 0000000000..1f48aba69c --- /dev/null +++ b/src/CourseBundle/Repository/CAttendanceCalendarRepository.php @@ -0,0 +1,184 @@ +createQueryBuilder('c') + ->where('c.attendance = :attendanceId') + ->setParameter('attendanceId', $attendanceId) + ->orderBy('c.dateTime', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Deletes all calendar events associated with a specific attendance. + * + * @param int $attendanceId + * @return int The number of deleted records + */ + public function deleteAllByAttendance(int $attendanceId): int + { + return $this->createQueryBuilder('c') + ->delete() + ->where('c.attendance = :attendanceId') + ->setParameter('attendanceId', $attendanceId) + ->getQuery() + ->execute(); + } + + /** + * Finds a specific calendar event by its ID and the associated attendance ID. + * + * @param int $calendarId + * @param int $attendanceId + * @return CAttendanceCalendar|null + */ + public function findByIdAndAttendance(int $calendarId, int $attendanceId): ?CAttendanceCalendar + { + return $this->createQueryBuilder('c') + ->where('c.id = :calendarId') + ->andWhere('c.attendance = :attendanceId') + ->setParameters([ + 'calendarId' => $calendarId, + 'attendanceId' => $attendanceId, + ]) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * Retrieves calendar events filtered by a date range. + * + * @param int $attendanceId + * @param \DateTime|null $startDate + * @param \DateTime|null $endDate + * @return CAttendanceCalendar[] + */ + public function findByDateRange( + int $attendanceId, + ?\DateTime $startDate, + ?\DateTime $endDate + ): array { + $qb = $this->createQueryBuilder('c') + ->where('c.attendance = :attendanceId') + ->setParameter('attendanceId', $attendanceId); + + if ($startDate) { + $qb->andWhere('c.dateTime >= :startDate') + ->setParameter('startDate', $startDate); + } + + if ($endDate) { + $qb->andWhere('c.dateTime <= :endDate') + ->setParameter('endDate', $endDate); + } + + return $qb->orderBy('c.dateTime', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Checks if a calendar event is blocked. + * + * @param int $calendarId + * @return bool + */ + public function isBlocked(int $calendarId): bool + { + return (bool) $this->createQueryBuilder('c') + ->select('c.blocked') + ->where('c.id = :calendarId') + ->setParameter('calendarId', $calendarId) + ->getQuery() + ->getSingleScalarResult(); + } + + public function findAttendanceWithData(int $attendanceId): array + { + $calendars = $this->createQueryBuilder('calendar') + ->andWhere('calendar.attendance = :attendanceId') + ->setParameter('attendanceId', $attendanceId) + ->orderBy('calendar.dateTime', 'ASC') + ->getQuery() + ->getResult(); + + $attendanceDates = array_map(function (CAttendanceCalendar $calendar) { + return [ + 'id' => $calendar->getIid(), + 'label' => $calendar->getDateTime()->format('M d, Y - h:i A'), + ]; + }, $calendars); + + $attendanceData = []; + foreach ($calendars as $calendar) { + /* @var CAttendanceSheet $sheet */ + foreach ($calendar->getSheets() as $sheet) { + $key = $sheet->getUser()->getId() . '-' . $calendar->getIid(); + $attendanceData[$key] = (int) $sheet->getPresence(); // Status: 1 (Present), 0 (Absent), null (No Status) + } + } + + return [ + 'attendanceDates' => $attendanceDates, + 'attendanceData' => $attendanceData, + ]; + } + + public function countByAttendanceAndGroup(int $attendanceId, ?int $groupId = null): int + { + $qb = $this->createQueryBuilder('calendar') + ->select('COUNT(calendar.iid)') + ->where('calendar.attendance = :attendanceId') + ->setParameter('attendanceId', $attendanceId); + + if ($groupId) { + $qb->join('calendar.groups', 'groups') + ->andWhere('groups.group = :groupId') + ->setParameter('groupId', $groupId); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + public function countDoneAttendanceByAttendanceAndGroup(int $attendanceId, ?int $groupId = null): int + { + $qb = $this->createQueryBuilder('calendar') + ->select('COUNT(calendar.iid)') + ->where('calendar.attendance = :attendanceId') + ->andWhere('calendar.doneAttendance = true') + ->setParameter('attendanceId', $attendanceId); + + if ($groupId) { + $qb->join('calendar.groups', 'groups') + ->andWhere('groups.group = :groupId') + ->setParameter('groupId', $groupId); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +} diff --git a/src/CourseBundle/Repository/CAttendanceSheetRepository.php b/src/CourseBundle/Repository/CAttendanceSheetRepository.php new file mode 100644 index 0000000000..805dfbd9da --- /dev/null +++ b/src/CourseBundle/Repository/CAttendanceSheetRepository.php @@ -0,0 +1,38 @@ +createQueryBuilder('sheet') + ->select('SUM(sheet.presence) as score') + ->join('sheet.attendanceCalendar', 'calendar') + ->where('calendar.attendance = :attendanceId') + ->andWhere('sheet.user = :userId') + ->setParameter('attendanceId', $attendanceId) + ->setParameter('userId', $userId); + + if ($groupId) { + $qb->join('calendar.groups', 'groups') + ->andWhere('groups.group = :groupId') + ->setParameter('groupId', $groupId); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +}
+ {{ + t( + "The attendance calendar allows you to register attendance lists (one per real session the students need to attend). Add new attendance lists here.", + ) + }} +
{{ t("Are you sure you want to delete this event?") }}
{{ t("No data available.") }}
+ {{ + t( + "The attendance calendar allows you to register attendance lists (one per real session the students need to attend).", + ) + }} +