Merge remote-tracking branch 'origin/master'

pull/5636/head
Angel Fernando Quiroz Campos 5 months ago
commit 77064d772e
No known key found for this signature in database
GPG Key ID: B284841AE3E562CD
  1. 3
      assets/vue/components/documents/FormNewDocument.vue
  2. 38
      assets/vue/components/documents/ResourceFileLink.vue
  3. 16
      assets/vue/components/documents/ResourceIcon.vue
  4. 86
      assets/vue/composables/datatableList.js
  5. 15
      assets/vue/composables/fileUtils.js
  6. 6
      assets/vue/mixins/ListMixin.js
  7. 126
      assets/vue/mixins/ListMixinVuetify.js
  8. 12
      assets/vue/views/documents/DocumentForHtmlEditor.vue
  9. 38
      assets/vue/views/documents/DocumentShow.vue
  10. 14
      assets/vue/views/documents/DocumentsList.vue
  11. 4
      assets/vue/views/documents/List.vue.vuetify
  12. 132
      assets/vue/views/filemanager/List.vue
  13. 4
      assets/vue/views/message/MessageShow.vue
  14. 130
      assets/vue/views/personalfile/List.vue
  15. 32
      assets/vue/views/personalfile/Shared.vue
  16. 18
      assets/vue/views/personalfile/Show.vue
  17. 2
      public/main/exercise/annotation_user.php
  18. 2
      public/main/exercise/hotspot_actionscript.as.php
  19. 2
      public/main/exercise/hotspot_actionscript_admin.as.php
  20. 2
      public/main/exercise/hotspot_answers.as.php
  21. 2
      public/main/gradebook/lib/be/category.class.php
  22. 8
      public/main/inc/lib/CourseChatUtils.php
  23. 14
      public/main/inc/lib/document.lib.php
  24. 6
      public/main/lp/learnpath.class.php
  25. 2
      public/main/lp/learnpathItem.class.php
  26. 7
      public/main/lp/lp_edit.php
  27. 9
      public/main/work/work.lib.php
  28. 1
      public/plugin/xapi/.htaccess
  29. 88
      public/plugin/xapi/README.md
  30. 210
      public/plugin/xapi/admin.php
  31. 40
      public/plugin/xapi/assets/css/cmi5_launch.css
  32. 2
      public/plugin/xapi/assets/js/cmi5_launch.js
  33. 192
      public/plugin/xapi/cmi5/launch.php
  34. 24
      public/plugin/xapi/cmi5/token.php
  35. 86
      public/plugin/xapi/cmi5/view.php
  36. 82
      public/plugin/xapi/cron/send_statements.php
  37. 58
      public/plugin/xapi/lang/english.php
  38. 51
      public/plugin/xapi/lang/french.php
  39. 58
      public/plugin/xapi/lang/spanish.php
  40. 14
      public/plugin/xapi/lrs.php
  41. 19
      public/plugin/xapi/php-xapi/lrs-bundle/LICENSE
  42. 0
      public/plugin/xapi/php-xapi/lrs-bundle/README.md
  43. 51
      public/plugin/xapi/php-xapi/lrs-bundle/composer.json
  44. 211
      public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementGetController.php
  45. 29
      public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementHeadController.php
  46. 69
      public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementPostController.php
  47. 68
      public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementPutController.php
  48. 39
      public/plugin/xapi/php-xapi/lrs-bundle/src/DependencyInjection/Configuration.php
  49. 66
      public/plugin/xapi/php-xapi/lrs-bundle/src/DependencyInjection/XApiLrsExtension.php
  50. 75
      public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/AlternateRequestSyntaxListener.php
  51. 17
      public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/ExceptionListener.php
  52. 43
      public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/SerializerListener.php
  53. 68
      public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/VersionListener.php
  54. 92
      public/plugin/xapi/php-xapi/lrs-bundle/src/Model/StatementsFilterFactory.php
  55. 21
      public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/controller.xml
  56. 12
      public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/doctrine.xml
  57. 25
      public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/event_listener.xml
  58. 12
      public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/factory.xml
  59. 18
      public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/orm.xml
  60. 29
      public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/routing.xml
  61. 32
      public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/serializer.xml
  62. 70
      public/plugin/xapi/php-xapi/lrs-bundle/src/Response/AttachmentResponse.php
  63. 129
      public/plugin/xapi/php-xapi/lrs-bundle/src/Response/MultipartResponse.php
  64. 28
      public/plugin/xapi/php-xapi/lrs-bundle/src/XApiLrsBundle.php
  65. 19
      public/plugin/xapi/php-xapi/repository-doctrine-orm/LICENSE
  66. 0
      public/plugin/xapi/php-xapi/repository-doctrine-orm/README.md
  67. 41
      public/plugin/xapi/php-xapi/repository-doctrine-orm/composer.json
  68. 19
      public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Actor.orm.xml
  69. 23
      public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Attachment.orm.xml
  70. 59
      public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Context.orm.xml
  71. 13
      public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Extensions.orm.xml
  72. 28
      public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Result.orm.xml
  73. 62
      public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Statement.orm.xml
  74. 83
      public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/StatementObject.orm.xml
  75. 14
      public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Verb.orm.xml
  76. 70
      public/plugin/xapi/php-xapi/repository-doctrine-orm/src/StatementRepository.php
  77. 7
      public/plugin/xapi/plugin.php
  78. 64
      public/plugin/xapi/src/Hook/XApiActivityHookObserver.php
  79. 30
      public/plugin/xapi/src/Hook/XApiCreateCourseHookObserver.php
  80. 25
      public/plugin/xapi/src/Hook/XApiLearningPathEndHookObserver.php
  81. 31
      public/plugin/xapi/src/Hook/XApiLearningPathItemViewedHookObserver.php
  82. 21
      public/plugin/xapi/src/Hook/XApiPortfolioCommentEditedHookObserver.php
  83. 21
      public/plugin/xapi/src/Hook/XApiPortfolioCommentScoredHookObserver.php
  84. 19
      public/plugin/xapi/src/Hook/XApiPortfolioDownloadedHookObserver.php
  85. 21
      public/plugin/xapi/src/Hook/XApiPortfolioItemAddedHookObserver.php
  86. 21
      public/plugin/xapi/src/Hook/XApiPortfolioItemCommentedHookObserver.php
  87. 21
      public/plugin/xapi/src/Hook/XApiPortfolioItemEditedHookObserver.php
  88. 24
      public/plugin/xapi/src/Hook/XApiPortfolioItemHighlightedHookObserver.php
  89. 21
      public/plugin/xapi/src/Hook/XApiPortfolioItemScoredHookObserver.php
  90. 27
      public/plugin/xapi/src/Hook/XApiPortfolioItemViewedHookObserver.php
  91. 25
      public/plugin/xapi/src/Hook/XApiQuizEndHookObserver.php
  92. 38
      public/plugin/xapi/src/Hook/XApiQuizQuestionAnsweredHookObserver.php
  93. 68
      public/plugin/xapi/src/Importer/PackageImporter.php
  94. 26
      public/plugin/xapi/src/Importer/XmlPackageImporter.php
  95. 85
      public/plugin/xapi/src/Importer/ZipPackageImporter.php
  96. 30
      public/plugin/xapi/src/Lrs/AboutController.php
  97. 80
      public/plugin/xapi/src/Lrs/ActivitiesProfileController.php
  98. 121
      public/plugin/xapi/src/Lrs/ActivitiesStateController.php
  99. 25
      public/plugin/xapi/src/Lrs/BaseController.php
  100. 218
      public/plugin/xapi/src/Lrs/LrsRequest.php
  101. Some files were not shown because too many files have changed in this diff Show More

@ -9,7 +9,8 @@
<BaseTinyEditor
v-if="
(item.resourceNode && item.resourceNode.resourceFile && item.resourceNode.resourceFile.text) || item.newDocument
(item.resourceNode && item.resourceNode.firstResourceFile && item.resourceNode.firstResourceFile.text) ||
item.newDocument
"
v-model="item.contentFile"
:title="t('Content')"

@ -1,8 +1,8 @@
<template>
<a
data-fancybox="gallery"
:href="resource.contentUrl"
:data-type="getDataType"
data-fancybox="gallery"
:href="resource.contentUrl"
:data-type="getDataType"
>
<ResourceIcon :resource-data="resource" />
{{ resource.title }}
@ -10,29 +10,29 @@
</template>
<script>
import ResourceIcon from './ResourceIcon.vue';
import ResourceIcon from "./ResourceIcon.vue"
export default {
name: 'ResourceFileLink',
name: "ResourceFileLink",
components: {
ResourceIcon
ResourceIcon,
},
props: {
resource: {
type: Object,
required: true,
},
},
computed: {
getDataType() {
if (this.resource.resourceNode.resourceFile.image) {
return 'image';
if (this.resource.resourceNode.firstResourceFile.image) {
return "image"
}
if (this.resource.resourceNode.resourceFile.video) {
return 'video';
if (this.resource.resourceNode.firstResourceFile.video) {
return "video"
}
return 'iframe';
}
},
props: {
resource: {
type: Object,
required: true,
}
return "iframe"
},
},
};
}
</script>

@ -4,19 +4,19 @@
icon="folder-generic"
/>
<BaseIcon
v-else-if="resourceData.resourceNode.resourceFile.image"
v-else-if="resourceData.resourceNode.firstResourceFile.image"
icon="file-image"
/>
<BaseIcon
v-else-if="resourceData.resourceNode.resourceFile.video"
v-else-if="resourceData.resourceNode.firstResourceFile.video"
icon="file-video"
/>
<BaseIcon
v-else-if="resourceData.resourceNode.resourceFile.text"
v-else-if="resourceData.resourceNode.firstResourceFile.text"
icon="file-text"
/>
<BaseIcon
v-else-if="'application/pdf' === resourceData.resourceNode.resourceFile.mimeType"
v-else-if="'application/pdf' === resourceData.resourceNode.firstResourceFile.mimeType"
icon="file-pdf"
/>
<BaseIcon
@ -30,15 +30,15 @@
</template>
<script setup>
import BaseIcon from "../basecomponents/BaseIcon.vue";
import {useFileUtils} from "../../composables/fileUtils";
import BaseIcon from "../basecomponents/BaseIcon.vue"
import { useFileUtils } from "../../composables/fileUtils"
const {isAudio} = useFileUtils()
const { isAudio } = useFileUtils()
defineProps({
resourceData: {
type: Object,
required: true,
},
});
})
</script>

@ -1,13 +1,13 @@
import { useStore } from 'vuex'
import { inject, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { isEmpty } from 'lodash'
import { useStore } from "vuex"
import { inject, ref } from "vue"
import { useRoute, useRouter } from "vue-router"
import { isEmpty } from "lodash"
import { useCidReq } from './cidReq'
import { useI18n } from 'vue-i18n';
import { useCidReq } from "./cidReq"
import { useI18n } from "vue-i18n"
import { useNotification } from "./notification"
export function useDatatableList (servicePrefix) {
export function useDatatableList(servicePrefix) {
const moduleName = servicePrefix.toLowerCase()
const store = useStore()
@ -30,19 +30,19 @@ export function useDatatableList (servicePrefix) {
itemsPerPage: 5,
})
function onUpdateOptions ({ page, itemsPerPage, sortBy, sortDesc }) {
function onUpdateOptions({ page, itemsPerPage, sortBy, sortDesc }) {
page = page || options.value.page
if (!isEmpty(route.query.filetype) && route.query.filetype === 'certificate') {
filters.value.filetype = 'certificate';
if (!isEmpty(route.query.filetype) && route.query.filetype === "certificate") {
filters.value.filetype = "certificate"
} else {
filters.value.filetype = ['file', 'folder'];
filters.value.filetype = ["file", "folder"]
}
let params = { ...filters.value }
if (1 === filters.value.loadNode) {
params['resourceNode.parent'] = route.params.node
params["resourceNode.parent"] = route.params.node
}
if (itemsPerPage > 0) {
@ -50,79 +50,81 @@ export function useDatatableList (servicePrefix) {
}
if (!isEmpty(sortBy)) {
params[`order[${sortBy}]`] = sortDesc ? 'desc' : 'asc'
params[`order[${sortBy}]`] = sortDesc ? "desc" : "asc"
}
let type = route.query.type
params = { ...params, cid, sid, gid, type, page }
store.dispatch(`${moduleName}/fetchAll`, params)
.then(() => options.value = { sortBy, sortDesc, itemsPerPage, page })
store
.dispatch(`${moduleName}/fetchAll`, params)
.then(() => (options.value = { sortBy, sortDesc, itemsPerPage, page }))
}
function goToAddItem () {
console.log('addHandler');
function goToAddItem() {
console.log("addHandler")
let folderParams = route.query;
let folderParams = route.query
router.push({
name: `${servicePrefix}Create`,
query: folderParams,
});
})
}
function goToEditItem (item) {
let folderParams = route.query;
folderParams['id'] = item['@id'];
function goToEditItem(item) {
let folderParams = route.query
folderParams["id"] = item["@id"]
if ('folder' === item.filetype || isEmpty(item.filetype)) {
if ("folder" === item.filetype || isEmpty(item.filetype)) {
router.push({
name: `${servicePrefix}Update`,
params: { id: item['@id'] },
query: folderParams
});
params: { id: item["@id"] },
query: folderParams,
})
}
if ('file' === item.filetype) {
folderParams['getFile'] = true;
if (item.resourceNode.resourceFile &&
item.resourceNode.resourceFile.mimeType &&
'text/html' === item.resourceNode.resourceFile.mimeType
if ("file" === item.filetype) {
folderParams["getFile"] = true
if (
item.resourceNode.firstResourceFile &&
item.resourceNode.firstResourceFile.mimeType &&
"text/html" === item.resourceNode.firstResourceFile.mimeType
) {
//folderParams['getFile'] = true;
}
this.$router.push({
name: `${servicePrefix}UpdateFile`,
params: { id: item['@id'] },
query: folderParams
});
params: { id: item["@id"] },
query: folderParams,
})
}
}
function onShowItem (item) {
console.log('listmixin showHandler', item);
function onShowItem(item) {
console.log("listmixin showHandler", item)
let folderParams = route.query;
let folderParams = route.query
if (item) {
folderParams['id'] = item['@id'];
folderParams["id"] = item["@id"]
}
router.push({
name: `${servicePrefix}Show`,
params: folderParams,
query: folderParams,
});
})
}
async function deleteItem (item) {
async function deleteItem(item) {
await store.dispatch(`${moduleName}/del`, item.value)
onUpdateOptions(options.value);
onUpdateOptions(options.value)
notification.showSuccessNotification(t('Deleted'))
notification.showSuccessNotification(t("Deleted"))
}
return {

@ -1,31 +1,28 @@
export function useFileUtils() {
const isImage = (fileData) => {
return isFile(fileData) && fileData.resourceNode.resourceFile.image
return isFile(fileData) && fileData.resourceNode.firstResourceFile.image
}
const isVideo = (fileData) => {
return isFile(fileData) && fileData.resourceNode.resourceFile.video
return isFile(fileData) && fileData.resourceNode.firstResourceFile.video
}
const isAudio = (fileData) => {
const mimeType = fileData.resourceNode.resourceFile.mimeType
const mimeType = fileData.resourceNode.firstResourceFile.mimeType
const isAudio = mimeType.split("/")[0].toLowerCase() === "audio"
return isFile(fileData) && isAudio
}
const isHtml = (fileData) => {
if (!isFile(fileData)) {
return false;
return false
}
const mimeType = fileData.resourceNode.resourceFile.mimeType
const mimeType = fileData.resourceNode.firstResourceFile.mimeType
return mimeType.split("/")[1].toLowerCase() === "html"
}
const isFile = (fileData) => {
return fileData.resourceNode && fileData.resourceNode.resourceFile
return fileData.resourceNode && fileData.resourceNode.firstResourceFile
}
return {

@ -257,9 +257,9 @@ export default {
if ("file" === item.filetype) {
folderParams["getFile"] = true
if (
item.resourceNode.resourceFile &&
item.resourceNode.resourceFile.mimeType &&
"text/html" === item.resourceNode.resourceFile.mimeType
item.resourceNode.firstResourceFile &&
item.resourceNode.firstResourceFile.mimeType &&
"text/html" === item.resourceNode.firstResourceFile.mimeType
) {
//folderParams['getFile'] = true;
}

@ -1,6 +1,6 @@
import isEmpty from 'lodash/isEmpty';
import { formatDateTime } from '../utils/dates';
import NotificationMixin from './NotificationMixin';
import isEmpty from "lodash/isEmpty"
import { formatDateTime } from "../utils/dates"
import NotificationMixin from "./NotificationMixin"
export default {
mixins: [NotificationMixin],
@ -9,106 +9,106 @@ export default {
options: {
sortBy: [],
page: 1,
itemsPerPage: 15
itemsPerPage: 15,
},
filters: {}
};
filters: {},
}
},
watch: {
$route() {
// react to route changes...
this.resetList = true;
this.onUpdateOptions(this.options);
let nodeId = this.$route.params['node'];
this.findResourceNode('/api/resource_nodes/'+ nodeId);
this.resetList = true
this.onUpdateOptions(this.options)
let nodeId = this.$route.params["node"]
this.findResourceNode("/api/resource_nodes/" + nodeId)
},
deletedItem(item) {
this.showMessage(`${item['@id']} deleted.`);
this.showMessage(`${item["@id"]} deleted.`)
},
error(message) {
message && this.showError(message);
message && this.showError(message)
},
items() {
this.options.totalItems = this.totalItems;
}
this.options.totalItems = this.totalItems
},
},
methods: {
onUpdateOptions({ page, itemsPerPage, sortBy, sortDesc, totalItems } = {}) {
let params = {
...this.filters
};
...this.filters,
}
if (itemsPerPage > 0) {
params = { ...params, itemsPerPage, page };
params = { ...params, itemsPerPage, page }
}
if (this.$route.params.node) {
params[`resourceNode.parent`] = this.$route.params.node;
params[`resourceNode.parent`] = this.$route.params.node
}
if (!isEmpty(sortBy) && !isEmpty(sortDesc)) {
params[`order[${sortBy[0]}]`] = sortDesc[0] ? 'desc' : 'asc'
params[`order[${sortBy[0]}]`] = sortDesc[0] ? "desc" : "asc"
}
this.resetList = true;
this.resetList = true
this.getPage(params).then(() => {
this.options.sortBy = sortBy;
this.options.sortDesc = sortDesc;
this.options.itemsPerPage = itemsPerPage;
this.options.totalItems = totalItems;
});
this.options.sortBy = sortBy
this.options.sortDesc = sortDesc
this.options.itemsPerPage = itemsPerPage
this.options.totalItems = totalItems
})
},
onSendFilter() {
this.resetList = true;
this.onUpdateOptions(this.options);
this.resetList = true
this.onUpdateOptions(this.options)
},
resetFilter() {
this.filters = {};
this.filters = {}
},
addHandler() {
let folderParams = this.$route.query;
this.$router.push({name: `${this.$options.servicePrefix}Create`, query: folderParams});
let folderParams = this.$route.query
this.$router.push({ name: `${this.$options.servicePrefix}Create`, query: folderParams })
},
addDocumentHandler() {
let folderParams = this.$route.query;
this.$router.push({ name: `${this.$options.servicePrefix}CreateFile` , query: folderParams});
let folderParams = this.$route.query
this.$router.push({ name: `${this.$options.servicePrefix}CreateFile`, query: folderParams })
},
uploadDocumentHandler() {
let folderParams = this.$route.query;
this.$router.push({ name: `${this.$options.servicePrefix}UploadFile` , query: folderParams});
let folderParams = this.$route.query
this.$router.push({ name: `${this.$options.servicePrefix}UploadFile`, query: folderParams })
},
showHandler(item) {
let folderParams = this.$route.query;
folderParams['id'] = item['@id'];
let folderParams = this.$route.query
folderParams["id"] = item["@id"]
this.$router.push({
name: `${this.$options.servicePrefix}Show`,
//params: { id: item['@id'] },
query: folderParams
});
query: folderParams,
})
},
handleClick(item) {
let folderParams = this.$route.query;
this.resetList = true;
this.$route.params.node = item['resourceNode']['id'];
let folderParams = this.$route.query
this.resetList = true
this.$route.params.node = item["resourceNode"]["id"]
this.$router.push({
name: `${this.$options.servicePrefix}List`,
params: {node: item['resourceNode']['id']},
params: { node: item["resourceNode"]["id"] },
query: folderParams,
});
})
/*this.$router.push({
name: `${this.$options.servicePrefix}List`,
@ -119,36 +119,38 @@ export default {
this.onUpdateOptions(this.options);*/
},
editHandler(item) {
let folderParams = this.$route.query;
folderParams['id'] = item['@id'];
let folderParams = this.$route.query
folderParams["id"] = item["@id"]
if ('folder' === item.filetype) {
if ("folder" === item.filetype) {
this.$router.push({
name: `${this.$options.servicePrefix}Update`,
params: { id: item['@id'] },
query: folderParams
});
params: { id: item["@id"] },
query: folderParams,
})
}
if ('file' === item.filetype) {
folderParams['getFile'] = false;
if ("file" === item.filetype) {
folderParams["getFile"] = false
if (item.resourceNode.resourceFile &&
item.resourceNode.resourceFile.mimeType &&
'text/html' === item.resourceNode.resourceFile.mimeType) {
folderParams['getFile'] = true;
if (
item.resourceNode.firstResourceFile &&
item.resourceNode.firstResourceFile.mimeType &&
"text/html" === item.resourceNode.firstResourceFile.mimeType
) {
folderParams["getFile"] = true
}
this.$router.push({
name: `${this.$options.servicePrefix}UpdateFile`,
params: { id: item['@id'] },
query: folderParams
});
params: { id: item["@id"] },
query: folderParams,
})
}
},
deleteHandler(item) {
this.deleteItem(item).then(() => this.onUpdateOptions(this.options));
this.deleteItem(item).then(() => this.onUpdateOptions(this.options))
},
formatDateTime
}
};
formatDateTime,
},
}

@ -45,7 +45,7 @@
field="resourceNode.title"
>
<template #body="slotProps">
<div v-if="slotProps.data && slotProps.data.resourceNode && slotProps.data.resourceNode.resourceFile">
<div v-if="slotProps.data && slotProps.data.resourceNode && slotProps.data.resourceNode.firstResourceFile">
<ResourceFileLink :resource="slotProps.data" />
</div>
<div v-else>
@ -73,10 +73,14 @@
<Column
:header="$t('Size')"
:sortable="true"
field="resourceNode.resourceFile.size"
field="resourceNode.firstResourceFile.size"
>
<template #body="slotProps">
{{ slotProps.data.resourceNode.resourceFile ? prettyBytes(slotProps.data.resourceNode.resourceFile.size) : "" }}
{{
slotProps.data.resourceNode.firstResourceFile
? prettyBytes(slotProps.data.resourceNode.firstResourceFile.size)
: ""
}}
</template>
</Column>
@ -171,7 +175,7 @@ export default {
columns: [
{ label: t("Title"), field: "title", name: "title", sortable: true },
{ label: t("Modified"), field: "resourceNode.updatedAt", name: "updatedAt", sortable: true },
{ label: t("Size"), field: "resourceNode.resourceFile.size", name: "size", sortable: true },
{ label: t("Size"), field: "resourceNode.firstResourceFile.size", name: "size", sortable: true },
{ label: t("Actions"), name: "action", sortable: false },
],
pageOptions: [10, 20, 50, t("All")],

@ -13,34 +13,28 @@
<h6 v-text="item.title" />
</div>
<div
class="document-show__section"
>
<div class="document-show__section">
<div class="document-show__content-side">
<div
v-if="item['resourceNode']['resourceFile']"
>
<div v-if="item.resourceNode.firstResourceFile">
<img
v-if="item.resourceNode.resourceFile.image"
v-if="item.resourceNode.firstResourceFile.image"
:src="item.contentUrl + '&w=500'"
:alt="item.title"
/>
<video
v-else-if="item['resourceNode']['resourceFile']['video']"
v-else-if="item.resourceNode.firstResourceFile.video"
controls
>
<source :src="item['contentUrl']" />
</video>
<iframe
v-if="'text/html' === item['resourceNode']['resourceFile']['mimeType']"
v-if="'text/html' === item.resourceNode.firstResourceFile.mimeType"
:src="item['contentUrl']"
/>
</div>
<div
v-else
>
<div v-else>
<BaseIcon icon="folder-generic" />
</div>
</div>
@ -51,9 +45,7 @@
<th v-text="$t('Author')" />
<td v-text="item.resourceNode.creator.username" />
</tr>
<tr
v-if="item.comment"
>
<tr v-if="item.comment">
<th v-text="$t('Comment')" />
<td v-text="item.comment" />
</tr>
@ -66,18 +58,18 @@
<tr>
<th v-text="$t('Updated at')" />
<td>
{{ item["resourceNode"] ? relativeDatetime(item["resourceNode"].updatedAt) : "" }}
{{ item.resourceNode ? relativeDatetime(item.resourceNode.updatedAt) : "" }}
</td>
</tr>
<tr v-if="item['resourceNode']['resourceFile']">
<tr v-if="item.resourceNode.firstResourceFile">
<th v-text="$t('File')" />
<td>
<a
:href="item['downloadUrl']"
class="btn btn--primary"
>
<BaseIcon icon="download" /> {{ $t("Download file") }}
</a>
<a
:href="item['downloadUrl']"
class="btn btn--primary"
>
<BaseIcon icon="download" /> {{ $t("Download file") }}
</a>
</td>
</tr>
</table>

@ -143,10 +143,14 @@
<Column
:header="t('Size')"
:sortable="true"
field="resourceNode.resourceFile.size"
field="resourceNode.firstResourceFile.size"
>
<template #body="slotProps">
{{ slotProps.data.resourceNode.resourceFile ? prettyBytes(slotProps.data.resourceNode.resourceFile.size) : "" }}
{{
slotProps.data.resourceNode.firstResourceFile
? prettyBytes(slotProps.data.resourceNode.firstResourceFile.size)
: ""
}}
</template>
</Column>
@ -654,9 +658,9 @@ function btnEditOnClick(item) {
folderParams.getFile = true
if (
item.resourceNode.resourceFile &&
item.resourceNode.resourceFile.mimeType &&
"text/html" === item.resourceNode.resourceFile.mimeType
item.resourceNode.firstResourceFile &&
item.resourceNode.firstResourceFile.mimeType &&
"text/html" === item.resourceNode.firstResourceFile.mimeType
) {
//folderParams.getFile = true;
}

@ -39,7 +39,7 @@
slot="item.resourceNode.title"
slot-scope="{ item }"
>
<div v-if="item['resourceNode']['resourceFile']">
<div v-if="item.resourceNode.firstResourceFile">
<a
data-fancybox="gallery"
:href=" item['contentUrl'] "
@ -102,7 +102,7 @@ export default {
headers: [
{text: 'Title', value: 'resourceNode.title', sortable: true},
{text: 'Modified', value: 'resourceNode.updatedAt', sortable: true},
{text: 'Size', value: 'resourceNode.resourceFile.size', sortable: true},
{text: 'Size', value: 'resourceNode.firstResourceFile.size', sortable: true},
{text: 'Actions', value: 'action', sortable: false}
],
selected: [],

@ -30,20 +30,20 @@
<DataTable
v-model:filters="filters"
v-model:selection="selectedItems"
:globalFilterFields="['resourceNode.title', 'resourceNode.updatedAt']"
:global-filter-fields="['resourceNode.title', 'resourceNode.updatedAt']"
:lazy="true"
:loading="isLoading"
:paginator="true"
:rows="10"
:rowsPerPageOptions="[5, 10, 20, 50]"
:totalRecords="totalItems"
:rows-per-page-options="[5, 10, 20, 50]"
:total-records="totalItems"
:value="items"
class="p-datatable-sm"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords}"
dataKey="iid"
filterDisplay="menu"
paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
responsiveLayout="scroll"
current-page-report-template="Showing {first} to {last} of {totalRecords}"
data-key="iid"
filter-display="menu"
paginator-template="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
responsive-layout="scroll"
@page="onPage($event)"
@sort="sortingChanged($event)"
>
@ -53,7 +53,7 @@
field="resourceNode.title"
>
<template #body="slotProps">
<div v-if="slotProps.data && slotProps.data.resourceNode && slotProps.data.resourceNode.resourceFile">
<div v-if="slotProps.data && slotProps.data.resourceNode && slotProps.data.resourceNode.firstResourceFile">
<ResourceFileLink :resource="slotProps.data" />
<v-icon
v-if="slotProps.data.resourceLinkListFromEntity && slotProps.data.resourceLinkListFromEntity.length > 0"
@ -76,12 +76,12 @@
<Column
:header="$t('Size')"
:sortable="true"
field="resourceNode.resourceFile.size"
field="resourceNode.firstResourceFile.size"
>
<template #body="slotProps">
{{
slotProps.data.resourceNode.resourceFile
? prettyBytes(slotProps.data.resourceNode.resourceFile.size)
slotProps.data.resourceNode.firstResourceFile
? prettyBytes(slotProps.data.resourceNode.firstResourceFile.size)
: ""
}}
</template>
@ -143,7 +143,7 @@
<small
v-if="submitted && !item.title"
class="p-error"
>$t('Title is required')</small
>$t('Title is required')</small
>
</div>
@ -163,14 +163,35 @@
</template>
</Dialog>
<Dialog v-model:visible="deleteItemDialog" :modal="true" :style="{ width: '450px' }" header="Confirm">
<Dialog
v-model:visible="deleteItemDialog"
:modal="true"
:style="{ width: '450px' }"
header="Confirm"
>
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle p-mr-3" style="font-size: 2rem"></i>
<span>Are you sure you want to delete <b>{{ itemToDelete?.title }}</b>?</span>
<i
class="pi pi-exclamation-triangle p-mr-3"
style="font-size: 2rem"
></i>
<span
>Are you sure you want to delete <b>{{ itemToDelete?.title }}</b
>?</span
>
</div>
<template #footer>
<Button class="p-button-text" icon="pi pi-times" label="No" @click="deleteItemDialog = false" />
<Button class="p-button-text" icon="pi pi-check" label="Yes" @click="deleteItemButton" />
<Button
class="p-button-text"
icon="pi pi-times"
label="No"
@click="deleteItemDialog = false"
/>
<Button
class="p-button-text"
icon="pi pi-check"
label="Yes"
@click="deleteItemButton"
/>
</template>
</Dialog>
@ -185,7 +206,7 @@
class="pi pi-exclamation-triangle p-mr-3"
style="font-size: 2rem"
/>
<span v-if="item">{{ $t('Are you sure you want to delete the selected items?') }}</span>
<span v-if="item">{{ $t("Are you sure you want to delete the selected items?") }}</span>
</div>
<template #footer>
<Button
@ -203,18 +224,33 @@
</template>
</Dialog>
<Dialog v-model:visible="detailsDialogVisible" :header="selectedItem.title || 'Item Details'" :modal="true" :style="{ width: '50%' }">
<Dialog
v-model:visible="detailsDialogVisible"
:header="selectedItem.title || 'Item Details'"
:modal="true"
:style="{ width: '50%' }"
>
<div v-if="Object.keys(selectedItem).length > 0">
<p><strong>Title:</strong> {{ selectedItem.resourceNode.title }}</p>
<p><strong>Modified:</strong> {{ relativeDatetime(selectedItem.resourceNode.updatedAt) }}</p>
<p><strong>Size:</strong> {{ prettyBytes(selectedItem.resourceNode.resourceFile.size) }}</p>
<p><strong>URL:</strong> <a :href="selectedItem.contentUrl" target="_blank">Open File</a></p>
<p><strong>Size:</strong> {{ prettyBytes(selectedItem.resourceNode.firstResourceFile.size) }}</p>
<p>
<strong>URL:</strong>
<a
:href="selectedItem.contentUrl"
target="_blank"
>Open File</a
>
</p>
</div>
<template #footer>
<Button class="p-button-text" label="Close" @click="closeDetailsDialog" />
<Button
class="p-button-text"
label="Close"
@click="closeDetailsDialog"
/>
</template>
</Dialog>
</template>
<script>
@ -263,9 +299,9 @@ export default {
sortable: true,
},
{
name: "resourceNode.resourceFile.size",
name: "resourceNode.firstResourceFile.size",
label: t("Size"),
field: "resourceNode.resourceFile.size",
field: "resourceNode.firstResourceFile.size",
sortable: true,
},
{ name: "action", label: t("Actions"), field: "action", sortable: false },
@ -273,7 +309,7 @@ export default {
columns: [
{ label: t("Title"), field: "title", name: "title", sortable: true },
{ label: t("Modified"), field: "resourceNode.updatedAt", name: "updatedAt", sortable: true },
{ label: t("Size"), field: "resourceNode.resourceFile.size", name: "size", sortable: true },
{ label: t("Size"), field: "resourceNode.firstResourceFile.size", name: "size", sortable: true },
{ label: t("Actions"), name: "action", sortable: false },
],
pageOptions: [10, 20, 50, t("All")],
@ -299,7 +335,7 @@ export default {
created() {
this.resetList = true
this.onUpdateOptions(this.options)
this.isFromEditor = window.location.search.includes('editor=tinymce');
this.isFromEditor = window.location.search.includes("editor=tinymce")
},
computed: {
// From crud.js list function
@ -331,15 +367,15 @@ export default {
selectedItem: {},
itemToDelete: null,
isFromEditor: false,
};
}
},
methods: {
showHandler(item) {
this.selectedItem = item;
this.detailsDialogVisible = true;
this.selectedItem = item
this.detailsDialogVisible = true
},
closeDetailsDialog() {
this.detailsDialogVisible = false;
this.detailsDialogVisible = false
},
// prime
onPage(event) {
@ -404,7 +440,7 @@ export default {
this.itemDialog = true
},
confirmDeleteItem(item) {
console.log('confirmDeleteItem :::', item)
console.log("confirmDeleteItem :::", item)
this.item = { ...item }
this.itemToDelete = { ...item }
this.deleteItemDialog = true
@ -423,21 +459,31 @@ export default {
this.selectedItems = null
},
deleteItemButton() {
console.log("deleteItem", this.itemToDelete);
console.log("deleteItem", this.itemToDelete)
if (this.itemToDelete && this.itemToDelete.id) {
this.deleteItem(this.itemToDelete)
.then(() => {
this.$toast.add({ severity: 'success', summary: 'Success', detail: 'Item deleted successfully', life: 3000 });
this.deleteItemDialog = false;
this.itemToDelete = null;
this.onUpdateOptions(this.options);
this.$toast.add({
severity: "success",
summary: "Success",
detail: "Item deleted successfully",
life: 3000,
})
this.deleteItemDialog = false
this.itemToDelete = null
this.onUpdateOptions(this.options)
})
.catch((error) => {
console.error("Error deleting the item:", error)
this.$toast.add({
severity: "error",
summary: "Error",
detail: "An error occurred while deleting the item",
life: 3000,
})
})
.catch(error => {
console.error("Error deleting the item:", error);
this.$toast.add({ severity: 'error', summary: 'Error', detail: 'An error occurred while deleting the item', life: 3000 });
});
} else {
console.error("No item to delete or item ID is missing");
console.error("No item to delete or item ID is missing")
}
},
onRowSelected(items) {

@ -112,7 +112,7 @@
:key="index"
>
<audio
v-if="attachment.resourceNode.resourceFile.audio"
v-if="attachment.resourceNode.firstResourceFile.audio"
controls
>
<source :src="attachment.downloadUrl" />
@ -124,7 +124,7 @@
class="btn btn--plain"
>
<BaseIcon icon="attachment" />
{{ attachment.resourceNode.resourceFile.originalName }}
{{ attachment.resourceNode.firstResourceFile.originalName }}
</a>
</li>
</ul>

@ -36,20 +36,20 @@
<DataTable
v-model:filters="filters"
v-model:selection="selectedItems"
:globalFilterFields="['resourceNode.title', 'resourceNode.updatedAt']"
:global-filter-fields="['resourceNode.title', 'resourceNode.updatedAt']"
:lazy="true"
:loading="isLoading"
:paginator="true"
:rows="10"
:rowsPerPageOptions="[5, 10, 20, 50]"
:totalRecords="totalItems"
:rows-per-page-options="[5, 10, 20, 50]"
:total-records="totalItems"
:value="items"
class="p-datatable-sm"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords}"
dataKey="iid"
filterDisplay="menu"
paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
responsiveLayout="scroll"
current-page-report-template="Showing {first} to {last} of {totalRecords}"
data-key="iid"
filter-display="menu"
paginator-template="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
responsive-layout="scroll"
@page="onPage($event)"
@sort="sortingChanged($event)"
>
@ -59,7 +59,7 @@
field="resourceNode.title"
>
<template #body="slotProps">
<div v-if="slotProps.data && slotProps.data.resourceNode && slotProps.data.resourceNode.resourceFile">
<div v-if="slotProps.data && slotProps.data.resourceNode && slotProps.data.resourceNode.firstResourceFile">
<ResourceFileLink :resource="slotProps.data" />
<v-icon
v-if="slotProps.data.resourceLinkListFromEntity && slotProps.data.resourceLinkListFromEntity.length > 0"
@ -82,12 +82,12 @@
<Column
:header="$t('Size')"
:sortable="true"
field="resourceNode.resourceFile.size"
field="resourceNode.firstResourceFile.size"
>
<template #body="slotProps">
{{
slotProps.data.resourceNode.resourceFile
? prettyBytes(slotProps.data.resourceNode.resourceFile.size)
slotProps.data.resourceNode.firstResourceFile
? prettyBytes(slotProps.data.resourceNode.firstResourceFile.size)
: ""
}}
</template>
@ -181,14 +181,35 @@
</template>
</Dialog>
<Dialog v-model:visible="deleteItemDialog" :modal="true" :style="{ width: '450px' }" header="Confirm">
<Dialog
v-model:visible="deleteItemDialog"
:modal="true"
:style="{ width: '450px' }"
header="Confirm"
>
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle p-mr-3" style="font-size: 2rem"></i>
<span>Are you sure you want to delete <b>{{ itemToDelete?.title }}</b>?</span>
<i
class="pi pi-exclamation-triangle p-mr-3"
style="font-size: 2rem"
></i>
<span
>Are you sure you want to delete <b>{{ itemToDelete?.title }}</b
>?</span
>
</div>
<template #footer>
<Button class="p-button-text" icon="pi pi-times" label="No" @click="deleteItemDialog = false" />
<Button class="p-button-text" icon="pi pi-check" label="Yes" @click="deleteItemButton" />
<Button
class="p-button-text"
icon="pi pi-times"
label="No"
@click="deleteItemDialog = false"
/>
<Button
class="p-button-text"
icon="pi pi-check"
label="Yes"
@click="deleteItemButton"
/>
</template>
</Dialog>
@ -203,7 +224,7 @@
class="pi pi-exclamation-triangle p-mr-3"
style="font-size: 2rem"
/>
<span v-if="item">{{ $t('Are you sure you want to delete the selected items?') }}</span>
<span v-if="item">{{ $t("Are you sure you want to delete the selected items?") }}</span>
</div>
<template #footer>
<Button
@ -221,18 +242,33 @@
</template>
</Dialog>
<Dialog v-model:visible="detailsDialogVisible" :header="selectedItem.title || 'Item Details'" :modal="true" :style="{ width: '50%' }">
<Dialog
v-model:visible="detailsDialogVisible"
:header="selectedItem.title || 'Item Details'"
:modal="true"
:style="{ width: '50%' }"
>
<div v-if="Object.keys(selectedItem).length > 0">
<p><strong>Title:</strong> {{ selectedItem.resourceNode.title }}</p>
<p><strong>Modified:</strong> {{ relativeDatetime(selectedItem.resourceNode.updatedAt) }}</p>
<p><strong>Size:</strong> {{ prettyBytes(selectedItem.resourceNode.resourceFile.size) }}</p>
<p><strong>URL:</strong> <a :href="selectedItem.contentUrl" target="_blank">Open File</a></p>
<p><strong>Size:</strong> {{ prettyBytes(selectedItem.resourceNode.firstResourceFile.size) }}</p>
<p>
<strong>URL:</strong>
<a
:href="selectedItem.contentUrl"
target="_blank"
>Open File</a
>
</p>
</div>
<template #footer>
<Button class="p-button-text" label="Close" @click="closeDetailsDialog" />
<Button
class="p-button-text"
label="Close"
@click="closeDetailsDialog"
/>
</template>
</Dialog>
</template>
<script>
@ -280,9 +316,9 @@ export default {
sortable: true,
},
{
name: "resourceNode.resourceFile.size",
name: "resourceNode.firstResourceFile.size",
label: t("Size"),
field: "resourceNode.resourceFile.size",
field: "resourceNode.firstResourceFile.size",
sortable: true,
},
{ name: "action", label: t("Actions"), field: "action", sortable: false },
@ -290,7 +326,7 @@ export default {
columns: [
{ label: t("Title"), field: "title", name: "title", sortable: true },
{ label: t("Modified"), field: "resourceNode.updatedAt", name: "updatedAt", sortable: true },
{ label: t("Size"), field: "resourceNode.resourceFile.size", name: "size", sortable: true },
{ label: t("Size"), field: "resourceNode.firstResourceFile.size", name: "size", sortable: true },
{ label: t("Actions"), name: "action", sortable: false },
],
pageOptions: [10, 20, 50, t("All")],
@ -316,7 +352,7 @@ export default {
created() {
this.resetList = true
this.onUpdateOptions(this.options)
this.isFromEditor = window.location.search.includes('editor=tinymce');
this.isFromEditor = window.location.search.includes("editor=tinymce")
},
computed: {
// From crud.js list function
@ -348,15 +384,15 @@ export default {
selectedItem: {},
itemToDelete: null,
isFromEditor: false,
};
}
},
methods: {
showHandler(item) {
this.selectedItem = item;
this.detailsDialogVisible = true;
this.selectedItem = item
this.detailsDialogVisible = true
},
closeDetailsDialog() {
this.detailsDialogVisible = false;
this.detailsDialogVisible = false
},
// prime
onPage(event) {
@ -421,7 +457,7 @@ export default {
this.itemDialog = true
},
confirmDeleteItem(item) {
console.log('confirmDeleteItem :::', item)
console.log("confirmDeleteItem :::", item)
this.item = { ...item }
this.itemToDelete = { ...item }
this.deleteItemDialog = true
@ -440,21 +476,31 @@ export default {
this.selectedItems = null
},
deleteItemButton() {
console.log("deleteItem", this.itemToDelete);
console.log("deleteItem", this.itemToDelete)
if (this.itemToDelete && this.itemToDelete.id) {
this.deleteItem(this.itemToDelete)
.then(() => {
this.$toast.add({ severity: 'success', summary: 'Success', detail: 'Item deleted successfully', life: 3000 });
this.deleteItemDialog = false;
this.itemToDelete = null;
this.onUpdateOptions(this.options);
this.$toast.add({
severity: "success",
summary: "Success",
detail: "Item deleted successfully",
life: 3000,
})
this.deleteItemDialog = false
this.itemToDelete = null
this.onUpdateOptions(this.options)
})
.catch((error) => {
console.error("Error deleting the item:", error)
this.$toast.add({
severity: "error",
summary: "Error",
detail: "An error occurred while deleting the item",
life: 3000,
})
})
.catch(error => {
console.error("Error deleting the item:", error);
this.$toast.add({ severity: 'error', summary: 'Error', detail: 'An error occurred while deleting the item', life: 3000 });
});
} else {
console.error("No item to delete or item ID is missing");
console.error("No item to delete or item ID is missing")
}
},
onRowSelected(items) {

@ -1,22 +1,26 @@
<template>
<Button :label="$t('Back')" icon="pi pi-chevron-left" @click="goBack" />
<Button
:label="$t('Back')"
icon="pi pi-chevron-left"
@click="goBack"
/>
<DataTable
v-model:filters="filters"
v-model:selection="selectedItems"
:globalFilterFields="['resourceNode.title', 'resourceNode.updatedAt']"
:global-filter-fields="['resourceNode.title', 'resourceNode.updatedAt']"
:lazy="true"
:loading="isLoading"
:paginator="true"
:rows="10"
:rowsPerPageOptions="[5, 10, 20, 50]"
:totalRecords="totalItems"
:rows-per-page-options="[5, 10, 20, 50]"
:total-records="totalItems"
:value="itemsShared"
class="p-datatable-sm"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords}"
dataKey="iid"
filterDisplay="menu"
paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
responsiveLayout="scroll"
current-page-report-template="Showing {first} to {last} of {totalRecords}"
data-key="iid"
filter-display="menu"
paginator-template="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
responsive-layout="scroll"
@page="onPage($event)"
@sort="sortingChanged($event)"
>
@ -26,7 +30,7 @@
field="resourceNode.title"
>
<template #body="slotProps">
<div v-if="slotProps.data && slotProps.data.resourceNode && slotProps.data.resourceNode.resourceFile">
<div v-if="slotProps.data && slotProps.data.resourceNode && slotProps.data.resourceNode.firstResourceFile">
<ResourceFileLink :resource="slotProps.data" />
</div>
<div v-else>
@ -45,12 +49,12 @@
<Column
:header="$t('Size')"
:sortable="true"
field="resourceNode.resourceFile.size"
field="resourceNode.firstResourceFile.size"
>
<template #body="slotProps">
{{
slotProps.data.resourceNode.resourceFile
? prettyBytes(slotProps.data.resourceNode.resourceFile.size)
slotProps.data.resourceNode.firstResourceFile
? prettyBytes(slotProps.data.resourceNode.firstResourceFile.size)
: ""
}}
</template>
@ -117,7 +121,7 @@ export default {
columns: [
{ label: t("Title"), field: "title", name: "title", sortable: true },
{ label: t("Modified"), field: "resourceNode.updatedAt", name: "updatedAt", sortable: true },
{ label: t("Size"), field: "resourceNode.resourceFile.size", name: "size", sortable: true },
{ label: t("Size"), field: "resourceNode.firstResourceFile.size", name: "size", sortable: true },
{ label: t("Actions"), name: "action", sortable: false },
],
pageOptions: [10, 20, 50, t("All")],

@ -1,6 +1,10 @@
<template>
<div>
<Button :label="$t('Back')" icon="pi pi-chevron-left" @click="goBack" />
<Button
:label="$t('Back')"
icon="pi pi-chevron-left"
@click="goBack"
/>
<Toolbar
v-if="item && isCurrentTeacher"
:handle-delete="del"
@ -21,16 +25,16 @@
>
<div class="w-1/2">
<div
v-if="item['resourceNode']['resourceFile']"
v-if="item.resourceNode.firstResourceFile"
class="flex justify-center"
>
<div class="w-64">
<q-img
v-if="item['resourceNode']['resourceFile']['image']"
v-if="item.resourceNode.firstResourceFile.image"
:src="item['contentUrl'] + '&w=300'"
spinner-color="primary"
/>
<span v-else-if="item['resourceNode']['resourceFile']['video']">
<span v-else-if="item.resourceNode.firstResourceFile.video">
<video controls>
<source :src="item['contentUrl']" />
</video>
@ -93,7 +97,7 @@
</td>
<td />
</tr>
<tr v-if="item['resourceNode']['resourceFile']">
<tr v-if="item.resourceNode.firstResourceFile">
<td>
<strong>{{ $t("File") }}</strong>
</td>
@ -143,6 +147,7 @@ export default {
Toolbar,
ShowLinks,
},
mixins: [ShowMixin],
data() {
const { relativeDatetime } = useFormatDate()
const securityStore = useSecurityStore()
@ -156,7 +161,6 @@ export default {
isCurrentTeacher,
}
},
mixins: [ShowMixin],
computed: {
...mapFields("personalfile", {
isLoading: "isLoading",
@ -165,7 +169,7 @@ export default {
},
methods: {
goBack() {
this.$router.go(-1);
this.$router.go(-1)
},
...mapActions("personalfile", {
deleteItem: "del",

@ -26,7 +26,7 @@ $objQuestion = $questionRepo->find($questionId);
$answer_type = $objQuestion->getType(); //very important
$resourceFile = $objQuestion->getResourceNode()->getResourceFile();
$resourceFile = $objQuestion->getResourceNode()->getResourceFiles()->first();
$pictureWidth = $resourceFile->getWidth();
$pictureHeight = $resourceFile->getHeight();
$imagePath = $questionRepo->getHotSpotImageUrl($objQuestion);

@ -36,7 +36,7 @@ $TBL_ANSWERS = Database::get_course_table(TABLE_QUIZ_ANSWER);
if (!$objQuestion->getResourceNode()->hasResourceFile()) {
api_not_allowed();
}
$resourceFile = $objQuestion->getResourceNode()->getResourceFile();
$resourceFile = $objQuestion->getResourceNode()->getResourceFiles()->first();
$pictureWidth = $resourceFile->getWidth();
$pictureHeight = $resourceFile->getHeight();
$imagePath = $questionRepo->getHotSpotImageUrl($objQuestion).'?'.api_get_cidreq();

@ -32,7 +32,7 @@ if (!$objQuestion) {
if (!$objQuestion->getResourceNode()->hasResourceFile()) {
api_not_allowed();
}
$resourceFile = $objQuestion->getResourceNode()->getResourceFile();
$resourceFile = $objQuestion->getResourceNode()->getResourceFiles()->first();
$pictureWidth = $resourceFile->getWidth();
$pictureHeight = $resourceFile->getHeight();
$imagePath = $questionRepo->getHotSpotImageUrl($objQuestion).'?'.api_get_cidreq();

@ -60,7 +60,7 @@ if (empty($objQuestion)) {
$answer_type = $objQuestion->getType(); //very important
$TBL_ANSWERS = Database::get_course_table(TABLE_QUIZ_ANSWER);
$resourceFile = $objQuestion->getResourceNode()->getResourceFile();
$resourceFile = $objQuestion->getResourceNode()->getResourceFiles()->first();
$pictureWidth = $resourceFile->getWidth();
$pictureHeight = $resourceFile->getHeight();
$imagePath = $questionRepo->getHotSpotImageUrl($objQuestion).'?'.api_get_cidreq();

@ -2077,7 +2077,7 @@ class Category implements GradebookItem
$html = [];
if (!empty($my_certificate)) {
$pathToCertificate = $category->getDocument()->getResourceNode()->getResourceFile()->getFile()->getPathname();
$pathToCertificate = $category->getDocument()->getResourceNode()->getResourceFiles()->first()->getFile()->getPathname();
$certificate_obj = new Certificate(
$my_certificate['id'],

@ -485,13 +485,7 @@ class CourseChatUtils
$resourceNode = $resource->getResourceNode();
}
if ($resourceNode->hasResourceFile()) {
//$resourceFile = $resourceNode->getResourceFile();
//$fileName = $this->getFilename($resourceFile);
return $this->repository->getResourceNodeFileContent($resourceNode);
}
return '';
return $this->repository->getResourceNodeFileContent($resourceNode);
$remove = 0;
$content = [];

@ -2214,10 +2214,10 @@ class DocumentManager
'rootOpen' => '<ul id="doc_list" class="list-group lp_resource">',
'rootClose' => '</ul>',
//'childOpen' => '<li class="doc_resource lp_resource_element ">',
'childOpen' => function ($child) {
'childOpen' => function ($child) {;
$id = $child['id'];
$disableDrag = '';
if (!$child['resourceFile']) {
if (!$child['resourceFiles']) {
$disableDrag = ' disable_drag ';
}
@ -2230,12 +2230,12 @@ class DocumentManager
'childClose' => '</li>',
'nodeDecorator' => function ($node) use ($icon, $folderIcon) {
$disableDrag = '';
if (!$node['resourceFile']) {
if (!$node['resourceFiles']) {
$disableDrag = ' disable_drag ';
}
$link = '<div class="flex flex-row gap-1 h-4 item_data '.$disableDrag.' ">';
$file = $node['resourceFile'];
$file = $node['resourceFiles'] ? current($node['resourceFiles']) : null;
$extension = '';
if ($file) {
$extension = pathinfo($file['title'], PATHINFO_EXTENSION);
@ -2243,7 +2243,7 @@ class DocumentManager
$folder = $folderIcon;
if ($node['resourceFile']) {
if ($node['resourceFiles']) {
$link .= '<a class="moved ui-sortable-handle" href="#">';
$link .= $icon;
$link .= '</a>';
@ -2272,12 +2272,12 @@ class DocumentManager
->from(ResourceNode::class, 'node')
->innerJoin('node.resourceType', 'type')
->innerJoin('node.resourceLinks', 'links')
->leftJoin('node.resourceFile', 'file')
->innerJoin('node.resourceFiles', 'files')
->addSelect('files')
->where('type = :type')
->andWhere('links.course = :course')
->setParameters(['type' => $type, 'course' => $course])
->orderBy('node.parent', 'ASC')
->addSelect('file')
;
$sessionId = api_get_session_id();

@ -8748,10 +8748,6 @@ class learnpath
/** @var CDocument $document */
$document = $repo->find($finalItem->path);
if ($document && $document->getResourceNode()->hasResourceFile()) {
return $repo->getResourceFileContent($document);
}
return '';
return $document ? $repo->getResourceFileContent($document) : '';
}
}

@ -4035,7 +4035,7 @@ class learnpathItem
);*/
if ($document) {
$name = '/audio/'.$document->getResourceNode()->getResourceFile()->getOriginalName();
$name = '/audio/'.$document->getResourceNode()->getResourceFiles()->first()->getOriginalName();
// Store the mp3 file in the lp_item table.
$table = Database::get_course_table(TABLE_LP_ITEM);
$sql = "UPDATE $table SET

@ -333,8 +333,11 @@ if ($form->validate()) {
}
if (isset($_REQUEST['remove_picture']) && $_REQUEST['remove_picture']) {
if ($lp->getResourceNode()->hasResourceFile()) {
$lp->getResourceNode()->setResourceFile(null);
$resourceFiles = $lp->getResourceNode()->getResourceFiles();
foreach ($resourceFiles as $resourceFile) {
$em->remove($resourceFile);
$em->flush();
}
}

@ -3987,7 +3987,7 @@ function getWorkComment(CStudentPublicationComment $commentEntity, array $course
$filePath = '';
$deleteUrl = api_get_path(WEB_CODE_PATH).
'work/view.php?'.api_get_cidreq().'&id='.$workId.'&action=delete_attachment&comment_id='.$id;
$fileName = $commentEntity->getResourceNode()->getResourceFile()->getTitle();
$fileName = $commentEntity->getResourceNode()->getResourceFiles()->first()->getTitle();
}
$comment['comment'] = $commentEntity->getComment();
$comment['delete_file_url'] = $deleteUrl;
@ -4016,10 +4016,7 @@ function deleteCommentFile($id, $courseInfo = [])
/** @var CStudentPublicationComment $commentEntity */
$commentEntity = $repo->findOneBy($criteria);
if ($commentEntity->getResourceNode()->hasResourceFile()) {
$file = $commentEntity->getResourceNode()->getResourceFile();
$commentEntity->getResourceNode()->setResourceFile(null);
foreach ($commentEntity->getResourceNode()->getResourceFiles() as $file) {
$em->remove($file);
$em->flush();
}
@ -5969,7 +5966,7 @@ function getFileContents($id, $courseInfo, $sessionId = 0, $correction = false,
$title = $titleCorrection = $studentPublication->getCorrection()->getTitle();
}
if ($hasFile) {
$title = $studentPublication->getResourceNode()->getResourceFile()->getTitle();
$title = $studentPublication->getResourceNode()->getResourceFiles()->first()->getTitle();
}
$title = str_replace(' ', '_', $title);

@ -0,0 +1 @@
AcceptPathInfo On

@ -0,0 +1,88 @@
# Experience API (xAPI)
Allows you to connect to an external Learning Record Store and use activities with the xAPI standard.
> You can import and use TinCan packages.
> Import CMI5 packages is to be considered a Beta state and still in development.
**Configuration**
Set LRS endpoint, username and password to integrate an external LRS in Chamilo LMS.
The fields "Learning path item viewed", "Learning path ended", "Quiz question answered" and "Quiz ended" allow enabling
hooks when the user views an item in learning path, completes a learning path, answers a quiz question and ends the exam.
The statements generated with these hooks are logged in Chamilo database, waiting to be sent to the LRS by a cron job.
The cron job to configure on your server is located in `CHAMILO_PATH/plugin/xapi/cron/send_statements.php`.
**Use the Statement API from Chamilo LMS**
You can use xAPI's "Statement API" to save some statements from another service.
You need to create credentials (username/password) to do this. First you need to enable the "menu_administrator" region
in the plugin configuration. You will then be able to create the credentials with the new page "Experience API (xAPI)"
inside de Plugins block in the Administration panel.
The endpoint for the statements API is "https://CHAMILO_DOMAIN/plugin/xapi/lrs.php/";
```mysql
CREATE TABLE xapi_attachment (identifier INT AUTO_INCREMENT NOT NULL, statement_id VARCHAR(255) DEFAULT NULL, usageType VARCHAR(255) NOT NULL, contentType VARCHAR(255) NOT NULL, length INT NOT NULL, sha2 VARCHAR(255) NOT NULL, display LONGTEXT NOT NULL COMMENT '(DC2Type:json)', hasDescription TINYINT(1) NOT NULL, description LONGTEXT DEFAULT NULL COMMENT '(DC2Type:json)', fileUrl VARCHAR(255) DEFAULT NULL, content LONGTEXT DEFAULT NULL, INDEX IDX_7148C9A1849CB65B (statement_id), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
CREATE TABLE xapi_object (identifier INT AUTO_INCREMENT NOT NULL, group_id INT DEFAULT NULL, actor_id INT DEFAULT NULL, verb_id INT DEFAULT NULL, object_id INT DEFAULT NULL, type VARCHAR(255) DEFAULT NULL, activityId VARCHAR(255) DEFAULT NULL, hasActivityDefinition TINYINT(1) DEFAULT NULL, hasActivityName TINYINT(1) DEFAULT NULL, activityName LONGTEXT DEFAULT NULL COMMENT '(DC2Type:json)', hasActivityDescription TINYINT(1) DEFAULT NULL, activityDescription LONGTEXT DEFAULT NULL COMMENT '(DC2Type:json)', activityType VARCHAR(255) DEFAULT NULL, activityMoreInfo VARCHAR(255) DEFAULT NULL, mbox VARCHAR(255) DEFAULT NULL, mboxSha1Sum VARCHAR(255) DEFAULT NULL, openId VARCHAR(255) DEFAULT NULL, accountName VARCHAR(255) DEFAULT NULL, accountHomePage VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, referenced_statement_id VARCHAR(255) DEFAULT NULL, activityExtensions_id INT DEFAULT NULL, parentContext_id INT DEFAULT NULL, groupingContext_id INT DEFAULT NULL, categoryContext_id INT DEFAULT NULL, otherContext_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_E2B68640303C7F1D (activityExtensions_id), INDEX IDX_E2B68640FE54D947 (group_id), UNIQUE INDEX UNIQ_E2B6864010DAF24A (actor_id), UNIQUE INDEX UNIQ_E2B68640C1D03483 (verb_id), UNIQUE INDEX UNIQ_E2B68640232D562B (object_id), INDEX IDX_E2B68640988A4CEC (parentContext_id), INDEX IDX_E2B686404F542860 (groupingContext_id), INDEX IDX_E2B68640AEA1B132 (categoryContext_id), INDEX IDX_E2B68640B73EEAB7 (otherContext_id), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
CREATE TABLE xapi_result (identifier INT AUTO_INCREMENT NOT NULL, extensions_id INT DEFAULT NULL, hasScore TINYINT(1) NOT NULL, scaled DOUBLE PRECISION DEFAULT NULL, raw DOUBLE PRECISION DEFAULT NULL, min DOUBLE PRECISION DEFAULT NULL, max DOUBLE PRECISION DEFAULT NULL, success TINYINT(1) DEFAULT NULL, completion TINYINT(1) DEFAULT NULL, response VARCHAR(255) DEFAULT NULL, duration VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_5971ECBFD0A19400 (extensions_id), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
CREATE TABLE xapi_verb (identifier INT AUTO_INCREMENT NOT NULL, id VARCHAR(255) NOT NULL, display LONGTEXT NOT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
CREATE TABLE xapi_extensions (identifier INT AUTO_INCREMENT NOT NULL, extensions LONGTEXT NOT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
CREATE TABLE xapi_context (identifier INT AUTO_INCREMENT NOT NULL, instructor_id INT DEFAULT NULL, team_id INT DEFAULT NULL, extensions_id INT DEFAULT NULL, registration VARCHAR(255) DEFAULT NULL, hasContextActivities TINYINT(1) DEFAULT NULL, revision VARCHAR(255) DEFAULT NULL, platform VARCHAR(255) DEFAULT NULL, language VARCHAR(255) DEFAULT NULL, statement VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_3D7771908C4FC193 (instructor_id), UNIQUE INDEX UNIQ_3D777190296CD8AE (team_id), UNIQUE INDEX UNIQ_3D777190D0A19400 (extensions_id), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
CREATE TABLE xapi_actor (identifier INT AUTO_INCREMENT NOT NULL, type VARCHAR(255) DEFAULT NULL, mbox VARCHAR(255) DEFAULT NULL, mboxSha1Sum VARCHAR(255) DEFAULT NULL, openId VARCHAR(255) DEFAULT NULL, accountName VARCHAR(255) DEFAULT NULL, accountHomePage VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, members VARCHAR(255) NOT NULL, PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
CREATE TABLE xapi_statement (id VARCHAR(255) NOT NULL, actor_id INT DEFAULT NULL, verb_id INT DEFAULT NULL, object_id INT DEFAULT NULL, result_id INT DEFAULT NULL, authority_id INT DEFAULT NULL, context_id INT DEFAULT NULL, created BIGINT DEFAULT NULL, `stored` BIGINT DEFAULT NULL, hasAttachments TINYINT(1) DEFAULT NULL, UNIQUE INDEX UNIQ_BAF6663B10DAF24A (actor_id), UNIQUE INDEX UNIQ_BAF6663BC1D03483 (verb_id), UNIQUE INDEX UNIQ_BAF6663B232D562B (object_id), UNIQUE INDEX UNIQ_BAF6663B7A7B643 (result_id), UNIQUE INDEX UNIQ_BAF6663B81EC865B (authority_id), UNIQUE INDEX UNIQ_BAF6663B6B00C1CF (context_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
ALTER TABLE xapi_attachment ADD CONSTRAINT FK_7148C9A1849CB65B FOREIGN KEY (statement_id) REFERENCES xapi_statement (id);
ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640303C7F1D FOREIGN KEY (activityExtensions_id) REFERENCES xapi_extensions (identifier);
ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640FE54D947 FOREIGN KEY (group_id) REFERENCES xapi_object (identifier);
ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B6864010DAF24A FOREIGN KEY (actor_id) REFERENCES xapi_object (identifier);
ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640C1D03483 FOREIGN KEY (verb_id) REFERENCES xapi_verb (identifier);
ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640232D562B FOREIGN KEY (object_id) REFERENCES xapi_object (identifier);
ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640988A4CEC FOREIGN KEY (parentContext_id) REFERENCES xapi_context (identifier);
ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B686404F542860 FOREIGN KEY (groupingContext_id) REFERENCES xapi_context (identifier);
ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640AEA1B132 FOREIGN KEY (categoryContext_id) REFERENCES xapi_context (identifier);
ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640B73EEAB7 FOREIGN KEY (otherContext_id) REFERENCES xapi_context (identifier);
ALTER TABLE xapi_result ADD CONSTRAINT FK_5971ECBFD0A19400 FOREIGN KEY (extensions_id) REFERENCES xapi_extensions (identifier);
ALTER TABLE xapi_context ADD CONSTRAINT FK_3D7771908C4FC193 FOREIGN KEY (instructor_id) REFERENCES xapi_object (identifier);
ALTER TABLE xapi_context ADD CONSTRAINT FK_3D777190296CD8AE FOREIGN KEY (team_id) REFERENCES xapi_object (identifier);
ALTER TABLE xapi_context ADD CONSTRAINT FK_3D777190D0A19400 FOREIGN KEY (extensions_id) REFERENCES xapi_extensions (identifier);
ALTER TABLE xapi_statement ADD CONSTRAINT FK_BAF6663B10DAF24A FOREIGN KEY (actor_id) REFERENCES xapi_object (identifier);
ALTER TABLE xapi_statement ADD CONSTRAINT FK_BAF6663BC1D03483 FOREIGN KEY (verb_id) REFERENCES xapi_verb (identifier);
ALTER TABLE xapi_statement ADD CONSTRAINT FK_BAF6663B232D562B FOREIGN KEY (object_id) REFERENCES xapi_object (identifier);
ALTER TABLE xapi_statement ADD CONSTRAINT FK_BAF6663B7A7B643 FOREIGN KEY (result_id) REFERENCES xapi_result (identifier);
ALTER TABLE xapi_statement ADD CONSTRAINT FK_BAF6663B81EC865B FOREIGN KEY (authority_id) REFERENCES xapi_object (identifier);
ALTER TABLE xapi_statement ADD CONSTRAINT FK_BAF6663B6B00C1CF FOREIGN KEY (context_id) REFERENCES xapi_context (identifier);
CREATE TABLE xapi_shared_statement (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(255) DEFAULT NULL, statement LONGTEXT NOT NULL COMMENT '(DC2Type:array)', sent TINYINT(1) DEFAULT '0' NOT NULL, INDEX idx_uuid (uuid), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
CREATE TABLE xapi_lrs_auth (id INT AUTO_INCREMENT NOT NULL, username VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, enabled TINYINT(1) NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
CREATE TABLE xapi_tool_launch (id INT AUTO_INCREMENT NOT NULL, c_id INT NOT NULL, session_id INT DEFAULT NULL, title VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, launch_url VARCHAR(255) NOT NULL, activity_id VARCHAR(255) DEFAULT NULL, activity_type VARCHAR(255) DEFAULT NULL, allow_multiple_attempts TINYINT(1) DEFAULT '1' NOT NULL, lrs_url VARCHAR(255) DEFAULT NULL, lrs_auth_username VARCHAR(255) DEFAULT NULL, lrs_auth_password VARCHAR(255) DEFAULT NULL, INDEX IDX_E18CB58391D79BD3 (c_id), INDEX IDX_E18CB583613FECDF (session_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
ALTER TABLE xapi_tool_launch ADD CONSTRAINT FK_E18CB58391D79BD3 FOREIGN KEY (c_id) REFERENCES course (id);
ALTER TABLE xapi_tool_launch ADD CONSTRAINT FK_E18CB583613FECDF FOREIGN KEY (session_id) REFERENCES session (id);
CREATE TABLE xapi_cmi5_item (id INT AUTO_INCREMENT NOT NULL, tree_root INT DEFAULT NULL, parent_id INT DEFAULT NULL, identifier VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, title LONGTEXT NOT NULL COMMENT '(DC2Type:json)', description LONGTEXT NOT NULL COMMENT '(DC2Type:json)', url VARCHAR(255) DEFAULT NULL, activity_type VARCHAR(255) DEFAULT NULL, launch_method VARCHAR(255) DEFAULT NULL, move_on VARCHAR(255) DEFAULT NULL, mastery_score DOUBLE PRECISION DEFAULT NULL, launch_parameters VARCHAR(255) DEFAULT NULL, entitlement_key VARCHAR(255) DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, lft INT NOT NULL, lvl INT NOT NULL, rgt INT NOT NULL, tool_id INT DEFAULT NULL, INDEX IDX_7CA116D88F7B22CC (tool_id), INDEX IDX_7CA116D8A977936C (tree_root), INDEX IDX_7CA116D8727ACA70 (parent_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
ALTER TABLE xapi_cmi5_item ADD CONSTRAINT FK_7CA116D8A977936C FOREIGN KEY (tree_root) REFERENCES xapi_cmi5_item (id) ON DELETE CASCADE;
ALTER TABLE xapi_cmi5_item ADD CONSTRAINT FK_7CA116D8727ACA70 FOREIGN KEY (parent_id) REFERENCES xapi_cmi5_item (id) ON DELETE CASCADE;
CREATE TABLE xapi_activity_state (id INT AUTO_INCREMENT NOT NULL, state_id VARCHAR(255) NOT NULL, activity_id VARCHAR(255) NOT NULL, agent LONGTEXT NOT NULL COMMENT '(DC2Type:json)', document_data LONGTEXT NOT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
CREATE TABLE xapi_activity_profile (id INT AUTO_INCREMENT NOT NULL, profile_id VARCHAR(255) NOT NULL, activity_id VARCHAR(255) NOT NULL, document_data LONGTEXT NOT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
```
**From 0.2 (beta) [2021-10-15]**
- With the LRS an internal log is registered based on the actor mbox's email or the actor account's name coming from the statement
To update, execute this queries:
```sql
CREATE TABLE xapi_internal_log (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, statement_id VARCHAR(255) NOT NULL, verb VARCHAR(255) NOT NULL, object_id VARCHAR(255) NOT NULL, activity_name VARCHAR(255) DEFAULT NULL, activity_description VARCHAR(255) DEFAULT NULL, score_scaled DOUBLE PRECISION DEFAULT NULL, score_raw DOUBLE PRECISION DEFAULT NULL, score_min DOUBLE PRECISION DEFAULT NULL, score_max DOUBLE PRECISION DEFAULT NULL, created_at DATETIME DEFAULT NULL, INDEX IDX_C1C667ACA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
ALTER TABLE xapi_internal_log ADD CONSTRAINT FK_C1C667ACA76ED395 FOREIGN KEY (user_id) REFERENCES user (id);
```
**From 0.3 (beta) [2021-11-11]**
- Fix: Add foreign keys with course/session in tool_launch table and foreign key with user in internal_log table.
```sql
ALTER TABLE xapi_internal_log ADD CONSTRAINT FK_C1C667ACA76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE;
ALTER TABLE xapi_tool_launch ADD CONSTRAINT FK_E18CB58391D79BD3 FOREIGN KEY (c_id) REFERENCES course (id) ON DELETE CASCADE;
ALTER TABLE xapi_tool_launch ADD CONSTRAINT FK_E18CB583613FECDF FOREIGN KEY (session_id) REFERENCES session (id) ON DELETE CASCADE;
```

@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\XApiLrsAuth;
use Symfony\Component\HttpFoundation\Request;
$cidReset = true;
require_once __DIR__.'/../../main/inc/global.inc.php';
api_protect_admin_script();
$request = Request::createFromGlobals();
$plugin = XApiPlugin::create();
$em = Database::getManager();
$pageBaseUrl = api_get_self();
$pageActions = '';
$pageContent = '';
/**
* @return FormValidator
*
* @throws Exception
*/
function createForm(?XApiLrsAuth $auth = null)
{
$pageBaseUrl = api_get_self();
$action = $pageBaseUrl.'?action=add';
if (null != $auth) {
$action = $pageBaseUrl."?action=edit&id={$auth->getId()}";
}
$form = new FormValidator('frm_xapi_auth', 'post', $action);
$form->addText('username', get_lang('Username'), true);
$form->addText('password', get_lang('Password'), true);
$form->addCheckBox('enabled', get_lang('Enabled'), get_lang('Yes'));
$form->addButtonSave(get_lang('Save'));
if (null != $auth) {
$form->setDefaults(
[
'username' => $auth->getUsername(),
'password' => $auth->getPassword(),
'enabled' => $auth->isEnabled(),
]
);
}
return $form;
}
switch ($request->query->getAlpha('action')) {
case 'add':
$form = createForm();
if ($form->validate()) {
$values = $form->exportValues();
$auth = new XApiLrsAuth();
$auth
->setUsername($values['username'])
->setPassword($values['password'])
->setEnabled(isset($values['enabled']))
->setCreatedAt(
api_get_utc_datetime(null, false, true)
)
;
$em->persist($auth);
$em->flush();
Display::addFlash(
Display::return_message(get_lang('ItemAdded'), 'success')
);
header('Location: '.$pageBaseUrl);
exit;
}
$pageActions = Display::url(
Display::return_icon('back.png', get_lang('Back'), [], ICON_SIZE_MEDIUM),
$pageBaseUrl
);
$pageContent = $form->returnForm();
break;
case 'edit':
$auth = $em->find(XApiLrsAuth::class, $request->query->getInt('id'));
if (null == $auth) {
api_not_allowed(true);
}
$form = createForm($auth);
if ($form->validate()) {
$values = $form->exportValues();
$auth
->setUsername($values['username'])
->setPassword($values['password'])
->setEnabled(isset($values['enabled']))
->setCreatedAt(
api_get_utc_datetime(null, false, true)
)
;
$em->persist($auth);
$em->flush();
Display::addFlash(
Display::return_message(get_lang('ItemUpdated'), 'success')
);
header('Location: '.$pageBaseUrl);
exit;
}
$pageActions = Display::url(
Display::return_icon('back.png', get_lang('Back'), [], ICON_SIZE_MEDIUM),
$pageBaseUrl
);
$pageContent = $form->returnForm();
break;
case 'delete':
$auth = $em->find(XApiLrsAuth::class, $request->query->getInt('id'));
if (null == $auth) {
api_not_allowed(true);
}
$em->remove($auth);
$em->flush();
Display::addFlash(
Display::return_message(get_lang('ItemDeleted'), 'success')
);
header('Location: '.$pageBaseUrl);
exit;
case 'list':
default:
$pageActions = Display::url(
Display::return_icon('add.png', get_lang('Add'), [], ICON_SIZE_MEDIUM),
$pageBaseUrl.'?action=add'
);
$pageContent = Display::return_message(get_lang('NoData'), 'warning');
$auths = $em->getRepository(XApiLrsAuth::class)->findAll();
if (count($auths) > 0) {
$row = 0;
$table = new HTML_Table(['class' => 'table table-striped table-hover']);
$table->setHeaderContents($row, 0, get_lang('Username'));
$table->setHeaderContents($row, 1, get_lang('Password'));
$table->setHeaderContents($row, 2, get_lang('Enabled'));
$table->setHeaderContents($row, 3, get_lang('CreatedAt'));
$table->setHeaderContents($row, 4, get_lang('Actions'));
foreach ($auths as $auth) {
$row++;
$actions = [
Display::url(
Display::return_icon('edit.png', get_lang('Edit')),
$pageBaseUrl.'?action=edit&id='.$auth->getId()
),
Display::url(
Display::return_icon('delete.png', get_lang('Edit')),
$pageBaseUrl.'?action=delete&id='.$auth->getId()
),
];
$table->setCellContents($row, 0, $auth->getUsername());
$table->setCellContents($row, 1, $auth->getPassword());
$table->setCellContents($row, 2, $auth->isEnabled() ? get_lang('Yes') : get_lang('No'));
$table->setCellContents($row, 3, api_convert_and_format_date($auth->getCreatedAt()));
$table->setCellContents($row, 4, implode(\PHP_EOL, $actions));
}
$pageContent = $table->toHtml();
}
break;
}
$interbreadcrumb[] = [
'name' => get_lang('Administration'),
'url' => api_get_path(WEB_CODE_PATH).'admin/index.php',
];
$view = new Template($plugin->get_title());
$view->assign('actions', Display::toolbarAction('xapi_actions', [$pageActions]));
$view->assign('content', $pageContent);
$view->display_one_col_template();

@ -0,0 +1,40 @@
.section-global {
margin: 0;
}
#pnl-left ul {
list-style: none;
margin: 0;
padding: 0;
}
#pnl-left ul li a {
padding: 5px 0;
display: block;
}
#pnl-left ul ul li {
padding: 0 0 0 15px ;
}
@media (min-width: 992px) {
#pnl-left {
overflow: auto;
position: absolute;
top: 0;
bottom: 0;
left: 0;
}
#pnl-right {
position: absolute;
right: 0;
top: 0;
bottom: 0;
}
#ifr-content {
min-width: 100%;
min-height: 100%;
padding: 0;
margin: 0;
position: absolute;
right: 0;
}
}

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\XApiCmi5Item;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
use Xabbuh\XApi\Model\Account;
use Xabbuh\XApi\Model\Activity;
use Xabbuh\XApi\Model\Agent;
use Xabbuh\XApi\Model\Context;
use Xabbuh\XApi\Model\Definition;
use Xabbuh\XApi\Model\DocumentData;
use Xabbuh\XApi\Model\InverseFunctionalIdentifier;
use Xabbuh\XApi\Model\IRI;
use Xabbuh\XApi\Model\IRL;
use Xabbuh\XApi\Model\LanguageMap;
use Xabbuh\XApi\Model\State;
use Xabbuh\XApi\Model\StateDocument;
use Xabbuh\XApi\Model\Statement;
use Xabbuh\XApi\Model\StatementId;
use Xabbuh\XApi\Model\Uuid;
use Xabbuh\XApi\Model\Verb;
require_once __DIR__.'/../../../main/inc/global.inc.php';
api_protect_course_script(true);
api_block_anonymous_users();
$request = HttpRequest::createFromGlobals();
$em = Database::getManager();
$item = $em->find(XApiCmi5Item::class, $request->query->getInt('id'));
$toolLaunch = $item->getTool();
if ($toolLaunch->getId() !== $request->query->getInt('tool')) {
api_not_allowed(
false,
Display::return_message(get_lang('NotAllwed'), 'error')
);
}
$plugin = XApiPlugin::create();
$user = api_get_user_entity(api_get_user_id());
$nowDate = api_get_utc_datetime(null, false, true)->format('c');
$registration = (string) Uuid::uuid4();
$actor = new Agent(
InverseFunctionalIdentifier::withAccount(
new Account(
$user->getCompleteName(),
IRL::fromString(api_get_path(WEB_PATH))
)
),
$user->getCompleteName()
);
$verb = new Verb(
IRI::fromString('http://adlnet.gov/expapi/verbs/launched'),
LanguageMap::create($plugin->getLangMap('Launched'))
);
$customActivityId = $plugin->generateIri($item->getId(), 'cmi5_item');
$activity = new Activity(
$customActivityId,
new Definition(
LanguageMap::create($item->getTitle()),
LanguageMap::create($item->getDescription()),
IRI::fromString($item->getIdentifier())
)
);
$context = (new Context())
->withPlatform(
api_get_setting('Institution').' - '.api_get_setting('siteName')
)
->withLanguage(api_get_language_isocode())
->withRegistration($registration)
;
$statementUuid = Uuid::uuid5(
$plugin->get(XApiPlugin::SETTING_UUID_NAMESPACE),
"cmi5_item/{$item->getId()}"
);
$statement = new Statement(
StatementId::fromUuid($statementUuid),
$actor,
$verb,
$activity,
null,
null,
api_get_utc_datetime(null, false, true),
null,
$context
);
$statementClient = XApiPlugin::create()->getXApiStatementClient();
// try {
// $statementClient->storeStatement($statement);
// } catch (ConflictException $e) {
// echo Display::return_message($e->getMessage(), 'error');
//
// exit;
// } catch (XApiException $e) {
// echo Display::return_message($e->getMessage(), 'error');
//
// exit;
// }
$viewSessionId = (string) Uuid::uuid4();
$state = new State(
$activity,
$actor,
'LMS.LaunchData',
(string) $registration
);
$documentDataData = [];
$documentDataData['contentTemplate'] = [
'extensions' => [
'https://w3id.org/xapi/cmi5/context/extensions/sessionid' => $viewSessionId,
],
];
$documentDataData['launchMode'] = 'Normal';
$documentDataData['launchMethod'] = $item->getLaunchMethod();
if ($item->getLaunchParameters()) {
$documentDataData['launchParameteres'] = $item->getLaunchParameters();
}
if ($item->getMasteryScore()) {
$documentDataData['masteryScore'] = $item->getMasteryScore();
}
if ($item->getEntitlementKey()) {
$documentDataData['entitlementKey'] = [
'courseStructure' => $item->getEntitlementKey(),
];
}
$documentData = new DocumentData($documentDataData);
try {
$plugin
->getXApiStateClient()
->createOrReplaceDocument(
new StateDocument($state, $documentData)
)
;
} catch (Exception $exception) {
echo Display::return_message($exception->getMessage(), 'error');
exit;
}
$launchUrl = $plugin->generateLaunchUrl(
'cmi5',
$item->getUrl(),
$customActivityId->getValue(),
$actor,
$registration,
$toolLaunch->getLrsUrl(),
$toolLaunch->getLrsAuthUsername(),
$toolLaunch->getLrsAuthPassword(),
$viewSessionId
);
if ('OwnWindow' === $item->getLaunchMethod()) {
Display::display_reduced_header();
echo '<br><p class="text-center">';
echo Display::toolbarButton(
$plugin->get_lang('LaunchNewAttempt'),
$launchUrl,
'external-link fa-fw',
'success',
[
'target' => '_blank',
]
);
echo '</div>';
Display::display_reduced_footer();
exit;
}
header("Location: $launchUrl");

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
use Symfony\Component\HttpFoundation\Response;
require_once __DIR__.'/../../../main/inc/global.inc.php';
$request = HttpRequest::createFromGlobals();
$response = new JsonResponse([], Response::HTTP_METHOD_NOT_ALLOWED);
if ('POST' === $request->getMethod()) {
$token = base64_encode(uniqid());
$response->setStatusCode(Response::HTTP_OK);
$response->setData(['auth-token' => $token]);
}
$response->send();

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\XApiCmi5Item;
use Chamilo\CoreBundle\Entity\XApiToolLaunch;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
use Xabbuh\XApi\Model\LanguageMap;
require_once __DIR__.'/../../../main/inc/global.inc.php';
api_protect_course_script(true);
api_block_anonymous_users();
$request = HttpRequest::createFromGlobals();
$em = Database::getManager();
$toolLaunch = $em->find(
XApiToolLaunch::class,
$request->query->getInt('id')
);
if (null === $toolLaunch
|| 'cmi5' !== $toolLaunch->getActivityType()
) {
header('Location: '.api_get_course_url());
exit;
}
$plugin = XApiPlugin::create();
$course = api_get_course_entity();
$session = api_get_session_entity();
$cidReq = api_get_cidreq();
$user = api_get_user_entity(api_get_user_id());
$interfaceLanguage = api_get_interface_language();
$itemsRepo = $em->getRepository(XApiCmi5Item::class);
$query = $itemsRepo->createQueryBuilder('item');
$query
->where($query->expr()->eq('item.tool', ':tool'))
->setParameter('tool', $toolLaunch->getId())
;
$tocHtml = $itemsRepo->buildTree(
$query->getQuery()->getArrayResult(),
[
'decorate' => true,
'rootOpen' => '<ul>',
'rootClose' => '</ul>',
'childOpen' => '<li>',
'childClose' => '</li>',
'nodeDecorator' => function ($node) use ($interfaceLanguage, $cidReq, $toolLaunch) {
$titleMap = LanguageMap::create($node['title']);
$title = XApiPlugin::extractVerbInLanguage($titleMap, $interfaceLanguage);
if ('block' === $node['type']) {
return Display::page_subheader($title, null, 'h4');
}
return Display::url(
$title,
"launch.php?tool={$toolLaunch->getId()}&id={$node['id']}&$cidReq",
[
'target' => 'ifr_content',
'class' => 'text-left btn-link',
]
);
},
]
);
$webPluginPath = api_get_path(WEB_PLUGIN_PATH);
$htmlHeadXtra[] = api_get_css($webPluginPath.'xapi/assets/css/cmi5_launch.css');
$htmlHeadXtra[] = api_get_js_simple($webPluginPath.'xapi/assets/js/cmi5_launch.js');
$view = new Template('', false, false, true, true, false);
$view->assign('tool', $toolLaunch);
$view->assign('toc_html', $tocHtml);
$view->assign('content', $view->fetch('xapi/views/cmi5_launch.twig'));
$view->display_no_layout_template();

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\XApiSharedStatement;
use Xabbuh\XApi\Common\Exception\ConflictException;
use Xabbuh\XApi\Common\Exception\XApiException;
use Xabbuh\XApi\Model\StatementId;
use Xabbuh\XApi\Model\Uuid;
use Xabbuh\XApi\Serializer\Symfony\Serializer;
use Xabbuh\XApi\Serializer\Symfony\StatementSerializer;
require_once __DIR__.'/../../../main/inc/global.inc.php';
if (\PHP_SAPI !== 'cli') {
exit;
}
echo 'XAPI: Cron to send statements.'.\PHP_EOL;
$em = Database::getManager();
$serializer = Serializer::createSerializer();
$statementSerializer = new StatementSerializer($serializer);
$notSentSharedStatements = $em
->getRepository(XApiSharedStatement::class)
->findBy(
['uuid' => null, 'sent' => false],
null,
100
)
;
$countNotSent = count($notSentSharedStatements);
if ($countNotSent > 0) {
echo '['.time().'] Trying to send '.$countNotSent.' statements to LRS'.\PHP_EOL;
$client = XApiPlugin::create()->getXapiStatementCronClient();
foreach ($notSentSharedStatements as $notSentSharedStatement) {
$notSentStatement = $statementSerializer->deserializeStatement(
json_encode($notSentSharedStatement->getStatement())
);
if (null == $notSentStatement->getId()) {
$notSentStatement = $notSentStatement->withId(
StatementId::fromUuid(Uuid::uuid4())
);
}
try {
echo '['.time()."] Sending shared statement ({$notSentSharedStatement->getId()})";
$sentStatement = $client->storeStatement($notSentStatement);
echo "\t\tStatement ID received: \"{$sentStatement->getId()->getValue()}\"";
} catch (ConflictException $e) {
echo $e->getMessage().\PHP_EOL;
continue;
} catch (XApiException $e) {
echo $e->getMessage().\PHP_EOL;
continue;
}
$notSentSharedStatement
->setUuid($sentStatement->getId()->getValue())
->setSent(true)
;
$em->persist($notSentSharedStatement);
echo "\t\tShared statement updated".\PHP_EOL;
}
$em->flush();
} else {
echo 'No statements to process.'.\PHP_EOL;
}

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
$strings['plugin_title'] = 'Experience API (xAPI)';
$strings['plugin_comment'] = 'Allows you to connect to an external (or internal) Learning Record Store and use activities compatible with the xAPI standard.';
$strings[XApiPlugin::SETTING_UUID_NAMESPACE] = 'UUID Namespace';
$strings[XApiPlugin::SETTING_UUID_NAMESPACE.'_help'] = 'Namespace for universally unique identifiers used as statement IDs.'
.'<br>This is generated automatically by Chamilo LMS. <strong>Don\'t replace it.</strong>';
$strings['lrs_url'] = 'LRS endpoint';
$strings['lrs_url_help'] = 'Base URL of the LRS';
$strings['lrs_auth_username'] = 'LRS user';
$strings['lrs_auth_username_help'] = 'Username for basic HTTP authentication';
$strings['lrs_auth_password'] = 'LRS password';
$strings['lrs_auth_password_help'] = 'Password for basic HTTP authentication';
$strings['cron_lrs_url'] = 'Cron: LRS endpoint';
$strings['cron_lrs_url_help'] = 'Alternative base URL of the LRS for the cron process';
$strings['cron_lrs_auth_username'] = 'Cron: LRS user';
$strings['cron_lrs_auth_username_help'] = 'Alternative username for basic HTTP authentication for the cron process';
$strings['cron_lrs_auth_password'] = 'Cron: LRS password';
$strings['cron_lrs_auth_password_help'] = 'Alternative password for basic HTTP authentication for the cron process';
$strings['lrs_lp_item_viewed_active'] = 'Learning path item viewed';
$strings['lrs_lp_end_active'] = 'Learning path ended';
$strings['lrs_quiz_active'] = 'Quiz ended';
$strings['lrs_quiz_question_active'] = 'Quiz question answered';
$strings['lrs_portfolio_active'] = 'Portfolio events';
$strings['NoActivities'] = 'No activities added yet';
$strings['ActivityTitle'] = 'Activity';
$strings['AddActivity'] = 'Add activity';
$strings['TinCanPackage'] = 'TinCan package (zip)';
$strings['Cmi5Package'] = 'Cmi5 package (zip)';
$strings['OnlyZipAllowed'] = 'Only ZIP file allowed (.zip).';
$strings['ActivityImported'] = 'Activity imported.';
$strings['EditActivity'] = 'Edit activity';
$strings['ActivityUpdated'] = 'Activity updated';
$strings['ActivityLaunchUrl'] = 'Launch URL';
$strings['ActivityId'] = 'Activity ID';
$strings['ActivityType'] = 'Activity type';
$strings['ActivityDeleted'] = 'Activity deleted';
$strings['ActivityLaunch'] = 'Launch';
$strings['ActivityFirstLaunch'] = 'First launch at';
$strings['ActivityLastLaunch'] = 'Last launch at';
$strings['LaunchNewAttempt'] = 'Launch new attempt';
$strings['LrsConfiguration'] = 'LRS Configuration';
$strings['Verb'] = 'Verb';
$strings['Actor'] = 'Actor';
$strings['ToolTinCan'] = 'Activities';
$strings['Terminated'] = 'Terminated';
$strings['Completed'] = 'Completed';
$strings['Answered'] = 'Answered';
$strings['Viewed'] = 'Viewed';
$strings['ActivityAddedToLPCannotBeAccessed'] = 'This activity has been included in a learning path, so it cannot be accessed by students directly from here.';
$strings['XApiPackage'] = 'XApi Package';
$strings['TinCanAllowMultipleAttempts'] = 'Allow multiple attempts';

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
$strings['plugin_title'] = 'Experience API (xAPI)';
$strings['plugin_comment'] = 'Permet l\'intégration d\'un Learning Record Store (interne ou externe) et de clients xAPI';
$strings[XApiPlugin::SETTING_UUID_NAMESPACE] = 'Namespace UUID';
$strings[XApiPlugin::SETTING_UUID_NAMESPACE.'_help'] = 'Namespace pour identifiants uniques universels qui servent comme IDs de déclaration xAPI.'
.'<br>Cette valeur est générée automatiquement par Chamilo, <strong>ne la modifiez pas.</strong>';
$strings['lrs_url'] = 'Point d\'entrée LRS';
$strings['lrs_url_help'] = 'URL de base du LRS';
$strings['lrs_auth_username'] = 'Utilisateur LRS';
$strings['lrs_auth_username_help'] = 'Nom d\'utilisateur pour l\'authentification HTTP de base';
$strings['lrs_auth_password'] = 'Mot de passe LRS';
$strings['lrs_auth_password_help'] = 'Mot de passe pour l\'authentification HTTP de base';
$strings['cron_lrs_url'] = 'Cron: LRS endpoint';
$strings['cron_lrs_url_help'] = 'Alternative base URL of the LRS for the cron process';
$strings['cron_lrs_auth_username'] = 'Cron: LRS user';
$strings['cron_lrs_auth_username_help'] = 'Alternative username for basic HTTP authentication for the cron process';
$strings['cron_lrs_auth_password'] = 'Cron: LRS password';
$strings['cron_lrs_auth_password_help'] = 'Alternative password for basic HTTP authentication for the cron process';
$strings['lrs_lp_item_viewed_active'] = 'Élément de parcours visionné';
$strings['lrs_lp_end_active'] = 'Parcours terminé';
$strings['lrs_quiz_active'] = 'Exercice terminé';
$strings['lrs_quiz_question_active'] = 'Question d\'exercice répondue';
$strings['lrs_portfolio_active'] = 'Événements de portfolio';
$strings['NoActivities'] = 'Aucune activité ajoutée pour l\'instant';
$strings['ActivityTitle'] = 'Activité';
$strings['AddActivity'] = 'Ajouter activité';
$strings['TinCanPackage'] = 'Paquet TinCan (zip)';
$strings['OnlyZipAllowed'] = 'Seuls les fichiers ZIP sont autorisés (.zip).';
$strings['ActivityImported'] = 'Activité importée.';
$strings['EditActivity'] = 'Éditer activité';
$strings['ActivityUpdated'] = 'Activité mise à jour';
$strings['ActivityLaunchUrl'] = 'URL de lancement';
$strings['ActivityId'] = 'ID d\'activité';
$strings['ActivityType'] = 'Type d\'activité';
$strings['ActivityDeleted'] = 'Activité supprimée';
$strings['ActivityLaunch'] = 'Lancer';
$strings['ActivityFirstLaunch'] = 'Premier lancement à';
$strings['ActivityLastLaunch'] = 'Dernier lancement à';
$strings['LaunchNewAttempt'] = 'Lancer nouvelle tentative';
$strings['LrsConfiguration'] = 'Configuration LRS';
$strings['Verb'] = 'Verbe';
$strings['Actor'] = 'Acteur';
$strings['ToolTinCan'] = 'Activités';
$strings['ActivityAddedToLPCannotBeAccessed'] = 'Cet activité fait partie d\'un parcours d\'apprentissage, il n\'est donc pas accessible par les étudiants depuis cette page';

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
$strings['plugin_title'] = 'Experience API (xAPI)';
$strings['plugin_comment'] = 'Permite incorporar un Learning Record Store externo (o interno) y usar actividades con la especificación xAPI.';
$strings[XApiPlugin::SETTING_UUID_NAMESPACE] = 'UUID Namespace';
$strings[XApiPlugin::SETTING_UUID_NAMESPACE.'_help'] = 'Namespace para los identificadores unicos universales (UUID) usados como IDs de statements.'
.'<br>Esto es generado automáticamente por Chamilo LMS. <strong>No reemplazarlo.</strong>';
$strings['lrs_url'] = 'LRS endpoint';
$strings['lrs_url_help'] = 'Base de la URL del LRS';
$strings['lrs_auth_username'] = 'Usuario del LRS';
$strings['lrs_auth_username_help'] = 'Usuario para autenticación con HTTP básica';
$strings['lrs_auth_password'] = 'Contraseña del LRS';
$strings['lrs_auth_password_help'] = 'Contraseña para autenticación con HTTP básica';
$strings['cron_lrs_url'] = 'Cron: LRS endpoint';
$strings['cron_lrs_url_help'] = 'Opcional. Base de la URL alternativa del LRS del proceso cron.';
$strings['cron_lrs_auth_username'] = 'Cron: Usuario del LRS';
$strings['cron_lrs_auth_username_help'] = 'Opcional. Usuario alternativo para autenticación con HTTP básica del proceso cron';
$strings['cron_lrs_auth_password'] = 'Cron: Contraseña del LRS';
$strings['cron_lrs_auth_password_help'] = 'Opcional. Contraseña alternativa para autenticación con HTTP básica del proceso cron';
$strings['lrs_lp_item_viewed_active'] = 'Visualización de contenido de lección';
$strings['lrs_lp_end_active'] = 'Finalización de lección';
$strings['lrs_quiz_active'] = 'Finalización de ejercicio';
$strings['lrs_quiz_question_active'] = 'Resolución de pregunta en ejercicio';
$strings['lrs_portfolio_active'] = 'Eventos en portafolio';
$strings['NoActivities'] = 'No hay actividades aún';
$strings['ActivityTitle'] = 'Actividad';
$strings['AddActivity'] = 'Agregar actividad';
$strings['TinCanPackage'] = 'Paquete TinCan (zip)';
$strings['Cmi5Package'] = 'Paquete Cmi5(zip)';
$strings['OnlyZipAllowed'] = 'Sólo archivos ZIP están permitidos (.zip).';
$strings['ActivityImported'] = 'Actividad importada.';
$strings['EditActivity'] = 'Editar actividad';
$strings['ActivityUpdated'] = 'Actividad actualizada';
$strings['ActivityLaunchUrl'] = 'URL de inicio';
$strings['ActivityId'] = 'ID de actividad';
$strings['ActivityType'] = 'Tipo de actividad';
$strings['ActivityDeleted'] = 'Actividad eliminada';
$strings['ActivityLaunch'] = 'Iniciar';
$strings['ActivityFirstLaunch'] = 'Primer inicio';
$strings['ActivityLastLaunch'] = 'Últimmo inicio';
$strings['LaunchNewAttempt'] = 'Iniciar nuevo intento';
$strings['LrsConfiguration'] = 'Configuración de LRS';
$strings['Verb'] = 'Verbo';
$strings['Actor'] = 'Actor';
$strings['ToolTinCan'] = 'Actividades';
$strings['Terminated'] = 'Terminó';
$strings['Completed'] = 'Completó';
$strings['Answered'] = 'Respondió';
$strings['Viewed'] = 'Visualizó';
$strings['ActivityAddedToLPCannotBeAccessed'] = 'Esta actividad ha sido incluida en una secuencia de aprendizaje, por lo cual no podrá ser accesible directamente por los estudiantes desde aquí.';
$strings['XApiPackage'] = 'Paquete XApi';
$strings['TinCanAllowMultipleAttempts'] = 'Permitir múltiples intentos';

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\PluginBundle\XApi\Lrs\LrsRequest;
$cidReset = true;
require_once __DIR__.'/../../main/inc/global.inc.php';
$lrsRequest = new LrsRequest();
$lrsRequest->send();

@ -0,0 +1,19 @@
Copyright (c) 2016-2017 Christian Flothmann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

@ -0,0 +1,51 @@
{
"name": "php-xapi/lrs-bundle",
"type": "symfony-bundle",
"description": "Experience API (xAPI) Learning Record Store (LRS) based on the Symfony Framework",
"keywords": ["xAPI", "Experience API", "Tin Can API", "LRS", "Learning Record Store", "Symfony", "bundle"],
"homepage": "https://github.com/php-xapi/lrs-bundle",
"license": "MIT",
"authors": [
{
"name": "Christian Flothmann",
"homepage": "https://github.com/xabbuh"
},
{
"name": "Jérôme Parmentier",
"homepage": "https://github.com/Lctrs"
}
],
"require": {
"php": "^7.1",
"php-xapi/exception": "^0.1 || ^0.2",
"php-xapi/model": "^1.1 || ^2.0 || ^3.0",
"php-xapi/repository-api": "^0.3@dev || ^0.4@dev",
"php-xapi/serializer": "^1.0 || ^2.0",
"php-xapi/symfony-serializer": "^1.0 || ^2.0",
"symfony/config": "^3.4 || ^4.3",
"symfony/dependency-injection": "^3.4 || ^4.3",
"symfony/http-foundation": "^3.4 || ^4.3",
"symfony/http-kernel": "^3.4 || ^4.3"
},
"require-dev": {
"phpspec/phpspec": "~2.3",
"php-xapi/json-test-fixtures": "^1.0 || ^2.0",
"php-xapi/test-fixtures": "^1.0.1",
"ramsey/uuid": "^2.9 || ^3.0"
},
"autoload": {
"psr-4": {
"XApi\\LrsBundle\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"spec\\XApi\\LrsBundle\\": "spec/"
}
},
"extra": {
"branch-alias": {
"dev-master": "0.1.x-dev"
}
}
}

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
/*
* This file is part of the xAPI package.
*
* (c) Christian Flothmann <christian.flothmann@xabbuh.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace XApi\LrsBundle\Controller;
use DateTime;
use Exception;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Xabbuh\XApi\Common\Exception\NotFoundException;
use Xabbuh\XApi\Model\IRL;
use Xabbuh\XApi\Model\Statement;
use Xabbuh\XApi\Model\StatementId;
use Xabbuh\XApi\Model\StatementResult;
use Xabbuh\XApi\Serializer\StatementResultSerializerInterface;
use Xabbuh\XApi\Serializer\StatementSerializerInterface;
use XApi\LrsBundle\Model\StatementsFilterFactory;
use XApi\LrsBundle\Response\AttachmentResponse;
use XApi\LrsBundle\Response\MultipartResponse;
use XApi\Repository\Api\StatementRepositoryInterface;
use const FILTER_VALIDATE_BOOLEAN;
/**
* @author Jérôme Parmentier <jerome.parmentier@acensi.fr>
*/
class StatementGetController
{
protected static array $getParameters = [
'statementId' => true,
'voidedStatementId' => true,
'agent' => true,
'verb' => true,
'activity' => true,
'registration' => true,
'related_activities' => true,
'related_agents' => true,
'since' => true,
'until' => true,
'limit' => true,
'format' => true,
'attachments' => true,
'ascending' => true,
'cursor' => true,
];
public function __construct(
protected readonly StatementRepositoryInterface $repository,
protected readonly StatementSerializerInterface $statementSerializer,
protected readonly StatementResultSerializerInterface $statementResultSerializer,
protected readonly StatementsFilterFactory $statementsFilterFactory
) {}
/**
* @return Response
*
* @throws BadRequestHttpException if the query parameters does not comply with xAPI specification
*/
public function getStatement(Request $request)
{
$query = new ParameterBag(array_intersect_key($request->query->all(), self::$getParameters));
$this->validate($query);
$includeAttachments = $query->filter('attachments', false, FILTER_VALIDATE_BOOLEAN);
try {
if (($statementId = $query->get('statementId')) !== null) {
$statement = $this->repository->findStatementById(StatementId::fromString($statementId));
$response = $this->buildSingleStatementResponse($statement, $includeAttachments);
} elseif (($voidedStatementId = $query->get('voidedStatementId')) !== null) {
$statement = $this->repository->findVoidedStatementById(StatementId::fromString($voidedStatementId));
$response = $this->buildSingleStatementResponse($statement, $includeAttachments);
} else {
$statements = $this->repository->findStatementsBy($this->statementsFilterFactory->createFromParameterBag($query));
$response = $this->buildMultiStatementsResponse($statements, $query, $includeAttachments);
}
} catch (NotFoundException $e) {
$response = $this->buildMultiStatementsResponse([], $query)
->setStatusCode(Response::HTTP_NOT_FOUND)
->setContent('')
;
} catch (Exception $exception) {
$response = Response::create('', Response::HTTP_BAD_REQUEST);
}
$now = new DateTime();
$response->headers->set('X-Experience-API-Consistent-Through', $now->format(DateTime::ATOM));
$response->headers->set('Content-Type', 'application/json');
return $response;
}
/**
* @param bool $includeAttachments true to include the attachments in the response, false otherwise
*
* @return JsonResponse|MultipartResponse
*/
protected function buildSingleStatementResponse(Statement $statement, $includeAttachments = false)
{
$json = $this->statementSerializer->serializeStatement($statement);
$response = new Response($json, 200);
if ($includeAttachments) {
$response = $this->buildMultipartResponse($response, [$statement]);
}
$response->setLastModified($statement->getStored());
return $response;
}
/**
* @param Statement[] $statements
* @param bool $includeAttachments true to include the attachments in the response, false otherwise
*
* @return JsonResponse|MultipartResponse
*/
protected function buildMultiStatementsResponse(array $statements, ParameterBag $query, $includeAttachments = false)
{
$moreUrlPath = $statements ? $this->generateMoreIrl($query) : null;
$json = $this->statementResultSerializer->serializeStatementResult(
new StatementResult($statements, $moreUrlPath)
);
$response = new Response($json, 200);
if ($includeAttachments) {
$response = $this->buildMultipartResponse($response, $statements);
}
return $response;
}
/**
* @param Statement[] $statements
*
* @return MultipartResponse
*/
protected function buildMultipartResponse(JsonResponse $statementResponse, array $statements)
{
$attachmentsParts = [];
foreach ($statements as $statement) {
foreach ((array) $statement->getAttachments() as $attachment) {
$attachmentsParts[] = new AttachmentResponse($attachment);
}
}
return new MultipartResponse($statementResponse, $attachmentsParts);
}
/**
* Validate the parameters.
*
* @throws BadRequestHttpException if the parameters does not comply with the xAPI specification
*/
protected function validate(ParameterBag $query): void
{
$hasStatementId = $query->has('statementId');
$hasVoidedStatementId = $query->has('voidedStatementId');
if ($hasStatementId && $hasVoidedStatementId) {
throw new BadRequestHttpException('Request must not have both statementId and voidedStatementId parameters at the same time.');
}
$hasAttachments = $query->has('attachments');
$hasFormat = $query->has('format');
$queryCount = $query->count();
if (($hasStatementId || $hasVoidedStatementId) && $hasAttachments && $hasFormat && $queryCount > 3) {
throw new BadRequestHttpException('Request must not contain statementId or voidedStatementId parameters, and also any other parameter besides "attachments" or "format".');
}
if (($hasStatementId || $hasVoidedStatementId) && ($hasAttachments || $hasFormat) && $queryCount > 2) {
throw new BadRequestHttpException('Request must not contain statementId or voidedStatementId parameters, and also any other parameter besides "attachments" or "format".');
}
if (($hasStatementId || $hasVoidedStatementId) && $queryCount > 1) {
throw new BadRequestHttpException('Request must not contain statementId or voidedStatementId parameters, and also any other parameter besides "attachments" or "format".');
}
}
protected function generateMoreIrl(ParameterBag $query): IRL
{
$params = $query->all();
$params['cursor'] = empty($params['cursor']) ? 1 : $params['cursor'] + 1;
return IRL::fromString(
'/plugin/xapi/lrs.php/statements?'.http_build_query($params)
);
}
}

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/*
* This file is part of the xAPI package.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace XApi\LrsBundle\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class StatementHeadController extends StatementGetController
{
/**
* @return Response
*
* @throws BadRequestHttpException if the query parameters does not comply with xAPI specification
*/
public function getStatement(Request $request)
{
return parent::getStatement($request)->setContent('');
}
}

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* This file is part of the xAPI package.
*
* (c) Christian Flothmann <christian.flothmann@xabbuh.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace XApi\LrsBundle\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Xabbuh\XApi\Common\Exception\NotFoundException;
use Xabbuh\XApi\Model\Statement;
use XApi\Repository\Api\StatementRepositoryInterface;
/**
* @author Jérôme Parmentier <jerome.parmentier@acensi.fr>
*/
final class StatementPostController
{
/**
* @var StatementRepositoryInterface
*/
private $repository;
public function __construct(StatementRepositoryInterface $repository)
{
$this->repository = $repository;
}
public function postStatements(Request $request, array $statements): JsonResponse
{
$statementsToStore = [];
/** @var Statement $statement */
foreach ($statements as $statement) {
if (null === $statementId = $statement->getId()) {
$statementsToStore[] = $statement;
continue;
}
try {
$existingStatement = $this->repository->findStatementById($statement->getId());
if (!$existingStatement->equals($statement)) {
throw new ConflictHttpException('The new statement is not equal to an existing statement with the same id.');
}
} catch (NotFoundException $e) {
$statementsToStore[] = $statement;
}
}
$uuids = [];
foreach ($statementsToStore as $statement) {
$uuids[] = $this->repository->storeStatement($statement, true)->getValue();
}
return new JsonResponse($uuids);
}
}

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* This file is part of the xAPI package.
*
* (c) Christian Flothmann <christian.flothmann@xabbuh.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace XApi\LrsBundle\Controller;
use InvalidArgumentException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Xabbuh\XApi\Common\Exception\NotFoundException;
use Xabbuh\XApi\Model\Statement;
use Xabbuh\XApi\Model\StatementId;
use XApi\Repository\Api\StatementRepositoryInterface;
/**
* @author Christian Flothmann <christian.flothmann@xabbuh.de>
*/
final class StatementPutController
{
private $repository;
public function __construct(StatementRepositoryInterface $repository)
{
$this->repository = $repository;
}
public function putStatement(Request $request, Statement $statement): Response
{
if (null === $statementId = $request->query->get('statementId')) {
throw new BadRequestHttpException('Required statementId parameter is missing.');
}
try {
$id = StatementId::fromString($statementId);
} catch (InvalidArgumentException $e) {
throw new BadRequestHttpException(sprintf('Parameter statementId ("%s") is not a valid UUID.', $statementId), $e);
}
if (null !== $statement->getId() && !$id->equals($statement->getId())) {
throw new ConflictHttpException(sprintf('Id parameter ("%s") and statement id ("%s") do not match.', $id->getValue(), $statement->getId()->getValue()));
}
try {
$existingStatement = $this->repository->findStatementById($id);
if (!$existingStatement->equals($statement)) {
throw new ConflictHttpException('The new statement is not equal to an existing statement with the same id.');
}
} catch (NotFoundException $e) {
$statement = $statement->withId($id);
$this->repository->storeStatement($statement, true);
}
return new Response('', 204);
}
}

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace XApi\LrsBundle\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
/**
* @author Christian Flothmann <christian.flothmann@xabbuh.de>
*/
final class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder()
{
$treeBuilder = new TreeBuilder();
$treeBuilder
->root('xapi_lrs')
->beforeNormalization()
->ifTrue(function ($v) {
return isset($v['type']) && \in_array($v['type'], ['mongodb', 'orm']) && !isset($v['object_manager_service']);
})
->thenInvalid('You need to configure the object manager service when the repository type is "mongodb" or orm".')
->end()
->children()
->enumNode('type')
->isRequired()
->values(['in_memory', 'mongodb', 'orm'])
->end()
->scalarNode('object_manager_service')->end()
->end()
->end()
;
return $treeBuilder;
}
}

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/*
* This file is part of the xAPI package.
*
* (c) Christian Flothmann <christian.flothmann@xabbuh.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace XApi\LrsBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
/**
* @author Christian Flothmann <christian.flothmann@xabbuh.de>
*/
final class XApiLrsExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
$loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('controller.xml');
$loader->load('event_listener.xml');
$loader->load('factory.xml');
$loader->load('serializer.xml');
switch ($config['type']) {
case 'in_memory':
break;
case 'mongodb':
$loader->load('doctrine.xml');
$loader->load('mongodb.xml');
$container->setAlias('xapi_lrs.doctrine.object_manager', $config['object_manager_service']);
$container->setAlias('xapi_lrs.repository.statement', 'xapi_lrs.repository.statement.doctrine');
break;
case 'orm':
$loader->load('doctrine.xml');
$loader->load('orm.xml');
$container->setAlias('xapi_lrs.doctrine.object_manager', $config['object_manager_service']);
$container->setAlias('xapi_lrs.repository.statement', 'xapi_lrs.repository.statement.doctrine');
break;
}
}
public function getAlias()
{
return 'xapi_lrs';
}
}

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/*
* This file is part of the xAPI package.
*
* (c) Christian Flothmann <christian.flothmann@xabbuh.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace XApi\LrsBundle\EventListener;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* @author Jérôme Parmentier <jerome.parmentier@acensi.fr>
*/
class AlternateRequestSyntaxListener
{
public function onKernelRequest(GetResponseEvent $event): void
{
if (!$event->isMasterRequest()) {
return;
}
$request = $event->getRequest();
if (!$request->attributes->has('xapi_lrs.route')) {
return;
}
if ('POST' !== $request->getMethod()) {
return;
}
if (null === $method = $request->query->get('method')) {
return;
}
if ($request->query->count() > 1) {
throw new BadRequestHttpException('Including other query parameters than "method" is not allowed. You have to send them as POST parameters inside the request body.');
}
$request->setMethod($method);
$request->query->remove('method');
if (null !== $content = $request->request->get('content')) {
$request->request->remove('content');
$request->initialize(
$request->query->all(),
$request->request->all(),
$request->attributes->all(),
$request->cookies->all(),
$request->files->all(),
$request->server->all(),
$content
);
}
foreach ($request->request as $key => $value) {
if (\in_array($key, ['Authorization', 'X-Experience-API-Version', 'Content-Type', 'Content-Length', 'If-Match', 'If-None-Match'], true)) {
$request->headers->set($key, $value);
} else {
$request->query->set($key, $value);
}
$request->request->remove($key);
}
}
}

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace XApi\LrsBundle\EventListener;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
/**
* Converts Experience API specific domain exceptions into proper HTTP responses.
*
* @author Christian Flothmann <christian.flothmann@xabbuh.de>
*/
class ExceptionListener
{
public function onKernelException(GetResponseForExceptionEvent $event): void {}
}

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace XApi\LrsBundle\EventListener;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Serializer\Exception\ExceptionInterface as BaseSerializerException;
use Xabbuh\XApi\Serializer\StatementSerializerInterface;
/**
* @author Christian Flothmann <christian.flothmann@xabbuh.de>
*/
class SerializerListener
{
private $statementSerializer;
public function __construct(StatementSerializerInterface $statementSerializer)
{
$this->statementSerializer = $statementSerializer;
}
public function onKernelRequest(GetResponseEvent $event): void
{
$request = $event->getRequest();
if (!$request->attributes->has('xapi_lrs.route')) {
return;
}
try {
switch ($request->attributes->get('xapi_serializer')) {
case 'statement':
$request->attributes->set('statement', $this->statementSerializer->deserializeStatement($request->getContent()));
break;
}
} catch (BaseSerializerException $e) {
throw new BadRequestHttpException(sprintf('The content of the request cannot be deserialized into a valid xAPI %s.', $request->attributes->get('xapi_serializer')), $e);
}
}
}

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* This file is part of the xAPI package.
*
* (c) Christian Flothmann <christian.flothmann@xabbuh.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace XApi\LrsBundle\EventListener;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* @author Jérôme Parmentier <jerome.parmentier@acensi.fr>
*/
class VersionListener
{
public function onKernelRequest(GetResponseEvent $event): void
{
if (!$event->isMasterRequest()) {
return;
}
$request = $event->getRequest();
if (!$request->attributes->has('xapi_lrs.route')) {
return;
}
if (null === $version = $request->headers->get('X-Experience-API-Version')) {
throw new BadRequestHttpException('Missing required "X-Experience-API-Version" header.');
}
if (preg_match('/^1\.0(?:\.\d+)?$/', $version)) {
if ('1.0' === $version) {
$request->headers->set('X-Experience-API-Version', '1.0.0');
}
return;
}
throw new BadRequestHttpException(sprintf('xAPI version "%s" is not supported.', $version));
}
public function onKernelResponse(FilterResponseEvent $event): void
{
if (!$event->isMasterRequest()) {
return;
}
if (!$event->getRequest()->attributes->has('xapi_lrs.route')) {
return;
}
$headers = $event->getResponse()->headers;
if (!$headers->has('X-Experience-API-Version')) {
$headers->set('X-Experience-API-Version', '1.0.3');
}
}
}

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/*
* This file is part of the xAPI package.
*
* (c) Christian Flothmann <christian.flothmann@xabbuh.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace XApi\LrsBundle\Model;
use DateTime;
use Symfony\Component\HttpFoundation\ParameterBag;
use Xabbuh\XApi\Model\Activity;
use Xabbuh\XApi\Model\IRI;
use Xabbuh\XApi\Model\StatementsFilter;
use Xabbuh\XApi\Model\Verb;
use Xabbuh\XApi\Serializer\ActorSerializerInterface;
use const FILTER_VALIDATE_BOOLEAN;
/**
* @author Jérôme Parmentier <jerome.parmentier@acensi.fr>
*/
class StatementsFilterFactory
{
private $actorSerializer;
public function __construct(ActorSerializerInterface $actorSerializer)
{
$this->actorSerializer = $actorSerializer;
}
/**
* @return StatementsFilter
*/
public function createFromParameterBag(ParameterBag $parameters)
{
$filter = new StatementsFilter();
if (($actor = $parameters->get('agent')) !== null) {
$filter->byActor($this->actorSerializer->deserializeActor($actor));
}
if (($verbId = $parameters->get('verb')) !== null) {
$filter->byVerb(new Verb(IRI::fromString($verbId)));
}
if (($activityId = $parameters->get('activity')) !== null) {
$filter->byActivity(new Activity(IRI::fromString($activityId)));
}
if (($registration = $parameters->get('registration')) !== null) {
$filter->byRegistration($registration);
}
if ($parameters->filter('related_activities', false, FILTER_VALIDATE_BOOLEAN)) {
$filter->enableRelatedActivityFilter();
} else {
$filter->disableRelatedActivityFilter();
}
if ($parameters->filter('related_agents', false, FILTER_VALIDATE_BOOLEAN)) {
$filter->enableRelatedAgentFilter();
} else {
$filter->disableRelatedAgentFilter();
}
if (($since = $parameters->get('since')) !== null) {
$filter->since(DateTime::createFromFormat(DateTime::ATOM, $since));
}
if (($until = $parameters->get('until')) !== null) {
$filter->until(DateTime::createFromFormat(DateTime::ATOM, $until));
}
if ($parameters->filter('ascending', false, FILTER_VALIDATE_BOOLEAN)) {
$filter->ascending();
} else {
$filter->descending();
}
$filter->cursor($parameters->getInt('cursor'));
$filter->limit($parameters->getInt('limit'));
return $filter;
}
}

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services
http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="xapi_lrs.controller.statement.get" class="XApi\LrsBundle\Controller\StatementGetController">
<argument type="service" id="xapi_lrs.repository.statement"/>
<argument type="service" id="xapi_lrs.statement.serializer"/>
<argument type="service" id="xapi_lrs.statement_result.serializer"/>
<argument type="service" id="xapi_lrs.factory.statements_filter"/>
</service>
<service id="xapi_lrs.controller.statement.post" class="XApi\LrsBundle\Controller\StatementPostController"/>
<service id="xapi_lrs.controller.statement.put" class="XApi\LrsBundle\Controller\StatementPutController">
<argument type="service" id="xapi_lrs.repository.statement"/>
</service>
</services>
</container>

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services
http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="xapi_lrs.repository.statement.doctrine" class="XApi\Repository\Doctrine\Repository\StatementRepository" public="false">
<argument type="service" id="xapi_lrs.repository.mapped_statement" />
</service>
</services>
</container>

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services
http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="xapi_lrs.event_listener.alternate_request_syntax" class="XApi\LrsBundle\EventListener\AlternateRequestSyntaxListener">
<tag name="kernel.event_listener" event="kernel.request" />
</service>
<service id="xapi_lrs.event_listener.exception" class="XApi\LrsBundle\EventListener\ExceptionListener">
</service>
<service id="xapi_lrs.event_listener.serializer" class="XApi\LrsBundle\EventListener\SerializerListener">
<argument type="service" id="xapi_lrs.statement.serializer" />
<tag name="kernel.event_listener" event="kernel.request" />
</service>
<service id="xapi_lrs.event_listener.version" class="XApi\LrsBundle\EventListener\VersionListener">
<tag name="kernel.event_listener" event="kernel.request" />
<tag name="kernel.event_listener" event="kernel.response" />
</service>
</services>
</container>

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services
http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="xapi_lrs.factory.statements_filter" class="XApi\LrsBundle\Model\StatementsFilterFactory">
<argument type="service" id="xapi_lrs.actor.serializer"/>
</service>
</services>
</container>

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services
http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="xapi_lrs.doctrine.class_metadata" class="Doctrine\ORM\Mapping\ClassMetadata" public="false">
<argument>XApi\Repository\Api\Mapping\MappedStatement</argument>
<factory service="xapi_lrs.doctrine.object_manager" method="getClassMetadata" />
</service>
<service id="xapi_lrs.repository.mapped_statement" class="XApi\Repository\ORM\MappedStatementRepository" public="false">
<argument type="service" id="xapi_lrs.doctrine.object_manager" />
<argument type="service" id="xapi_lrs.doctrine.class_metadata" />
</service>
</services>
</container>

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" ?>
<routes xmlns="http://symfony.com/schema/routing"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/routing
http://symfony.com/schema/routing/routing-1.0.xsd">
<route id="xapi_lrs.statement.put" path="/statements" methods="PUT">
<default key="_controller">xapi_lrs.controller.statement.put:putStatement</default>
<default key="xapi_serializer">statement</default>
<default key="xapi_lrs.route">
<bool>true</bool>
</default>
</route>
<route id="xapi_lrs.statement.post" path="/statements" methods="POST">
<default key="_controller">xapi_lrs.controller.statement.post:postStatements</default>
<default key="xapi_serializer">statement</default>
<default key="xapi_lrs.route">
<bool>true</bool>
</default>
</route>
<route id="xapi_lrs.statement.get" path="/statements" methods="GET">
<default key="_controller">xapi_lrs.controller.statement.get:getStatement</default>
<default key="xapi_lrs.route">
<bool>true</bool>
</default>
</route>
</routes>

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services
http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="xapi_lrs.statement.serializer" class="Xabbuh\XApi\Serializer\StatementSerializerInterface" public="false">
<factory service="xapi_lrs.serializer.factory" method="createStatementSerializer"/>
</service>
<service id="xapi_lrs.statement_result.serializer" class="Xabbuh\XApi\Serializer\StatementResultSerializerInterface" public="false">
<factory service="xapi_lrs.serializer.factory" method="createStatementResultSerializer"/>
</service>
<service id="xapi_lrs.actor.serializer" class="Xabbuh\XApi\Serializer\ActorSerializerInterface" public="false">
<factory service="xapi_lrs.serializer.factory" method="createActorSerializer"/>
</service>
<service id="xapi_lrs.document_data.serializer" class="Xabbuh\XApi\Serializer\DocumentDataSerializerInterface" public="false">
<factory service="xapi_lrs.serializer.factory" method="createDocumentDataSerializer"/>
</service>
<service id="xapi_lrs.serializer_factory" class="Xabbuh\XApi\Serializer\Symfony\SerializerFactory" public="false">
<argument type="service" id="xapi_lrs.serializer"/>
</service>
<service id="xapi_lrs.serializer" class="Symfony\Component\Serializer\SerializerInterface" public="false">
<factory class="Xabbuh\XApi\Serializer\Symfony\Serializer" method="createSerializer"/>
</service>
</services>
</container>

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/*
* This file is part of the xAPI package.
*
* (c) Christian Flothmann <christian.flothmann@xabbuh.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace XApi\LrsBundle\Response;
use LogicException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Xabbuh\XApi\Model\Attachment;
/**
* @author Jérôme Parmentier <jerome.parmentier@acensi.fr>
*/
class AttachmentResponse extends Response
{
protected $attachment;
public function __construct(Attachment $attachment)
{
parent::__construct(null);
$this->attachment = $attachment;
}
public function prepare(Request $request): void
{
if (!$this->headers->has('Content-Type')) {
$this->headers->set('Content-Type', $this->attachment->getContentType());
}
$this->headers->set('Content-Transfer-Encoding', 'binary');
$this->headers->set('X-Experience-API-Hash', $this->attachment->getSha2());
}
/**
* @throws LogicException
*/
public function sendContent(): void
{
throw new LogicException('An AttachmentResponse is only meant to be part of a multipart Response.');
}
/**
* @throws LogicException when the content is not null
*/
public function setContent($content): void
{
if (null !== $content) {
throw new LogicException('The content cannot be set on an AttachmentResponse instance.');
}
}
/**
* @return string|null
*/
public function getContent()
{
return $this->attachment->getContent();
}
}

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
/*
* This file is part of the xAPI package.
*
* (c) Christian Flothmann <christian.flothmann@xabbuh.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace XApi\LrsBundle\Response;
use LogicException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @author Jérôme Parmentier <jerome.parmentier@acensi.fr>
*/
class MultipartResponse extends Response
{
protected $subtype;
protected $boundary;
protected $statementPart;
/**
* @var Response[]
*/
protected $parts;
/**
* @param AttachmentResponse[] $attachmentsParts
* @param int $status
* @param string|null $subtype
*/
public function __construct(JsonResponse $statementPart, array $attachmentsParts = [], $status = 200, array $headers = [], $subtype = null)
{
parent::__construct(null, $status, $headers);
if (null === $subtype) {
$subtype = 'mixed';
}
$this->subtype = $subtype;
$this->boundary = uniqid('', true);
$this->statementPart = $statementPart;
$this->setAttachmentsParts($attachmentsParts);
}
/**
* @return $this
*/
public function addAttachmentPart(AttachmentResponse $part)
{
if (null !== $part->getContent()) {
$this->parts[] = $part;
}
return $this;
}
/**
* @param AttachmentResponse[] $attachmentsParts
*
* @return $this
*/
public function setAttachmentsParts(array $attachmentsParts)
{
$this->parts = [$this->statementPart];
foreach ($attachmentsParts as $part) {
$this->addAttachmentPart($part);
}
return $this;
}
public function prepare(Request $request)
{
foreach ($this->parts as $part) {
$part->prepare($request);
}
$this->headers->set('Content-Type', sprintf('multipart/%s; boundary="%s"', $this->subtype, $this->boundary));
$this->headers->set('Transfer-Encoding', 'chunked');
return parent::prepare($request);
}
public function sendContent()
{
$content = '';
foreach ($this->parts as $part) {
$content .= sprintf('--%s', $this->boundary)."\r\n";
$content .= $part->headers."\r\n";
$content .= $part->getContent();
$content .= "\r\n";
}
$content .= sprintf('--%s--', $this->boundary)."\r\n";
echo $content;
return $this;
}
/**
* @throws LogicException when the content is not null
*/
public function setContent($content): void
{
if (null !== $content) {
throw new LogicException('The content cannot be set on a MultipartResponse instance.');
}
}
/**
* @return false
*/
public function getContent()
{
return false;
}
}

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/*
* This file is part of the xAPI package.
*
* (c) Christian Flothmann <christian.flothmann@xabbuh.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace XApi\LrsBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
use XApi\LrsBundle\DependencyInjection\XApiLrsExtension;
/**
* @author Christian Flothmann <christian.flothmann@xabbuh.de>
*/
class XApiLrsBundle extends Bundle
{
public function getContainerExtension()
{
return new XApiLrsExtension();
}
}

@ -0,0 +1,19 @@
Copyright (c) 2016-2017 Christian Flothmann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

@ -0,0 +1,41 @@
{
"name": "php-xapi/repository-doctrine-orm",
"description": "Doctrine based ORM implementations of an Experience API (xAPI) repository",
"keywords": ["xAPI", "Tin Can API", "Experience API", "storage", "database", "repository", "entity", "Doctrine", "ORM"],
"homepage": "https://github.com/php-xapi/repository-orm/",
"license": "MIT",
"authors": [
{
"name": "Christian Flothmann",
"homepage": "https://github.com/xabbuh"
}
],
"require": {
"php": "^5.6 || ^7.0",
"doctrine/orm": "^2.3",
"php-xapi/repository-api": "^0.4",
"php-xapi/repository-doctrine": "^0.4"
},
"require-dev": {
"symfony/phpunit-bridge": "^3.4 || ^4.0"
},
"provide": {
"php-xapi/repository-implementation": "0.3"
},
"autoload": {
"psr-4": {
"XApi\\Repository\\ORM\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"XApi\\Repository\\ORM\\Tests\\": "tests/"
}
},
"minimum-stability": "dev",
"extra": {
"branch-alias": {
"dev-master": "0.1.x-dev"
}
}
}

@ -0,0 +1,19 @@
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="XApi\Repository\Doctrine\Mapping\Actor" table="xapi_actor">
<id name="identifier" type="integer">
<generator strategy="AUTO" />
</id>
<field name="type" type="string" nullable="true" />
<field name="mbox" type="string" nullable="true" />
<field name="mboxSha1Sum" type="string" nullable="true" />
<field name="openId" type="string" nullable="true" />
<field name="accountName" type="string" nullable="true" />
<field name="accountHomePage" type="string" nullable="true" />
<field name="name" type="string" nullable="true" />
</entity>
</doctrine-mapping>

@ -0,0 +1,23 @@
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="XApi\Repository\Doctrine\Mapping\Attachment" table="xapi_attachment">
<id name="identifier" type="integer">
<generator strategy="AUTO" />
</id>
<field name="usageType" type="string" />
<field name="contentType" type="string" />
<field name="length" type="integer" />
<field name="sha2" type="string" />
<field name="display" type="json_array" />
<field name="hasDescription" type="boolean" />
<field name="description" type="json_array" nullable="true" />
<field name="fileUrl" type="string" nullable="true" />
<field name="content" type="text" nullable="true" />
<many-to-one field="statement" target-entity="XApi\Repository\Doctrine\Mapping\Statement" inversed-by="attachments" />
</entity>
</doctrine-mapping>

@ -0,0 +1,59 @@
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="XApi\Repository\Doctrine\Mapping\Context" table="xapi_context">
<id name="identifier" type="integer">
<generator strategy="AUTO" />
</id>
<field name="registration" type="string" nullable="true" />
<field name="hasContextActivities" type="boolean" nullable="true" />
<field name="revision" type="string" nullable="true" />
<field name="platform" type="string" nullable="true" />
<field name="language" type="string" nullable="true" />
<field name="statement" type="string" nullable="true" />
<one-to-one field="instructor" target-entity="XApi\Repository\Doctrine\Mapping\StatementObject">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
<one-to-one field="team" target-entity="XApi\Repository\Doctrine\Mapping\StatementObject">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
<one-to-one field="extensions" target-entity="XApi\Repository\Doctrine\Mapping\Extensions">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
<!-- context activities -->
<one-to-many field="parentActivities" target-entity="XApi\Repository\Doctrine\Mapping\StatementObject" mapped-by="parentContext">
<cascade>
<cascade-all />
</cascade>
</one-to-many>
<one-to-many field="groupingActivities" target-entity="XApi\Repository\Doctrine\Mapping\StatementObject" mapped-by="groupingContext">
<cascade>
<cascade-all />
</cascade>
</one-to-many>
<one-to-many field="categoryActivities" target-entity="XApi\Repository\Doctrine\Mapping\StatementObject" mapped-by="categoryContext">
<cascade>
<cascade-all />
</cascade>
</one-to-many>
<one-to-many field="otherActivities" target-entity="XApi\Repository\Doctrine\Mapping\StatementObject" mapped-by="otherContext">
<cascade>
<cascade-all />
</cascade>
</one-to-many>
</entity>
</doctrine-mapping>

@ -0,0 +1,13 @@
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="XApi\Repository\Doctrine\Mapping\Extensions" table="xapi_extensions">
<id name="identifier" type="integer">
<generator strategy="AUTO" />
</id>
<field name="extensions" type="json_array" />
</entity>
</doctrine-mapping>

@ -0,0 +1,28 @@
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="XApi\Repository\Doctrine\Mapping\Result" table="xapi_result">
<id name="identifier" type="integer">
<generator strategy="AUTO" />
</id>
<field name="hasScore" type="boolean" />
<field name="scaled" type="float" nullable="true" />
<field name="raw" type="float" nullable="true" />
<field name="min" type="float" nullable="true" />
<field name="max" type="float" nullable="true" />
<field name="success" type="boolean" nullable="true" />
<field name="completion" type="boolean" nullable="true" />
<field name="response" type="string" nullable="true" />
<field name="duration" type="string" nullable="true" />
<one-to-one field="extensions" target-entity="XApi\Repository\Doctrine\Mapping\Extensions">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
</entity>
</doctrine-mapping>

@ -0,0 +1,62 @@
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="XApi\Repository\Doctrine\Mapping\Statement"
repository-class="XApi\Repository\ORM\StatementRepository"
table="xapi_statement">
<id name="id" type="string">
<generator strategy="NONE" />
</id>
<field name="created" type="bigint" nullable="true" />
<field name="stored" type="bigint" nullable="true" />
<field name="hasAttachments" type="boolean" />
<one-to-one field="actor" target-entity="XApi\Repository\Doctrine\Mapping\StatementObject">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
<one-to-one field="verb" target-entity="XApi\Repository\Doctrine\Mapping\Verb">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
<one-to-one field="object" target-entity="XApi\Repository\Doctrine\Mapping\StatementObject">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
<one-to-one field="result" target-entity="XApi\Repository\Doctrine\Mapping\Result">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
<one-to-one field="authority" target-entity="XApi\Repository\Doctrine\Mapping\StatementObject">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
<one-to-one field="context" target-entity="XApi\Repository\Doctrine\Mapping\Context">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
<!-- attachments -->
<one-to-many field="attachments" target-entity="XApi\Repository\Doctrine\Mapping\Attachment" mapped-by="statement">
<cascade>
<cascade-all />
</cascade>
</one-to-many>
</entity>
</doctrine-mapping>

@ -0,0 +1,83 @@
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="XApi\Repository\Doctrine\Mapping\StatementObject" table="xapi_object">
<id name="identifier" type="integer">
<generator strategy="AUTO" />
</id>
<!-- discriminator column -->
<field name="type" type="string" nullable="true" />
<!-- activity -->
<field name="activityId" type="string" nullable="true" />
<field name="hasActivityDefinition" type="boolean" nullable="true" />
<field name="hasActivityName" type="boolean" nullable="true" />
<field name="activityName" type="json_array" nullable="true" />
<field name="hasActivityDescription" type="boolean" nullable="true" />
<field name="activityDescription" type="json_array" nullable="true" />
<field name="activityType" type="string" nullable="true" />
<field name="activityMoreInfo" type="string" nullable="true" />
<!-- actor -->
<field name="mbox" type="string" nullable="true" />
<field name="mboxSha1Sum" type="string" nullable="true" />
<field name="openId" type="string" nullable="true" />
<field name="accountName" type="string" nullable="true" />
<field name="accountHomePage" type="string" nullable="true" />
<field name="name" type="string" nullable="true" />
<!-- statement reference -->
<field name="referencedStatementId" type="string" nullable="true" />
<!-- sub statement -->
<one-to-one field="actor" target-entity="XApi\Repository\Doctrine\Mapping\StatementObject">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
<one-to-one field="verb" target-entity="XApi\Repository\Doctrine\Mapping\Verb">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
<one-to-one field="object" target-entity="XApi\Repository\Doctrine\Mapping\StatementObject">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
<!-- activity extensions -->
<one-to-one field="activityExtensions" target-entity="XApi\Repository\Doctrine\Mapping\Extensions">
<cascade>
<cascade-all />
</cascade>
<join-column referenced-column-name="identifier" />
</one-to-one>
<!-- group members -->
<one-to-many target-entity="XApi\Repository\Doctrine\Mapping\StatementObject" mapped-by="group" field="members" />
<many-to-one target-entity="XApi\Repository\Doctrine\Mapping\StatementObject" field="group" inversed-by="members">
<join-column referenced-column-name="identifier" />
</many-to-one>
<!-- context activities -->
<many-to-one target-entity="XApi\Repository\Doctrine\Mapping\Context" field="parentContext" inversed-by="parentActivities">
<join-column referenced-column-name="identifier" />
</many-to-one>
<many-to-one target-entity="XApi\Repository\Doctrine\Mapping\Context" field="groupingContext" inversed-by="groupingActivities">
<join-column referenced-column-name="identifier" />
</many-to-one>
<many-to-one target-entity="XApi\Repository\Doctrine\Mapping\Context" field="categoryContext" inversed-by="categoryActivities">
<join-column referenced-column-name="identifier" />
</many-to-one>
<many-to-one target-entity="XApi\Repository\Doctrine\Mapping\Context" field="otherContext" inversed-by="otherActivities">
<join-column referenced-column-name="identifier" />
</many-to-one>
</entity>
</doctrine-mapping>

@ -0,0 +1,14 @@
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="XApi\Repository\Doctrine\Mapping\Verb" table="xapi_verb">
<id name="identifier" type="integer">
<generator strategy="AUTO" />
</id>
<field name="id" type="string" />
<field name="display" type="json_array" />
</entity>
</doctrine-mapping>

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/*
* This file is part of the xAPI package.
*
* (c) Christian Flothmann <christian.flothmann@xabbuh.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace XApi\Repository\ORM;
use Doctrine\ORM\EntityRepository;
use XApi\Repository\Doctrine\Mapping\Context;
use XApi\Repository\Doctrine\Mapping\Statement;
use XApi\Repository\Doctrine\Repository\Mapping\StatementRepository as BaseStatementRepository;
/**
* @author Christian Flothmann <christian.flothmann@xabbuh.de>
*/
final class StatementRepository extends EntityRepository implements BaseStatementRepository
{
public function findStatement(array $criteria)
{
return parent::findOneBy($criteria);
}
public function findStatements(array $criteria)
{
if (!empty($criteria['registration'])) {
$contexts = $this->_em->getRepository(Context::class)->findBy([
'registration' => $criteria['registration'],
]);
$criteria['context'] = $contexts;
}
if (!empty($criteria['verb'])) {
$verbs = $this->_em->getRepository(Verb::class)->findBy(['id' => $criteria['verb']]);
$criteria['verb'] = $verbs;
}
unset(
$criteria['registration'],
$criteria['related_activities'],
$criteria['related_agents'],
$criteria['ascending'],
);
return parent::findBy(
$criteria,
['created' => 'ASC'],
$criteria['limit'] ?? null,
$criteria['cursor'] ?? null
);
}
public function storeStatement(Statement $mappedStatement, $flush = true): void
{
$this->_em->persist($mappedStatement);
if ($flush) {
$this->_em->flush();
}
}
}

@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
$plugin_info = XApiPlugin::create()->get_info();

@ -0,0 +1,64 @@
<?php
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\XApiSharedStatement;
use Xabbuh\XApi\Model\Statement;
use Xabbuh\XApi\Serializer\Symfony\Serializer;
use Xabbuh\XApi\Serializer\Symfony\StatementSerializer;
/**
* Class XApiActivityHookObserver.
*/
abstract class XApiActivityHookObserver extends HookObserver
{
/**
* @var \XApiPlugin
*/
protected $plugin;
/**
* XApiActivityHookObserver constructor.
*/
protected function __construct()
{
parent::__construct(
'plugin/xapi/src/XApiPlugin.php',
'xapi'
);
$this->plugin = XApiPlugin::create();
}
/**
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
protected function saveSharedStatement(Statement $statement): XApiSharedStatement
{
$statementSerialized = $this->serializeStatement($statement);
$sharedStmt = new XApiSharedStatement(
json_decode($statementSerialized, true)
);
$em = Database::getManager();
$em->persist($sharedStmt);
$em->flush();
return $sharedStmt;
}
/**
* Serialize a statement to JSON.
*
* @return string
*/
private function serializeStatement(Statement $statement)
{
$serializer = Serializer::createSerializer();
$statementSerializer = new StatementSerializer($serializer);
return $statementSerializer->serializeStatement($statement);
}
}

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
class XApiCreateCourseHookObserver extends HookObserver implements HookCreateCourseObserverInterface
{
protected function __construct()
{
parent::__construct(
'plugin/xapi/src/XApiPlugin.php',
'xapi'
);
}
public function hookCreateCourse(HookCreateCourseEventInterface $hook): void
{
$data = $hook->getEventData();
$type = $data['type'];
$courseInfo = $data['course_info'];
$plugin = XApiPlugin::create();
if (HOOK_EVENT_TYPE_POST == $type) {
$plugin->addCourseToolForTinCan($courseInfo['real_id']);
}
}
}

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\PluginBundle\XApi\ToolExperience\Statement\LearningPathCompleted;
class XApiLearningPathEndHookObserver extends XApiActivityHookObserver implements HookLearningPathEndObserverInterface
{
public function notifyLearningPathEnd(HookLearningPathEndEventInterface $event): void
{
$data = $event->getEventData();
$em = Database::getManager();
$lpView = $em->find('ChamiloCourseBundle:CLpView', $data['lp_view_id']);
$lp = $em->find('ChamiloCourseBundle:CLp', $lpView->getLpId());
$learningPathEnded = new LearningPathCompleted($lpView, $lp);
$statement = $learningPathEnded->generate();
$this->saveSharedStatement($statement);
}
}

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\PluginBundle\XApi\ToolExperience\Statement\LearningPathItemViewed;
class XApiLearningPathItemViewedHookObserver extends XApiActivityHookObserver implements HookLearningPathItemViewedObserverInterface
{
public function hookLearningPathItemViewed(HookLearningPathItemViewedEventInterface $event)
{
$data = $event->getEventData();
$em = Database::getManager();
$lpItemView = $em->find('ChamiloCourseBundle:CLpItemView', $data['item_view_id']);
$lpItem = $em->find('ChamiloCourseBundle:CLpItem', $lpItemView->getLpItemId());
if ('quiz' == $lpItem->getItemType()) {
return null;
}
$lpView = $em->find('ChamiloCourseBundle:CLpView', $lpItemView->getLpViewId());
$lpItemViewed = new LearningPathItemViewed($lpItemView, $lpItem, $lpView);
$statement = $lpItemViewed->generate();
$this->saveSharedStatement($statement);
}
}

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\PortfolioComment;
use Chamilo\PluginBundle\XApi\ToolExperience\Statement\PortfolioCommentEdited;
class XApiPortfolioCommentEditedHookObserver extends XApiActivityHookObserver implements HookPortfolioCommentEditedObserverInterface
{
public function hookCommentEdited(HookPortfolioCommentEditedEventInterface $hookEvent): void
{
/** @var PortfolioComment $comment */
$comment = $hookEvent->getEventData()['comment'];
$statement = (new PortfolioCommentEdited($comment))->generate();
$this->saveSharedStatement($statement);
}
}

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\PortfolioComment;
use Chamilo\PluginBundle\XApi\ToolExperience\Statement\PortfolioCommentScored;
class XApiPortfolioCommentScoredHookObserver extends XApiActivityHookObserver implements HookPortfolioCommentScoredObserverInterface
{
public function hookCommentScored(HookPortfolioCommentScoredEventInterface $hookEvent): void
{
/** @var PortfolioComment $comment */
$comment = $hookEvent->getEventData()['comment'];
$statement = (new PortfolioCommentScored($comment))->generate();
$this->saveSharedStatement($statement);
}
}

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use Chamilo\PluginBundle\XApi\ToolExperience\Statement\PortfolioDownloaded;
use Chamilo\UserBundle\Entity\User;
class XApiPortfolioDownloadedHookObserver extends XApiActivityHookObserver implements HookPortfolioDownloadedObserverInterface
{
public function hookPortfolioDownloaded(HookPortfolioDownloadedEventInterface $hookEvent): void
{
/** @var User $owner */
$owner = $hookEvent->getEventData()['owner'];
$statement = (new PortfolioDownloaded($owner))->generate();
$this->saveSharedStatement($statement);
}
}

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\PluginBundle\XApi\ToolExperience\Statement\PortfolioItemShared;
class XApiPortfolioItemAddedHookObserver extends XApiActivityHookObserver implements HookPortfolioItemAddedObserverInterface
{
public function hookItemAdded(HookPortfolioItemAddedEventInterface $hookEvent): void
{
$item = $hookEvent->getEventData()['portfolio'];
$portfolioItemShared = new PortfolioItemShared($item);
$statement = $portfolioItemShared->generate();
$this->saveSharedStatement($statement);
}
}

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\PluginBundle\XApi\ToolExperience\Statement\PortfolioItemCommented;
class XApiPortfolioItemCommentedHookObserver extends XApiActivityHookObserver implements HookPortfolioItemCommentedObserverInterface
{
public function hookItemCommented(HookPortfolioItemCommentedEventInterface $hookEvent): void
{
$comment = $hookEvent->getEventData()['comment'];
$portfolioItemCommented = new PortfolioItemCommented($comment);
$statement = $portfolioItemCommented->generate();
$this->saveSharedStatement($statement);
}
}

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\Portfolio;
use Chamilo\PluginBundle\XApi\ToolExperience\Statement\PortfolioItemEdited;
class XApiPortfolioItemEditedHookObserver extends XApiActivityHookObserver implements HookPortfolioItemEditedObserverInterface
{
public function hookItemEdited(HookPortfolioItemEditedEventInterface $hookEvent): void
{
/** @var Portfolio $item */
$item = $hookEvent->getEventData()['item'];
$statement = (new PortfolioItemEdited($item))->generate();
$this->saveSharedStatement($statement);
}
}

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Chamilo\CoreBundle\Entity\Portfolio;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\ORM\ORMException;
class XApiPortfolioItemHighlightedHookObserver extends XApiActivityHookObserver implements HookPortfolioItemHighlightedObserverInterface
{
/**
* @throws OptimisticLockException
* @throws ORMException
*/
public function hookItemHighlighted(HookPortfolioItemHighlightedEventInterface $hookEvent): void
{
/** @var Portfolio $item */
$item = $hookEvent->getEventData()['item'];
$statement = (new PortfolioItemHighlighted($item))->generate();
$this->saveSharedStatement($statement);
}
}

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\Portfolio;
use Chamilo\PluginBundle\XApi\ToolExperience\Statement\PortfolioItemScored;
class XApiPortfolioItemScoredHookObserver extends XApiActivityHookObserver implements HookPortfolioItemScoredObserverInterface
{
public function hookItemScored(HookPortfolioItemScoredEventInterface $hookEvent): void
{
/** @var Portfolio $item */
$item = $hookEvent->getEventData()['item'];
$statement = (new PortfolioItemScored($item))->generate();
$this->saveSharedStatement($statement);
}
}

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\Portfolio;
use Chamilo\PluginBundle\XApi\ToolExperience\Statement\PortfolioItemViewed;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\ORM\ORMException;
class XApiPortfolioItemViewedHookObserver extends XApiActivityHookObserver implements HookPortfolioItemViewedObserverInterface
{
/**
* @throws OptimisticLockException
* @throws ORMException
*/
public function hookItemViewed(HookPortfolioItemViewedEventInterface $hookEvent): void
{
/** @var Portfolio $item */
$item = $hookEvent->getEventData()['portfolio'];
$statement = (new PortfolioItemViewed($item))->generate();
$this->saveSharedStatement($statement);
}
}

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\PluginBundle\XApi\ToolExperience\Statement\QuizCompleted;
class XApiQuizEndHookObserver extends XApiActivityHookObserver implements HookQuizEndObserverInterface
{
public function hookQuizEnd(HookQuizEndEventInterface $hookEvent): void
{
$data = $hookEvent->getEventData();
$em = Database::getManager();
$exe = $em->find('ChamiloCoreBundle:TrackEExercises', $data['exe_id']);
$quiz = $em->find('ChamiloCourseBundle:CQuiz', $exe->getExeExoId());
$quizCompleted = new QuizCompleted($exe, $quiz);
$statement = $quizCompleted->generate();
$this->saveSharedStatement($statement);
}
}

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\TrackEAttempt;
use Chamilo\CoreBundle\Entity\TrackEExercises;
use Chamilo\CourseBundle\Entity\CQuiz;
use Chamilo\CourseBundle\Entity\CQuizQuestion;
use Chamilo\PluginBundle\XApi\ToolExperience\Statement\QuizQuestionAnswered;
class XApiQuizQuestionAnsweredHookObserver extends XApiActivityHookObserver implements HookQuizQuestionAnsweredObserverInterface
{
public function hookQuizQuestionAnswered(HookQuizQuestionAnsweredEventInterface $event): void
{
$data = $event->getEventData();
$em = Database::getManager();
$attemptRepo = $em->getRepository(TrackEAttempt::class);
$exe = $em->find(TrackEExercises::class, $data['exe_id']);
$question = $em->find(CQuizQuestion::class, $data['question']['id']);
$attempt = $attemptRepo->findOneBy(
[
'exeId' => $exe->getExeId(),
'questionId' => $question->getId(),
]
);
$quiz = $em->find(CQuiz::class, $data['quiz']['id']);
$quizQuestionAnswered = new QuizQuestionAnswered($attempt, $question, $quiz);
$statement = $quizQuestionAnswered->generate();
$this->saveSharedStatement($statement);
}
}

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\PluginBundle\XApi\Importer;
use Chamilo\CoreBundle\Entity\Course;
use Exception;
/**
* Class AbstractImporter.
*/
abstract class PackageImporter
{
/**
* @var Course
*/
protected $course;
/**
* @var string
*/
protected $courseDirectoryPath;
/**
* @var array
*/
protected $packageFileInfo;
/**
* @var string
*/
protected $packageType;
protected function __construct(array $fileInfo, Course $course)
{
$this->packageFileInfo = $fileInfo;
$this->course = $course;
$this->courseDirectoryPath = api_get_path(SYS_COURSE_PATH).$this->course->getDirectory();
}
/**
* @return \Chamilo\PluginBundle\XApi\Importer\XmlPackageImporter|\Chamilo\PluginBundle\XApi\Importer\ZipPackageImporter
*/
public static function create(array $fileInfo, Course $course)
{
if ('text/xml' === $fileInfo['type']) {
return new XmlPackageImporter($fileInfo, $course);
}
return new ZipPackageImporter($fileInfo, $course);
}
/**
* @return mixed
*
* @throws Exception
*/
abstract public function import(): string;
public function getPackageType(): string
{
return $this->packageType;
}
}

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\PluginBundle\XApi\Importer;
use Exception;
/**
* Class XmlImporter.
*/
class XmlPackageImporter extends PackageImporter
{
public function import(): string
{
if (!\in_array($this->packageFileInfo['name'], ['tincan.xml', 'cmi5.xml'])) {
throw new Exception('Invalid package');
}
$this->packageType = explode('.', $this->packageFileInfo['name'], 2)[0];
return $this->packageFileInfo['tmp_name'];
}
}

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\PluginBundle\XApi\Importer;
use DocumentManager;
use Exception;
use PclZip;
use Symfony\Component\Filesystem\Filesystem;
/**
* Class ZipImporter.
*/
class ZipPackageImporter extends PackageImporter
{
public function import(): string
{
$zipFile = new PclZip($this->packageFileInfo['tmp_name']);
$zipContent = $zipFile->listContent();
$packageSize = array_reduce(
$zipContent,
function ($accumulator, $zipEntry) {
if (preg_match('~.(php.*|phtml)$~i', $zipEntry['filename'])) {
throw new Exception("File \"{$zipEntry['filename']}\" contains a PHP script");
}
if (\in_array($zipEntry['filename'], ['tincan.xml', 'cmi5.xml'])) {
$this->packageType = explode('.', $zipEntry['filename'], 2)[0];
}
return $accumulator + $zipEntry['size'];
}
);
if (empty($this->packageType)) {
throw new Exception('Invalid package');
}
$this->validateEnoughSpace($packageSize);
$pathInfo = pathinfo($this->packageFileInfo['name']);
$packageDirectoryPath = $this->generatePackageDirectory($pathInfo['filename']);
$zipFile->extract($packageDirectoryPath);
return "$packageDirectoryPath/{$this->packageType}.xml";
}
/**
* @throws Exception
*/
protected function validateEnoughSpace(int $packageSize): void
{
$courseSpaceQuota = DocumentManager::get_course_quota($this->course->getCode());
if (!enough_size($packageSize, $this->courseDirectoryPath, $courseSpaceQuota)) {
throw new Exception('Not enough space to storage package.');
}
}
private function generatePackageDirectory(string $name): string
{
$directoryPath = implode(
'/',
[
$this->courseDirectoryPath,
$this->packageType,
api_replace_dangerous_char($name),
]
);
$fs = new Filesystem();
$fs->mkdir(
$directoryPath,
api_get_permissions_for_new_directories()
);
return $directoryPath;
}
}

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\PluginBundle\XApi\Lrs;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Class AboutController.
*/
class AboutController extends BaseController
{
public function get(): Response
{
$json = [
'version' => [
'1.0.3',
'1.0.2',
'1.0.1',
'1.0.0',
],
];
return JsonResponse::create($json);
}
}

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\PluginBundle\XApi\Lrs;
use Chamilo\CoreBundle\Entity\XApiActivityProfile;
use Database;
use Symfony\Component\HttpFoundation\Response;
/**
* Class ActivitiesProfileController.
*/
class ActivitiesProfileController extends BaseController
{
public function get(): Response
{
$profileId = $this->httpRequest->query->get('profileId');
$activityId = $this->httpRequest->query->get('activityId');
$em = Database::getManager();
$profileRepo = $em->getRepository(XApiActivityProfile::class);
/** @var XApiActivityProfile $activityProfile */
$activityProfile = $profileRepo->findOneBy(
[
'profileId' => $profileId,
'activityId' => $activityId,
]
);
if (empty($activityProfile)) {
return Response::create(null, Response::HTTP_NO_CONTENT);
}
return Response::create(
json_encode($activityProfile->getDocumentData())
);
}
public function head(): Response
{
return $this->get()->setContent('');
}
public function put(): Response
{
$profileId = $this->httpRequest->query->get('profileId');
$activityId = $this->httpRequest->query->get('activityId');
$documentData = $this->httpRequest->getContent();
$em = Database::getManager();
$profileRepo = $em->getRepository(XApiActivityProfile::class);
/** @var XApiActivityProfile $activityProfile */
$activityProfile = $profileRepo->findOneBy(
[
'profileId' => $profileId,
'activityId' => $activityId,
]
);
if (empty($activityProfile)) {
$activityProfile = new XApiActivityProfile();
$activityProfile
->setProfileId($profileId)
->setActivityId($activityId)
;
}
$activityProfile->setDocumentData(json_decode($documentData, true));
$em->persist($activityProfile);
$em->flush();
return Response::create(null, Response::HTTP_NO_CONTENT);
}
}

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\PluginBundle\XApi\Lrs;
use Chamilo\CoreBundle\Entity\XApiActivityState;
use Database;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Xabbuh\XApi\Model\Actor;
use Xabbuh\XApi\Serializer\Symfony\Serializer;
/**
* Class ActivitiesStateController.
*/
class ActivitiesStateController extends BaseController
{
public function get(): Response
{
$serializer = Serializer::createSerializer();
$requestedAgent = $this->httpRequest->query->get('agent');
$activityId = $this->httpRequest->query->get('activityId');
$stateId = $this->httpRequest->query->get('stateId');
$state = Database::select(
'*',
Database::get_main_table('xapi_activity_state'),
[
'where' => [
'state_id = ? AND activity_id = ? AND MD5(agent) = ?' => [
Database::escape_string($stateId),
Database::escape_string($activityId),
md5($requestedAgent),
],
],
],
'first'
);
if (empty($state)) {
return JsonResponse::create([], Response::HTTP_NOT_FOUND);
}
$requestedAgent = $serializer->deserialize(
$this->httpRequest->query->get('agent'),
Actor::class,
'json'
);
/** @var Actor $stateAgent */
$stateAgent = $serializer->deserialize(
$state['agent'],
Actor::class,
'json'
);
if (!$stateAgent->equals($requestedAgent)) {
return JsonResponse::create([], Response::HTTP_NOT_FOUND);
}
$documentData = json_decode($state['document_data'], true);
return JsonResponse::create($documentData);
}
public function head(): Response
{
return $this->get()->setContent('');
}
public function post(): Response
{
return $this->put();
}
public function put(): Response
{
$activityId = $this->httpRequest->query->get('activityId');
$agent = $this->httpRequest->query->get('agent');
$stateId = $this->httpRequest->query->get('stateId');
$documentData = $this->httpRequest->getContent();
$state = Database::select(
'id',
Database::get_main_table('xapi_activity_state'),
[
'where' => [
'state_id = ? AND activity_id = ? AND MD5(agent) = ?' => [
Database::escape_string($stateId),
Database::escape_string($activityId),
md5($agent),
],
],
],
'first'
);
$em = Database::getManager();
if (empty($state)) {
$state = new XApiActivityState();
$state
->setActivityId($activityId)
->setAgent(json_decode($agent, true))
->setStateId($stateId)
;
} else {
$state = $em->find(XApiActivityState::class, $state['id']);
}
$state->setDocumentData(json_decode($documentData, true));
$em->persist($state);
$em->flush();
return Response::create('', Response::HTTP_NO_CONTENT);
}
}

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\PluginBundle\XApi\Lrs;
use Symfony\Component\HttpFoundation\Request;
/**
* Class BaseController.
*/
abstract class BaseController
{
/**
* @var Request
*/
protected $httpRequest;
public function __construct(Request $httpRequest)
{
$this->httpRequest = $httpRequest;
}
}

@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\PluginBundle\XApi\Lrs;
use Chamilo\CoreBundle\Entity\XApiLrsAuth;
use Database;
use Exception;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Xabbuh\XApi\Common\Exception\AccessDeniedException;
use Xabbuh\XApi\Common\Exception\XApiException;
/**
* Class LrsRequest.
*/
class LrsRequest
{
/**
* @var HttpRequest
*/
private $request;
public function __construct()
{
$this->request = HttpRequest::createFromGlobals();
}
public function send(): void
{
try {
$this->alternateRequestSyntax();
$controllerName = $this->getControllerName();
$methodName = $this->getMethodName();
$response = $this->generateResponse($controllerName, $methodName);
} catch (XApiException $xApiException) {
$response = HttpResponse::create('', HttpResponse::HTTP_BAD_REQUEST);
} catch (HttpException $httpException) {
$response = HttpResponse::create(
$httpException->getMessage(),
$httpException->getStatusCode()
);
} catch (Exception $exception) {
$response = HttpResponse::create($exception->getMessage(), HttpResponse::HTTP_BAD_REQUEST);
}
$response->headers->set('X-Experience-API-Version', '1.0.3');
$response->send();
}
/**
* @throws AccessDeniedException
*/
private function validateAuth(): bool
{
if (!$this->request->headers->has('Authorization')) {
throw new AccessDeniedException();
}
$authHeader = $this->request->headers->get('Authorization');
$parts = explode('Basic ', $authHeader, 2);
if (empty($parts[1])) {
throw new AccessDeniedException();
}
$authDecoded = base64_decode($parts[1]);
$parts = explode(':', $authDecoded, 2);
if (empty($parts) || 2 !== \count($parts)) {
throw new AccessDeniedException();
}
list($username, $password) = $parts;
$auth = Database::getManager()
->getRepository(XApiLrsAuth::class)
->findOneBy(
['username' => $username, 'password' => $password, 'enabled' => true]
)
;
if (null == $auth) {
throw new AccessDeniedException();
}
return true;
}
private function validateVersion(): void
{
$version = $this->request->headers->get('X-Experience-API-Version');
if (null === $version) {
throw new BadRequestHttpException('The "X-Experience-API-Version" header is required.');
}
if (preg_match('/^1\.0(?:\.\d+)?$/', $version)) {
if ('1.0' === $version) {
$this->request->headers->set('X-Experience-API-Version', '1.0.0');
}
return;
}
throw new BadRequestHttpException("The xAPI version \"$version\" is not supported.");
}
private function getControllerName(): ?string
{
$segments = explode('/', $this->request->getPathInfo());
$segments = array_filter($segments);
$segments = array_values($segments);
if (empty($segments)) {
throw new BadRequestHttpException('Bad request');
}
$segments = array_map('ucfirst', $segments);
$controllerName = implode('', $segments).'Controller';
return "Chamilo\\PluginBundle\\XApi\\Lrs\\$controllerName";
}
private function getMethodName(): string
{
$method = $this->request->getMethod();
return strtolower($method);
}
/**
* @throws AccessDeniedException
*/
private function generateResponse(string $controllerName, string $methodName): HttpResponse
{
if (!class_exists($controllerName)
|| !method_exists($controllerName, $methodName)
) {
throw new NotFoundHttpException();
}
if (AboutController::class !== $controllerName) {
$this->validateAuth();
$this->validateVersion();
}
/** @var HttpResponse $response */
return \call_user_func(
[
new $controllerName($this->request),
$methodName,
]
);
}
private function alternateRequestSyntax(): void
{
if ('POST' !== $this->request->getMethod()) {
return;
}
if (null === $method = $this->request->query->get('method')) {
return;
}
if ($this->request->query->count() > 1) {
throw new BadRequestHttpException('Including other query parameters than "method" is not allowed. You have to send them as POST parameters inside the request body.');
}
$this->request->setMethod($method);
$this->request->query->remove('method');
if (null !== $content = $this->request->request->get('content')) {
$this->request->request->remove('content');
$this->request->initialize(
$this->request->query->all(),
$this->request->request->all(),
$this->request->attributes->all(),
$this->request->cookies->all(),
$this->request->files->all(),
$this->request->server->all(),
$content
);
}
$headerNames = [
'Authorization',
'X-Experience-API-Version',
'Content-Type',
'Content-Length',
'If-Match',
'If-None-Match',
];
foreach ($this->request->request as $key => $value) {
if (\in_array($key, $headerNames, true)) {
$this->request->headers->set($key, $value);
} else {
$this->request->query->set($key, $value);
}
$this->request->request->remove($key);
}
}
}

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

Loading…
Cancel
Save