Merge remote-tracking branch 'upstream/master' into matra-22057

pull/5831/head
Christian Beeznest 9 months ago
commit ad82e0be9b
  1. 2
      assets/css/scss/atoms/_autocomplete.scss
  2. 10
      assets/css/scss/atoms/_buttons.scss
  3. 2
      assets/css/scss/organisms/_cards.scss
  4. 30
      assets/vue/components/basecomponents/BaseAutocomplete.vue
  5. 31
      assets/vue/components/basecomponents/BaseCard.vue
  6. 14
      assets/vue/components/basecomponents/BaseDialog.vue
  7. 3
      assets/vue/components/basecomponents/ChamiloIcons.js
  8. 65
      assets/vue/components/layout/TopbarLoggedIn.vue
  9. 84
      assets/vue/components/skill/SkillProfileDialog.vue
  10. 47
      assets/vue/components/skill/SkillProfileMatches.vue
  11. 30
      assets/vue/components/skill/SkillWheelGraph.vue
  12. 132
      assets/vue/components/skill/SkillWheelProfileList.vue
  13. 172
      assets/vue/composables/sidebarMenu.js
  14. 285
      assets/vue/composables/skill/skillWheel.js
  15. 2
      assets/vue/router/index.js
  16. 18
      assets/vue/router/skill.js
  17. 47
      assets/vue/services/skillProfileService.js
  18. 18
      assets/vue/services/skillService.js
  19. 10
      assets/vue/views/course/CourseHome.vue
  20. 9
      assets/vue/views/course/Create.vue
  21. 180
      assets/vue/views/skill/SkillWheel.vue
  22. 1
      composer.json
  23. 799
      composer.lock
  24. 21
      config/authentication.yaml
  25. 14
      config/packages/knpu_oauth2_client.yaml
  26. 1
      config/packages/security.yaml
  27. 2
      package.json
  28. 46
      public/main/admin/course_category.php
  29. 4
      public/main/admin/course_user_import.php
  30. 9
      public/main/admin/index.php
  31. 4
      public/main/admin/statistics/index.php
  32. 9
      public/main/admin/user_import.php
  33. 44
      public/main/exercise/question_list_admin.inc.php
  34. 6
      public/main/inc/lib/SkillModel.php
  35. 2
      public/main/inc/lib/api.lib.php
  36. 10
      public/main/inc/lib/course.lib.php
  37. 73
      public/main/inc/lib/course_category.lib.php
  38. 1
      public/main/inc/lib/database.constants.inc.php
  39. 6
      public/main/inc/lib/display.lib.php
  40. 18
      public/main/inc/lib/export.lib.inc.php
  41. 121
      public/main/inc/lib/statistics.lib.php
  42. 25
      public/main/inc/lib/usergroup.lib.php
  43. 3
      public/main/inc/lib/userportal.lib.php
  44. 23
      public/main/inc/lib/zombie/zombie_manager.class.php
  45. 16
      public/main/inc/lib/zombie/zombie_report.class.php
  46. 2
      public/main/my_space/ti_report.php
  47. 6
      public/main/skills/assign.php
  48. 12
      public/main/skills/skill.php
  49. 8
      public/main/skills/skill_level.php
  50. 2
      public/main/skills/skill_list.php
  51. 8
      public/main/skills/skill_profile.php
  52. 2
      public/main/template/default/skill/drh_report.html.twig
  53. 6
      public/main/template/default/skill/profile.html.twig
  54. 12
      public/main/user/subscribe_user.php
  55. 69
      src/CoreBundle/Command/LpProgressReminderCommand.php
  56. 5
      src/CoreBundle/Command/SendEventRemindersCommand.php
  57. 4
      src/CoreBundle/Component/Utils/ActionIcon.php
  58. 2
      src/CoreBundle/Controller/Admin/AdminController.php
  59. 4
      src/CoreBundle/Controller/Admin/IndexBlocksController.php
  60. 11
      src/CoreBundle/Controller/Admin/SettingsController.php
  61. 2
      src/CoreBundle/Controller/CourseController.php
  62. 1
      src/CoreBundle/Controller/IndexController.php
  63. 26
      src/CoreBundle/Controller/OAuth2/AzureProviderController.php
  64. 119
      src/CoreBundle/Controller/ValidationTokenController.php
  65. 19
      src/CoreBundle/DataFixtures/ExtraFieldFixtures.php
  66. 23
      src/CoreBundle/DataTransformer/SkillTreeNodeTransformer.php
  67. 38
      src/CoreBundle/Decorator/OAuth2ProviderFactoryDecorator.php
  68. 71
      src/CoreBundle/Entity/ExtraField.php
  69. 11
      src/CoreBundle/Entity/Level.php
  70. 74
      src/CoreBundle/Entity/Skill.php
  71. 4
      src/CoreBundle/Entity/SkillLevelProfile.php
  72. 63
      src/CoreBundle/Entity/SkillProfile.php
  73. 22
      src/CoreBundle/Entity/SkillRelProfile.php
  74. 10
      src/CoreBundle/Entity/SkillRelUser.php
  75. 4
      src/CoreBundle/Entity/TrackEDefault.php
  76. 96
      src/CoreBundle/Entity/ValidationToken.php
  77. 27
      src/CoreBundle/Form/DataTransformer/ResourceToIdentifierTransformer.php
  78. 33
      src/CoreBundle/Migrations/Schema/V200/Version20230913162700.php
  79. 83
      src/CoreBundle/Migrations/Schema/V200/Version20231022124700.php
  80. 28
      src/CoreBundle/Migrations/Schema/V200/Version20241209103000.php
  81. 42
      src/CoreBundle/Migrations/Schema/V200/Version20241211183300.php
  82. 3
      src/CoreBundle/Repository/CourseRelUserRepository.php
  83. 3
      src/CoreBundle/Repository/ExtraFieldValuesRepository.php
  84. 15
      src/CoreBundle/Repository/ResourceRepository.php
  85. 3
      src/CoreBundle/Repository/SessionRelCourseRelUserRepository.php
  86. 41
      src/CoreBundle/Repository/TrackEDefaultRepository.php
  87. 37
      src/CoreBundle/Repository/ValidationTokenRepository.php
  88. 6
      src/CoreBundle/Resources/views/Validation/success.html.twig
  89. 83
      src/CoreBundle/Security/Authenticator/OAuth2/AzureAuthenticator.php
  90. 2
      src/CoreBundle/Security/Authenticator/OAuth2/GenericAuthenticator.php
  91. 30
      src/CoreBundle/Service/CourseService.php
  92. 60
      src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php
  93. 198
      src/CoreBundle/ServiceHelper/AzureAuthenticatorHelper.php
  94. 48
      src/CoreBundle/ServiceHelper/ValidationTokenHelper.php
  95. 10
      src/CoreBundle/Settings/CourseSettingsSchema.php
  96. 20
      src/CoreBundle/Settings/PlatformSettingsSchema.php
  97. 118
      src/CoreBundle/Settings/SettingsManager.php
  98. 10
      src/CoreBundle/Tool/ToolChain.php
  99. 3
      src/CourseBundle/Repository/CLpRepository.php
  100. 3
      src/CourseBundle/Repository/CQuizRepository.php
  101. Some files were not shown because too many files have changed in this diff Show More

@ -92,7 +92,7 @@
@apply cursor-default inline-flex items-center flex-initial;
&-icon {
@apply cursor-pointer;
@apply cursor-pointer inline-flex;
}
}

@ -53,7 +53,7 @@
&:hover,
&:focus {
@apply bg-gray-30 border-gray-30 text-white;
@apply bg-gray-90 border-gray-90 text-white;
}
}
@ -170,7 +170,7 @@ $border-color_12: #9333EA;
$textColor: 'white';
@if('warning' == $color) {
$textColor: 'gray-30';
$textColor: 'gray-90';
}
@apply bg-#{$color} text-#{$textColor};
@ -199,7 +199,7 @@ $border-color_12: #9333EA;
@if ('warning' == $color) {
@apply bg-#{$color} border-#{$color} text-gray-90;
} @else if('black' == $color) {
@apply bg-gray-30 border-gray-30 text-white;
@apply bg-gray-90 border-gray-90 text-white;
} @else {
@apply bg-#{$color} border-#{$color} text-white;
}
@ -326,7 +326,7 @@ $border-color_12: #9333EA;
// plain button
.p-button.p-button-plain {
@include filled-style('gray-30', 'black');
@include filled-style('gray-90', 'black');
&.p-button-outlined {
@include outlined-style('black');
@ -339,7 +339,7 @@ $border-color_12: #9333EA;
.p-buttonset.p-button-plain {
> .p-button {
@include filled-style('gray-30', 'black');
@include filled-style('gray-90', 'black');
&.p-button-outlined {
@include outlined-style('black');

@ -1,5 +1,5 @@
.p-card {
@apply rounded-lg shadow-lg;
@apply rounded-lg bg-white drop-shadow-lg;
.p-card-body {
@apply flex flex-col space-y-4 p-4;

@ -12,7 +12,25 @@
:min-length="3"
@complete="onComplete"
@item-select="$emit('item-select', $event)"
/>
>
<template
v-if="hasChipSlot"
#chip="{ value }"
>
<slot
:value="value"
name="chip"
></slot>
</template>
<template #removetokenicon="slopProps">
<span class="p-autocomplete-token-icon">
<BaseIcon
icon="close"
@click="slopProps.removeCallback"
/>
</span>
</template>
</AutoComplete>
<label
v-t="label"
:for="id"
@ -27,9 +45,10 @@
</template>
<script setup>
import { ref } from "vue"
import { onMounted, ref, useSlots } from "vue"
import FloatLabel from "primevue/floatlabel"
import AutoComplete from "primevue/autocomplete"
import BaseIcon from "./BaseIcon.vue"
const modelValue = defineModel({
type: [Array, String],
@ -92,4 +111,11 @@ const onComplete = async (event) => {
suggestions.value = []
}
}
const slots = useSlots()
const hasChipSlot = ref(false)
onMounted(() => {
hasChipSlot.value = !!slots.chip
})
</script>

@ -1,10 +1,23 @@
<template>
<Card
:class="customClass"
>
<template #header>
<Card :class="customClass">
<template
#header
v-if="slots.header"
>
<slot name="header"></slot>
</template>
<template
#title
v-if="slots.title"
>
<slot name="title"></slot>
</template>
<template
#footer
v-if="slots.footer"
>
<slot name="footer"></slot>
</template>
<template #content>
<slot></slot>
</template>
@ -12,8 +25,8 @@
</template>
<script setup>
import Card from 'primevue/card'
import {computed} from "vue"
import Card from "primevue/card"
import { computed, useSlots } from "vue"
const props = defineProps({
plain: {
@ -22,10 +35,12 @@ const props = defineProps({
},
})
const slots = useSlots()
const customClass = computed(() => {
let resultClass = ''
let resultClass = ""
if (props.plain) {
resultClass += 'bg-gray-15 border border-gray-25 shadow-none '
resultClass += "bg-gray-15 border border-gray-25 shadow-none "
}
return resultClass
})

@ -3,15 +3,16 @@ import Dialog from "primevue/dialog"
import { iconValidator } from "./validators"
import BaseIcon from "./BaseIcon.vue"
const isVisible = defineModel("isVisible", {
required: true,
type: Boolean,
})
defineProps({
title: {
type: String,
required: true,
},
isVisible: {
type: Boolean,
required: true,
},
headerIcon: {
type: String,
default: "",
@ -23,16 +24,13 @@ defineProps({
},
},
})
defineEmits(["update:isVisible"])
</script>
<template>
<Dialog
:modal="true"
:visible="isVisible"
v-model:visible="isVisible"
class="p-fluid"
@update:visible="$emit('update:isVisible', $event)"
>
<template #header>
<div class="text-left">

@ -125,4 +125,7 @@ export const chamiloIconToClass = {
"add-event-reminder": "mdi mdi-alarm-plus",
"session-star": "mdi mdi-star",
"next": "mdi mdi-arrow-right-bold-box",
"crosshairs": "mdi mdi-crosshairs",
"square": "mdi mdi-square",
"wheel": "mdi mdi-tire",
};

@ -97,33 +97,44 @@ const ticketUrl = computed(() => {
})
const elUserSubmenu = ref(null)
const userSubmenuItems = computed(() => [
{
label: props.currentUser.fullName,
items: [
{
label: t("My profile"),
url: router.resolve({ name: "AccountHome" }).href,
},
{
label: t("My General Certificate"),
url: "/main/social/my_skills_report.php?a=generate_custom_skill",
},
{
label: t("My skills"),
url: "/main/social/my_skills_report.php",
},
{
separator: true,
},
{
label: t("Sign out"),
url: "/logout",
icon: "mdi mdi-logout-variant",
},
],
},
])
const userSubmenuItems = computed(() => {
const items = [
{
label: props.currentUser.fullName,
items: [
{
label: t("My profile"),
url: router.resolve({ name: "AccountHome" }).href,
},
],
},
]
if (platformConfigStore.getSetting("platform.show_tabs").indexOf("topbar_certificate") > -1) {
items[0].items.push({
label: t("My General Certificate"),
url: "/main/social/my_skills_report.php?a=generate_custom_skill",
})
}
if (platformConfigStore.getSetting("platform.show_tabs").indexOf("topbar_skills") > -1) {
items[0].items.push({
label: t("My skills"),
url: "/main/social/my_skills_report.php",
})
}
items[0].items.push(
{ separator: true },
{
label: t("Sign out"),
url: "/logout",
icon: "mdi mdi-logout-variant",
}
)
return items
})
function toggleUserMenu(event) {
elUserSubmenu.value.toggle(event)

@ -0,0 +1,84 @@
<script setup>
import { computed, reactive, unref } from "vue"
import { useI18n } from "vue-i18n"
import BaseTextArea from "../basecomponents/BaseTextArea.vue"
import BaseButton from "../basecomponents/BaseButton.vue"
import BaseDialog from "../basecomponents/BaseDialog.vue"
import BaseInputText from "../basecomponents/BaseInputText.vue"
import { createProfile, updateProfile } from "../../services/skillProfileService"
const { t } = useI18n()
const profile = reactive({
title: "",
description: "",
})
const isVisible = defineModel("visible", {
required: true,
type: Boolean,
})
const skills = defineModel("skills", {
type: Array,
required: false,
})
const emit = defineEmits(["saved"])
async function saveProfile() {
if (profile["@id"]) {
await updateProfile({
iri: profile["@id"],
title: profile.title,
description: profile.description,
})
} else {
await createProfile({
title: profile.title,
description: profile.description,
skills: skills.value.map((skill) => ({ skill: skill["@id"] })),
})
}
isVisible.value = false
emit("saved", unref(profile))
}
</script>
<template>
<BaseDialog
:title="t('Skill profile')"
v-model:is-visible="isVisible"
>
<BaseInputText
id="name_profile"
v-model="profile.title"
:label="t('Title')"
/>
<BaseTextArea
v-model="profile.description"
:label="t('Description')"
/>
<template #footer>
<BaseButton
:label="t('Cancel')"
icon="close"
type="black"
@click="isVisible = false"
/>
<BaseButton
:label="t('Save')"
icon="save"
type="primary"
@click="saveProfile"
/>
<pre>{{ skillIdList }}</pre>
</template>
</BaseDialog>
</template>

@ -0,0 +1,47 @@
<script setup>
import { ref } from "vue"
import Skeleton from "primevue/skeleton"
import { useNotification } from "../../composables/notification"
import * as skillProfileService from "../../services/skillProfileService"
const { showErrorNotification } = useNotification()
const containerEl = ref()
const isLoading = ref(false)
/**
* @param {Array<Object>} skills
* @returns {Promise<void>}
*/
async function searchProfileMatches(skills) {
isLoading.value = true
const skillIdList = skills.map((skill) => skill.id)
try {
containerEl.value.innerHTML = await skillProfileService.matchProfiles(skillIdList)
} catch (e) {
showErrorNotification(e)
} finally {
isLoading.value = false
}
}
defineExpose({
searchProfileMatches,
})
</script>
<template>
<Skeleton
v-if="isLoading"
height="10rem"
/>
<div
v-show="!isLoading"
ref="containerEl"
/>
</template>

@ -0,0 +1,30 @@
<script setup>
import { onMounted } from "vue"
import Skeleton from "primevue/skeleton"
import { useSkillWheel } from "../../composables/skill/skillWheel"
const { wheelContainer, isLoading, loadSkills, showRoot, showSkill } = useSkillWheel()
defineExpose({
showRoot,
showSkill,
})
onMounted(() => {
loadSkills()
})
</script>
<template>
<div class="aspect-square">
<div ref="wheelContainer" />
<Skeleton
shape="circle"
v-if="isLoading"
size="100%"
/>
</div>
</template>

@ -0,0 +1,132 @@
<script setup>
import { onMounted, ref } from "vue"
import { useI18n } from "vue-i18n"
import BaseCard from "../basecomponents/BaseCard.vue"
import BaseButton from "../basecomponents/BaseButton.vue"
import Skeleton from "primevue/skeleton"
import * as skillProfileService from "../../services/skillProfileService"
import { useNotification } from "../../composables/notification"
import { useConfirm } from "primevue/useconfirm"
const { t } = useI18n()
const { showErrorNotification, showSuccessNotification } = useNotification()
const confirm = useConfirm()
const isLoading = ref(true)
const profileList = ref([])
async function loadProfiles() {
try {
isLoading.value = true
const { items } = await skillProfileService.findAll()
profileList.value = items
} catch (e) {
showErrorNotification(e)
} finally {
isLoading.value = false
}
}
defineExpose({
loadProfiles,
})
onMounted(() => {
loadProfiles()
})
async function onClickDeleteProfile(profile) {
confirm.require({
message: t('Are you sure you want to delete "%s"?', [profile.title]),
header: t("Delete skill profile"),
icon: "mdi mdi-alert",
async accept() {
if (!profile) {
return
}
try {
isLoading.value = true
await skillProfileService.deleteProfile(profile["@id"])
showSuccessNotification(t("Skill profile deleted"))
} catch (e) {
showErrorNotification(e)
} finally {
isLoading.value = false
}
await loadProfiles()
},
})
}
const emit = defineEmits(["searchProfile"])
</script>
<template>
<BaseCard>
<template #title>{{ t("Skill profiles") }}</template>
<div
v-if="isLoading"
class="space-y-2"
>
<div
v-for="v in 3"
:key="v"
class="flex flex-row gap-2 items-center"
>
<Skeleton
class="mr-auto"
width="10rem"
/>
<Skeleton size="2.5rem" />
<Skeleton size="2.5rem" />
</div>
</div>
<ul
v-else-if="profileList.length > 0"
class="space-y-2"
>
<li
v-for="(profile, i) in profileList"
:key="i"
class="flex flex-row gap-2 items-center"
>
<span
class="mr-auto"
v-text="profile.title"
/>
<BaseButton
:label="t('Search')"
icon="search"
only-icon
type="black"
@click="emit('searchProfile', profile)"
/>
<BaseButton
:label="t('Delete')"
icon="delete"
only-icon
type="danger"
@click="onClickDeleteProfile(profile)"
/>
</li>
</ul>
<p
v-else
v-t="'No skill profiles'"
/>
</BaseCard>
</template>

@ -25,24 +25,29 @@ export function useSidebarMenu() {
return false
}
const menuItemsBeforeMyCourse = computed(() => {
const items = []
if (showTabsSetting.indexOf("campus_homepage") > -1) {
items.push({
icon: "mdi mdi-home",
label: t("Home"),
route: { name: "Home" },
})
const createMenuItem = (key, icon, label, routeName, subItems = null) => {
if (showTabsSetting.indexOf(key) > -1) {
const item = {
icon: `mdi ${icon}`,
label: t(label),
}
if (routeName) item.route = { name: routeName }
if (subItems) item.items = subItems
return item
}
return null
}
return items
const menuItemsBeforeMyCourse = computed(() => {
const items = []
items.push(createMenuItem("campus_homepage", "mdi-home", "Home", "Home"))
return items.filter(Boolean)
})
const menuItemMyCourse = computed(() => {
const items = []
if (securityStore.isAuthenticated) {
if (securityStore.isAuthenticated && showTabsSetting.indexOf("my_courses") > -1) {
const courseItems = []
if (enrolledStore.isEnrolledInCourses) {
@ -76,33 +81,33 @@ export function useSidebarMenu() {
const menuItemsAfterMyCourse = computed(() => {
const items = []
if (showCatalogue > -1) {
if (showTabsSetting.indexOf("catalogue") > -1) {
if (showCatalogue == 0 || showCatalogue == 2) {
items.push({
icon: "mdi mdi-bookmark-multiple",
label: t("Explore more courses"),
route: { name: "CatalogueCourses" },
})
items.push(
createMenuItem(
"catalogue",
"mdi-bookmark-multiple",
"Explore more courses",
"CatalogueCourses"
)
)
}
if (showCatalogue > 0) {
items.push({
icon: "mdi mdi-bookmark-multiple-outline",
label: t("Sessions catalogue"),
route: { name: "CatalogueSessions" },
})
items.push(
createMenuItem(
"catalogue",
"mdi-bookmark-multiple-outline",
"Sessions catalogue",
"CatalogueSessions"
)
)
}
}
if (showTabsSetting.indexOf("my_agenda") > -1) {
items.push({
icon: "mdi mdi-calendar-text",
label: t("Events"),
route: { name: "CCalendarEventList" },
})
}
items.push(createMenuItem("my_agenda", "mdi-calendar-text", "Events", "CCalendarEventList"))
if (showTabsSetting.indexOf("reporting") > -1) {
let subItems = []
const subItems = []
if (securityStore.isTeacher || securityStore.isHRM || securityStore.isSessionAdmin) {
subItems.push({
@ -155,75 +160,58 @@ export function useSidebarMenu() {
})
}
if (platformConfigStore.plugins?.bbb?.show_global_conference_link) {
items.push({
icon: "mdi mdi-video",
label: t("Videoconference"),
url: platformConfigStore.plugins.bbb.listingURL,
})
}
if (securityStore.isStudentBoss || securityStore.isStudent) {
items.push({
icon: "mdi mdi-text-box-search",
items: [
{
label: t("Diagnosis Management"),
url: "/main/search/load_search.php",
visible: securityStore.isStudentBoss,
},
{
label: t("Diagnostic Form"),
url: "/main/search/search.php",
},
],
label: t("Diagnosis"),
})
}
if (securityStore.isAdmin || securityStore.isSessionAdmin) {
const adminItems = [
items.push(
createMenuItem(
"videoconference",
"mdi-video",
"Videoconference",
null,
platformConfigStore.plugins?.bbb?.show_global_conference_link
? [
{
label: t("Conference Room"),
url: platformConfigStore.plugins.bbb.listingURL,
},
]
: null
)
)
items.push(
createMenuItem("diagnostics", "mdi-text-box-search", "Diagnosis Management", null, [
{
label: t("Administration"),
route: { name: "AdminIndex" },
label: t("Diagnosis Management"),
url: "/main/search/load_search.php",
visible: securityStore.isStudentBoss,
},
]
if (
securityStore.isSessionAdmin &&
"true" === platformConfigStore.getSetting("session.limit_session_admin_list_users")
) {
adminItems.push({
label: t("Add user"),
url: "/main/admin/user_add.php",
})
} else {
adminItems.push({
label: t("Users"),
url: "/main/admin/user_list.php",
})
}
{
label: t("Diagnostic Form"),
url: "/main/search/search.php",
},
])
)
if (showTabsSetting.indexOf("platform_administration") > -1) {
if (securityStore.isAdmin || securityStore.isSessionAdmin) {
const adminItems = [
{ label: t("Administration"), route: { name: "AdminIndex" } },
...(securityStore.isSessionAdmin &&
"true" === platformConfigStore.getSetting("session.limit_session_admin_list_users")
? [{ label: t("Add user"), url: "/main/admin/user_add.php" }]
: [{ label: t("Users"), url: "/main/admin/user_list.php" }]),
{ label: t("Courses"), url: "/main/admin/course_list.php" },
{ label: t("Sessions"), url: "/main/session/session_list.php" },
]
if (securityStore.isAdmin) {
adminItems.push({
label: t("Courses"),
url: "/main/admin/course_list.php",
items.push({
icon: "mdi mdi-cog",
items: adminItems,
label: t("Administration"),
})
}
adminItems.push({
label: t("Sessions"),
url: "/main/session/session_list.php",
})
items.push({
icon: "mdi mdi-cog",
items: adminItems,
label: t("Administration"),
})
}
return items
return items.filter(Boolean)
})
async function initialize() {

@ -0,0 +1,285 @@
import { ref, unref, watch } from "vue"
import * as d3 from "d3"
import { getSkillTree } from "../../services/skillService"
import { useNotification } from "../notification"
export function useSkillWheel() {
const isLoading = ref(true)
const skillList = ref([])
const wheelContainer = ref(null)
const { showErrorNotification } = useNotification()
const colorList = ["#deebf7", "#9ecae1", "#3182bd"]
let root
let centralCircle
let path
const width = 928
const height = width
const radius = width / 6
function transformSkillToWheelItem({
id,
title,
shortCode,
status,
children,
hasGradebook,
isSearched,
isAchievedByUser,
}) {
const item = {
id,
name: title,
shortCode,
status,
children: [],
hasGradebook,
isSearched,
isAchievedByUser,
}
if (children.length) {
for (const child of children) {
item.children.push(transformSkillToWheelItem(child))
}
} else {
item.value = 1
}
return item
}
function render() {
const data = {
name: "root",
children: unref(skillList).map(transformSkillToWheelItem),
}
// Compute the layout.
const hierarchy = d3
.hierarchy(data)
.sum((d) => d.value)
.sort((a, b) => b.id - a.id)
root = d3.partition().size([2 * Math.PI, hierarchy.height + 1])(hierarchy)
root.each((d) => (d.current = d))
// Create the arc generator.
const arc = d3
.arc()
.startAngle((d) => d.x0)
.endAngle((d) => d.x1)
.padAngle((d) => Math.min((d.x1 - d.x0) / 2, 0.005))
.padRadius(radius * 1.5)
.innerRadius((d) => d.y0 * radius)
.outerRadius((d) => Math.max(d.y0 * radius, d.y1 * radius - 1))
// Create the SVG container.
const svg = d3
.create("svg")
.attr("viewBox", [-width / 2, -height / 2, width, width])
.style("font", "10px sans-serif")
// Append the arcs.
path = svg
.append("g")
.selectAll("path")
.data(root.descendants().slice(1))
.join("path")
.attr("fill", setFillColor)
.attr("fill-opacity", (d) => (arcVisible(d.current) ? 1 : 0))
.attr("pointer-events", (d) => (arcVisible(d.current) ? "auto" : "none"))
.attr("d", (d) => arc(d.current))
.attr("id", (d) => "skill-" + d.data.id)
// Make them clickable if they have children.
path
.filter((d) => d.children)
.style("cursor", "pointer")
.on("click", clicked)
path.append("title").text(
(d) =>
`${d
.ancestors()
.filter((d) => d.depth > 0)
.map(setNodeText)
.reverse()
.join("/")}`,
)
const label = svg
.append("g")
.attr("pointer-events", "none")
.attr("text-anchor", "middle")
.style("user-select", "none")
.selectAll("text")
.data(root.descendants().slice(1))
.join("text")
.attr("dy", "0.35em")
.attr("fill-opacity", (d) => +labelVisible(d.current))
.attr("transform", (d) => labelTransform(d.current))
.text(setNodeText)
centralCircle = svg
.append("circle")
.datum(root)
.attr("r", radius)
.attr("fill", "none")
.attr("pointer-events", "all")
.on("click", clicked)
// Handle zoom on click.
function clicked(event, p) {
centralCircle.datum(p.parent || root)
root.each(
(d) =>
(d.target = {
x0: Math.max(0, Math.min(1, (d.x0 - p.x0) / (p.x1 - p.x0))) * 2 * Math.PI,
x1: Math.max(0, Math.min(1, (d.x1 - p.x0) / (p.x1 - p.x0))) * 2 * Math.PI,
y0: Math.max(0, d.y0 - p.depth),
y1: Math.max(0, d.y1 - p.depth),
}),
)
const t = svg.transition().duration(750)
// Transition the data on all arcs, even the ones that aren’t visible,
// so that if this transition is interrupted, entering arcs will start
// the next transition from the desired position.
path
.transition(t)
.tween("data", (d) => {
const i = d3.interpolate(d.current, d.target)
return (t) => (d.current = i(t))
})
.filter(function (d) {
return +this.getAttribute("fill-opacity") || arcVisible(d.target)
})
.attr("fill-opacity", (d) => (arcVisible(d.target) ? 1 : 0))
.attr("pointer-events", (d) => (arcVisible(d.target) ? "auto" : "none"))
.attrTween("d", (d) => () => arc(d.current))
label
.filter(function (d) {
return +this.getAttribute("fill-opacity") || labelVisible(d.target)
})
.transition(t)
.attr("fill-opacity", (d) => +labelVisible(d.target))
.attrTween("transform", (d) => () => labelTransform(d.current))
}
function arcVisible(d) {
return d.y1 <= 3 && d.y0 >= 1 && d.x1 > d.x0
}
function labelVisible(d) {
return d.y1 <= 3 && d.y0 >= 1 && (d.y1 - d.y0) * (d.x1 - d.x0) > 0.03
}
function labelTransform(d) {
const x = (((d.x0 + d.x1) / 2) * 180) / Math.PI
const y = ((d.y0 + d.y1) / 2) * radius
return `rotate(${x - 90}) translate(${y},0) rotate(${x < 180 ? 0 : 180})`
}
function setFillColor(d, i) {
if (d.data.hasGradebook) {
return "#F89406"
}
if (d.data.isSearched) {
return "#B94A48"
}
if (d.data.isAchievedByUser) {
return "#A1D99B"
}
if (!d.data.status) {
return "#48616C"
}
return colorList[i % colorList.length]
}
function setNodeText(d) {
if (d.data.shortCode) {
return d.data.shortCode
}
return d.data.name
}
return svg.node()
}
function showRoot() {
if (isLoading.value) {
return
}
centralCircle.datum(root).dispatch("click")
}
function showSkill(skillId) {
if (isLoading.value) {
return
}
const skillNode = root.descendants().find((d) => d.data.id === skillId)
if (!skillNode) {
return
}
if (skillNode.children && skillNode.children.length > 0) {
centralCircle.datum(skillNode).dispatch("click")
return
}
if (skillNode.parent) {
centralCircle.datum(skillNode.parent).dispatch("click")
return
}
showRoot()
}
async function loadSkills() {
isLoading.value = true
try {
skillList.value = await getSkillTree()
} catch (e) {
showErrorNotification(e)
} finally {
isLoading.value = false
}
}
watch(skillList, () => {
if (wheelContainer.value) {
wheelContainer.value.innerHTML = ""
wheelContainer.value.appendChild(render())
}
})
return {
wheelContainer,
isLoading,
loadSkills,
showRoot,
showSkill,
}
}

@ -14,6 +14,7 @@ import publicPageRoutes from "./publicPage"
import socialNetworkRoutes from "./social"
import termsRoutes from "./terms"
import fileManagerRoutes from "./filemanager"
import skillRoutes from "./skill"
//import courseCategoryRoutes from './coursecategory';
import documents from "./documents"
@ -251,6 +252,7 @@ const router = createRouter({
toolIntroRoutes,
pageRoutes,
publicPageRoutes,
skillRoutes,
],
})

@ -0,0 +1,18 @@
export default {
path: "/skill",
component: () => import("../components/layout/SimpleRouterViewLayout.vue"),
children: [
{
name: "SkillWheel",
path: "wheel",
meta: {
requiresAuth: true,
requiresAdmin: true,
requiresSessionAdmin: false,
requiresHR: true,
showBreadcrumb: false,
},
component: () => import("../views/skill/SkillWheel.vue"),
},
],
}

@ -0,0 +1,47 @@
import baseService from "./baseService"
/**
* @returns {Promise<{totalItems, items}>}
*/
export async function findAll() {
return baseService.getCollection("/api/skill_profiles")
}
/**
* @param {string} title
* @param {string} description
* @param {Array<{skill}>} skills
* @returns {Promise<Object>}
*/
export async function createProfile({ title, description, skills }) {
return baseService.post("/api/skill_profiles", { title, description, skills })
}
/**
* @param {string} iri
* @param {string} title
* @param {string} description
* @returns {Promise<Object>}
*/
export async function updateProfile({ iri, title, description }) {
return baseService.put(iri, { title, description })
}
/**
* @param {string} iri
* @returns {Promise<void>}
*/
export async function deleteProfile(iri) {
await baseService.delete(iri)
}
/**
* @param {Array<number>} idList
* @returns {Promise<string>}
*/
export async function matchProfiles(idList) {
return await baseService.get("/main/inc/ajax/skill.ajax.php", {
a: "profile_matches",
skill_id: idList,
})
}

@ -0,0 +1,18 @@
import baseService from "./baseService"
/**
* @returns {Promise<Array>}
*/
export async function getSkillTree() {
const { items } = await baseService.getCollection("/api/skills/tree")
return items
}
/**
* @param {Object} searchParams
* @returns {Promise<{totalItems, items}>}
*/
export async function findAll(searchParams) {
return await baseService.getCollection("api/skills", searchParams)
}

@ -288,10 +288,12 @@ courseService.loadCTools(course.value.id, session.value?.id).then((cTools) => {
return false
})
.map((adminTool) => ({
label: adminTool.tool.titleToShow,
url: adminTool.url,
}))
.map((adminTool) => {
return {
label: t(adminTool.tool.titleToShow),
url: adminTool.url,
}
})
noAdminToolsIndex.reverse().forEach((element) => tools.value.splice(element, 1))

@ -45,14 +45,19 @@ const submitCourse = async (formData) => {
const response = await courseService.createCourse(formData)
const courseId = response.courseId
const sessionId = 0
if (!courseId) {
throw new Error(t('Course ID is missing. Unable to navigate to the course home page.'))
}
showSuccessNotification(t('Course created successfully.'))
await router.push(`/course/${courseId}/home?sid=${sessionId}`)
} catch (error) {
console.error(error)
const errorMessage = error.response && error.response.data && error.response.data.message
const errorMessage = error.message || (error.response && error.response.data && error.response.data.message
? error.response.data.message
: t('An unexpected error occurred.')
: t('An unexpected error occurred.'))
showErrorNotification(errorMessage)
if (error.response && error.response.data && error.response.data.violations) {

@ -0,0 +1,180 @@
<script setup>
import { ref } from "vue"
import { useI18n } from "vue-i18n"
import SectionHeader from "../../components/layout/SectionHeader.vue"
import BaseAutocomplete from "../../components/basecomponents/BaseAutocomplete.vue"
import BaseIcon from "../../components/basecomponents/BaseIcon.vue"
import BaseButton from "../../components/basecomponents/BaseButton.vue"
import BaseCard from "../../components/basecomponents/BaseCard.vue"
import SkillWheelProfileList from "../../components/skill/SkillWheelProfileList.vue"
import SkillProfileDialog from "../../components/skill/SkillProfileDialog.vue"
import SkillWheelGraph from "../../components/skill/SkillWheelGraph.vue"
import { useNotification } from "../../composables/notification"
import * as skillService from "../../services/skillService"
import SkillProfileMatches from "../../components/skill/SkillProfileMatches.vue"
const { t } = useI18n()
const { showErrorNotification } = useNotification()
const profileListEL = ref()
const wheelEl = ref()
const foundSkills = ref([])
/**
* @param {string} query
* @returns {Promise<Object[]>}
*/
async function findSkills(query) {
try {
const { items } = await skillService.findAll({ title: query })
return items.map((item) => ({ name: item.title, value: item["@id"], ...item }))
} catch (e) {
showErrorNotification(e)
return []
}
}
const showSkilProfileForm = ref(false)
const profileMatchesEl = ref()
const showProfileMatches = ref(false)
async function onClickSearchProfileMatches() {
showProfileMatches.value = true
await profileMatchesEl.value.searchProfileMatches(foundSkills.value)
}
async function onClickViewSkillWheel() {
showProfileMatches.value = false
wheelEl.value.showRoot()
}
async function onSearchProfile(profile) {
const profileSkills = profile.skills.map((skillRelProfile) => skillRelProfile.skill)
showProfileMatches.value = true
await profileMatchesEl.value.searchProfileMatches(profileSkills)
}
</script>
<template>
<SectionHeader :title="t('Skill wheel')" />
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div class="xl:col-span-1 skill-options flex flex-col gap-4">
<SkillWheelProfileList
ref="profileListEL"
@search-profile="onSearchProfile"
/>
<BaseCard>
<template #title>{{ t("What skills are you looking for?") }}</template>
<template #footer>
<BaseButton
:label="t('View skills wheel')"
icon="wheel"
type="secondary"
@click="onClickViewSkillWheel()"
/>
</template>
<BaseAutocomplete
id="skill_id"
v-model="foundSkills"
:label="t('Enter the skill name to search')"
:search="findSkills"
is-multiple
>
<template #chip="{ value }">
{{ value.name }}
<span
class="p-autocomplete-token-icon"
@click="wheelEl.showSkill(value.id)"
>
<BaseIcon
icon="crosshairs"
size="small"
/>
</span>
</template>
</BaseAutocomplete>
<BaseButton
:disabled="!foundSkills.length"
:label="t('Search profile matches')"
icon="search"
type="black"
@click="onClickSearchProfileMatches"
/>
<p v-t="'Is this what you were looking for?'" />
<BaseButton
:disabled="!foundSkills.length"
:label="t('Save this search')"
icon="search"
type="black"
@click="showSkilProfileForm = true"
/>
</BaseCard>
<BaseCard>
<template #title>{{ t("Legend") }}</template>
<ul class="fa-ul">
<li>
<BaseIcon
class="skill-legend-basic"
icon="square"
/>
{{ t("Basic skills") }}
</li>
<li>
<BaseIcon
class="skill-legend-add"
icon="square"
/>
{{ t("Skills you can learn") }}
</li>
<li>
<BaseIcon
class="skill-legend-search"
icon="square"
/>
{{ t("Skills searched for") }}
</li>
</ul>
</BaseCard>
</div>
<div class="xl:col-span-2">
<SkillWheelGraph
v-show="!showProfileMatches"
ref="wheelEl"
/>
<SkillProfileMatches
v-show="showProfileMatches"
ref="profileMatchesEl"
/>
</div>
</div>
<SkillProfileDialog
v-model:skills="foundSkills"
v-model:visible="showSkilProfileForm"
@saved="profileListEL.loadProfiles()"
/>
</template>

@ -155,6 +155,7 @@
"symfony/yaml": "6.4.*",
"symfonycasts/reset-password-bundle": "^1.8",
"szymach/c-pchart": "^3.0",
"thenetworg/oauth2-azure": "^2.2",
"twig/cssinliner-extra": "^3.3",
"twig/extra-bundle": "^3.0",
"twig/inky-extra": "^3.3",

799
composer.lock generated

File diff suppressed because it is too large Load Diff

@ -13,12 +13,6 @@ parameters:
urlAccessToken: ''
urlResourceOwnerDetails: ''
responseResourceOwnerId: 'sub'
# accessTokenMethod: 'POST'
# responseError: 'error'
# responseCode: ''
# scopeSeparator: ' '
scopes:
- openid
allow_create_new_users: true
allow_update_user_info: false
resource_owner_username_field: null
@ -38,8 +32,7 @@ parameters:
title: 'Facebook'
client_id: ''
client_secret: ''
graph_api_version: 'v20.0'
redirect_params: { }
#graph_api_version: 'v20.0'
keycloak:
enabled: false
@ -48,8 +41,10 @@ parameters:
client_secret: ''
auth_server_url: ''
realm: ''
version: ''
encryption_algorithm: null
encryption_key_path: null
encryption_key: null
redirect_params: { }
#version: ''
azure:
enabled: false
title: 'Azure'
client_id: ''
client_secret: ''

@ -5,6 +5,10 @@ knpu_oauth2_client:
provider_class: League\OAuth2\Client\Provider\GenericProvider
client_id: ''
client_secret: ''
provider_options:
responseResourceOwnerId: 'sub'
scopes:
- openid
redirect_route: chamilo.oauth2_generic_check
facebook:
@ -12,16 +16,20 @@ knpu_oauth2_client:
client_id: ''
client_secret: ''
redirect_route: chamilo.oauth2_facebook_check
graph_api_version: ''
redirect_params: { }
graph_api_version: 'v20.0'
keycloak:
type: keycloak
client_id: ''
client_secret: ''
redirect_route: chamilo.oauth2_keycloak_check
redirect_params: { }
auth_server_url: null
realm: null
azure:
type: azure
client_id: ''
redirect_route: chamilo.oauth2_azure_check
client_secret: ' '
# configure your clients as described here: https://github.com/knpuniversity/oauth2-client-bundle#configuration

@ -118,6 +118,7 @@ security:
- Chamilo\CoreBundle\Security\Authenticator\OAuth2\GenericAuthenticator
- Chamilo\CoreBundle\Security\Authenticator\OAuth2\FacebookAuthenticator
- Chamilo\CoreBundle\Security\Authenticator\OAuth2\KeycloakAuthenticator
- Chamilo\CoreBundle\Security\Authenticator\OAuth2\AzureAuthenticator
access_control:
- {path: ^/login, roles: PUBLIC_ACCESS}

@ -40,6 +40,7 @@
"chart.js": "^4.0.0",
"colorjs.io": "^0.5.2",
"cropper": "^4.1.0",
"d3": "^7.9.0",
"datepair.js": "^0.4.17",
"dotenv": "^16.4.5",
"dropzone": "^5.9.3",
@ -113,6 +114,7 @@
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.15",
"@types/d3": "^7",
"@vue/compiler-sfc": "^3.5.12",
"autoprefixer": "^10.4.20",
"core-js": "3.39.0",

@ -22,7 +22,7 @@ $parentInfo = [];
if (!empty($categoryId)) {
$categoryInfo = $parentInfo = CourseCategory::getCategoryById($categoryId);
}
$parentId = $parentInfo ? $parentInfo['id'] : null;
$parentId = $parentInfo ? $parentInfo['parent_id'] : null;
switch ($action) {
case 'delete':
@ -37,36 +37,43 @@ switch ($action) {
}
if (!empty($categoryId)) {
$categoryRepo->delete($categoryRepo->find($categoryId));
CourseCategory::reorganizeTreePos($parentId);
Display::addFlash(Display::return_message(get_lang('Deleted')));
}
header('Location: '.api_get_self().'?category='.Security::remove_XSS($category));
exit;
break;
case 'export':
$courses = CourseCategory::getCoursesInCategory($categoryId);
if (!empty($courses)) {
if ($courses && count($courses) > 0) {
$name = api_get_local_time().'_'.$categoryInfo['code'];
$courseList = [];
/* @var \Chamilo\CoreBundle\Entity\Course $course */
foreach ($courses as $course) {
$courseList[] = $course->getTitle();
$courseList[] = [$course->getTitle()];
}
Export::arrayToCsv($courseList, $name);
}
header('Location: '.api_get_self());
exit;
$header = [get_lang('Course Title')];
Export::arrayToCsvSimple($courseList, $name, false, $header);
} else {
Display::addFlash(Display::return_message(get_lang('No courses found for this category'), 'warning'));
header('Location: '.api_get_self().'?category='.Security::remove_XSS($categoryId));
exit;
}
break;
case 'moveUp':
CourseCategory::moveNodeUp($categoryId, $_GET['tree_pos'], $category);
Display::addFlash(Display::return_message(get_lang('Update successful')));
if (CourseCategory::moveNodeUp($categoryId, $_GET['tree_pos'], $parentId)) {
Display::addFlash(Display::return_message(get_lang('Update successful')));
} else {
Display::addFlash(Display::return_message(get_lang('Cannot move category up'), 'error'));
}
header('Location: '.api_get_self().'?category='.Security::remove_XSS($category));
exit;
break;
case 'add':
if (isset($_POST['formSent']) && $_POST['formSent']) {
$categoryEntity = CourseCategory::add(
$_POST['code'],
$_POST['name'],
$_POST['title'],
$_POST['auth_course_child'],
$_POST['description'],
$parentId,
@ -85,7 +92,7 @@ switch ($action) {
if (isset($_POST['formSent']) && $_POST['formSent']) {
$categoryEntity = CourseCategory::edit(
$categoryId,
$_REQUEST['name'],
$_REQUEST['title'],
$_REQUEST['auth_course_child'],
$_REQUEST['code'],
$_REQUEST['description']
@ -127,7 +134,7 @@ if ('add' === $action || 'edit' === $action) {
$form_title = 'add' === $action ? get_lang('Add category') : get_lang('Edit this category');
if (!empty($categoryInfo)) {
$form_title .= ' '.get_lang('Into').' '.$categoryInfo['name'];
$form_title .= ' '.get_lang('Into').' '.$categoryInfo['title'];
}
$url = api_get_self().'?action='.Security::remove_XSS($action).'&id='.Security::remove_XSS($categoryId);
$form = new FormValidator('course_category', 'post', $url);
@ -137,15 +144,15 @@ if ('add' === $action || 'edit' === $action) {
if ('true' === api_get_setting('editor.save_titles_as_html')) {
$form->addHtmlEditor(
'name',
'title',
get_lang('Category name'),
true,
false,
['ToolbarSet' => 'TitleAsHtml']
);
} else {
$form->addElement('text', 'name', get_lang('Category name'));
$form->addRule('name', get_lang('Please enter a code and a name for the category'), 'required');
$form->addElement('text', 'title', get_lang('Category name'));
$form->addRule('title', get_lang('Please enter a code and a name for the category'), 'required');
}
$form->addRule('code', get_lang('Please enter a code and a name for the category'), 'required');
@ -184,8 +191,9 @@ if ('add' === $action || 'edit' === $action) {
$asset = $assetRepo->find($categoryInfo['asset_id']);
$image = $assetRepo->getAssetUrl($asset);
$escapedImageUrl = htmlspecialchars($image, ENT_QUOTES, 'UTF-8');
$form->addLabel(get_lang('Image'), "<img src=$image />");
$form->addLabel(get_lang('Image'), "<img src='$escapedImageUrl' alt='Image' />");
}
if ('edit' === $action && !empty($categoryInfo)) {
@ -223,7 +231,7 @@ if ('add' === $action || 'edit' === $action) {
}
echo Display::toolbarAction('categories', [$actions]);
if (!empty($parentInfo)) {
echo Display::page_subheader($parentInfo['name'].' ('.$parentInfo['code'].')');
echo Display::page_subheader($parentInfo['title'].' ('.$parentInfo['code'].')');
}
echo CourseCategory::listCategories($categoryInfo);
}

@ -163,7 +163,7 @@ $form = new FormValidator('course_user_import');
$form->addElement('header', '', $tool_name);
$form->addElement('file', 'import_file', get_lang('Import marks in an assessment'));
$form->addElement('checkbox', 'subscribe', get_lang('Action'), get_lang('Add user in the course only if not yet in'));
$form->addElement('checkbox', 'unsubscribe', '', get_lang('Remove user from course if his name is not in the list'));
$form->addElement('checkbox', 'unsubscribe', '', get_lang('Remove users from any courses that are not mentioned explicitly in this file'));
$form->addButtonImport(get_lang('Import'));
$form->setDefaults(['subscribe' => '1', 'unsubscribe' => 1]);
$errors = [];
@ -174,7 +174,7 @@ if ($form->validate()) {
if (0 == count($errors)) {
$inserted_in_course = save_data($users_courses);
// Build the alert message in case there were visual codes subscribed to.
if ($_POST['subscribe']) {
if (isset($_POST['subscribe']) && $_POST['subscribe']) {
//$warn = get_lang('The users have been subscribed to the following courses because several courses share the same visual code').': ';
} else {
$warn = get_lang('The users have been unsubscribed from the following courses because several courses share the same visual code').': ';

@ -10,6 +10,8 @@ use Chamilo\CoreBundle\Component\Utils\ActionIcon;
use Chamilo\CoreBundle\Component\Utils\ToolIcon;
use Chamilo\CoreBundle\Component\Utils\ObjectIcon;
use Chamilo\CoreBundle\Component\Utils\StateIcon;
use Chamilo\CoreBundle\Framework\Container;
use Symfony\Component\HttpFoundation\RedirectResponse;
// Resetting the course id.
$cidReset = true;
@ -17,6 +19,13 @@ $cidReset = true;
// Including some necessary chamilo files.
require_once __DIR__.'/../inc/global.inc.php';
$response = new RedirectResponse(
Container::getRouter()->generate('admin')
);
$response->send();
exit;
// Setting the section (for the tabs).
$this_section = SECTION_PLATFORM_ADMIN;

@ -372,6 +372,8 @@ switch ($report) {
$sessions = [];
if ($validated) {
$values = $form->getSubmitValues();
$dateStart = $values['range_start'];
$dateEnd = $values['range_end'];
$first = DateTime::createFromFormat('Y-m-d', $dateStart);
$second = DateTime::createFromFormat('Y-m-d', $dateEnd);
$numberOfWeeks = 0;
@ -549,7 +551,7 @@ switch ($report) {
foreach ($sessions as $session) {
$courseList = SessionManager::getCoursesInSession($session['id']);
$table->setCellContents($row, 0, $session['name']);
$table->setCellContents($row, 0, $session['title']);
$table->setCellContents($row, 1, api_get_local_time($session['display_start_date']));
$table->setCellContents($row, 2, api_get_local_time($session['display_end_date']));

@ -57,15 +57,6 @@ function validate_data($users, $checkUniqueEmail = false)
$user['message'] .= Display::return_message(get_lang('This login is too long'), 'warning');
$user['has_error'] = true;
}
// 2.1.1
$hasDash = strpos($username, '-');
if (false !== $hasDash) {
$user['message'] .= Display::return_message(
get_lang('The username cannot contain the \' - \' character'),
'warning'
);
$user['has_error'] = true;
}
// 2.2. Check whether the username was used twice in import file.
if (isset($usernames[$username])) {
$user['message'] .= Display::return_message(get_lang('Login is used twice'), 'warning');

@ -135,11 +135,7 @@ $token = Security::get_token();
//deletes a session when using don't know question type (ugly fix)
Session::erase('less_answer');
// If we are in a test
$inATest = isset($exerciseId) && $exerciseId > 0;
if (!$inATest) {
echo Display::return_message(get_lang('Choose question type'), 'warning');
} else {
if (isset($exerciseId) && $exerciseId > 0) {
if ($nbrQuestions) {
// In the building exercise mode show question list ordered as is.
$objExercise->setCategoriesGrouping(false);
@ -149,32 +145,14 @@ if (!$inATest) {
$objExercise->questionSelectionType = EX_Q_SELECTION_ORDERED;
$allowQuestionOrdering = true;
$showPagination = api_get_setting('exercise.show_question_pagination');
$length = api_get_setting('exercise.question_pagination_length');
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
if (!empty($showPagination) && $nbrQuestions > $showPagination) {
$length = api_get_setting('exercise.question_pagination_length');
$url = api_get_self().'?'.api_get_cidreq();
// Use pagination for exercise with more than 200 questions.
$allowQuestionOrdering = false;
$start = ($page - 1) * $length;
$questionList = $objExercise->getQuestionForTeacher($start, $length);
$paginator = new Knp\Component\Pager\Paginator();
$pagination = $paginator->paginate([]);
$pagination->setTotalItemCount($nbrQuestions);
$pagination->setItemNumberPerPage($length);
$pagination->setCurrentPageNumber($page);
$pagination->renderer = function ($data) use ($url) {
$render = '<ul class="pagination">';
for ($i = 1; $i <= $data['pageCount']; $i++) {
$pageContent = '<li><a href="'.$url.'&page='.$i.'">'.$i.'</a></li>';
if ($data['current'] == $i) {
$pageContent = '<li class="active"><a href="#" >'.$i.'</a></li>';
}
$render .= $pageContent;
}
$render .= '</ul>';
return $render;
};
echo $pagination;
$questionList = $objExercise->selectQuestionList(true, true);
$questionList = array_slice($questionList, $start, $length);
} else {
// Classic order
$questionList = $objExercise->selectQuestionList(true, true);
@ -331,7 +309,17 @@ if (!$inATest) {
}
echo '</div>'; //question list div
// Pagination navigation
$totalPages = ceil($nbrQuestions / $length);
echo '<div class="pagination flex justify-center mt-4">';
for ($i = 1; $i <= $totalPages; $i++) {
$isActive = ($i == $page) ? 'bg-primary text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-200';
echo '<a href="?'.http_build_query(array_merge($_GET, ['page' => $i])).'" class="mx-1 px-4 py-2 border '.$isActive.' rounded">'.$i.'</a>';
}
echo '</div>';
} else {
echo Display::return_message(get_lang('Questions list (there is no question so far).'), 'warning');
}
} else {
echo Display::return_message(get_lang('Choose question type'), 'warning');
}

@ -288,7 +288,7 @@ class SkillModel extends Model
'criteria' => $skill->getCriteria(),
'status' => $skill->getStatus(),
'asset_id' => (string) $skill->getAsset()?->getId(),
'profile_id' => $skill->getProfile()?->getId(),
'profile_id' => $skill->getLevelProfile()?->getId(),
'icons_small' => sprintf('badges/%s-small.png', sha1($skill['title'])),
];
}
@ -1023,7 +1023,7 @@ class SkillModel extends Model
if (!empty($skills)) {
foreach ($skills as &$skill) {
if (0 == $skill['parent_id']) {
$skill['parent_id'] = 1;
$skill['parent_id'] = '-1';
}
// because except main keys (id, title, children) others keys
@ -1070,7 +1070,7 @@ class SkillModel extends Model
// Check if the skill has related gradebooks
$skill['data']['skill_has_gradebook'] = false;
if (isset($skill['gradebooks']) && !empty($skill['gradebooks'])) {
if (!empty($skill['gradebooks'])) {
$skill['data']['skill_has_gradebook'] = true;
}
$refs[$skill['id']] = &$skill;

@ -640,7 +640,7 @@ define('RESOURCE_SCORM', 'Scorm');
define('RESOURCE_SURVEY', 'survey');
define('RESOURCE_SURVEYQUESTION', 'survey_question');
define('RESOURCE_SURVEYINVITATION', 'survey_invitation');
//define('RESOURCE_WIKI', 'wiki');
define('RESOURCE_WIKI', 'wiki');
define('RESOURCE_THEMATIC', 'thematic');
define('RESOURCE_ATTENDANCE', 'attendance');
define('RESOURCE_WORK', 'work');

@ -6345,13 +6345,11 @@ class CourseManager
/**
* Helper function to check if there is a course template and, if so, to
* copy the template as basis for the new course.
*
* @param string $courseCode Course code
* @param int $courseTemplate 0 if no course template is defined
*/
public static function useTemplateAsBasisIfRequired($courseCode, $courseTemplate)
public static function useTemplateAsBasisIfRequired(string $courseCode, int $courseTemplate): void
{
$template = api_get_setting('course_creation_use_template');
$template = is_numeric($template) ? intval($template) : null;
$teacherCanSelectCourseTemplate = 'true' === api_get_setting('teacher_can_select_course_template');
$courseTemplate = isset($courseTemplate) ? intval($courseTemplate) : 0;
@ -6365,7 +6363,7 @@ class CourseManager
$originCourse = api_get_course_info_by_id($template);
}
if ($useTemplate) {
if ($useTemplate && !empty($originCourse)) {
// Include the necessary libraries to generate a course copy
// Call the course copy object
$originCourse['official_code'] = $originCourse['code'];
@ -6768,7 +6766,7 @@ class CourseManager
if (isset($params['course_template'])) {
self::useTemplateAsBasisIfRequired(
$course->getCode(),
$params['course_template']
(int) $params['course_template']
);
}
$params['course_code'] = $course->getCode();

@ -240,54 +240,57 @@ class CourseCategory
*
* @return bool
*/
public static function moveNodeUp($code, $tree_pos, $parent_id)
public static function moveNodeUp($categoryId, $treePos, $parentId): bool
{
$table = Database::get_main_table(TABLE_MAIN_CATEGORY);
$code = Database::escape_string($code);
$tree_pos = (int) $tree_pos;
$parent_id = Database::escape_string($parent_id);
$categoryId = (int) $categoryId;
$treePos = (int) $treePos;
$parentIdCondition = " AND (parent_id IS NULL OR parent_id = '' )";
if (!empty($parent_id)) {
$parentIdCondition = " AND parent_id = '$parent_id' ";
$parentIdCondition = "parent_id IS NULL";
if (!empty($parentId)) {
$parentIdCondition = "parent_id = '".Database::escape_string($parentId)."'";
}
$sql = "SELECT code,tree_pos
FROM $table
WHERE
tree_pos < $tree_pos
$parentIdCondition
ORDER BY tree_pos DESC
LIMIT 0,1";
self::reorganizeTreePos($parentId);
$sql = "SELECT id, tree_pos
FROM $table
WHERE $parentIdCondition AND tree_pos < $treePos
ORDER BY tree_pos DESC
LIMIT 1";
$result = Database::query($sql);
if (!$row = Database::fetch_array($result)) {
$sql = "SELECT code, tree_pos
FROM $table
WHERE
tree_pos > $tree_pos
$parentIdCondition
ORDER BY tree_pos DESC
LIMIT 0,1";
$result2 = Database::query($sql);
if (!$row = Database::fetch_array($result2)) {
return false;
}
$previousCategory = Database::fetch_array($result);
if (!$previousCategory) {
return false;
}
$sql = "UPDATE $table
SET tree_pos ='".$row['tree_pos']."'
WHERE code='$code'";
Database::query($sql);
$sql = "UPDATE $table
SET tree_pos = '$tree_pos'
WHERE code= '".$row['code']."'";
Database::query($sql);
Database::query("UPDATE $table SET tree_pos = {$previousCategory['tree_pos']} WHERE id = $categoryId");
Database::query("UPDATE $table SET tree_pos = $treePos WHERE id = {$previousCategory['id']}");
return true;
}
public static function reorganizeTreePos($parentId): void
{
$table = Database::get_main_table(TABLE_MAIN_CATEGORY);
$parentIdCondition = "parent_id IS NULL";
if (!empty($parentId)) {
$parentIdCondition = "parent_id = '".Database::escape_string($parentId)."'";
}
$sql = "SELECT id FROM $table WHERE $parentIdCondition ORDER BY tree_pos";
$result = Database::query($sql);
$newTreePos = 1;
while ($row = Database::fetch_array($result)) {
Database::query("UPDATE $table SET tree_pos = $newTreePos WHERE id = {$row['id']}");
$newTreePos++;
}
}
/**
* @param string $categoryCode
*

@ -232,6 +232,7 @@ define('TABLE_NOTEBOOK', 'notebook');
// Message
define('TABLE_MESSAGE', 'message');
define('TABLE_MESSAGE_ATTACHMENT', 'message_attachment');
define('TABLE_MESSAGE_REL_USER', 'message_rel_user');
// Attendance Sheet
define('TABLE_ATTENDANCE', 'attendance');

@ -1200,11 +1200,13 @@ class Display
// Default row quantity
if (!isset($extra_params['rowList'])) {
$extra_params['rowList'] = [20, 50, 100, 500, 1000, $all_value];
$defaultRowList = [20, 50, 100, 500, 1000, $all_value];
$rowList = api_get_setting('platform.table_row_list', true);
if (!empty($rowList) && isset($rowList['options'])) {
if (is_array($rowList) && isset($rowList['options']) && is_array($rowList['options'])) {
$rowList = $rowList['options'];
$rowList[] = $all_value;
} else {
$rowList = $defaultRowList;
}
$extra_params['rowList'] = $rowList;
}

@ -55,7 +55,7 @@ class Export
*
* @return string|void Returns the file path if $writeOnly is true, otherwise sends the file for download and exits.
*/
public static function arrayToCsvSimple(array $data, string $filename = 'export', bool $writeOnly = false)
public static function arrayToCsvSimple(array $data, string $filename = 'export', bool $writeOnly = false, array $header = [])
{
$file = api_get_path(SYS_ARCHIVE_PATH) . uniqid('') . '.csv';
@ -65,16 +65,12 @@ class Export
throw new \RuntimeException("Unable to create or open the file: $file");
}
if (is_array($data)) {
foreach ($data as $row) {
$line = '';
if (is_array($row)) {
foreach ($row as $value) {
$line .= '"' . str_replace('"', '""', (string)$value) . '";';
}
}
fwrite($handle, rtrim($line, ';') . "\n");
}
if (!empty($header)) {
fputcsv($handle, $header, ';');
}
foreach ($data as $row) {
fputcsv($handle, (array)$row, ';');
}
fclose($handle);

@ -2,6 +2,7 @@
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Component\Utils\ChamiloApi;
use Chamilo\CoreBundle\Entity\MessageRelUser;
use Chamilo\CoreBundle\Entity\UserRelUser;
use Chamilo\CoreBundle\Component\Utils\ActionIcon;
@ -136,47 +137,48 @@ class Statistics
$tblCourseCategory = Database::get_main_table(TABLE_MAIN_CATEGORY);
$tblCourseRelCategory = Database::get_main_table(TABLE_MAIN_COURSE_REL_CATEGORY);
$urlId = api_get_current_access_url_id();
$active_filter = $onlyActive ? ' AND active = 1' : '';
$status_filter = isset($status) ? ' AND status = '.intval($status) : '';
$conditions = [];
$conditions[] = "u.active <> " . USER_SOFT_DELETED;
if ($onlyActive) {
$conditions[] = "u.active = 1";
}
if (isset($status)) {
$conditions[] = "u.status = " . intval($status);
}
$where = implode(' AND ', $conditions);
if (api_is_multiple_url_enabled()) {
$sql = "SELECT COUNT(DISTINCT(u.id)) AS number
FROM $user_table as u, $access_url_rel_user_table as url
WHERE
u.active <> ".USER_SOFT_DELETED." AND
u.id = url.user_id AND
access_url_id = $urlId
$status_filter $active_filter";
FROM $user_table as u
INNER JOIN $access_url_rel_user_table as url ON u.id = url.user_id
WHERE $where AND url.access_url_id = $urlId";
if (isset($categoryCode)) {
$categoryCode = Database::escape_string($categoryCode);
$sql = "SELECT COUNT(DISTINCT(cu.user_id)) AS number
FROM $course_user_table cu, $course_table c, $access_url_rel_user_table as url, $tblCourseRelCategory crc, $tblCourseCategory cc
WHERE
c.id = cu.c_id AND
cc.code = '$categoryCode' AND
crc.course_category_id = cc.id AND
crc.course_id = c.id AND
cu.user_id = url.user_id AND
access_url_id = $urlId
$status_filter $active_filter";
FROM $course_user_table cu
INNER JOIN $course_table c ON c.id = cu.c_id
INNER JOIN $access_url_rel_user_table as url ON cu.user_id = url.user_id
INNER JOIN $tblCourseRelCategory crc ON crc.course_id = c.id
INNER JOIN $tblCourseCategory cc ON cc.id = crc.course_category_id
WHERE $where AND url.access_url_id = $urlId AND cc.code = '$categoryCode'";
}
} else {
$sql = "SELECT COUNT(DISTINCT(id)) AS number
FROM $user_table
WHERE 1 = 1 AND active <> ".USER_SOFT_DELETED." $status_filter $active_filter";
FROM $user_table u
WHERE $where";
if (isset($categoryCode)) {
$categoryCode = Database::escape_string($categoryCode);
$status_filter = isset($status) ? ' AND status = '.intval($status) : '';
$sql = "SELECT COUNT(DISTINCT(cu.user_id)) AS number
FROM $course_user_table cu, $course_table c, $tblCourseRelCategory crc, $tblCourseCategory cc
WHERE
c.id = cu.c_id AND
cc.code = '$categoryCode' AND
crc.course_category_id = cc.id AND
crc.course_id = c.id AND
$status_filter
$active_filter
";
FROM $course_user_table cu
INNER JOIN $course_table c ON c.id = cu.c_id
INNER JOIN $tblCourseRelCategory crc ON crc.course_id = c.id
INNER JOIN $tblCourseCategory cc ON cc.id = crc.course_category_id
INNER JOIN $user_table u ON u.id = cu.user_id
WHERE $where AND cc.code = '$categoryCode'";
}
}
@ -890,7 +892,7 @@ class Statistics
'get',
api_get_path(WEB_CODE_PATH).'admin/statistics/index.php',
'',
'width=200px',
['style' => 'width:200px'],
false
);
$renderer = &$form->defaultRenderer();
@ -941,7 +943,7 @@ class Statistics
$access_url_rel_course_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_COURSE);
$urlId = api_get_current_access_url_id();
$columns[0] = 't.c_id';
$columns[0] = 'c_id';
$columns[1] = 'access_date';
$sql_order[SORT_ASC] = 'ASC';
$sql_order[SORT_DESC] = 'DESC';
@ -972,15 +974,15 @@ class Statistics
if (api_is_multiple_url_enabled()) {
$sql = "SELECT * FROM $table t , $access_url_rel_course_table a
WHERE
t.c_id = a.c_id AND
c_id = a.c_id AND
access_url_id='".$urlId."'
GROUP BY t.c_id
HAVING t.c_id <> ''
GROUP BY c_id
HAVING c_id <> ''
AND DATEDIFF( '".api_get_utc_datetime()."' , access_date ) <= ".$date_diff;
} else {
$sql = "SELECT * FROM $table t
GROUP BY t.c_id
HAVING t.c_id <> ''
GROUP BY c_id
HAVING c_id <> ''
AND DATEDIFF( '".api_get_utc_datetime()."' , access_date ) <= ".$date_diff;
}
$sql .= ' ORDER BY `'.$columns[$column].'` '.$sql_order[$direction];
@ -1030,33 +1032,42 @@ class Statistics
*/
public static function getMessages($messageType)
{
$message_table = Database::get_main_table(TABLE_MESSAGE);
$user_table = Database::get_main_table(TABLE_MAIN_USER);
$access_url_rel_user_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
$messageTable = Database::get_main_table(TABLE_MESSAGE);
$messageRelUserTable = Database::get_main_table(TABLE_MESSAGE_REL_USER);
$userTable = Database::get_main_table(TABLE_MAIN_USER);
$accessUrlRelUserTable = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
$urlId = api_get_current_access_url_id();
switch ($messageType) {
case 'sent':
$field = 'user_sender_id';
$field = 'm.user_sender_id';
$joinCondition = "m.id = mru.message_id AND mru.receiver_type = " . MessageRelUser::TYPE_SENDER;
break;
case 'received':
$field = 'user_receiver_id';
$field = 'mru.user_id';
$joinCondition = "m.id = mru.message_id AND mru.receiver_type = " . MessageRelUser::TYPE_TO;
break;
}
if (api_is_multiple_url_enabled()) {
$sql = "SELECT lastname, firstname, username, COUNT($field) AS count_message
FROM $access_url_rel_user_table as url, $message_table m
LEFT JOIN $user_table u ON m.$field = u.id AND u.active <> ".USER_SOFT_DELETED."
WHERE url.user_id = m.$field AND access_url_id='".$urlId."'
GROUP BY m.$field
ORDER BY count_message DESC ";
$sql = "SELECT u.lastname, u.firstname, u.username, COUNT(DISTINCT m.id) AS count_message
FROM $messageTable m
INNER JOIN $messageRelUserTable mru ON $joinCondition
INNER JOIN $userTable u ON $field = u.id
INNER JOIN $accessUrlRelUserTable url ON u.id = url.user_id
WHERE url.access_url_id = $urlId
AND u.active <> " . USER_SOFT_DELETED . "
GROUP BY $field
ORDER BY count_message DESC";
} else {
$sql = "SELECT lastname, firstname, username, COUNT($field) AS count_message
FROM $message_table m
LEFT JOIN $user_table u ON m.$field = u.id AND u.active <> ".USER_SOFT_DELETED."
GROUP BY m.$field ORDER BY count_message DESC ";
$sql = "SELECT u.lastname, u.firstname, u.username, COUNT(DISTINCT m.id) AS count_message
FROM $messageTable m
INNER JOIN $messageRelUserTable mru ON $joinCondition
INNER JOIN $userTable u ON $field = u.id
WHERE u.active <> " . USER_SOFT_DELETED . "
GROUP BY $field
ORDER BY count_message DESC";
}
$res = Database::query($sql);
$messages_sent = [];
@ -1065,9 +1076,9 @@ class Statistics
$messages['username'] = get_lang('Unknown');
}
$users = api_get_person_name(
$messages['firstname'],
$messages['lastname']
).'<br />('.$messages['username'].')';
$messages['firstname'],
$messages['lastname']
) . '<br />(' . $messages['username'] . ')';
$messages_sent[$users] = $messages['count_message'];
}
@ -1144,7 +1155,7 @@ class Statistics
"SELECT count(distinct(login_user_id)) AS number ".
" FROM $table $table_url ".
" WHERE DATE_ADD(login_date, INTERVAL 31 DAY) >= '$now' $where_url";
$sql[sprintf(get_lang('Last %i months'), 6)] =
$sql[sprintf(get_lang('Last %d months'), 6)] =
"SELECT count(distinct(login_user_id)) AS number ".
" FROM $table $table_url ".
" WHERE DATE_ADD(login_date, INTERVAL 6 MONTH) >= '$now' $where_url";

@ -1371,7 +1371,7 @@ class UserGroupModel extends Model
}
$result = Database::store_result(
Database::query("SELECT u.* FROM $sqlFrom WHERE $sqlWhere ORDER BY title $sord LIMIT $start, $limit")
Database::query("SELECT DISTINCT u.* FROM $sqlFrom WHERE $sqlWhere ORDER BY title $sord LIMIT $start, $limit")
);
$new_result = [];
@ -1588,8 +1588,9 @@ class UserGroupModel extends Model
return false;
}
public function update($params, $showQuery = false)
public function update($params, $showQuery = false): bool
{
$em = Database::getManager();
$repo = Container::getUsergroupRepository();
/** @var Usergroup $userGroup */
$userGroup = $repo->find($params['id']);
@ -1597,7 +1598,22 @@ class UserGroupModel extends Model
return false;
}
//$params['updated_on'] = api_get_utc_datetime();
if (isset($params['title'])) {
$userGroup->setTitle($params['title']);
}
if (isset($params['description'])) {
$userGroup->setDescription($params['description']);
}
if (isset($params['visibility'])) {
$userGroup->setVisibility($params['visibility']);
}
if (isset($params['url'])) {
$userGroup->setUrl($params['url']);
}
$userGroup
->setGroupType(isset($params['group_type']) ? Usergroup::SOCIAL_CLASS : Usergroup::NORMAL_CLASS)
->setAllowMembersToLeaveGroup(isset($params['allow_members_leave_group']) ? 1 : 0)
@ -1612,7 +1628,8 @@ class UserGroupModel extends Model
}
}
$repo->update($userGroup);
$em->persist($userGroup);
$em->flush();
if (isset($params['delete_picture'])) {
$this->delete_group_picture($params['id']);

@ -5,6 +5,7 @@
use Chamilo\CoreBundle\Component\Utils\ActionIcon;
use Chamilo\CoreBundle\Component\Utils\ObjectIcon;
use Chamilo\CoreBundle\Component\Utils\ToolIcon;
use Chamilo\CoreBundle\Framework\Container;
/**
* Class IndexManager.
@ -216,7 +217,7 @@ class IndexManager
if (($allowSkillsManagement && api_is_drh()) || api_is_platform_admin()) {
$items[] = [
'icon' => Display::getMdiIcon(ActionIcon::EDIT_BADGE, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('My skills')),
'link' => api_get_path(WEB_CODE_PATH).'skills/skills_wheel.php',
'link' => Container::getRouter()->generate('skill_wheel'),
'title' => get_lang('Manage skills'),
];
}

@ -36,8 +36,14 @@ class ZombieManager
$column = 'user.firstname',
$direction = 'desc'
) {
$column = str_replace('user.', '', $column);
if (empty($column)) {
$column = 'user.firstname';
$column = 'firstname';
}
$validColumns = ['id', 'official_code', 'firstname', 'lastname', 'username', 'auth_source', 'email', 'status', 'registration_date', 'active', 'login_date'];
if (!in_array($column, $validColumns)) {
$column = 'firstname';
}
$ceiling = is_numeric($ceiling) ? (int) $ceiling : strtotime($ceiling);
$ceiling = date('Y-m-d H:i:s', $ceiling);
@ -46,7 +52,7 @@ class ZombieManager
$login_table = Database::get_main_table(TABLE_STATISTIC_TRACK_E_LOGIN);
$sql = 'SELECT
user.user_id,
user.id,
user.official_code,
user.firstname,
user.lastname,
@ -66,20 +72,20 @@ class ZombieManager
WHERE
access.login_date = (SELECT MAX(a.login_date)
FROM $login_table as a
WHERE a.login_user_id = user.user_id
WHERE a.login_user_id = user.id
) AND
access.login_date <= '$ceiling' AND
user.user_id = access.login_user_id AND
url.user_id = user.user_id AND url.access_url_id=$current_url_id";
user.id = access.login_user_id AND
url.user_id = user.id AND url.access_url_id=$current_url_id";
} else {
$sql .= " FROM $user_table as user, $login_table as access
WHERE
access.login_date = (SELECT MAX(a.login_date)
FROM $login_table as a
WHERE a.login_user_id = user.user_id
WHERE a.login_user_id = user.id
) AND
access.login_date <= '$ceiling' AND
user.user_id = access.login_user_id";
user.id = access.login_user_id";
}
if ($active_only) {
@ -87,6 +93,7 @@ class ZombieManager
}
$sql .= !str_contains($sql, 'WHERE') ? ' WHERE user.active <> '.USER_SOFT_DELETED : ' AND user.active <> '.USER_SOFT_DELETED;
$column = str_replace('user.', '', $column);
$sql .= " ORDER BY `$column` $direction";
if (!is_null($from) && !is_null($count)) {
$count = (int) $count;
@ -107,7 +114,7 @@ class ZombieManager
$zombies = self::listZombies($ceiling);
$ids = [];
foreach ($zombies as $zombie) {
$ids[] = $zombie['user_id'];
$ids[] = $zombie['id'];
}
UserManager::deactivate_users($ids);
}

@ -3,7 +3,7 @@
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Component\Utils\StateIcon;
use Symfony\Component\HttpFoundation\Request;
/**
* Description of zombie_report.
*
@ -14,12 +14,14 @@ use Chamilo\CoreBundle\Component\Utils\StateIcon;
class ZombieReport implements Countable
{
protected $additional_parameters = [];
protected $request;
protected $parameters_form = null;
public function __construct($additional_parameters = [])
public function __construct($additional_parameters = [], Request $request = null)
{
$this->additional_parameters = $additional_parameters;
$this->request = $request ?? Request::createFromGlobals();
}
/**
@ -121,7 +123,7 @@ class ZombieReport implements Countable
public function get_ceiling($format = null)
{
$result = Request::get('ceiling');
$result = $this->request->get('ceiling');
$result = $result ? $result : ZombieManager::last_year();
$result = is_array($result) && 1 == count($result) ? reset($result) : $result;
@ -137,7 +139,7 @@ class ZombieReport implements Countable
public function get_active_only()
{
$result = Request::get('active_only', false);
$result = $this->request->get('active_only', $this->request->query->get('active_only'));
$result = 'true' === $result ? true : $result;
$result = 'false' === $result ? false : $result;
$result = (bool) $result;
@ -156,12 +158,12 @@ class ZombieReport implements Countable
return 'display';
}
return Request::post('action', 'display');
return $this->request->request->get('action', 'display');
}
public function perform_action()
{
$ids = Request::post('id');
$ids = $this->request->request->get('id');
if (empty($ids)) {
return $ids;
}
@ -198,7 +200,7 @@ class ZombieReport implements Countable
$result = [];
foreach ($items as $item) {
$row = [];
$row[] = $item['user_id'];
$row[] = $item['id'];
$row[] = $item['official_code'];
$row[] = $item['firstname'];
$row[] = $item['lastname'];

@ -170,7 +170,7 @@ if ($form->validate()) {
$sessionArray = [];
foreach ($coachData['week'][$dateWeekToCheck]['sessions'] as $session) {
$date2 = new DateTime($session['display_start_date']);
$name = $session['name'];
$name = $session['title'];
$showName = true;
if (in_array($session['session_id'], $sessionAdded)) {

@ -123,13 +123,13 @@ $skillId = isset($_REQUEST['id']) ? (int) $_REQUEST['id'] : key($skillsOptions);
$skill = $skillRepo->find($skillId);
$profile = false;
if ($skill) {
$profile = $skill->getProfile();
$profile = $skill->getLevelProfile();
}
if (!empty($subSkillList)) {
$skillFromLastSkill = $skillRepo->find(end($subSkillList));
if ($skillFromLastSkill) {
$profile = $skillFromLastSkill->getProfile();
$profile = $skillFromLastSkill->getLevelProfile();
}
}
@ -139,7 +139,7 @@ if (!$profile) {
krsort($parents);
foreach ($parents as $parent) {
$skillParentId = $parent['skill_id'];
$profile = $skillRepo->find($skillParentId)->getProfile();
$profile = $skillRepo->find($skillParentId)->getLevelProfile();
if ($profile) {
break;

@ -1,7 +1,7 @@
<?php
/* For license terms, see /license.txt */
use Chamilo\CoreBundle\Entity\Profile;
use Chamilo\CoreBundle\Entity\SkillLevelProfile;
use Chamilo\CoreBundle\Entity\Skill;
/**
@ -11,7 +11,7 @@ $cidReset = true;
require_once __DIR__.'/../inc/global.inc.php';
api_protect_admin_script();
$em = Database::getManager();
$profiles = $em->getRepository(Profile::class)->findAll();
$profiles = $em->getRepository(SkillLevelProfile::class)->findAll();
$list = $em->getRepository(Skill::class)->findAll();
$listAction = api_get_self();
@ -40,11 +40,11 @@ $form->addHidden('id', $id);
$form->addButtonSave(get_lang('Update'));
if (!empty($item)) {
$profile = $item->getProfile();
$profile = $item->getLevelProfile();
if ($profile) {
$form->setDefaults(
[
'profile_id' => $item->getProfile()->getId(),
'profile_id' => $item->getLevelProfile()->getId(),
]
);
}
@ -63,9 +63,9 @@ switch ($action) {
if ($form->validate()) {
$values = $form->exportValues();
$profile = $em->getRepository(Profile::class)->find($values['profile_id']);
$profile = $em->getRepository(SkillLevelProfile::class)->find($values['profile_id']);
if ($profile) {
$item->setProfile($profile);
$item->setLevelProfile($profile);
$em->persist($item);
$em->flush();
Display::addFlash(Display::return_message(get_lang('Update successful')));

@ -2,7 +2,7 @@
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\Level;
use Chamilo\CoreBundle\Entity\Profile;
use Chamilo\CoreBundle\Entity\SkillLevelProfile;
use Chamilo\CoreBundle\Component\Utils\ActionIcon;
use Chamilo\CoreBundle\Component\Utils\ObjectIcon;
/**
@ -15,7 +15,7 @@ require_once __DIR__.'/../inc/global.inc.php';
api_protect_admin_script();
$em = Database::getManager();
$profiles = $em->getRepository(Profile::class)->findAll();
$profiles = $em->getRepository(SkillLevelProfile::class)->findAll();
$list = $em->getRepository(Level::class)->findAll();
$listAction = api_get_self();
@ -64,7 +64,7 @@ switch ($action) {
if ($form->validate()) {
$values = $form->exportValues();
if (isset($values['profile_id']) && !empty($values['profile_id'])) {
$profile = $em->getRepository(Profile::class)->find($values['profile_id']);
$profile = $em->getRepository(SkillLevelProfile::class)->find($values['profile_id']);
if ($profile) {
$item = new Level();
$item->setTitle($values['title']);
@ -101,7 +101,7 @@ switch ($action) {
$item->setTitle($values['title']);
$item->setShortTitle($values['short_title']);
$profile = $em->getRepository(Profile::class)->find($values['profile_id']);
$profile = $em->getRepository(SkillLevelProfile::class)->find($values['profile_id']);
if ($profile) {
$item->setProfile($profile);
}

@ -126,7 +126,7 @@ switch ($action) {
$toolbar .= Display::url(
Display::getMdiIcon(ObjectIcon::WHEEL, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Skills wheel')),
api_get_path(WEB_CODE_PATH).'skills/skills_wheel.php',
Container::getRouter()->generate('skill_wheel'),
['title' => get_lang('Skills wheel')]
);

@ -3,7 +3,7 @@
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\Level;
use Chamilo\CoreBundle\Entity\Profile;
use Chamilo\CoreBundle\Entity\SkillLevelProfile;
use Chamilo\CoreBundle\Entity\Skill;
use Chamilo\CoreBundle\Component\Utils\ActionIcon;
use Chamilo\CoreBundle\Component\Utils\ObjectIcon;
@ -17,7 +17,7 @@ require_once __DIR__.'/../inc/global.inc.php';
api_protect_admin_script();
$em = Database::getManager();
$list = $em->getRepository(Profile::class)->findAll();
$list = $em->getRepository(SkillLevelProfile::class)->findAll();
$listAction = api_get_self();
@ -30,7 +30,7 @@ $id = isset($_GET['id']) ? $_GET['id'] : '';
$item = null;
if (!empty($id)) {
$item = $em->getRepository(Profile::class)->find($id);
$item = $em->getRepository(SkillLevelProfile::class)->find($id);
if (!$item) {
api_not_allowed();
}
@ -89,7 +89,7 @@ switch ($action) {
$tpl->assign('form', $formToDisplay);
if ($form->validate()) {
$values = $form->exportValues();
$item = new Profile();
$item = new SkillLevelProfile();
$item->setTitle($values['name']);
$em->persist($item);
$em->flush();

@ -4,7 +4,7 @@
{{ 'Skills wheel'|trans }}
</a>
{% if allow_drh_skills_management %}
<a class="btn btn--plain" href="{{ _p.web_main }}skills/skills_wheel.php">
<a class="btn btn--plain" href="{{ url('skill_wheel') }}">
{{ 'Manage skills'|trans }}
</a>
{% endif %}

@ -1,4 +1,4 @@
{% if search_skill_list is not null %}
{% if search_skill_list is defined and search_skill_list is not null %}
<div class="skills-skills">
<h3>{{ "Skills"|trans }}</h3>
<ul class="holder">
@ -15,7 +15,7 @@
</div>
{% endif %}
{% if profiles is not null %}
{% if profiles is defined and profiles is not null %}
<div class="skills-profiles">
<h3>{{ "Skill profiles"|trans }}</h3>
<ul class="holder">
@ -92,7 +92,7 @@
</div>
{% endfor %}
{% else %}
{% if search_skill_list is null %}
{% if search_skill_list is defined and search_skill_list is null %}
<div class="warning-message">{{ "No results found"|trans }}</div>
{% endif %}
{% endif %}

@ -235,11 +235,7 @@ function get_number_of_users()
$sessionId = api_get_session_id();
$courseId = api_get_course_int_id();
$studentRoleFilter = " AND (
u.roles LIKE '%ROLE_STUDENT%'
)
";
$studentRoleFilter = '';
$teacherRoleFilter = " AND (
u.roles LIKE '%ROLE_TEACHER%' OR
u.roles LIKE '%ROLE_ADMIN%' OR
@ -481,11 +477,7 @@ function get_user_data($from, $number_of_items, $column, $direction)
u.id AS col5";
}
$studentRoleFilter = " AND (
u.roles LIKE '%ROLE_STUDENT%'
)
";
$studentRoleFilter = '';
$teacherRoleFilter = " AND (
u.roles LIKE '%ROLE_TEACHER%' OR
u.roles LIKE '%ROLE_ADMIN%' OR

@ -15,13 +15,14 @@ use Chamilo\CoreBundle\Repository\TrackEDefaultRepository;
use Chamilo\CoreBundle\ServiceHelper\MessageHelper;
use DateTime;
use DateTimeZone;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
use Exception;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
class LpProgressReminderCommand extends Command
{
@ -44,8 +45,7 @@ class LpProgressReminderCommand extends Command
parent::__construct();
}
protected function configure()
protected function configure(): void
{
$this
->setDescription('Send LP progress reminders to users based on "number_of_days_for_completion".')
@ -54,7 +54,8 @@ class LpProgressReminderCommand extends Command
null,
InputOption::VALUE_NONE,
'If set, will output detailed debug information'
);
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
@ -65,11 +66,12 @@ class LpProgressReminderCommand extends Command
// Retrieve LPs with completion days
$lpItems = $this->extraFieldValuesRepository->getLpIdWithDaysForCompletion();
if ($debugMode && !empty($lpItems)) {
$output->writeln('LP Items retrieved: ' . print_r($lpItems, true));
$output->writeln('LP Items retrieved: '.print_r($lpItems, true));
}
if (empty($lpItems)) {
$output->writeln('No learning paths with days for completion found.');
return Command::SUCCESS;
}
@ -82,7 +84,7 @@ class LpProgressReminderCommand extends Command
// Retrieve all courses from the CourseRepository
$courses = $this->courseRepository->findAll();
if ($debugMode && !empty($courses)) {
$output->writeln('Courses retrieved: ' . count($courses));
$output->writeln('Courses retrieved: '.\count($courses));
}
foreach ($courses as $course) {
@ -94,14 +96,14 @@ class LpProgressReminderCommand extends Command
$sessionCourseUsers = $this->sessionRelCourseRelUserRepository->getSessionCourseUsers($courseId, $lpIds);
if ($debugMode && (!empty($courseUsers) || !empty($sessionCourseUsers))) {
$output->writeln('Processing course ID: ' . $courseId);
$output->writeln('Processing course ID: '.$courseId);
if (!empty($courseUsers)) {
$output->writeln('Course users retrieved: ' . count($courseUsers));
//$output->writeln('Course retrieved: ' . print_r($courseUsers, true));
$output->writeln('Course users retrieved: '.\count($courseUsers));
// $output->writeln('Course retrieved: ' . print_r($courseUsers, true));
}
if (!empty($sessionCourseUsers)) {
$output->writeln('Session users retrieved: ' . count($sessionCourseUsers));
//$output->writeln('Session retrieved: ' . print_r($sessionCourseUsers, true));
$output->writeln('Session users retrieved: '.\count($sessionCourseUsers));
// $output->writeln('Session retrieved: ' . print_r($sessionCourseUsers, true));
}
}
@ -113,6 +115,7 @@ class LpProgressReminderCommand extends Command
}
$output->writeln('LP progress reminder process finished.');
return Command::SUCCESS;
}
@ -129,7 +132,7 @@ class LpProgressReminderCommand extends Command
$sessionId = $checkSession ? ($user['sessionId'] ?? 0) : 0;
if ($lpId === null) {
if (null === $lpId) {
foreach ($lpItems as $lpId => $nbDaysForLpCompletion) {
$this->sendReminderIfNeeded(
$userId,
@ -177,11 +180,12 @@ class LpProgressReminderCommand extends Command
if ($debugMode) {
echo "No registration date found for user $userId in course $courseId (session ID: $sessionId).\n";
}
return;
}
if ($debugMode) {
$sessionInfo = $sessionId > 0 ? "in session ID $sessionId" : "without a session";
$sessionInfo = $sessionId > 0 ? "in session ID $sessionId" : 'without a session';
echo "Registration date: {$registrationDate->format('Y-m-d H:i:s')}, Days for completion: $nbDaysForLpCompletion, LP ID: {$lpId}, $sessionInfo\n";
}
@ -202,8 +206,8 @@ class LpProgressReminderCommand extends Command
private function logReminderSent(int $userId, string $courseTitle, int $nbRemind, bool $debugMode, int $lpId, int $sessionId = 0): void
{
if ($debugMode) {
$sessionInfo = $sessionId > 0 ? sprintf("in session ID %d", $sessionId) : "without a session";
echo sprintf(
$sessionInfo = $sessionId > 0 ? \sprintf('in session ID %d', $sessionId) : 'without a session';
echo \sprintf(
"Reminder number %d sent to user ID %d for the course %s (LP ID: %d) %s.\n",
$nbRemind,
$userId,
@ -244,11 +248,10 @@ class LpProgressReminderCommand extends Command
$interval = $reminderStartDate->diff($currentDate);
$diffDays = (int) $interval->format('%a');
return ($diffDays >= self::NUMBER_OF_DAYS_TO_RESEND_NOTIFICATION &&
$diffDays % self::NUMBER_OF_DAYS_TO_RESEND_NOTIFICATION === 0) || $diffDays === 0;
return ($diffDays >= self::NUMBER_OF_DAYS_TO_RESEND_NOTIFICATION
&& 0 === $diffDays % self::NUMBER_OF_DAYS_TO_RESEND_NOTIFICATION) || 0 === $diffDays;
}
/**
* Sends a reminder email to the user regarding their LP progress.
*/
@ -256,7 +259,7 @@ class LpProgressReminderCommand extends Command
{
$user = $this->userRepository->find($toUserId);
if (!$user) {
throw new \Exception("User not found");
throw new Exception('User not found');
}
$platformUrl = $this->urlGenerator->generate('index', [], UrlGeneratorInterface::ABSOLUTE_URL);
@ -265,17 +268,17 @@ class LpProgressReminderCommand extends Command
$trainingCenterName = 'Your Training Center';
$trainers = 'Trainer Name';
$hello = $this->translator->trans("Hello %s");
$youAreRegCourse = $this->translator->trans("You are registered in the training %s since the %s");
$thisMessageIsAbout = $this->translator->trans("You are receiving this message because you have completed a learning path with a %s progress of your training.<br/>Your progress must be 100 to consider that your training was carried out.<br/>If you have the slightest problem, you should contact with your trainer.");
$hello = $this->translator->trans('Hello %s');
$youAreRegCourse = $this->translator->trans('You are registered in the training %s since the %s');
$thisMessageIsAbout = $this->translator->trans('You are receiving this message because you have completed a learning path with a %s progress of your training.<br/>Your progress must be 100 to consider that your training was carried out.<br/>If you have the slightest problem, you should contact with your trainer.');
$stepsToRemind = $this->translator->trans("As a reminder, to access the training platform:<br/>1. Connect to the platform at the address: %s <br/>2. Then enter: <br/>Your username: %s <br/>Your password: This was emailed to you.<br/>if you forgot it and can't find it, you can retrieve it by going to %s <br/><br/>Thank you for doing what is necessary.");
$lpRemindFooter = $this->translator->trans("The training center<p>%s</p>Trainers:<br/>%s");
$lpRemindFooter = $this->translator->trans('The training center<p>%s</p>Trainers:<br/>%s');
$hello = sprintf($hello, $user->getFullName());
$youAreRegCourse = sprintf($youAreRegCourse, $courseName, $registrationDate->format('Y-m-d'));
$thisMessageIsAbout = sprintf($thisMessageIsAbout, $lpProgress);
$stepsToRemind = sprintf($stepsToRemind, $platformUrl, $user->getUsername(), $recoverPasswordUrl);
$lpRemindFooter = sprintf($lpRemindFooter, $trainingCenterName, $trainers);
$hello = \sprintf($hello, $user->getFullName());
$youAreRegCourse = \sprintf($youAreRegCourse, $courseName, $registrationDate->format('Y-m-d'));
$thisMessageIsAbout = \sprintf($thisMessageIsAbout, $lpProgress);
$stepsToRemind = \sprintf($stepsToRemind, $platformUrl, $user->getUsername(), $recoverPasswordUrl);
$lpRemindFooter = \sprintf($lpRemindFooter, $trainingCenterName, $trainers);
$messageContent = $this->twig->render('@ChamiloCore/Mailer/Legacy/lp_progress_reminder_body.html.twig', [
'HelloX' => $hello,
@ -288,13 +291,13 @@ class LpProgressReminderCommand extends Command
try {
$this->messageHelper->sendMessageSimple(
$toUserId,
sprintf("Reminder number %d for the course %s", $nbRemind, $courseName),
\sprintf('Reminder number %d for the course %s', $nbRemind, $courseName),
$messageContent
);
return true;
} catch (\Exception $e) {
throw new \Exception('Error sending reminder: ' . $e->getMessage());
} catch (Exception $e) {
throw new Exception('Error sending reminder: '.$e->getMessage());
}
}
}

@ -47,7 +47,8 @@ class SendEventRemindersCommand extends Command
$this
->setDescription('Send notification messages to users that have reminders from events in their agenda.')
->addOption('debug', null, InputOption::VALUE_NONE, 'Enable debug mode')
->setHelp('This command sends notifications to users who have pending reminders for calendar events.');
->setHelp('This command sends notifications to users who have pending reminders for calendar events.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
@ -127,6 +128,7 @@ class SendEventRemindersCommand extends Command
$this->sendReminderMessage($user, $event, $senderId, $debug, $io, $sentRemindersCount);
}
}
break;
case 'course':
@ -146,6 +148,7 @@ class SendEventRemindersCommand extends Command
if ($debug) {
error_log("No course found for resource link in session ID: {$session->getId()}");
}
break;
}

@ -160,4 +160,8 @@ enum ActionIcon: string
case EDIT_BADGE = 'shield-edit-outline';
case ADD_EVENT_REMINDER = 'alarm-plus';
case SWAP_FILE = 'file-swap';
case ADD_FILE_VARIATION = 'file-replace';
}

@ -67,7 +67,7 @@ class AdminController extends BaseController
$fileUrls[$file->getId()] = null;
$creator = null;
}
$filePaths[$file->getId()] = '/upload/resources'.$this->resourceNodeRepository->getFilename($file);
$filePaths[$file->getId()] = '/upload/resource'.$this->resourceNodeRepository->getFilename($file);
}
return $this->render('@ChamiloCore/Admin/files_info.html.twig', [

@ -416,7 +416,7 @@ class IndexBlocksController extends BaseController
];
$items[] = [
'class' => 'item-stats-report',
'url' => $this->generateUrl('legacy_main', ['name' => 'mySpace/company_reports.php']),
'url' => $this->generateUrl('legacy_main', ['name' => 'my_space/company_reports.php']),
'label' => $this->translator->trans('Reports'),
];
$items[] = [
@ -580,7 +580,7 @@ class IndexBlocksController extends BaseController
$items = [];
$items[] = [
'class' => 'item-skill-wheel',
'url' => $this->generateUrl('legacy_main', ['name' => 'skills/skills_wheel.php']),
'route' => ['name' => 'SkillWheel'],
'label' => $this->translator->trans('Skills wheel'),
];
$items[] = [

@ -152,15 +152,20 @@ class SettingsController extends BaseController
$manager->save($form->getData());
$message = $this->trans('Settings have been successfully updated');
} catch (ValidatorException $validatorException) {
// $message = $this->trans($exception->getMessage(), [], 'validators');
$message = $this->trans($validatorException->getMessage());
$messageType = 'error';
}
$this->addFlash($messageType, $message);
if (!empty($keywordFromGet)) {
return $this->redirect($request->headers->get('referer'));
if (!empty($keyword)) {
return $this->redirectToRoute('chamilo_platform_settings_search', [
'keyword' => $keyword,
]);
}
return $this->redirectToRoute('chamilo_platform_settings', [
'namespace' => $namespace,
]);
}
$schemas = $manager->getSchemas();

@ -784,7 +784,7 @@ class CourseController extends ToolBaseController
public function getAutoLaunchLPId(
Request $request,
Course $course,
CLPRepository $lpRepository,
CLpRepository $lpRepository,
EntityManagerInterface $em
): JsonResponse {
$data = $request->getContent();

@ -31,6 +31,7 @@ class IndexController extends BaseController
#[Route('/social', name: 'chamilo_core_socialnetwork', options: ['expose' => true])]
#[Route('/admin', name: 'admin', options: ['expose' => true])]
#[Route('/p/{slug}', name: 'public_page')]
#[Route('/skill/wheel', name: 'skill_wheel')]
public function index(): Response
{
return $this->render('@ChamiloCore/Layout/no_layout.html.twig', ['content' => '']);

@ -0,0 +1,26 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CoreBundle\Controller\OAuth2;
use Chamilo\CoreBundle\ServiceHelper\AuthenticationConfigHelper;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class AzureProviderController extends AbstractProviderController
{
#[Route('/connect/azure', name: 'chamilo.oauth2_azure_start')]
public function connect(
ClientRegistry $clientRegistry,
AuthenticationConfigHelper $authenticationConfigHelper,
): Response {
return $this->getStartResponse('azure', $clientRegistry, $authenticationConfigHelper);
}
#[Route('/connect/azure/check', name: 'chamilo.oauth2_azure_check')]
public function connectCheck(): void {}
}

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\CoreBundle\Controller;
use Chamilo\CoreBundle\Entity\ValidationToken;
use Chamilo\CoreBundle\Repository\TrackEDefaultRepository;
use Chamilo\CoreBundle\Repository\ValidationTokenRepository;
use Chamilo\CoreBundle\ServiceHelper\ValidationTokenHelper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
#[Route('/validate')]
class ValidationTokenController extends AbstractController
{
public function __construct(
private readonly ValidationTokenHelper $validationTokenHelper,
private readonly ValidationTokenRepository $tokenRepository,
private readonly TrackEDefaultRepository $trackEDefaultRepository,
private readonly Security $security
) {}
#[Route('/{type}/{hash}', name: 'validate_token')]
public function validate(string $type, string $hash): Response
{
$token = $this->tokenRepository->findOneBy([
'type' => $this->validationTokenHelper->getTypeId($type),
'hash' => $hash
]);
if (!$token) {
throw $this->createNotFoundException('Invalid token.');
}
// Process the action related to the token type
$this->processAction($token);
// Remove the used token
$this->tokenRepository->remove($token, true);
// Register the token usage event
$this->registerTokenUsedEvent($token);
return $this->render('@ChamiloCore/Validation/success.html.twig', [
'type' => $type,
]);
}
#[Route('/test/generate-token/{type}/{resourceId}', name: 'test_generate_token')]
public function testGenerateToken(string $type, int $resourceId): Response
{
$typeId = $this->validationTokenHelper->getTypeId($type);
$token = new ValidationToken($typeId, $resourceId);
$this->tokenRepository->save($token, true);
$validationLink = $this->generateUrl('validate_token', [
'type' => $type,
'hash' => $token->getHash(),
], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL);
return new Response("Generated token: {$token->getHash()}<br>Validation link: <a href='{$validationLink}'>{$validationLink}</a>");
}
private function processAction(ValidationToken $token): void
{
switch ($token->getType()) {
case 1: // Assuming 1 is for 'ticket'
$this->processTicketValidation($token);
break;
case 2: // Assuming 2 is for 'user'
// Implement user validation logic here
break;
default:
throw new \InvalidArgumentException('Unrecognized token type');
}
}
private function processTicketValidation(ValidationToken $token): void
{
$ticketId = $token->getResourceId();
// Simulate ticket validation logic
// Here you would typically check if the ticket exists and is valid
// For now, we'll just print a message to simulate this
// Replace this with your actual ticket validation logic
$ticketValid = $this->validateTicket($ticketId);
if (!$ticketValid) {
throw new \RuntimeException('Invalid ticket.');
}
// If the ticket is valid, you can mark it as used or perform other actions
// For example, update the ticket status in the database
// $this->ticketRepository->markAsUsed($ticketId);
}
private function validateTicket(int $ticketId): bool
{
// Here you would implement the logic to check if the ticket is valid.
// This is a placeholder function to simulate validation.
// For testing purposes, let's assume all tickets are valid.
// In a real implementation, you would query your database or service.
return true; // Assume the ticket is valid for now
}
private function registerTokenUsedEvent(ValidationToken $token): void
{
$user = $this->security->getUser();
$userId = $user?->getId();
$this->trackEDefaultRepository->registerTokenUsedEvent($token, $userId);
}
}

@ -8,6 +8,7 @@ namespace Chamilo\CoreBundle\DataFixtures;
use Chamilo\CoreBundle\Entity\ExtraField;
use Chamilo\CoreBundle\Entity\ExtraFieldOptions;
use Chamilo\CoreBundle\ServiceHelper\AzureAuthenticatorHelper;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
@ -599,6 +600,24 @@ class ExtraFieldFixtures extends Fixture implements FixtureGroupInterface
'item_type' => ExtraField::QUESTION_FIELD_TYPE,
'value_type' => ExtraField::FIELD_TYPE_INTEGER,
],
[
'variable' => AzureAuthenticatorHelper::EXTRA_FIELD_ORGANISATION_EMAIL,
'display_text' => 'Organisation e-mail',
'item_type' => ExtraField::USER_FIELD_TYPE,
'value_type' => ExtraField::FIELD_TYPE_TEXT,
],
[
'variable' => AzureAuthenticatorHelper::EXTRA_FIELD_AZURE_ID,
'display_text' => 'Azure ID (mailNickname)',
'item_type' => ExtraField::USER_FIELD_TYPE,
'value_type' => ExtraField::FIELD_TYPE_TEXT,
],
[
'variable' => AzureAuthenticatorHelper::EXTRA_FIELD_AZURE_UID,
'display_text' => 'Azure UID (internal ID)',
'item_type' => ExtraField::USER_FIELD_TYPE,
'value_type' => ExtraField::FIELD_TYPE_TEXT,
],
];
}

@ -21,24 +21,25 @@ readonly class SkillTreeNodeTransformer implements DataTransformerInterface
{
\assert($object instanceof Skill);
$leaf = new SkillTreeNode();
$leaf->id = $object->getId();
$leaf->title = $object->getTitle();
$leaf->status = $object->getStatus();
$skillNode = new SkillTreeNode();
$skillNode->id = $object->getId();
$skillNode->title = $object->getTitle();
$skillNode->status = $object->getStatus();
$skillNode->isSearched = $object->getProfiles()->count() > 0;
$skillNode->hasGradebook = $object->getGradeBookCategories()->count() > 0;
if (($shortCode = $object->getShortCode())
&& 'false' === $this->settingsManager->getSetting('skill.show_full_skill_name_on_skill_wheel')
) {
$leaf->shortCode = $shortCode;
$skillNode->shortCode = $shortCode;
}
$leaf->children = [];
$skillNode->children = $object->getChildSkills()
->map(fn(Skill $childSkill) => $this->transform($childSkill, $to, $context))
->toArray()
;
foreach ($object->getChildSkills() as $childSkill) {
$leaf->children[] = $this->transform($childSkill, $to, $context);
}
return $leaf;
return $skillNode;
}
public function supportsTransformation($data, string $to, array $context = []): bool

@ -7,15 +7,14 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Decorator;
use Chamilo\CoreBundle\ServiceHelper\AuthenticationConfigHelper;
use KnpU\OAuth2ClientBundle\DependencyInjection\KnpUOAuth2ClientExtension;
use KnpU\OAuth2ClientBundle\DependencyInjection\ProviderFactory;
use KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Provider\Facebook;
use League\OAuth2\Client\Provider\GenericProvider;
use Stevenmaguire\OAuth2\Client\Provider\Keycloak;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use TheNetworg\OAuth2\Client\Provider\Azure;
#[AsDecorator(decorates: 'knpu.oauth2.provider_factory')]
readonly class OAuth2ProviderFactoryDecorator
@ -33,22 +32,31 @@ readonly class OAuth2ProviderFactoryDecorator
array $redirectParams = [],
array $collaborators = []
): AbstractProvider {
$options = match ($class) {
GenericProvider::class => $this->getProviderOptions('generic'),
Facebook::class => $this->getProviderOptions('facebook'),
Keycloak::class => $this->getProviderOptions('keycloak'),
$customConfig = match ($class) {
GenericProvider::class => $this->authenticationConfigHelper->getProviderConfig('generic'),
Facebook::class => $this->authenticationConfigHelper->getProviderConfig('facebook'),
Keycloak::class => $this->authenticationConfigHelper->getProviderConfig('keycloak'),
Azure::class => $this->authenticationConfigHelper->getProviderConfig('azure'),
};
return $this->inner->createProvider($class, $options, $redirectUri, $redirectParams, $collaborators);
}
private function getProviderOptions(string $providerName): array
{
/** @var KnpUOAuth2ClientExtension $extension */
$extension = (new KnpUOAuth2ClientBundle())->getContainerExtension();
$redirectParams = $customConfig['redirect_params'] ?? [];
$customOptions = match ($class) {
GenericProvider::class => $this->authenticationConfigHelper->getProviderOptions(
'generic',
[
'client_id' => $customConfig['client_id'],
'client_secret' => $customConfig['client_secret'],
...$customConfig['provider_options'],
],
),
Facebook::class => $this->authenticationConfigHelper->getProviderOptions('facebook', $customConfig),
Keycloak::class => $this->authenticationConfigHelper->getProviderOptions('keycloak', $customConfig),
Azure::class => $this->authenticationConfigHelper->getProviderOptions('azure', $customConfig),
};
$configParams = $this->authenticationConfigHelper->getParams($providerName);
$options = $customOptions + $options;
return $extension->getConfigurator($providerName)->getProviderOptions($configParams);
return $this->inner->createProvider($class, $options, $redirectUri, $redirectParams, $collaborators);
}
}

@ -21,7 +21,22 @@ use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(operations: [new Get(security: 'is_granted(\'ROLE_ADMIN\')'), new Put(security: 'is_granted(\'ROLE_ADMIN\')'), new GetCollection(security: 'is_granted(\'ROLE_ADMIN\')'), new Post(security: 'is_granted(\'ROLE_ADMIN\')')], security: 'is_granted(\'ROLE_ADMIN\')', denormalizationContext: ['groups' => ['extra_field:write']], normalizationContext: ['groups' => ['extra_field:read']])]
#[ApiResource(
operations: [
new Get(security: "is_granted('ROLE_ADMIN')"),
new Put(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(security: "is_granted('ROLE_ADMIN')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: [
'groups' => ['extra_field:read'],
],
denormalizationContext: [
'groups' => ['extra_field:write'],
],
security: "is_granted('ROLE_ADMIN')"
),
]
#[ORM\Table(name: 'extra_field')]
#[ORM\Entity]
#[ORM\MappedSuperclass]
@ -79,51 +94,63 @@ class ExtraField
#[ORM\Id]
#[ORM\GeneratedValue]
protected ?int $id = null;
#[Groups(['extra_field:read', 'extra_field:write'])]
#[ORM\Column(name: 'item_type', type: 'integer')]
protected int $itemType;
#[Groups(['extra_field:read', 'extra_field:write'])]
#[ORM\Column(name: 'value_type', type: 'integer')]
protected int $valueType;
#[Assert\NotBlank]
#[Groups(['extra_field:read', 'extra_field:write'])]
#[ORM\Column(name: 'variable', type: 'string', length: 255)]
protected string $variable;
#[Groups(['extra_field:read', 'extra_field:write'])]
#[ORM\Column(name: 'description', type: 'text', nullable: true)]
protected ?string $description;
#[Assert\NotBlank]
#[Groups(['extra_field:read', 'extra_field:write'])]
#[Gedmo\Translatable]
#[ORM\Column(name: 'display_text', type: 'string', length: 255, nullable: true, unique: false)]
#[ORM\Column(name: 'display_text', type: 'string', length: 255, unique: false, nullable: true)]
protected ?string $displayText = null;
#[ORM\Column(name: 'helper_text', type: 'text', nullable: true, unique: false)]
#[ORM\Column(name: 'helper_text', type: 'text', unique: false, nullable: true)]
protected ?string $helperText = null;
#[ORM\Column(name: 'default_value', type: 'text', nullable: true, unique: false)]
#[ORM\Column(name: 'default_value', type: 'text', unique: false, nullable: true)]
protected ?string $defaultValue = null;
#[ORM\Column(name: 'field_order', type: 'integer', nullable: true, unique: false)]
#[ORM\Column(name: 'field_order', type: 'integer', unique: false, nullable: true)]
protected ?int $fieldOrder = null;
#[ORM\Column(name: 'visible_to_self', type: 'boolean', nullable: true, unique: false)]
#[ORM\Column(name: 'visible_to_self', type: 'boolean', options: ['default' => false])]
protected ?bool $visibleToSelf = false;
#[ORM\Column(name: 'visible_to_others', type: 'boolean', nullable: true, unique: false)]
#[ORM\Column(name: 'visible_to_others', type: 'boolean', options: ['default' => false])]
protected ?bool $visibleToOthers = false;
#[ORM\Column(name: 'changeable', type: 'boolean', nullable: true, unique: false)]
#[ORM\Column(name: 'changeable', type: 'boolean', options: ['default' => false])]
protected ?bool $changeable = false;
#[ORM\Column(name: 'filter', type: 'boolean', nullable: true, unique: false)]
#[ORM\Column(name: 'filter', type: 'boolean', options: ['default' => false])]
protected ?bool $filter = false;
/**
* @var Collection<int, ExtraFieldOptions>
*/
#[Groups(['extra_field:read'])]
#[ORM\OneToMany(targetEntity: ExtraFieldOptions::class, mappedBy: 'field')]
#[ORM\OneToMany(mappedBy: 'field', targetEntity: ExtraFieldOptions::class)]
protected Collection $options;
/**
* @var Collection<int, Tag>
*/
#[ORM\OneToMany(targetEntity: Tag::class, mappedBy: 'field')]
#[ORM\OneToMany(mappedBy: 'field', targetEntity: Tag::class)]
protected Collection $tags;
#[Gedmo\Timestampable(on: 'create')]
#[ORM\Column(name: 'created_at', type: 'datetime')]
protected DateTime $createdAt;
@ -151,20 +178,24 @@ class ExtraField
{
return $this->id;
}
public function getItemType(): int
{
return $this->itemType;
}
public function setItemType(int $itemType): self
{
$this->itemType = $itemType;
return $this;
}
public function getValueType(): int
{
return $this->valueType;
}
public function setValueType(int $valueType): self
{
$this->valueType = $valueType;
@ -176,6 +207,7 @@ class ExtraField
{
return $this->variable;
}
public function setVariable(string $variable): self
{
$this->variable = $variable;
@ -187,6 +219,7 @@ class ExtraField
{
return $this->displayText;
}
public function setDisplayText(string $displayText): self
{
$this->displayText = $displayText;
@ -198,6 +231,7 @@ class ExtraField
{
return $this->defaultValue;
}
public function setDefaultValue(string $defaultValue): self
{
$this->defaultValue = $defaultValue;
@ -209,6 +243,7 @@ class ExtraField
{
return $this->fieldOrder;
}
public function setFieldOrder(int $fieldOrder): self
{
$this->fieldOrder = $fieldOrder;
@ -220,46 +255,55 @@ class ExtraField
{
return $this->changeable;
}
public function setChangeable(bool $changeable): self
{
$this->changeable = $changeable;
return $this;
}
public function isFilter(): bool
{
return $this->filter;
}
public function setFilter(bool $filter): self
{
$this->filter = $filter;
return $this;
}
public function isVisibleToSelf(): bool
{
return $this->visibleToSelf;
}
public function setVisibleToSelf(bool $visibleToSelf): self
{
$this->visibleToSelf = $visibleToSelf;
return $this;
}
public function isVisibleToOthers(): bool
{
return $this->visibleToOthers;
}
public function setVisibleToOthers(bool $visibleToOthers): self
{
$this->visibleToOthers = $visibleToOthers;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(string $description): self
{
$this->description = $description;
@ -274,6 +318,7 @@ class ExtraField
{
return $this->options;
}
public function setOptions(Collection $options): self
{
$this->options = $options;
@ -288,6 +333,7 @@ class ExtraField
{
return $this->tags;
}
public function setTags(Collection $tags): self
{
$this->tags = $tags;
@ -302,6 +348,7 @@ class ExtraField
return $this->tags->exists(fn ($key, Tag $tag) => $tagName === $tag->getTag());
}
public function getTypeToString(): string
{
return match ($this->getItemType()) {
@ -309,10 +356,12 @@ class ExtraField
default => 'text',
};
}
public function getHelperText(): string
{
return $this->helperText;
}
public function setHelperText(string $helperText): self
{
$this->helperText = $helperText;

@ -33,9 +33,9 @@ class Level implements Stringable
protected string $shortTitle;
#[Gedmo\SortableGroup]
#[ORM\ManyToOne(targetEntity: Profile::class, inversedBy: 'levels')]
#[ORM\ManyToOne(targetEntity: SkillLevelProfile::class, inversedBy: 'levels')]
#[ORM\JoinColumn(name: 'profile_id', referencedColumnName: 'id')]
protected ?Profile $profile = null;
protected ?SkillLevelProfile $profile = null;
public function __toString(): string
{
@ -89,15 +89,12 @@ class Level implements Stringable
return $this;
}
/**
* @return Profile
*/
public function getProfile()
public function getProfile(): ?SkillLevelProfile
{
return $this->profile;
}
public function setProfile(Profile $profile): self
public function setProfile(SkillLevelProfile $profile): self
{
$this->profile = $profile;

@ -52,6 +52,7 @@ use Symfony\Component\Validator\Constraints as Assert;
security: "is_granted('ROLE_ADMIN')"
)]
#[ApiFilter(SearchFilter::class, properties: ['issuedSkills.user' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['title' => 'partial'])]
#[ORM\Table(name: 'skill')]
#[ORM\Entity(repositoryClass: SkillRepository::class)]
class Skill implements Stringable, Translatable
@ -59,15 +60,15 @@ class Skill implements Stringable, Translatable
public const STATUS_DISABLED = 0;
public const STATUS_ENABLED = 1;
#[Groups(['skill:read'])]
#[Groups(['skill:read', 'skill_profile:read'])]
#[ORM\Column(name: 'id', type: 'integer')]
#[ORM\Id]
#[ORM\GeneratedValue]
protected ?int $id = null;
#[ORM\ManyToOne(targetEntity: Profile::class, inversedBy: 'skills')]
#[ORM\ManyToOne(targetEntity: SkillLevelProfile::class, inversedBy: 'skills')]
#[ORM\JoinColumn(name: 'profile_id', referencedColumnName: 'id')]
protected ?Profile $profile = null;
protected ?SkillLevelProfile $levelProfile = null;
/**
* @var Collection<int, SkillRelUser>
@ -141,6 +142,12 @@ class Skill implements Stringable, Translatable
#[Gedmo\Locale]
private ?string $locale = null;
/**
* @var Collection<int, SkillRelProfile>
*/
#[ORM\OneToMany(mappedBy: 'skill', targetEntity: SkillRelProfile::class, cascade: ['persist'])]
private Collection $profiles;
public function __construct()
{
$this->issuedSkills = new ArrayCollection();
@ -151,6 +158,7 @@ class Skill implements Stringable, Translatable
$this->icon = '';
$this->description = '';
$this->status = self::STATUS_ENABLED;
$this->profiles = new ArrayCollection();
}
public function __toString(): string
@ -259,14 +267,14 @@ class Skill implements Stringable, Translatable
return $this->id;
}
public function getProfile(): ?Profile
public function getLevelProfile(): ?SkillLevelProfile
{
return $this->profile;
return $this->levelProfile;
}
public function setProfile(Profile $profile): self
public function setLevelProfile(SkillLevelProfile $levelProfile): self
{
$this->profile = $profile;
$this->levelProfile = $levelProfile;
return $this;
}
@ -279,6 +287,28 @@ class Skill implements Stringable, Translatable
return $this->issuedSkills;
}
public function addIssuedSkill(SkillRelUser $issuedSkill): static
{
if (!$this->issuedSkills->contains($issuedSkill)) {
$this->issuedSkills->add($issuedSkill);
$issuedSkill->setSkill($this);
}
return $this;
}
public function removeIssuedSkill(SkillRelUser $issuedSkill): static
{
if ($this->issuedSkills->removeElement($issuedSkill)) {
// set the owning side to null (unless already changed)
if ($issuedSkill->getSkill() === $this) {
$issuedSkill->setSkill(null);
}
}
return $this;
}
/**
* @return Collection<int, SkillRelItem>
*/
@ -440,4 +470,34 @@ class Skill implements Stringable, Translatable
->map(fn(SkillRelSkill $skillRelSkill): Skill => $skillRelSkill->getSkill())
;
}
/**
* @return Collection<int, SkillRelProfile>
*/
public function getProfiles(): Collection
{
return $this->profiles;
}
public function addProfile(SkillRelProfile $profile): static
{
if (!$this->profiles->contains($profile)) {
$this->profiles->add($profile);
$profile->setSkill($this);
}
return $this;
}
public function removeProfile(SkillRelProfile $profile): static
{
if ($this->profiles->removeElement($profile)) {
// set the owning side to null (unless already changed)
if ($profile->getSkill() === $this) {
$profile->setSkill(null);
}
}
return $this;
}
}

@ -13,7 +13,7 @@ use Stringable;
#[ORM\Table(name: 'skill_level_profile')]
#[ORM\Entity]
class Profile implements Stringable
class SkillLevelProfile implements Stringable
{
#[ORM\Column(name: 'id', type: 'integer')]
#[ORM\Id]
@ -26,7 +26,7 @@ class Profile implements Stringable
/**
* @var Collection<int, Skill>
*/
#[ORM\OneToMany(mappedBy: 'profile', targetEntity: Skill::class, cascade: ['persist'])]
#[ORM\OneToMany(mappedBy: 'levelProfile', targetEntity: Skill::class, cascade: ['persist'])]
protected Collection $skills;
/**

@ -6,25 +6,58 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Entity;
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 Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
normalizationContext: [
'groups' => ['skill_profile:read'],
],
denormalizationContext: [
'groups' => ['skill_profile:write'],
],
paginationEnabled: false,
)]
#[ORM\Table(name: 'skill_profile')]
#[ORM\Entity]
class SkillProfile
{
#[Groups(['skill_profile:read'])]
#[ORM\Column(name: 'id', type: 'integer')]
#[ORM\Id]
#[ORM\GeneratedValue]
protected ?int $id = null;
#[Groups(['skill_profile:write', 'skill_profile:read'])]
#[Assert\NotBlank]
#[ORM\Column(name: 'title', type: 'string', length: 255, nullable: false)]
protected string $title;
#[Groups(['skill_profile:write', 'skill_profile:read'])]
#[ORM\Column(name: 'description', type: 'text', nullable: false)]
protected string $description;
/**
* @var Collection<int, SkillRelProfile>
*/
#[Groups(['skill_profile:write', 'skill_profile:read'])]
#[ORM\OneToMany(mappedBy: 'profile', targetEntity: SkillRelProfile::class, cascade: ['persist'])]
private Collection $skills;
public function __construct()
{
$this->skills = new ArrayCollection();
}
public function setTitle(string $title): self
{
$this->title = $title;
@ -62,4 +95,34 @@ class SkillProfile
{
return $this->id;
}
/**
* @return Collection<int, SkillRelProfile>
*/
public function getSkills(): Collection
{
return $this->skills;
}
public function addSkill(SkillRelProfile $skill): static
{
if (!$this->skills->contains($skill)) {
$this->skills->add($skill);
$skill->setProfile($this);
}
return $this;
}
public function removeSkill(SkillRelProfile $skill): static
{
if ($this->skills->removeElement($skill)) {
// set the owning side to null (unless already changed)
if ($skill->getProfile() === $this) {
$skill->setProfile(null);
}
}
return $this;
}
}

@ -7,6 +7,7 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Table(name: 'skill_rel_profile')]
#[ORM\Entity]
@ -17,37 +18,38 @@ class SkillRelProfile
#[ORM\GeneratedValue]
protected ?int $id = null;
#[ORM\ManyToOne(targetEntity: Skill::class, cascade: ['persist'])]
#[ORM\JoinColumn(name: 'skill_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
protected Skill $skill;
#[Groups(['skill_profile:write', 'skill_profile:read'])]
#[ORM\ManyToOne(inversedBy: 'profiles')]
#[ORM\JoinColumn(onDelete: 'CASCADE')]
private ?Skill $skill = null;
#[ORM\ManyToOne(targetEntity: SkillProfile::class, cascade: ['persist'])]
#[ORM\JoinColumn(name: 'profile_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
protected SkillProfile $profile;
#[ORM\ManyToOne(inversedBy: 'skills')]
#[ORM\JoinColumn(onDelete: 'CASCADE')]
private ?SkillProfile $profile = null;
public function getId(): ?int
{
return $this->id;
}
public function getSkill(): Skill
public function getSkill(): ?Skill
{
return $this->skill;
}
public function setSkill(Skill $skill): self
public function setSkill(?Skill $skill): static
{
$this->skill = $skill;
return $this;
}
public function getProfile(): SkillProfile
public function getProfile(): ?SkillProfile
{
return $this->profile;
}
public function setProfile(SkillProfile $profile): self
public function setProfile(?SkillProfile $profile): static
{
$this->profile = $profile;

@ -50,20 +50,20 @@ class SkillRelUser
#[ORM\GeneratedValue]
protected ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class, cascade: ['persist'], inversedBy: 'achievedSkills')]
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'achievedSkills')]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
protected User $user;
#[Groups(['skill_rel_user:read'])]
#[ORM\ManyToOne(targetEntity: Skill::class, cascade: ['persist'], inversedBy: 'issuedSkills')]
#[ORM\ManyToOne(targetEntity: Skill::class, inversedBy: 'issuedSkills')]
#[ORM\JoinColumn(name: 'skill_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
protected ?Skill $skill = null;
#[ORM\ManyToOne(targetEntity: Course::class, cascade: ['persist'], inversedBy: 'issuedSkills')]
#[ORM\ManyToOne(targetEntity: Course::class, inversedBy: 'issuedSkills')]
#[ORM\JoinColumn(name: 'course_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
protected ?Course $course = null;
#[ORM\ManyToOne(targetEntity: Session::class, cascade: ['persist'], inversedBy: 'issuedSkills')]
#[ORM\ManyToOne(targetEntity: Session::class, inversedBy: 'issuedSkills')]
#[ORM\JoinColumn(name: 'session_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
protected ?Session $session = null;
@ -111,7 +111,7 @@ class SkillRelUser
$this->acquiredSkillAt = new DateTime();
}
public function setSkill(Skill $skill): self
public function setSkill(?Skill $skill): self
{
$this->skill = $skill;

@ -68,7 +68,7 @@ class TrackEDefault
return $this->defaultUserId;
}
public function setCId(int $cId): self
public function setCId(?int $cId): self
{
$this->cId = $cId;
@ -153,7 +153,7 @@ class TrackEDefault
return $this->defaultValue;
}
public function setSessionId(int $sessionId): self
public function setSessionId(?int $sessionId): self
{
$this->sessionId = $sessionId;

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\CoreBundle\Entity;
use Chamilo\CoreBundle\Repository\ValidationTokenRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* ValidationToken entity.
*/
#[ORM\Table(name: 'validation_token')]
#[ORM\Index(columns: ['type', 'hash'], name: 'idx_type_hash')]
#[ORM\Entity(repositoryClass: ValidationTokenRepository::class)]
class ValidationToken
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
#[ORM\Column(type: 'integer')]
protected ?int $id = null;
#[ORM\Column(type: 'integer')]
protected int $type;
#[ORM\Column(type: 'bigint')]
protected int $resourceId;
#[ORM\Column(type: 'string', length: 64)]
protected string $hash;
#[ORM\Column(type: 'datetime')]
protected \DateTime $createdAt;
public function __construct(int $type, int $resourceId)
{
$this->type = $type;
$this->resourceId = $resourceId;
$this->hash = hash('sha256', uniqid((string) rand(), true));
$this->createdAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getType(): int
{
return $this->type;
}
public function setType(int $type): self
{
$this->type = $type;
return $this;
}
public function getResourceId(): int
{
return $this->resourceId;
}
public function setResourceId(int $resourceId): self
{
$this->resourceId = $resourceId;
return $this;
}
public function getHash(): string
{
return $this->hash;
}
public function getCreatedAt(): \DateTime
{
return $this->createdAt;
}
public function setCreatedAt(\DateTime $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
/**
* Generates a validation link.
*/
public static function generateLink(int $type, int $resourceId): string
{
$token = new self($type, $resourceId);
return '/validate/' . $type . '/' . $token->getHash();
}
}

@ -31,23 +31,34 @@ final class ResourceToIdentifierTransformer implements DataTransformerInterface
return null;
}
/* @psalm-suppress ArgumentTypeCoercion */
Assert::isInstanceOf($value, $this->repository->getClassName());
if (is_object($value) && method_exists($value, 'getId')) {
return $value;
}
if (is_numeric($value)) {
return $this->repository->find($value);
}
return PropertyAccess::createPropertyAccessor()->getValue($value, $this->identifier);
return $value;
}
public function reverseTransform($value)
{
if (null === $value) {
if (null === $value || '' === $value) {
return null;
}
$resource = $this->repository->findOneBy([
$this->identifier => $value,
]);
if (is_object($value) && method_exists($value, 'getId')) {
return $value;
}
$resource = $this->repository->find($value);
if (null === $resource) {
throw new TransformationFailedException(\sprintf('Object "%s" with identifier "%s"="%s" does not exist.', $this->repository->getClassName(), $this->identifier, $value));
throw new TransformationFailedException(sprintf(
'Object "%s" with identifier "%s" does not exist.',
$this->repository->getClassName(),
$value
));
}
return $resource;

@ -32,7 +32,7 @@ final class Version20230913162700 extends AbstractMigrationChamilo
$resourceNodeRepo = $this->container->get(ResourceNodeRepository::class);
$q = $this->entityManager->createQuery('SELECT c FROM Chamilo\CoreBundle\Entity\Course c');
/*$updateConfigurations = [
$updateConfigurations = [
['table' => 'c_tool_intro', 'field' => 'intro_text'],
['table' => 'c_course_description', 'field' => 'content'],
['table' => 'c_quiz', 'fields' => ['description', 'text_when_finished']],
@ -48,7 +48,7 @@ final class Version20230913162700 extends AbstractMigrationChamilo
['table' => 'c_survey', 'fields' => ['title', 'subtitle']],
['table' => 'c_survey_question', 'fields' => ['survey_question', 'survey_question_comment']],
['table' => 'c_survey_question_option', 'field' => 'option_text'],
];*/
];
/** @var Course $course */
foreach ($q->toIterable() as $course) {
@ -59,9 +59,9 @@ final class Version20230913162700 extends AbstractMigrationChamilo
continue;
}
/* foreach ($updateConfigurations as $config) {
foreach ($updateConfigurations as $config) {
$this->updateContent($config, $courseDirectory, $courseId, $documentRepo);
}*/
}
$this->updateHtmlContent($courseDirectory, $courseId, $documentRepo, $resourceNodeRepo);
}
@ -155,13 +155,6 @@ final class Version20230913162700 extends AbstractMigrationChamilo
$documentPath = str_replace('/courses/'.$courseDirectory.'/document/', '/', $videoPath);
error_log('Debugging Replace URLs:');
error_log('Full URL: ' . $fullUrl);
error_log('Video Path: ' . $videoPath);
error_log('Actual Course Directory: ' . $actualCourseDirectory);
error_log('Processed Document Path: ' . $documentPath);
/*
$sql = "SELECT iid, path, resource_node_id FROM c_document WHERE c_id = $courseId AND path LIKE '$documentPath'";
$result = $this->connection->executeQuery($sql);
$documents = $result->fetchAllAssociative();
@ -177,7 +170,7 @@ final class Version20230913162700 extends AbstractMigrationChamilo
$contentText = str_replace($matches[0][$index], $replacement, $contentText);
}
}
}*/
}
}
return $contentText;
@ -214,9 +207,11 @@ final class Version20230913162700 extends AbstractMigrationChamilo
throw new Exception("Course with ID $courseId not found.");
}
$document = $documentRepo->findCourseResourceByTitle($title, $course->getResourceNode(), $course);
if (null !== $document) {
return $document;
$existingDocument = $documentRepo->findResourceByTitleInCourse($title, $course);
if ($existingDocument) {
error_log("Document '$title' already exists for course {$course->getId()}. Skipping creation.");
return $existingDocument;
}
if (file_exists($appCourseOldPath) && !is_dir($appCourseOldPath)) {
@ -235,6 +230,8 @@ final class Version20230913162700 extends AbstractMigrationChamilo
$documentRepo->addFileFromPath($document, $title, $appCourseOldPath);
error_log("Document '$title' successfully created for course $courseId.");
return $document;
}
$generalCoursesPath = $this->getUpdateRootPath().'/app/courses/';
@ -259,9 +256,11 @@ final class Version20230913162700 extends AbstractMigrationChamilo
return $document;
}
throw new Exception('File not found in any location.');
error_log("File '$title' not found for course $courseId. Skipping.");
return null;
} catch (Exception $e) {
error_log('Migration error: '.$e->getMessage());
error_log('Error in createNewDocument: '.$e->getMessage());
return null;
}

@ -12,8 +12,6 @@ use Chamilo\CourseBundle\Repository\CDocumentRepository;
use Doctrine\DBAL\Schema\Schema;
use Exception;
use const PREG_NO_ERROR;
final class Version20231022124700 extends AbstractMigrationChamilo
{
public function getDescription(): string
@ -116,48 +114,63 @@ final class Version20231022124700 extends AbstractMigrationChamilo
// Pattern to find and replace cidReq, id_session, and gidReq
$pattern = '/((https?:\/\/[^\/\s]*|)\/[^?\s]+?)\?(.*?)(cidReq=([a-zA-Z0-9_]+))((?:&|&amp;)id_session=([0-9]+))?((?:&|&amp;)gidReq=([0-9]+))?(.*)/i';
$newContent = @preg_replace_callback(
$pattern,
function ($matches) {
$code = $matches[5];
try {
$newContent = @preg_replace_callback(
$pattern,
function ($matches) {
$code = $matches[5] ?? null;
$courseId = null;
$sql = 'SELECT id FROM course WHERE code = :code ORDER BY id DESC LIMIT 1';
$stmt = $this->connection->executeQuery($sql, ['code' => $code]);
$course = $stmt->fetch();
if (!$code) {
error_log('Missing cidReq in URL: '.$matches[0]);
if ($course) {
$courseId = $course['id'];
}
return $matches[0];
}
if (null === $courseId) {
return $matches[0]; // If the courseId is not found, return the original URL.
}
$courseId = null;
$sql = 'SELECT id FROM course WHERE code = :code ORDER BY id DESC LIMIT 1';
$stmt = $this->connection->executeQuery($sql, ['code' => $code]);
$course = $stmt->fetch();
// Ensure sid and gid are always populated
$sessionId = isset($matches[7]) && !empty($matches[7]) ? $matches[7] : '0';
$groupId = isset($matches[9]) && !empty($matches[9]) ? $matches[9] : '0';
$remainingParams = isset($matches[10]) ? $matches[10] : '';
if ($course) {
$courseId = $course['id'];
}
// Prepare new URL with updated parameters
$newParams = "cid=$courseId&sid=$sessionId&gid=$groupId";
$beforeCidReqParams = isset($matches[3]) ? $matches[3] : '';
if (null === $courseId) {
error_log('Course ID not found for cidReq: '.$code);
// Ensure other parameters are maintained
if (!empty($remainingParams)) {
$newParams .= '&'.ltrim($remainingParams, '&amp;');
}
return $matches[0];
}
$finalUrl = $matches[1].'?'.$beforeCidReqParams.$newParams;
// Ensure sid and gid are always populated
$sessionId = $matches[7] ?? '0';
$groupId = $matches[9] ?? '0';
$remainingParams = $matches[10] ?? '';
return str_replace('&amp;', '&', $finalUrl); // Replace any remaining &amp; with &
},
$content
);
// Prepare new URL with updated parameters
$newParams = "cid=$courseId&sid=$sessionId&gid=$groupId";
$beforeCidReqParams = $matches[3] ?? '';
// Ensure other parameters are maintained
if (!empty($remainingParams)) {
$newParams .= '&'.ltrim($remainingParams, '&amp;');
}
$finalUrl = $matches[1].'?'.$beforeCidReqParams.$newParams;
return str_replace('&amp;', '&', $finalUrl);
},
$content
);
if (false === $newContent || null === $newContent) {
error_log('preg_replace_callback failed for content: '.substr($content, 0, 500));
return $content;
}
} catch (Exception $e) {
error_log('Exception in replaceURLParametersInContent: '.$e->getMessage());
if (PREG_NO_ERROR !== preg_last_error()) {
error_log('Error encountered in preg_replace_callback: '.preg_last_error());
$newContent = $content;
return $content;
}
return $newContent;

@ -0,0 +1,28 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CoreBundle\Migrations\Schema\V200;
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo;
use Doctrine\DBAL\Schema\Schema;
class Version20241209103000 extends AbstractMigrationChamilo
{
public function getDescription(): string
{
return 'Change extra field boolean columns (visible_to_self, visible_to_others, changeable, filter) to not accept null values.';
}
public function up(Schema $schema): void
{
$this->addSql('UPDATE extra_field SET visible_to_self = 0 WHERE visible_to_self IS NULL');
$this->addSql('UPDATE extra_field SET visible_to_others = 0 WHERE visible_to_others IS NULL');
$this->addSql('UPDATE extra_field SET changeable = 0 WHERE changeable IS NULL');
$this->addSql('UPDATE extra_field SET filter = 0 WHERE filter IS NULL');
$this->addSql('ALTER TABLE extra_field CHANGE visible_to_self visible_to_self TINYINT(1) DEFAULT 0 NOT NULL, CHANGE visible_to_others visible_to_others TINYINT(1) DEFAULT 0 NOT NULL, CHANGE changeable changeable TINYINT(1) DEFAULT 0 NOT NULL, CHANGE filter filter TINYINT(1) DEFAULT 0 NOT NULL');
}
}

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\CoreBundle\Migrations\Schema\V200;
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo;
use Doctrine\DBAL\Schema\Schema;
final class Version20241211183300 extends AbstractMigrationChamilo
{
public function getDescription(): string
{
return 'Migration for creating the validation_token table';
}
public function up(Schema $schema): void
{
if (!$schema->hasTable('validation_token')) {
$this->addSql("
CREATE TABLE validation_token (
id INT AUTO_INCREMENT NOT NULL,
type INT NOT NULL,
resource_id BIGINT NOT NULL,
hash CHAR(64) NOT NULL,
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime)',
INDEX idx_type_hash (type, hash),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB ROW_FORMAT = DYNAMIC
");
}
}
public function down(Schema $schema): void
{
if ($schema->hasTable('validation_token')) {
$this->addSql('DROP TABLE validation_token');
}
}
}

@ -35,7 +35,8 @@ class CourseRelUserRepository extends ServiceEntityRepository
->andWhere('rn.parent = c.resourceNode')
->andWhere('(lpv.progress < 100 OR lpv.progress IS NULL)')
->setParameter('courseId', $courseId)
->setParameter('lpIds', $lpIds);
->setParameter('lpIds', $lpIds)
;
return $qb->getQuery()->getResult();
}

@ -193,7 +193,8 @@ class ExtraFieldValuesRepository extends ServiceEntityRepository
->innerJoin(CLp::class, 'lp', 'WITH', 'lp.iid = efv.itemId')
->where('ef.variable = :variable')
->andWhere('efv.fieldValue > 0')
->setParameter('variable', 'number_of_days_for_completion');
->setParameter('variable', 'number_of_days_for_completion')
;
return $qb->getQuery()->getResult();
}

@ -933,4 +933,19 @@ abstract class ResourceRepository extends ServiceEntityRepository
->getOneOrNullResult()
;
}
public function findResourceByTitleInCourse(
string $title,
Course $course,
?Session $session = null,
?CGroup $group = null
): ?ResourceInterface {
$qb = $this->getResourcesByCourse($course, $session, $group);
$this->addTitleQueryBuilder($title, $qb);
$qb->setMaxResults(1);
return $qb->getQuery()->getOneOrNullResult();
}
}

@ -35,7 +35,8 @@ class SessionRelCourseRelUserRepository extends ServiceEntityRepository
->andWhere('rn.parent = c.resourceNode')
->andWhere('(lpv.progress < 100 OR lpv.progress IS NULL)')
->setParameter('courseId', $courseId)
->setParameter('lpIds', $lpIds);
->setParameter('lpIds', $lpIds)
;
return $qb->getQuery()->getResult();
}

@ -7,9 +7,12 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Repository;
use Chamilo\CoreBundle\Entity\TrackEDefault;
use Chamilo\CoreBundle\Entity\ValidationToken;
use DateTime;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Exception;
use RuntimeException;
class TrackEDefaultRepository extends ServiceEntityRepository
{
@ -21,9 +24,9 @@ class TrackEDefaultRepository extends ServiceEntityRepository
/**
* Retrieves the registration date of a user in a specific course or session.
*/
public function getUserCourseRegistrationAt(int $courseId, int $userId, ?int $sessionId = 0): ?\DateTime
public function getUserCourseRegistrationAt(int $courseId, int $userId, ?int $sessionId = 0): ?DateTime
{
$serializedPattern = sprintf('s:2:"id";i:%d;', $userId);
$serializedPattern = \sprintf('s:2:"id";i:%d;', $userId);
$qb = $this->createQueryBuilder('te')
->select('te.defaultDate')
@ -34,12 +37,14 @@ class TrackEDefaultRepository extends ServiceEntityRepository
->setParameter('courseId', $courseId)
->setParameter('valueType', 'user_object')
->setParameter('eventType', 'user_subscribed')
->setParameter('serializedPattern', '%' . $serializedPattern . '%');
->setParameter('serializedPattern', '%'.$serializedPattern.'%')
;
if ($sessionId > 0) {
$qb->andWhere('te.sessionId = :sessionId')
->setParameter('sessionId', $sessionId);
} elseif ($sessionId === 0) {
->setParameter('sessionId', $sessionId)
;
} elseif (0 === $sessionId) {
$qb->andWhere('te.sessionId = 0');
} else {
$qb->andWhere('te.sessionId IS NULL');
@ -51,14 +56,32 @@ class TrackEDefaultRepository extends ServiceEntityRepository
try {
$result = $query->getOneOrNullResult();
if ($result && isset($result['defaultDate'])) {
return $result['defaultDate'] instanceof \DateTime
return $result['defaultDate'] instanceof DateTime
? $result['defaultDate']
: new \DateTime($result['defaultDate']);
: new DateTime($result['defaultDate']);
}
} catch (\Exception $e) {
throw new \RuntimeException('Error fetching registration date: ' . $e->getMessage());
} catch (Exception $e) {
throw new RuntimeException('Error fetching registration date: '.$e->getMessage());
}
return null;
}
/**
* Registers an event when a validation token is used.
*/
public function registerTokenUsedEvent(ValidationToken $token, ?int $userId = null): void
{
$event = new TrackEDefault();
$event->setDefaultUserId($userId ?? 0);
$event->setCId(null);
$event->setDefaultDate(new \DateTime());
$event->setDefaultEventType('VALIDATION_TOKEN_USED');
$event->setDefaultValueType('validation_token');
$event->setDefaultValue(\json_encode(['hash' => $token->getHash()]));
$event->setSessionId(null);
$this->_em->persist($event);
$this->_em->flush();
}
}

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\CoreBundle\Repository;
use Chamilo\CoreBundle\Entity\ValidationToken;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ValidationTokenRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ValidationToken::class);
}
public function save(ValidationToken $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(ValidationToken $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}

@ -0,0 +1,6 @@
{% extends "@ChamiloCore/Layout/layout_one_col.html.twig" %}
{% block content %}
<h1>Validation Successful</h1>
<p>The token for {{ type }} has been successfully validated.</p>
{% endblock %}

@ -0,0 +1,83 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CoreBundle\Security\Authenticator\OAuth2;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Repository\Node\UserRepository;
use Chamilo\CoreBundle\ServiceHelper\AccessUrlHelper;
use Chamilo\CoreBundle\ServiceHelper\AuthenticationConfigHelper;
use Chamilo\CoreBundle\ServiceHelper\AzureAuthenticatorHelper;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use League\OAuth2\Client\Token\AccessToken;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Routing\RouterInterface;
use TheNetworg\OAuth2\Client\Provider\Azure;
class AzureAuthenticator extends AbstractAuthenticator
{
protected string $providerName = 'azure';
public function __construct(
ClientRegistry $clientRegistry,
RouterInterface $router,
UserRepository $userRepository,
AuthenticationConfigHelper $authenticationConfigHelper,
AccessUrlHelper $urlHelper,
EntityManagerInterface $entityManager,
private readonly AzureAuthenticatorHelper $azureHelper,
) {
parent::__construct(
$clientRegistry,
$router,
$userRepository,
$authenticationConfigHelper,
$urlHelper,
$entityManager
);
}
public function supports(Request $request): ?bool
{
return 'chamilo.oauth2_azure_check' === $request->attributes->get('_route');
}
/**
* @throws NonUniqueResultException
*/
protected function userLoader(AccessToken $accessToken): User
{
/** @var Azure $provider */
$provider = $this->client->getOAuth2Provider();
$me = $provider->get('/me', $accessToken);
if (empty($me['mail'])) {
throw new UnauthorizedHttpException(
'The mail field is empty in Azure AD and is needed to set the organisation email for this user.'
);
}
if (empty($me['mailNickname'])) {
throw new UnauthorizedHttpException(
'The mailNickname field is empty in Azure AD and is needed to set the unique username for this user.'
);
}
if (empty($me['objectId'])) {
throw new UnauthorizedHttpException(
'The id field is empty in Azure AD and is needed to set the unique Azure ID for this user.'
);
}
$userId = $this->azureHelper->registerUser($me);
return $this->userRepository->find($userId);
}
}

@ -62,7 +62,7 @@ class GenericAuthenticator extends AbstractAuthenticator
protected function userLoader(AccessToken $accessToken): User
{
$providerParams = $this->authenticationConfigHelper->getParams('generic');
$providerParams = $this->authenticationConfigHelper->getProviderConfig('generic');
/** @var GenericResourceOwner $resourceOwner */
$resourceOwner = $this->client->fetchUserFromToken($accessToken);

@ -42,6 +42,7 @@ use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Contracts\Translation\TranslatorInterface;
use Throwable;
class CourseService
{
@ -166,7 +167,7 @@ class CourseService
if (isset($rawParams['course_template'])) {
$this->useTemplateAsBasisIfRequired(
$course->getCode(),
$rawParams['course_template']
(int) $rawParams['course_template']
);
}
@ -629,22 +630,27 @@ class CourseService
$courseTemplate = isset($courseTemplate) ? (int) $courseTemplate : 0;
$useTemplate = false;
if ($teacherCanSelectCourseTemplate && $courseTemplate) {
if ($teacherCanSelectCourseTemplate && $courseTemplate > 0) {
$useTemplate = true;
$originCourse = $this->courseRepository->findCourseAsArray($courseTemplate);
$originCourse = $this->courseRepository->findCourseAsArray((int) $courseTemplate);
} elseif (!empty($templateSetting)) {
$useTemplate = true;
$originCourse = $this->courseRepository->findCourseAsArray($templateSetting);
$originCourse = $this->courseRepository->findCourseAsArray((int) $templateSetting);
}
if ($useTemplate && $originCourse) {
$originCourse['official_code'] = $originCourse['code'];
$cb = new CourseBuilder(null, $originCourse);
$course = $cb->build(null, $originCourse['code']);
$cr = new CourseRestorer($course);
$cr->set_file_option();
$cr->restore($courseCode);
if ($useTemplate && !empty($originCourse)) {
try {
$originCourse['official_code'] = $originCourse['code'];
$cb = new CourseBuilder(null, $originCourse);
$course = $cb->build(null, $originCourse['code']);
$cr = new CourseRestorer($course);
$cr->set_file_option();
$cr->restore($courseCode);
} catch (Exception $e) {
error_log('Error during course template application: ' . $e->getMessage());
} catch (Throwable $t) {
error_log('Unexpected error during course template application: ' . $t->getMessage());
}
}
}

@ -21,7 +21,7 @@ readonly class AuthenticationConfigHelper
private UrlGeneratorInterface $urlGenerator,
) {}
public function getParams(string $providerName, ?AccessUrl $url = null): array
public function getProviderConfig(string $providerName, ?AccessUrl $url = null): array
{
$providers = $this->getProvidersForUrl($url);
@ -34,7 +34,7 @@ readonly class AuthenticationConfigHelper
public function isEnabled(string $methodName, ?AccessUrl $url = null): bool
{
$configParams = $this->getParams($methodName, $url);
$configParams = $this->getProviderConfig($methodName, $url);
return $configParams['enabled'] ?? false;
}
@ -50,7 +50,7 @@ readonly class AuthenticationConfigHelper
$enabledProviders[] = [
'name' => $providerName,
'title' => $providerParams['title'] ?? u($providerName)->title(),
'url' => $this->urlGenerator->generate("chamilo.oauth2_{$providerName}_start"),
'url' => $this->urlGenerator->generate(sprintf("chamilo.oauth2_%s_start", $providerName)),
];
}
}
@ -74,4 +74,58 @@ readonly class AuthenticationConfigHelper
throw new InvalidArgumentException('Invalid access URL configuration');
}
public function getProviderOptions(string $providerType, array $config): array
{
$defaults = match($providerType) {
'generic' => [
'clientId' => $config['client_id'],
'clientSecret' => $config['client_secret'],
'urlAuthorize' => $config['urlAuthorize'],
'urlAccessToken' => $config['urlAccessToken'],
'urlResourceOwnerDetails' => $config['urlResourceOwnerDetails'],
'accessTokenMethod' => $config['accessTokenMethod'] ?? null,
'accessTokenResourceOwnerId' => $config['accessTokenResourceOwnerId'] ?? null,
'scopeSeparator' => $config['scopeSeparator'] ?? null,
'responseError' => $config['responseError'] ?? null,
'responseCode' => $config['responseCode'] ?? null,
'responseResourceOwnerId' => $config['responseResourceOwnerId'] ?? null,
'scopes' => $config['scopes'] ?? null,
'pkceMethod' => $config['pkceMethod'] ?? null,
],
'facebook' => [
'clientId' => $config['client_id'],
'clientSecret' => $config['client_secret'],
'graphApiVersion' => $config['graph_api_version'] ?? null,
],
'keycloak' => [
'clientId' => $config['client_id'],
'clientSecret' => $config['client_secret'],
'authServerUrl' => $config['auth_server_url'],
'realm' => $config['realm'],
'version' => $config['version'] ?? null,
'encryptionAlgorithm' => $config['encryption_algorithm'] ?? null,
'encryptionKeyPath' => $config['encryption_key_path'] ?? null,
'encryptionKey' => $config['encryption_key'] ?? null,
],
'azure' => [
'clientId' => $config['client_id'],
'clientSecret' => $config['client_secret'],
'clientCertificatePrivateKey' => $config['client_certificate_private_key'] ?? null,
'clientCertificateThumbprint' => $config['client_certificate_thumbprint'] ?? null,
'urlLogin' => $config['url_login'] ?? null,
'pathAuthorize' => $config['path_authorize'] ?? null,
'pathToken' => $config['path_token'] ?? null,
'scope' => $config['scope'] ?? null,
'tenant' => $config['tenant'] ?? null,
'urlAPI' => $config['url_api'] ?? null,
'resource' => $config['resource'] ?? null,
'API_VERSION' => $config['api_version'] ?? null,
'authWithResource' => $config['auth_with_resource'] ?? null,
'defaultEndPointVersion' => $config['default_end_point_version'] ?? null,
],
};
return array_filter($defaults, fn($value) => $value !== null);
}
}

@ -0,0 +1,198 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CoreBundle\ServiceHelper;
use Chamilo\CoreBundle\Entity\ExtraField;
use Chamilo\CoreBundle\Entity\ExtraFieldValues;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Repository\ExtraFieldRepository;
use Chamilo\CoreBundle\Repository\ExtraFieldValuesRepository;
use Chamilo\CoreBundle\Repository\Node\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
readonly class AzureAuthenticatorHelper
{
public const EXTRA_FIELD_ORGANISATION_EMAIL = 'organisationemail';
public const EXTRA_FIELD_AZURE_ID = 'azure_id';
public const EXTRA_FIELD_AZURE_UID = 'azure_uid';
public function __construct(
private ExtraFieldValuesRepository $extraFieldValuesRepo,
private ExtraFieldRepository $extraFieldRepo,
private UserRepository $userRepository,
private EntityManagerInterface $entityManager,
private AccessUrlHelper $urlHelper,
) {}
/**
* @throws NonUniqueResultException
*/
public function registerUser(array $azureUserInfo, string $azureUidKey = 'objectId'): User
{
if (empty($azureUserInfo)) {
throw new UnauthorizedHttpException('User info not found.');
}
[
$firstNme,
$lastName,
$username,
$email,
$phone,
$authSource,
$active,
$extra,
] = $this->formatUserData($azureUserInfo, $azureUidKey);
$userId = $this->getUserIdByVerificationOrder($azureUserInfo, $azureUidKey);
if (empty($userId)) {
$user = (new User())
->setCreatorId($this->userRepository->getRootUser()->getId())
;
} else {
$user = $this->userRepository->find($userId);
}
$user
->setFirstname($firstNme)
->setLastname($lastName)
->setEmail($email)
->setUsername($username)
->setPlainPassword('azure')
->setStatus(STUDENT)
->setAuthSource($authSource)
->setPhone($phone)
->setActive($active)
->setRoleFromStatus(STUDENT)
;
$this->userRepository->updateUser($user);
$url = $this->urlHelper->getCurrent();
$url->addUser($user);
$this->entityManager->flush();
$this->extraFieldValuesRepo->updateItemData(
$this->getOrganizationEmailField(),
$user,
$extra['extra_'.self::EXTRA_FIELD_ORGANISATION_EMAIL]
);
$this->extraFieldValuesRepo->updateItemData(
$this->getAzureIdField(),
$user,
$extra['extra_'.self::EXTRA_FIELD_AZURE_ID]
);
$this->extraFieldValuesRepo->updateItemData(
$this->getAzureUidField(),
$user,
$extra['extra_'.self::EXTRA_FIELD_AZURE_UID]
);
return $user;
}
private function getOrganizationEmailField()
{
return $this->extraFieldRepo->findByVariable(
ExtraField::USER_FIELD_TYPE,
self::EXTRA_FIELD_ORGANISATION_EMAIL
);
}
private function getAzureIdField()
{
return $this->extraFieldRepo->findByVariable(
ExtraField::USER_FIELD_TYPE,
self::EXTRA_FIELD_AZURE_ID
);
}
private function getAzureUidField()
{
return $this->extraFieldRepo->findByVariable(
ExtraField::USER_FIELD_TYPE,
self::EXTRA_FIELD_AZURE_UID
);
}
/**
* @throws NonUniqueResultException
*/
public function getUserIdByVerificationOrder(array $azureUserData, string $azureUidKey = 'objectId'): ?int
{
$selectedOrder = $this->getExistingUserVerificationOrder();
$organisationEmailField = $this->getOrganizationEmailField();
$azureIdField = $this->getAzureIdField();
$azureUidField = $this->getAzureUidField();
/** @var array<int, ExtraFieldValues> $positionsAndFields */
$positionsAndFields = [
1 => $this->extraFieldValuesRepo->findByVariableAndValue($organisationEmailField, $azureUserData['mail']),
2 => $this->extraFieldValuesRepo->findByVariableAndValue($azureIdField, $azureUserData['mailNickname']),
3 => $this->extraFieldValuesRepo->findByVariableAndValue($azureUidField, $azureUserData[$azureUidKey]),
];
foreach ($selectedOrder as $position) {
if (!empty($positionsAndFields[$position])) {
return $positionsAndFields[$position]->getItemId();
}
}
return null;
}
public function getExistingUserVerificationOrder(): array
{
return [1, 2, 3];
}
private function formatUserData(
array $azureUserData,
string $azureUidKey
): array {
$phone = null;
if (isset($azureUserData['telephoneNumber'])) {
$phone = $azureUserData['telephoneNumber'];
} elseif (isset($azureUserData['businessPhones'][0])) {
$phone = $azureUserData['businessPhones'][0];
} elseif (isset($azureUserData['mobilePhone'])) {
$phone = $azureUserData['mobilePhone'];
}
// If the option is set to create users, create it
$firstNme = $azureUserData['givenName'];
$lastName = $azureUserData['surname'];
$email = $azureUserData['mail'];
$username = $azureUserData['userPrincipalName'];
$authSource = 'azure';
$active = ($azureUserData['accountEnabled'] ? 1 : 0);
$extra = [
'extra_'.self::EXTRA_FIELD_ORGANISATION_EMAIL => $azureUserData['mail'],
'extra_'.self::EXTRA_FIELD_AZURE_ID => $azureUserData['mailNickname'],
'extra_'.self::EXTRA_FIELD_AZURE_UID => $azureUserData[$azureUidKey],
];
return [
$firstNme,
$lastName,
$username,
$email,
$phone,
$authSource,
$active,
$extra,
];
}
}

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\CoreBundle\ServiceHelper;
use Chamilo\CoreBundle\Entity\ValidationToken;
use Chamilo\CoreBundle\Repository\ValidationTokenRepository;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class ValidationTokenHelper
{
public function __construct(
private readonly ValidationTokenRepository $tokenRepository,
private readonly UrlGeneratorInterface $urlGenerator,
) {}
public function generateLink(int $type, int $resourceId): string
{
$token = new ValidationToken($type, $resourceId);
$this->tokenRepository->save($token, true);
return $this->urlGenerator->generate('validate_token', [
'type' => $this->getTypeString($type),
'hash' => $token->getHash(),
], UrlGeneratorInterface::ABSOLUTE_URL);
}
public function getTypeId(string $type): int
{
return match ($type) {
'ticket' => 1,
'user' => 2,
default => throw new \InvalidArgumentException('Unrecognized validation type'),
};
}
private function getTypeString(int $type): string
{
return match ($type) {
1 => 'ticket',
2 => 'user',
default => throw new \InvalidArgumentException('Unrecognized validation type'),
};
}
}

@ -130,10 +130,10 @@ class CourseSettingsSchema extends AbstractSettingsSchema
'course_hide_tools',
new ArrayToIdentifierTransformer()
)
/* ->setTransformer(
->setTransformer(
'course_creation_use_template',
new ResourceToIdentifierTransformer($this->getRepository())
)*/
new ResourceToIdentifierTransformer($this->getRepository(), 'id')
)
;
$allowedTypes = [
@ -229,7 +229,9 @@ class CourseSettingsSchema extends AbstractSettingsSchema
'class' => Course::class,
'placeholder' => 'Choose ...',
'empty_data' => null,
'data' => null,
'choice_label' => 'title',
'choice_value' => 'id',
'required' => false,
]
)
->add('hide_scorm_export_link', YesNoType::class)

@ -19,15 +19,17 @@ use Symfony\Component\Form\FormBuilderInterface;
class PlatformSettingsSchema extends AbstractSettingsSchema
{
private static array $tabs = [
'TabsCampusHomepage' => 'campus_homepage',
'TabsMyCourses' => 'my_courses',
'TabsReporting' => 'reporting',
'TabsPlatformAdministration' => 'platform_administration',
'mypersonalopenarea' => 'my_agenda',
'TabsMyAgenda' => 'my_profile',
'TabsMyGradebook' => 'my_gradebook',
'TabsSocial' => 'social',
'TabsDashboard' => 'dashboard',
'MenuCampusHomepage' => 'campus_homepage',
'MenuMyCourses' => 'my_courses',
'MenuReporting' => 'reporting',
'MenuPlatformAdministration' => 'platform_administration',
'MenuMyAgenda' => 'my_agenda',
'MenuSocial' => 'social',
'MenuVideoConference' => 'videoconference',
'MenuDiagnostics' => 'diagnostics',
'MenuCatalogue' => 'catalogue',
'TopbarCertificate' => 'topbar_certificate',
'TopbarSkills' => 'topbar_skills',
];
public function buildSettings(AbstractSettingsBuilder $builder): void

@ -259,13 +259,7 @@ class SettingsManager implements SettingsManagerInterface
foreach ($settingsBuilder->getTransformers() as $parameter => $transformer) {
if (\array_key_exists($parameter, $parameters)) {
if ('course_creation_use_template' === $parameter) {
if (empty($parameters[$parameter])) {
$parameters[$parameter] = null;
}
} else {
$parameters[$parameter] = $transformer->reverseTransform($parameters[$parameter]);
}
$parameters[$parameter] = $transformer->reverseTransform($parameters[$parameter]);
}
}
@ -290,10 +284,8 @@ class SettingsManager implements SettingsManagerInterface
// 2. Is defined as an array in class DocumentSettingsSchema
// 3. Add transformer for that variable "ArrayToIdentifierTransformer"
// 4. Here we recover the transformer and convert the array to string
foreach ($settingsBuilder->getTransformers() as $parameter => $transformer) {
if (\array_key_exists($parameter, $parameters)) {
$parameters[$parameter] = $transformer->transform($parameters[$parameter]);
}
foreach ($parameters as $parameter => $value) {
$parameters[$parameter] = $this->transformToString($value);
}
$settings->setParameters($parameters);
@ -329,11 +321,6 @@ class SettingsManager implements SettingsManagerInterface
->setAccessUrlLocked(1)
;
// @var ConstraintViolationListInterface $errors
/*$errors = $this->validator->validate($parameter);
if (0 < $errors->count()) {
throw new ValidatorException($errors->get(0)->getMessage());
}*/
$this->manager->persist($parameter);
}
}
@ -359,10 +346,8 @@ class SettingsManager implements SettingsManagerInterface
// 2. Is defined as an array in class DocumentSettingsSchema
// 3. Add transformer for that variable "ArrayToIdentifierTransformer"
// 4. Here we recover the transformer and convert the array to string
foreach ($settingsBuilder->getTransformers() as $parameter => $transformer) {
if (\array_key_exists($parameter, $parameters)) {
$parameters[$parameter] = $transformer->transform($parameters[$parameter]);
}
foreach ($parameters as $parameter => $value) {
$parameters[$parameter] = $this->transformToString($value);
}
$settings->setParameters($parameters);
$persistedParameters = $this->repository->findBy([
@ -373,12 +358,6 @@ class SettingsManager implements SettingsManagerInterface
$persistedParametersMap[$parameter->getVariable()] = $parameter;
}
// @var SettingsEvent $event
/*$event = $this->eventDispatcher->dispatch(
SettingsEvent::PRE_SAVE,
new SettingsEvent($settings, $parameters)
);*/
$url = $this->getUrl();
$simpleCategoryName = str_replace('chamilo_core.settings.', '', $namespace);
@ -397,77 +376,11 @@ class SettingsManager implements SettingsManagerInterface
->setAccessUrlLocked(1)
;
// @var ConstraintViolationListInterface $errors
/*$errors = $this->validator->validate($parameter);
if (0 < $errors->count()) {
throw new ValidatorException($errors->get(0)->getMessage());
}*/
$this->manager->persist($parameter);
}
$this->manager->persist($parameter);
}
$this->manager->flush();
// $schemaAlias = $settings->getSchemaAlias();
// $schemaAliasChamilo = str_replace('chamilo_core.settings.', '', $schemaAlias);
//
// $schema = $this->schemaRegistry->get($schemaAlias);
//
// $settingsBuilder = new SettingsBuilder();
// $schema->buildSettings($settingsBuilder);
//
// $parameters = $settingsBuilder->resolve($settings->getParameters());
//
// foreach ($settingsBuilder->getTransformers() as $parameter => $transformer) {
// if (array_key_exists($parameter, $parameters)) {
// $parameters[$parameter] = $transformer->transform($parameters[$parameter]);
// }
// }
//
// /** @var \Sylius\Bundle\SettingsBundle\Event\SettingsEvent $event */
// $event = $this->eventDispatcher->dispatch(
// SettingsEvent::PRE_SAVE,
// new SettingsEvent($settings)
// );
//
// /** @var SettingsCurrent $url */
// $url = $event->getSettings()->getAccessUrl();
//
// foreach ($parameters as $name => $value) {
// if (isset($persistedParametersMap[$name])) {
// if ($value instanceof Course) {
// $value = $value->getId();
// }
// $persistedParametersMap[$name]->setValue($value);
// } else {
// $setting = new Settings();
// $setting->setSchemaAlias($schemaAlias);
//
// $setting
// ->setNamespace($schemaAliasChamilo)
// ->setName($name)
// ->setValue($value)
// ->setUrl($url)
// ->setAccessUrlLocked(0)
// ->setAccessUrlChangeable(1)
// ;
//
// /** @var ConstraintViolationListInterface $errors */
// /*$errors = $this->->validate($parameter);
// if (0 < $errors->count()) {
// throw new ValidatorException($errors->get(0)->getMessage());
// }*/
// $this->manager->persist($setting);
// $this->manager->flush();
// }
// }
/*$parameters = $settingsBuilder->resolve($settings->getParameters());
* $settings->setParameters($parameters);
* $this->eventDispatcher->dispatch(SettingsEvent::PRE_SAVE, new SettingsEvent($settings));
* $this->manager->persist($settings);
* $this->manager->flush();
* $this->eventDispatcher->dispatch(SettingsEvent::POST_SAVE, new SettingsEvent($settings));*/
}
/**
@ -1078,4 +991,25 @@ class SettingsManager implements SettingsManagerInterface
return $settings[$variable] ?? $defaultCategory;
}
private function transformToString($value): string
{
if (is_array($value)) {
return implode(',', $value);
}
if ($value instanceof Course) {
return (string) $value->getId();
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_null($value)) {
return '';
}
return (string) $value;
}
}

@ -133,7 +133,7 @@ class ToolChain
public function addToolsInCourse(Course $course): Course
{
$manager = $this->entityManager;
$toolVisibility = $this->settingsManager->getSetting('course.active_tools_on_create');
$activeToolsOnCreate = $this->settingsManager->getSetting('course.active_tools_on_create') ?? [];
// Hardcoded tool list order
$toolList = [
@ -157,7 +157,6 @@ class ToolChain
'survey',
'wiki',
'notebook',
// 'blog',
'course_tool',
'course_homepage',
'tracking',
@ -171,7 +170,6 @@ class ToolChain
$tools = $this->handlerCollection->getCollection();
foreach ($tools as $tool) {
$visibility = \in_array($tool->getTitle(), $toolVisibility, true);
$criteria = [
'title' => $tool->getTitle(),
];
@ -179,8 +177,10 @@ class ToolChain
continue;
}
$linkVisibility = ResourceLink::VISIBILITY_PUBLISHED;
if (\in_array($tool->getTitle(), ['course_setting', 'course_maintenance'])) {
$visibility = in_array($tool->getTitle(), $activeToolsOnCreate, true);
$linkVisibility = $visibility ? ResourceLink::VISIBILITY_PUBLISHED : ResourceLink::VISIBILITY_DRAFT;
if (in_array($tool->getTitle(), ['course_setting', 'course_maintenance'])) {
$linkVisibility = ResourceLink::VISIBILITY_DRAFT;
}

@ -99,7 +99,8 @@ final class CLpRepository extends ResourceRepository implements ResourceWithLink
{
$qb = $this->getResourcesByCourse($course, $session)
->select('resource.iid')
->andWhere('resource.autolaunch = 1');
->andWhere('resource.autolaunch = 1')
;
$qb->setMaxResults(1);

@ -143,7 +143,8 @@ final class CQuizRepository extends ResourceRepository implements ResourceWithLi
{
$qb = $this->getResourcesByCourse($course, $session)
->select('resource.iid')
->andWhere('resource.autoLaunch = 1');
->andWhere('resource.autoLaunch = 1')
;
$qb->setMaxResults(1);

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save