diff --git a/assets/vue/components/course/CourseCard.vue b/assets/vue/components/course/CourseCard.vue index 20a7c6ca4e..390a6b419d 100644 --- a/assets/vue/components/course/CourseCard.vue +++ b/assets/vue/components/course/CourseCard.vue @@ -26,6 +26,9 @@ v-text="session.title" /> {{ course.title }} + + ({{ (course.duration / 60 / 60).toFixed(2) }} hours) + 'true' === platformConfigStore.getSetting("course.show_course_duration")) + const teachers = computed(() => { if (props.session?.courseCoachesSubscriptions) { return props.session.courseCoachesSubscriptions diff --git a/assets/vue/graphql/queries/Course.js b/assets/vue/graphql/queries/Course.js index 3421ba6105..d619d70fff 100644 --- a/assets/vue/graphql/queries/Course.js +++ b/assets/vue/graphql/queries/Course.js @@ -9,6 +9,7 @@ export const GET_STICKY_COURSES = gql` title illustrationUrl sticky + duration } } } diff --git a/assets/vue/graphql/queries/CourseRelUser.js b/assets/vue/graphql/queries/CourseRelUser.js index 87df143edd..68429daef6 100644 --- a/assets/vue/graphql/queries/CourseRelUser.js +++ b/assets/vue/graphql/queries/CourseRelUser.js @@ -8,7 +8,8 @@ export const GET_COURSE_REL_USER = gql` course { _id, title, - illustrationUrl + illustrationUrl, + duration, users(status: 1, first: 4) { edges { node { diff --git a/assets/vue/views/course/CatalogueCourses.vue b/assets/vue/views/course/CatalogueCourses.vue index 5df86301b8..9dccafd082 100644 --- a/assets/vue/views/course/CatalogueCourses.vue +++ b/assets/vue/views/course/CatalogueCourses.vue @@ -40,7 +40,7 @@ {{ $t("Loading courses. Please wait.") }} - + {{ data.title }} {{ data.description }} + + + + + {{ (data.duration / 60 / 60).toFixed(2) }} hours + + + + - + + {{ data.teachers.map(teacher => teacher.user.fullName).join(', ') }} + {{ data.courseLanguage }} @@ -99,7 +110,7 @@ :header="$t('Categories')" :sortable="true" field="categories" - style="min-width: 11rem" + style="min-width: 8rem; text-align: center;" > - - + @@ -163,12 +174,16 @@ import DataTable from "primevue/datatable" import Column from "primevue/column" import Rating from "primevue/rating" import TeacherBar from "../../components/TeacherBar.vue" -import BaseAppLink from "../../components/basecomponents/BaseAppLink.vue" +import { usePlatformConfig } from "../../store/platformConfig" const status = ref(null) const courses = ref([]) const filters = ref(null) +const platformConfigStore = usePlatformConfig() +const showCourseDuration = 'true' === platformConfigStore.getSetting("course.show_course_duration") + + const load = function () { status.value = true axios @@ -176,7 +191,13 @@ const load = function () { .then((response) => { status.value = false if (Array.isArray(response.data)) { - response.data.forEach((course) => (course.courseLanguage = getOriginalLanguageName(course.courseLanguage))) + response.data.forEach((course) => { + course.courseLanguage = getOriginalLanguageName(course.courseLanguage) + + if (course.duration) { + course.duration = course.duration + } + }) courses.value = response.data } }) @@ -265,3 +286,24 @@ const onRatingChange = function (event, trackCourseRanking, courseId) { load() initFilters() + diff --git a/public/main/admin/course_add.php b/public/main/admin/course_add.php index 28f57d7685..5748851187 100644 --- a/public/main/admin/course_add.php +++ b/public/main/admin/course_add.php @@ -148,6 +148,14 @@ $form->addCheckBox('sticky', null, get_lang('Sticky')); $obj = new GradeModel(); $obj->fill_grade_model_select_in_form($form); +if ('true' === api_get_setting('course.show_course_duration')) { + $form->addElement('text', 'duration', get_lang('Duration (in minutes)'), [ + 'id' => 'duration', + 'maxlength' => 10, + ]); + $form->addRule('duration', get_lang('This field should be numeric'), 'numeric'); +} + //Extra fields $extra_field = new ExtraField('course'); $extra = $extra_field->addElements($form); @@ -182,20 +190,24 @@ $form->setDefaults($values); // Validate the form if ($form->validate()) { - $course = $form->exportValues(); + $courseData = $form->exportValues(); - $course_teachers = isset($course['course_teachers']) ? $course['course_teachers'] : null; - $course['exemplary_content'] = empty($course['exemplary_content']) ? false : true; - $course['teachers'] = $course_teachers; - $course['wanted_code'] = $course['visual_code']; - $course['gradebook_model_id'] = isset($course['gradebook_model_id']) ? $course['gradebook_model_id'] : null; + $course_teachers = isset($courseData['course_teachers']) ? $courseData['course_teachers'] : null; + $courseData['exemplary_content'] = empty($courseData['exemplary_content']) ? false : true; + $courseData['teachers'] = $course_teachers; + $courseData['wanted_code'] = $courseData['visual_code']; + $courseData['gradebook_model_id'] = isset($courseData['gradebook_model_id']) ? $courseData['gradebook_model_id'] : null; + + if (isset($courseData['duration'])) { + $courseData['duration'] = $courseData['duration'] * 60; // Convert minutes to seconds + } - if (!empty($course['course_language'])) { + if (!empty($courseData['course_language'])) { $translator = Container::$container->get('translator'); - $translator->setLocale($course['course_language']); + $translator->setLocale($courseData['course_language']); } - $course = CourseManager::create_course($course); + $course = CourseManager::create_course($courseData); if (null !== $course) { Display::addFlash( Display::return_message( diff --git a/public/main/admin/course_edit.php b/public/main/admin/course_edit.php index bb020af0e4..0e183a262a 100644 --- a/public/main/admin/course_edit.php +++ b/public/main/admin/course_edit.php @@ -252,6 +252,14 @@ $form->addRule('disk_quota', get_lang('This field should be numeric'), 'numeric' $form->addText('video_url', get_lang('Video URL'), false); $form->addCheckBox('sticky', null, get_lang('Sticky')); +if ('true' === api_get_setting('course.show_course_duration')) { + $form->addElement('text', 'duration', get_lang('Duration (in minutes)'), [ + 'id' => 'duration', + 'maxlength' => 10, + ]); + $form->addRule('duration', get_lang('This field should be numeric'), 'numeric'); +} + // Extra fields $extraField = new ExtraField('course'); $extra = $extraField->addElements( @@ -289,6 +297,11 @@ $courseInfo['disk_quota'] = round(DocumentManager::get_course_quota($courseInfo[ $courseInfo['real_code'] = $courseInfo['code']; $courseInfo['add_teachers_to_sessions_courses'] = $courseInfo['add_teachers_to_sessions_courses'] ?? 0; +// Set default duration in minutes +if (isset($courseInfo['duration'])) { + $courseInfo['duration'] = $courseInfo['duration'] / 60; +} + $form->setDefaults($courseInfo); // Validate form @@ -296,6 +309,10 @@ if ($form->validate()) { $course = $form->getSubmitValues(); $visibility = $course['visibility']; + if (isset($course['duration'])) { + $course['duration'] = $course['duration'] * 60; + } + // @todo should be check in the CidReqListener /*global $_configuration; @@ -362,9 +379,13 @@ if ($form->validate()) { ->setUnsubscribe($course['unsubscribe']) ->setVisibility($visibility) ->setSticky(1 === (int) ($course['sticky'] ?? 0)) - ->setVideoUrl($params['video_url'] ?? '') + ->setVideoUrl($course['video_url'] ?? '') ; + if (isset($course['duration'])) { + $courseEntity->setDuration($course['duration']); + } + $em->persist($courseEntity); $em->flush(); diff --git a/public/main/inc/lib/add_course.lib.inc.php b/public/main/inc/lib/add_course.lib.inc.php index fd6823ff92..57df90656a 100644 --- a/public/main/inc/lib/add_course.lib.inc.php +++ b/public/main/inc/lib/add_course.lib.inc.php @@ -842,6 +842,10 @@ class AddCourse ->setCreator(api_get_user_entity()) ; + if (isset($params['duration'])) { + $course->setDuration($params['duration']); + } + if (!empty($categories)) { if (!is_array($categories)) { $categories = [$categories]; diff --git a/public/main/inc/lib/api.lib.php b/public/main/inc/lib/api.lib.php index 04e60381ca..32b35264a7 100644 --- a/public/main/inc/lib/api.lib.php +++ b/public/main/inc/lib/api.lib.php @@ -2281,6 +2281,10 @@ function api_format_course_array(Course $course = null) $courseData['course_image'] = $image.'?filter=course_picture_small'; $courseData['course_image_large'] = $image.'?filter=course_picture_medium'; + if ('true' === api_get_setting('course.show_course_duration') && null !== $course->getDuration()) { + $courseData['duration'] = $course->getDuration(); + } + return $courseData; } diff --git a/src/CoreBundle/Controller/PlatformConfigurationController.php b/src/CoreBundle/Controller/PlatformConfigurationController.php index b4194faaa1..dbce79878e 100644 --- a/src/CoreBundle/Controller/PlatformConfigurationController.php +++ b/src/CoreBundle/Controller/PlatformConfigurationController.php @@ -82,6 +82,7 @@ class PlatformConfigurationController extends AbstractController 'forum.global_forums_course_id', 'document.students_download_folders', 'social.hide_social_groups_block', + 'course.show_course_duration', ]; $user = $this->userHelper->getCurrent(); diff --git a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php index 4300bcee82..7aec44f5f7 100644 --- a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php +++ b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php @@ -1617,6 +1617,11 @@ class SettingsCurrentFixtures extends Fixture implements FixtureGroupInterface 'title' => 'View courses in a grid layout', 'comment' => 'View courses in a layout with several courses per line. Otherwise, the layout will show one course per line.', ], + [ + 'name' => 'show_course_duration', + 'title' => 'Show courses duration', + 'comment' => 'Display the course duration next to the course title in the course catalogue and the courses list.', + ], ], 'certificate' => [ [ diff --git a/src/CoreBundle/Entity/Course.php b/src/CoreBundle/Entity/Course.php index c56941a67c..bfccb9c2f8 100644 --- a/src/CoreBundle/Entity/Course.php +++ b/src/CoreBundle/Entity/Course.php @@ -342,6 +342,10 @@ class Course extends AbstractResource implements ResourceInterface, ResourceWith #[ORM\JoinColumn(name: 'room_id', referencedColumnName: 'id')] protected ?Room $room; + #[Groups(['course:read', 'course_rel_user:read', 'course:write'])] + #[ORM\Column(type: 'integer', nullable: true)] + private ?int $duration = null; + public function __construct() { $this->visibility = self::OPEN_PLATFORM; @@ -1169,6 +1173,18 @@ class Course extends AbstractResource implements ResourceInterface, ResourceWith return '/img/session_default.svg'; } + public function getDuration(): ?int + { + return $this->duration; + } + + public function setDuration(?int $duration): self + { + $this->duration = $duration; + + return $this; + } + public function getResourceIdentifier(): int { return $this->getId(); diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20240704120400.php b/src/CoreBundle/Migrations/Schema/V200/Version20240704120400.php new file mode 100644 index 0000000000..4221a1cbe5 --- /dev/null +++ b/src/CoreBundle/Migrations/Schema/V200/Version20240704120400.php @@ -0,0 +1,42 @@ +addSql('ALTER TABLE course ADD duration INT DEFAULT NULL'); + $this->addSql('ALTER TABLE c_survey ADD duration INT DEFAULT NULL'); + $this->addSql('ALTER TABLE c_quiz ADD duration INT DEFAULT NULL'); + $this->addSql('ALTER TABLE c_quiz_question ADD duration INT DEFAULT NULL'); + $this->addSql('ALTER TABLE c_lp ADD duration INT DEFAULT NULL'); + $this->addSql('ALTER TABLE c_lp_item ADD duration INT DEFAULT NULL'); + $this->addSql('ALTER TABLE c_student_publication ADD duration INT DEFAULT NULL'); + $this->addSql('ALTER TABLE c_attendance_calendar ADD duration INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE course DROP duration'); + $this->addSql('ALTER TABLE c_survey DROP duration'); + $this->addSql('ALTER TABLE c_quiz DROP duration'); + $this->addSql('ALTER TABLE c_quiz_question DROP duration'); + $this->addSql('ALTER TABLE c_lp DROP duration'); + $this->addSql('ALTER TABLE c_lp_item DROP duration'); + $this->addSql('ALTER TABLE c_student_publication DROP duration'); + $this->addSql('ALTER TABLE c_attendance_calendar DROP duration'); + } +} diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20240704120500.php b/src/CoreBundle/Migrations/Schema/V200/Version20240704120500.php new file mode 100644 index 0000000000..a19b99bf27 --- /dev/null +++ b/src/CoreBundle/Migrations/Schema/V200/Version20240704120500.php @@ -0,0 +1,91 @@ +migrateStudentPublicationDuration(); + $this->migrateAttendanceCalendarDuration(); + } + + public function down(Schema $schema): void + { + // Revert changes if necessary + $this->addSql('UPDATE c_student_publication SET duration = NULL WHERE duration IS NOT NULL'); + $this->addSql('UPDATE c_attendance_calendar SET duration = NULL WHERE duration IS NOT NULL'); + } + + private function migrateStudentPublicationDuration(): void + { + $sql = 'SELECT selected_value FROM settings_current WHERE variable = "considered_working_time" AND selected_value IS NOT NULL AND selected_value != "" AND selected_value != "false"'; + $selectedValue = $this->connection->fetchOne($sql); + + if ($selectedValue) { + $sql = 'SELECT s.*, efv.field_value + FROM c_student_publication s + INNER JOIN extra_field_values efv ON s.iid = efv.item_id + INNER JOIN extra_field ef ON efv.field_id = ef.id + WHERE ef.variable = ? AND ef.item_type = ?'; + + $params = [$selectedValue, ExtraField::WORK_FIELD_TYPE]; + $data = $this->connection->fetchAllAssociative($sql, $params); + + foreach ($data as $item) { + $id = $item['iid']; + $workTime = (int) $item['field_value']; + + $durationInSeconds = $workTime * 60; + + $this->addSql("UPDATE c_student_publication SET duration = ? WHERE iid = ?", [$durationInSeconds, $id]); + } + } + } + + private function migrateAttendanceCalendarDuration(): void + { + $sql = 'SELECT s.*, efv.field_value + FROM c_attendance_calendar s + INNER JOIN extra_field_values efv ON s.iid = efv.item_id + INNER JOIN extra_field ef ON efv.field_id = ef.id + WHERE ef.variable = "duration" AND ef.item_type = ?'; + + $params = [ExtraField::ATTENDANCE_CALENDAR_TYPE]; + $data = $this->connection->fetchAllAssociative($sql, $params); + + foreach ($data as $item) { + $id = $item['iid']; + $duration = $item['field_value']; + + $matches = []; + $newDuration = null; + + if (preg_match('/(\d+)([h:](\d+)?)?/', $duration, $matches)) { + $hours = (int)$matches[1]; + $minutes = 0; + if (!empty($matches[3])) { + $minutes = (int)$matches[3]; + } + $newDuration = ($hours * 3600) + ($minutes * 60); + } + + if ($newDuration !== null) { + $this->addSql('UPDATE c_attendance_calendar SET duration = ? WHERE iid = ?', [$newDuration, $id]); + } + } + } +} diff --git a/src/CoreBundle/Settings/CourseSettingsSchema.php b/src/CoreBundle/Settings/CourseSettingsSchema.php index e0f476bc0f..2eb06e7258 100644 --- a/src/CoreBundle/Settings/CourseSettingsSchema.php +++ b/src/CoreBundle/Settings/CourseSettingsSchema.php @@ -119,6 +119,7 @@ class CourseSettingsSchema extends AbstractSettingsSchema 'course_configuration_tool_extra_fields_to_show_and_edit' => '', 'course_creation_user_course_extra_field_relation_to_prefill' => '', 'allow_edit_tool_visibility_in_session' => 'true', + 'show_course_duration' => 'false', ] ) ->setTransformer( @@ -365,6 +366,7 @@ class CourseSettingsSchema extends AbstractSettingsSchema ] ) ->add('allow_edit_tool_visibility_in_session', YesNoType::class) + ->add('show_course_duration', YesNoType::class) ; $this->updateFormFieldsFromSettingsInfo($builder); diff --git a/src/CourseBundle/Entity/CAttendanceCalendar.php b/src/CourseBundle/Entity/CAttendanceCalendar.php index 1170561ddd..c1b948be33 100644 --- a/src/CourseBundle/Entity/CAttendanceCalendar.php +++ b/src/CourseBundle/Entity/CAttendanceCalendar.php @@ -43,6 +43,9 @@ class CAttendanceCalendar )] protected Collection $sheets; + #[ORM\Column(name: 'duration', type: 'integer', nullable: true)] + protected ?int $duration = null; + public function getIid(): ?int { return $this->iid; @@ -113,4 +116,16 @@ class CAttendanceCalendar return $this; } + + public function getDuration(): ?int + { + return $this->duration; + } + + public function setDuration(?int $duration): self + { + $this->duration = $duration; + + return $this; + } } diff --git a/src/CourseBundle/Entity/CLp.php b/src/CourseBundle/Entity/CLp.php index 27a8da7c71..a2cb14bb17 100644 --- a/src/CourseBundle/Entity/CLp.php +++ b/src/CourseBundle/Entity/CLp.php @@ -149,6 +149,9 @@ class CLp extends AbstractResource implements ResourceInterface, ResourceShowCou #[ORM\JoinColumn(name: 'asset_id', referencedColumnName: 'id')] protected ?Asset $asset = null; + #[ORM\Column(name: 'duration', type: 'integer', nullable: true)] + protected ?int $duration = null; + public function __construct() { $now = new DateTime(); @@ -602,6 +605,18 @@ class CLp extends AbstractResource implements ResourceInterface, ResourceShowCou return $this; } + public function getDuration(): ?int + { + return $this->duration; + } + + public function setDuration(?int $duration): self + { + $this->duration = $duration; + + return $this; + } + public function getResourceIdentifier(): int|Uuid { return $this->getIid(); diff --git a/src/CourseBundle/Entity/CLpItem.php b/src/CourseBundle/Entity/CLpItem.php index a887b80df3..179e2775c2 100644 --- a/src/CourseBundle/Entity/CLpItem.php +++ b/src/CourseBundle/Entity/CLpItem.php @@ -114,6 +114,9 @@ class CLpItem implements Stringable #[ORM\Column(name: 'lvl', type: 'integer')] protected ?int $lvl; + #[ORM\Column(name: 'duration', type: 'integer', nullable: true)] + protected ?int $duration = null; + public function __construct() { $this->children = new ArrayCollection(); @@ -531,4 +534,16 @@ class CLpItem implements Stringable return $this; } + + public function getDuration(): ?int + { + return $this->duration; + } + + public function setDuration(?int $duration): self + { + $this->duration = $duration; + + return $this; + } } diff --git a/src/CourseBundle/Entity/CQuiz.php b/src/CourseBundle/Entity/CQuiz.php index 41b78b239c..c51eaa6e97 100644 --- a/src/CourseBundle/Entity/CQuiz.php +++ b/src/CourseBundle/Entity/CQuiz.php @@ -152,6 +152,9 @@ class CQuiz extends AbstractResource implements ResourceInterface, ResourceShowC #[ORM\OneToMany(mappedBy: 'quiz', targetEntity: TrackEExercise::class)] protected Collection $attempts; + #[ORM\Column(name: 'duration', type: 'integer', nullable: true)] + protected ?int $duration = null; + public function __construct() { $this->questions = new ArrayCollection(); @@ -656,6 +659,18 @@ class CQuiz extends AbstractResource implements ResourceInterface, ResourceShowC new ArrayCollection($this->questionsCategories->toArray()); } + public function getDuration(): ?int + { + return $this->duration; + } + + public function setDuration(?int $duration): self + { + $this->duration = $duration; + + return $this; + } + public function getResourceIdentifier(): int|Uuid { return $this->getIid(); diff --git a/src/CourseBundle/Entity/CQuizQuestion.php b/src/CourseBundle/Entity/CQuizQuestion.php index b92ca046f7..19a57df8e9 100644 --- a/src/CourseBundle/Entity/CQuizQuestion.php +++ b/src/CourseBundle/Entity/CQuizQuestion.php @@ -90,6 +90,9 @@ class CQuizQuestion extends AbstractResource implements ResourceInterface, Strin #[ORM\Column(name: 'mandatory', type: 'integer')] protected int $mandatory; + #[ORM\Column(name: 'duration', type: 'integer', nullable: true)] + protected ?int $duration = null; + public function __construct() { $this->categories = new ArrayCollection(); @@ -352,6 +355,18 @@ class CQuizQuestion extends AbstractResource implements ResourceInterface, Strin return $this->iid; } + public function getDuration(): ?int + { + return $this->duration; + } + + public function setDuration(?int $duration): self + { + $this->duration = $duration; + + return $this; + } + public function getResourceIdentifier(): int|Uuid { return $this->getIid(); diff --git a/src/CourseBundle/Entity/CStudentPublication.php b/src/CourseBundle/Entity/CStudentPublication.php index 7e0e4b154d..8355364fa0 100644 --- a/src/CourseBundle/Entity/CStudentPublication.php +++ b/src/CourseBundle/Entity/CStudentPublication.php @@ -173,6 +173,9 @@ class CStudentPublication extends AbstractResource implements ResourceInterface, #[ORM\Column(name: 'filesize', type: 'integer', nullable: true)] protected ?int $fileSize = null; + #[ORM\Column(name: 'duration', type: 'integer', nullable: true)] + protected ?int $duration = null; + public function __construct() { $this->description = ''; @@ -503,6 +506,18 @@ class CStudentPublication extends AbstractResource implements ResourceInterface, return $this; } + public function getDuration(): ?int + { + return $this->duration; + } + + public function setDuration(?int $duration): self + { + $this->duration = $duration; + + return $this; + } + public function getResourceIdentifier(): int { return $this->getIid(); diff --git a/src/CourseBundle/Entity/CSurvey.php b/src/CourseBundle/Entity/CSurvey.php index 2aa2aa1e36..a022dce18d 100644 --- a/src/CourseBundle/Entity/CSurvey.php +++ b/src/CourseBundle/Entity/CSurvey.php @@ -155,6 +155,9 @@ class CSurvey extends AbstractResource implements ResourceInterface, Stringable #[ORM\Column(name: 'display_question_number', type: 'boolean', options: ['default' => true])] protected bool $displayQuestionNumber; + #[ORM\Column(name: 'duration', type: 'integer', nullable: true)] + protected ?int $duration = null; + public function __construct() { $this->title = ''; @@ -624,6 +627,18 @@ class CSurvey extends AbstractResource implements ResourceInterface, Stringable return $this->iid; } + public function getDuration(): ?int + { + return $this->duration; + } + + public function setDuration(?int $duration): self + { + $this->duration = $duration; + + return $this; + } + public function getResourceName(): string { return (string) $this->getCode();