66
77namespace Chamilo \CoreBundle \Command ;
88
9+ use Chamilo \CoreBundle \Command \DoctrineMigrationsMigrateCommandDecorator ;
910use Doctrine \DBAL \Connection ;
1011use Doctrine \DBAL \Exception as DbalException ;
1112use Doctrine \DBAL \ParameterType ;
@@ -54,6 +55,13 @@ protected function configure(): void
5455 'Alias of --drop-c-item-property (drops legacy table c_item_property after successful migration, only if no pending attendances remain). '
5556 );
5657
58+ $ this ->addOption (
59+ 'drop-c-id-session-id-from-c-attendance ' ,
60+ null ,
61+ InputOption::VALUE_NONE ,
62+ 'Drop legacy columns c_attendance.c_id and c_attendance.session_id after successful migration (only if no pending attendances remain). '
63+ );
64+
5765 $ this ->addOption (
5866 'dry-run ' ,
5967 null ,
@@ -73,39 +81,77 @@ protected function execute(InputInterface $input, OutputInterface $output): int
7381 $ dropItemProperty = (bool ) $ input ->getOption ('drop-c-item-property ' )
7482 || (bool ) $ input ->getOption ('drop-c-item-properties ' );
7583
84+ $ dropAttendanceLegacyColumns = (bool ) $ input ->getOption ('drop-c-id-session-id-from-c-attendance ' );
85+
7686 if ($ dryRun && $ dropItemProperty ) {
7787 $ io ->note ('Dry-run enabled: ignoring --drop-c-item-property (no schema changes will be applied). ' );
7888 $ dropItemProperty = false ;
7989 }
8090
91+ if ($ dryRun && $ dropAttendanceLegacyColumns ) {
92+ $ io ->note ('Dry-run enabled: ignoring --drop-c-id-session-id-from-c-attendance (no schema changes will be applied). ' );
93+ $ dropAttendanceLegacyColumns = false ;
94+ }
95+
8196 $ fallbackAdminId = $ this ->getFallbackAdminId ();
8297 $ uuidIsBinary = $ this ->detectUuidIsBinary ();
8398
8499 $ hasItemProperty = $ this ->tableExists ('c_item_property ' );
100+
101+ // We rely on c_attendance.c_id to map attendances to courses (c_item_property.c_id is ignored).
85102 $ hasAttendanceCId = $ this ->tableHasColumn ('c_attendance ' , 'c_id ' );
103+ $ hasAttendanceLegacyId = $ this ->tableHasColumn ('c_attendance ' , 'id ' );
104+ $ hasAttendanceSessionId = $ this ->tableHasColumn ('c_attendance ' , 'session_id ' );
105+
86106 $ hasAttendanceTitle = $ this ->tableHasColumn ('c_attendance ' , 'title ' );
87107 $ hasAttendanceName = $ this ->tableHasColumn ('c_attendance ' , 'name ' );
88108
89- // At this migration stage, c_attendance.session_id is expected to be removed.
90- // We only rely on c_item_property.session_id when available.
91- $ hasItemPropertySessionId = $ hasItemProperty && $ this ->tableHasColumn ('c_item_property ' , 'session_id ' );
92-
93- if (!$ hasItemProperty && !$ hasAttendanceCId ) {
94- $ io ->error ('Cannot determine attendance->course mapping: c_item_property does not exist and c_attendance.c_id does not exist. ' );
109+ if (!$ hasAttendanceCId ) {
110+ $ io ->error ('Cannot map attendances to courses: c_attendance.c_id is missing. This command expects c_id to be available in c_attendance. ' );
95111 return Command::FAILURE ;
96112 }
97113
98- if ($ hasItemProperty && !$ hasItemPropertySessionId ) {
99- $ io ->note ('c_item_property.session_id is not available. Session context will be stored as NULL in resource_link. ' );
114+ if (!$ hasAttendanceSessionId ) {
115+ $ io ->note ('c_attendance.session_id is not available. Session context will be stored as NULL in resource_link. ' );
116+ }
117+
118+ // Respect the same env-flag used during Doctrine migrations (only migrate gradebook-linked attendances).
119+ $ skipAttendances = (bool ) getenv (DoctrineMigrationsMigrateCommandDecorator::SKIP_ATTENDANCES_FLAG );
120+ $ gradebookIds = [];
121+
122+ if ($ skipAttendances ) {
123+ $ io ->note ('SKIP_ATTENDANCES flag detected: only gradebook-linked attendances will be migrated. ' );
124+
125+ // gradebook_link.type=7 (attendance). Some datasets may link to attendance.iid or attendance.id.
126+ $ join = 'a.iid = gl.ref_id ' ;
127+ if ($ hasAttendanceLegacyId ) {
128+ $ join = '(a.iid = gl.ref_id OR a.id = gl.ref_id) ' ;
129+ }
130+
131+ $ ids = $ this ->connection ->fetchFirstColumn (
132+ "SELECT DISTINCT a.iid
133+ FROM gradebook_link gl
134+ INNER JOIN c_attendance a ON {$ join }
135+ WHERE gl.type = 7 "
136+ );
137+
138+ $ ids = array_map ('intval ' , $ ids );
139+ $ gradebookIds = array_fill_keys ($ ids , true );
100140 }
101141
102- $ courseIds = $ this ->getCourseIdsToProcess ($ hasItemProperty , $ hasAttendanceCId );
142+ $ courseIds = $ this ->getCourseIdsToProcess ();
103143
104144 if (0 === \count ($ courseIds )) {
105145 $ io ->success ('No attendances to migrate (nothing pending). ' );
146+
106147 if ($ dropItemProperty ) {
107148 $ this ->maybeDropItemProperty ($ io );
108149 }
150+
151+ if ($ dropAttendanceLegacyColumns ) {
152+ $ this ->maybeDropAttendanceLegacyColumns ($ io );
153+ }
154+
109155 return Command::SUCCESS ;
110156 }
111157
@@ -149,11 +195,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
149195
150196 $ attendanceRows = $ this ->fetchPendingAttendancesForCourse (
151197 courseId: $ courseId ,
152- hasItemProperty: $ hasItemProperty ,
153- hasAttendanceCId: $ hasAttendanceCId ,
154198 hasAttendanceTitle: $ hasAttendanceTitle ,
155199 hasAttendanceName: $ hasAttendanceName ,
156- hasItemPropertySessionId: $ hasItemPropertySessionId
200+ hasAttendanceSessionId: $ hasAttendanceSessionId ,
201+ hasAttendanceLegacyId: $ hasAttendanceLegacyId
157202 );
158203
159204 if (0 === \count ($ attendanceRows )) {
@@ -168,22 +213,52 @@ protected function execute(InputInterface $input, OutputInterface $output): int
168213 try {
169214 foreach ($ attendanceRows as $ row ) {
170215 $ attendanceId = (int ) $ row ['iid ' ];
216+
217+ if ($ skipAttendances && !isset ($ gradebookIds [$ attendanceId ])) {
218+ continue ;
219+ }
220+
171221 $ attendanceTitle = $ this ->pickAttendanceTitle ($ row , $ attendanceId );
172222
173- // session_id is read from c_item_property (when available).
174- // Normalize 0 -> NULL as expected by resource_link.session_id.
175- $ attendanceSessionId = isset ($ row ['session_id ' ]) ? (int ) $ row ['session_id ' ] : 0 ;
176- $ attendanceSessionId = 0 === $ attendanceSessionId ? null : $ attendanceSessionId ;
223+ $ attendanceLegacyId = null ;
224+ if ($ hasAttendanceLegacyId && isset ($ row ['legacy_id ' ]) && null !== $ row ['legacy_id ' ]) {
225+ $ legacy = (int ) $ row ['legacy_id ' ];
226+ $ attendanceLegacyId = $ legacy > 0 ? $ legacy : null ;
227+ }
228+
229+ // IMPORTANT:
230+ // - We ignore c_item_property.session_id because it can be incoherent.
231+ // - We store session context using c_attendance.session_id (when available).
232+ $ attendanceSessionId = null ;
233+ if ($ hasAttendanceSessionId && isset ($ row ['attendance_session_id ' ]) && null !== $ row ['attendance_session_id ' ]) {
234+ $ tmp = (int ) $ row ['attendance_session_id ' ];
235+ $ attendanceSessionId = $ tmp > 0 ? $ tmp : null ;
236+ }
177237
238+ // Metadata from c_item_property:
239+ // - Trust only tool + ref (and optionally legacy ref=a.id).
240+ // - Do NOT filter by c_id to avoid relying on incoherent mappings.
178241 $ ip = [];
179242 if ($ hasItemProperty ) {
180- $ ip = $ this ->connection ->fetchAssociative (
181- "SELECT insert_date, lastedit_date, lastedit_user_id, visibility, start_visible, end_visible, to_group_id, to_user_id
182- FROM c_item_property
183- WHERE tool = 'attendance' AND ref = :ref AND c_id = :cid
184- LIMIT 1 " ,
185- ['ref ' => $ attendanceId , 'cid ' => $ courseId ]
186- ) ?: [];
243+ $ sql = "SELECT insert_date, lastedit_date, lastedit_user_id, visibility, start_visible, end_visible, to_group_id, to_user_id
244+ FROM c_item_property
245+ WHERE tool = 'attendance' AND ref = :iid
246+ ORDER BY insert_date ASC
247+ LIMIT 1 " ;
248+
249+ $ params = ['iid ' => $ attendanceId ];
250+
251+ if (null !== $ attendanceLegacyId ) {
252+ $ sql = "SELECT insert_date, lastedit_date, lastedit_user_id, visibility, start_visible, end_visible, to_group_id, to_user_id
253+ FROM c_item_property
254+ WHERE tool = 'attendance'
255+ AND (ref = :iid OR ref = :legacyId)
256+ ORDER BY CASE WHEN ref = :iid THEN 1 ELSE 0 END DESC, insert_date ASC
257+ LIMIT 1 " ;
258+ $ params ['legacyId ' ] = $ attendanceLegacyId ;
259+ }
260+
261+ $ ip = $ this ->connection ->fetchAssociative ($ sql , $ params ) ?: [];
187262 }
188263
189264 $ insertDate = $ ip ['insert_date ' ] ?? $ this ->nowUtc ();
@@ -236,7 +311,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
236311 'user_id ' => $ toUserId ,
237312 ]);
238313
239- // resource_node.path should follow the same structure as the standard migration :
314+ // resource_node.path format :
240315 // <parentPath>/<title>-<attendanceIid>-<nodeId>/
241316 $ segmentTitle = trim (str_replace (['/ ' , '\\' ], '- ' , $ attendanceTitle ));
242317 $ segmentTitle = preg_replace ('/\s+/ ' , ' ' , $ segmentTitle ) ?: $ segmentTitle ;
@@ -291,74 +366,49 @@ protected function execute(InputInterface $input, OutputInterface $output): int
291366 }
292367 }
293368
369+ if ($ dropAttendanceLegacyColumns ) {
370+ $ this ->maybeDropAttendanceLegacyColumns ($ io );
371+ } else {
372+ if ($ this ->tableHasColumn ('c_attendance ' , 'c_id ' ) || $ this ->tableHasColumn ('c_attendance ' , 'session_id ' )) {
373+ $ io ->note ('c_attendance legacy columns still exist. You can drop them later or rerun this command with --drop-c-id-session-id-from-c-attendance once you confirm no pending attendances remain. ' );
374+ }
375+ }
376+
294377 return Command::SUCCESS ;
295378 }
296379
297- private function getCourseIdsToProcess (bool $ hasItemProperty , bool $ hasAttendanceCId ): array
380+ private function getCourseIdsToProcess (): array
298381 {
299- if ($ hasItemProperty ) {
300- return $ this ->connection ->fetchFirstColumn (
301- "SELECT DISTINCT c_id
302- FROM c_item_property
303- WHERE tool = 'attendance'
304- ORDER BY c_id "
305- );
306- }
307-
308- // Fallback: legacy schema still has c_attendance.c_id
309- if ($ hasAttendanceCId ) {
310- return $ this ->connection ->fetchFirstColumn (
311- "SELECT DISTINCT c_id
312- FROM c_attendance
313- WHERE resource_node_id IS NULL
314- ORDER BY c_id "
315- );
316- }
317-
318- return [];
382+ // We rely on c_attendance.c_id to identify the course ownership.
383+ return $ this ->connection ->fetchFirstColumn (
384+ "SELECT DISTINCT c_id
385+ FROM c_attendance
386+ WHERE resource_node_id IS NULL
387+ AND c_id IS NOT NULL
388+ ORDER BY c_id "
389+ );
319390 }
320391
321392 private function fetchPendingAttendancesForCourse (
322393 int $ courseId ,
323- bool $ hasItemProperty ,
324- bool $ hasAttendanceCId ,
325394 bool $ hasAttendanceTitle ,
326395 bool $ hasAttendanceName ,
327- bool $ hasItemPropertySessionId
396+ bool $ hasAttendanceSessionId ,
397+ bool $ hasAttendanceLegacyId
328398 ): array {
329399 $ selectTitle = $ hasAttendanceTitle ? 'a.title ' : 'NULL AS title ' ;
330400 $ selectName = $ hasAttendanceName ? 'a.name ' : 'NULL AS name ' ;
331-
332- // session_id comes ONLY from c_item_property when available, otherwise NULL.
333- $ selectSession = $ hasItemPropertySessionId ? 'ip.session_id ' : 'NULL ' ;
334-
335- if ($ hasItemProperty ) {
336- return $ this ->connection ->fetchAllAssociative (
337- "SELECT a.iid, {$ selectTitle }, {$ selectName }, {$ selectSession } AS session_id
338- FROM c_attendance a
339- INNER JOIN c_item_property ip
340- ON ip.tool = 'attendance'
341- AND ip.ref = a.iid
342- AND ip.c_id = :cid
343- WHERE a.resource_node_id IS NULL
344- ORDER BY a.iid " ,
345- ['cid ' => $ courseId ]
346- );
347- }
348-
349- // Fallback using legacy c_id (no c_item_property available).
350- // At this stage, we cannot infer a session_id, so we store NULL.
351- if ($ hasAttendanceCId ) {
352- return $ this ->connection ->fetchAllAssociative (
353- "SELECT a.iid, {$ selectTitle }, {$ selectName }, NULL AS session_id
354- FROM c_attendance a
355- WHERE a.c_id = :cid AND a.resource_node_id IS NULL
356- ORDER BY a.iid " ,
357- ['cid ' => $ courseId ]
358- );
359- }
360-
361- return [];
401+ $ selectSession = $ hasAttendanceSessionId ? 'a.session_id AS attendance_session_id ' : 'NULL AS attendance_session_id ' ;
402+ $ selectLegacyId = $ hasAttendanceLegacyId ? 'a.id AS legacy_id ' : 'NULL AS legacy_id ' ;
403+
404+ return $ this ->connection ->fetchAllAssociative (
405+ "SELECT a.iid, {$ selectTitle }, {$ selectName }, {$ selectSession }, {$ selectLegacyId }
406+ FROM c_attendance a
407+ WHERE a.c_id = :cid
408+ AND a.resource_node_id IS NULL
409+ ORDER BY a.iid " ,
410+ ['cid ' => $ courseId ]
411+ );
362412 }
363413
364414 private function pickAttendanceTitle (array $ row , int $ attendanceId ): string
@@ -405,6 +455,57 @@ private function maybeDropItemProperty(SymfonyStyle $io): void
405455 }
406456 }
407457
458+ /**
459+ * Drops legacy columns from c_attendance.
460+ * Only runs if no pending attendances remain.
461+ */
462+ private function maybeDropAttendanceLegacyColumns (SymfonyStyle $ io ): void
463+ {
464+ if (!$ this ->tableExists ('c_attendance ' )) {
465+ $ io ->note ('Table "c_attendance" does not exist - nothing to drop. ' );
466+ return ;
467+ }
468+
469+ $ pending = (int ) $ this ->connection ->fetchOne ('SELECT COUNT(*) FROM c_attendance WHERE resource_node_id IS NULL ' );
470+ if ($ pending > 0 ) {
471+ $ io ->warning ("Not dropping legacy columns from c_attendance: {$ pending } attendances are still pending (resource_node_id IS NULL). " );
472+ return ;
473+ }
474+
475+ $ sm = $ this ->connection ->createSchemaManager ();
476+ $ table = $ sm ->introspectTable ('c_attendance ' );
477+
478+ $ columnsToDrop = ['c_id ' , 'session_id ' ];
479+ $ dropList = [];
480+
481+ foreach ($ columnsToDrop as $ col ) {
482+ if ($ table ->hasColumn ($ col )) {
483+ $ dropList [] = $ col ;
484+ }
485+ }
486+
487+ if (0 === \count ($ dropList )) {
488+ $ io ->note ('c_attendance does not have legacy columns c_id/session_id - nothing to drop. ' );
489+ return ;
490+ }
491+
492+ $ io ->section ('Dropping legacy columns from c_attendance... ' );
493+
494+ try {
495+ foreach ($ dropList as $ col ) {
496+ // Keep it explicit to avoid relying on non-portable "IF EXISTS" syntax.
497+ $ this ->connection ->executeStatement ("ALTER TABLE c_attendance DROP COLUMN {$ col }" );
498+ $ io ->writeln (" - Dropped column c_attendance. {$ col }" );
499+ }
500+
501+ $ io ->success ('Legacy columns dropped from c_attendance. ' );
502+ } catch (DbalException $ e ) {
503+ $ io ->error ('Failed to drop legacy columns from c_attendance: ' .$ e ->getMessage ());
504+ } catch (\Throwable $ e ) {
505+ $ io ->error ('Failed to drop legacy columns from c_attendance: ' .$ e ->getMessage ());
506+ }
507+ }
508+
408509 private function getFallbackAdminId (): int
409510 {
410511 $ id = $ this ->connection ->fetchOne (
0 commit comments