Gradebook: Fixes and enhancements for certificate handling in documents and gradebook - #5074

pull/5116/head
christianbeeznst 10 months ago
parent 3985e3fc62
commit c457ad2f77
  1. 17
      assets/vue/components/Toolbar.vue
  2. 2
      assets/vue/components/basecomponents/ChamiloIcons.js
  3. 38
      assets/vue/components/resource_links/ShowLinks.vue
  4. 6
      assets/vue/composables/datatableList.js
  5. 19
      assets/vue/views/documents/CreateFile.vue
  6. 206
      assets/vue/views/documents/DocumentsList.vue
  7. 26
      assets/vue/views/documents/DocumentsUpload.vue
  8. 44
      assets/vue/views/documents/UpdateFile.vue
  9. 6
      public/main/gradebook/lib/fe/displaygradebook.php
  10. 5
      public/main/inc/lib/document.lib.php
  11. 2
      public/plugin/customcertificate/src/export_pdf_all_in_one.php
  12. 2
      public/plugin/customcertificate/src/print_certificate.php
  13. 72
      src/CoreBundle/Controller/GradebookController.php
  14. 22
      src/CoreBundle/Controller/ResourceController.php
  15. 22
      src/CoreBundle/Traits/GradebookControllerTrait.php
  16. 2
      src/CourseBundle/Entity/CDocument.php

@ -1,6 +1,14 @@
<template>
<PrimeToolbar>
<template #start>
<PrimeButton
v-if="handleBack"
:label="$t('Back')"
icon="mdi mdi-arrow-left"
class="p-button-outlined"
@click="backAction"
/>
<PrimeButton
v-if="handleList"
:label="$t('List')"
@ -98,6 +106,10 @@ export default {
type: Function,
required: false
},
handleBack: {
type: Function,
required: false
},
handleSubmit: {
type: Function,
required: false
@ -145,6 +157,11 @@ export default {
}
},
methods: {
backAction() {
if (this.handleBack) {
this.handleBack();
}
},
listItem() {
if (this.handleList) {
this.handleList();

@ -109,4 +109,6 @@ export const chamiloIconToClass = {
"anonymous": "mdi mdi-incognito",
"settings": "mdi mdi-tools",
"ticket": "mdi mdi-ticket-account",
"certificate-selected": "mdi mdi-star",
"certificate-not-selected": "mdi mdi-star-outline",
};

@ -4,12 +4,9 @@
:key="index"
class="field space-y-2"
>
<div
v-if="link.course"
:class="{ 'text-right text-body-2': editStatus }"
>
<span class="mdi mdi-book" />
{{ t("Course: {0}", [link.course.resourceNode.title]) }}
<div v-if="link.course" :class="{ 'text-right text-body-2': editStatus }">
<span class="mdi mdi-book"></span>
{{ $t("Course") }}: {{ link.course.resourceNode.title }}
</div>
<div
@ -17,7 +14,7 @@
:class="{ 'text-right text-body-2': editStatus }"
>
<span class="mdi mdi-book-open" />
{{ t("Session: {0}", [link.session.title]) }}
{{ $t("Session") }}: {{ link.session.title }}
</div>
<div
@ -25,27 +22,21 @@
:class="{ 'text-right text-body-2': editStatus }"
>
<span class="mdi mdi-people" />
{{ t("Group: {0}", [link.group.resourceNode.title]) }}
{{ $t("Group") }}: {{ link.group.resourceNode.title }}
</div>
<div
v-if="link.userGroup"
v-t="{ path: 'Class: {0}', args: [link.userGroup.resourceNode.title] }"
/>
<div v-if="link.userGroup">
{{ $t("Class") }}: {{ link.userGroup.resourceNode.title }}
</div>
<div v-if="link.user">
<span class="mdi mdi-account" />
<!-- @todo add avatar -->
<!-- <q-avatar size="32px">-->
<!-- <img :src="link.user.illustrationUrl + '?w=80&h=80&fit=crop'" />-->
<!-- </q-avatar>-->
<span class="mdi mdi-account"></span>
{{ link.user.username }}
</div>
<div
v-if="showStatus"
v-t="{ path: 'Status: {0}', args: [link.visibilityName] }"
/>
<div v-if="showStatus">
{{ $t("Status") }}: {{ link.visibilityName }}
</div>
<div v-if="editStatus">
<div class="p-float-label">
@ -56,10 +47,7 @@
option-label="label"
option-value="value"
/>
<label
v-t="'Status'"
:for="`link-${link.id}-status`"
/>
<label for="`link-${link.id}-status`">{{ $t("Status") }}</label>
</div>
</div>
</div>

@ -33,10 +33,10 @@ export function useDatatableList (servicePrefix) {
function onUpdateOptions ({ page, itemsPerPage, sortBy, sortDesc }) {
page = page || options.value.page
if (!isEmpty(route.query.cert) && route.query.cert === '1') {
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 }

@ -2,6 +2,7 @@
<Toolbar
:handle-reset="resetForm"
:handle-submit="onSendFormData"
:handle-back="handleBack"
/>
<div class="documents-layout">
@ -17,15 +18,14 @@
:errors="violations"
:values="item"
/>
<Panel
v-if="$route.query.filetype === 'certificate' "
:header="$t('Create your certificate copy-pasting the following tags. They will be replaced in the document by their student-specific value:')"
>
<div v-html="finalTags" />
</Panel>
</div>
</div>
<Panel
v-if="$route.query.cert === '1'"
:header="$t('Create your certificate copy-pasting the following tags. They will be replaced in the document by their student-specific value:')"
>
<div v-html="finalTags" />
</Panel>
<Loading :visible="isLoading" />
</template>
@ -60,7 +60,7 @@ export default {
},
mixins: [CreateMixin],
data() {
const filetype = this.$route.query.cert === '1' ? 'certificate' : 'file';
const filetype = this.$route.query.filetype === 'certificate' ? 'certificate' : 'file';
const finalTags = this.getCertificateTags();
return {
item: {
@ -90,6 +90,9 @@ export default {
},
methods: {
handleBack() {
this.$router.back();
},
addTemplateToEditor(templateContent) {
this.item.contentFile = templateContent;
},

@ -1,68 +1,83 @@
<template>
<BaseToolbar v-if="securityStore.isAuthenticated && isCurrentTeacher">
<BaseButton
v-if="showBackButtonIfNotRootFolder"
:label="t('Back')"
icon="back"
type="black"
@click="back"
/>
<BaseButton
:label="t('New document')"
icon="file-add"
type="black"
@click="goToNewDocument"
/>
<BaseButton
:disabled="true"
:label="t('New drawing')"
icon="drawing"
type="black"
/>
<BaseButton
:label="t('Record audio')"
icon="record-add"
type="black"
@click="showRecordAudioDialog"
/>
<BaseButton
:label="t('Upload')"
icon="file-upload"
type="black"
@click="goToUploadFile"
/>
<BaseButton
v-if="$route.query.cert !== '1'"
:label="t('New folder')"
icon="folder-plus"
type="black"
@click="openNew"
/>
<BaseButton
:disabled="true"
:label="t('New cloud file')"
icon="file-cloud-add"
type="black"
/>
<BaseButton
:disabled="!hasImageInDocumentEntries"
:label="t('Slideshow')"
icon="view-gallery"
type="black"
@click="showSlideShowWithFirstImage"
/>
<BaseButton
:label="t('Usage')"
icon="usage"
type="black"
@click="showUsageDialog"
/>
<BaseButton
:disabled="true"
:label="t('Download all')"
icon="download"
type="black"
/>
<template v-if="isCertificateMode">
<BaseButton
:label="t('Create certificate')"
icon="file-add"
type="black"
@click="goToNewDocument"
/>
<BaseButton
:label="t('Upload')"
icon="file-upload"
type="black"
@click="goToUploadFile"
/>
</template>
<template v-else>
<BaseButton
v-if="showBackButtonIfNotRootFolder"
:label="t('Back')"
icon="back"
type="black"
@click="back"
/>
<BaseButton
:label="t('New document')"
icon="file-add"
type="black"
@click="goToNewDocument"
/>
<BaseButton
:disabled="true"
:label="t('New drawing')"
icon="drawing"
type="black"
/>
<BaseButton
:label="t('Record audio')"
icon="record-add"
type="black"
@click="showRecordAudioDialog"
/>
<BaseButton
:label="t('Upload')"
icon="file-upload"
type="black"
@click="goToUploadFile"
/>
<BaseButton
:label="t('New folder')"
icon="folder-plus"
type="black"
@click="openNew"
/>
<BaseButton
:disabled="true"
:label="t('New cloud file')"
icon="file-cloud-add"
type="black"
/>
<BaseButton
:disabled="!hasImageInDocumentEntries"
:label="t('Slideshow')"
icon="view-gallery"
type="black"
@click="showSlideShowWithFirstImage"
/>
<BaseButton
:label="t('Usage')"
icon="usage"
type="black"
@click="showUsageDialog"
/>
<BaseButton
:disabled="true"
:label="t('Download all')"
icon="download"
type="black"
/>
</template>
</BaseToolbar>
<DataTable
@ -153,14 +168,6 @@
@click="btnChangeVisibilityOnClick(slotProps.data)"
/>
<BaseButton
v-if="securityStore.isAuthenticated && isCurrentTeacher && $route.query.cert === '1'"
:icon="null === slotProps.data.gradebookCategory ? 'mdi mdi-file-plus' : 'mdi mdi-file-plus-outline'"
class="p-button-icon-only p-button-plain p-button-outlined p-button-sm"
type="black"
@click="btnChangeAttachedCertificateOnClick(slotProps.data)"
/>
<BaseButton
v-if="securityStore.isAuthenticated && isCurrentTeacher"
icon="edit"
@ -176,6 +183,14 @@
type="danger"
@click="confirmDeleteItem(slotProps.data)"
/>
<BaseButton
v-if="isCertificateMode"
:icon="slotProps.data.iid === defaultCertificateId ? 'certificate-selected' : 'certificate-not-selected'"
:class="{ 'selected': slotProps.data.iid === defaultCertificateId }"
size="small"
type="black"
@click="selectAsDefaultCertificate(slotProps.data)"
/>
</div>
</template>
</Column>
@ -358,6 +373,12 @@ const hasImageInDocumentEntries = computed(() => {
return items.value.find((i) => isImage(i)) !== undefined
})
const isCertificateMode = computed(() => {
return route.query.filetype === 'certificate';
});
const defaultCertificateId = ref(null);
onMounted(() => {
filters.value.loadNode = 1
@ -370,6 +391,7 @@ onMounted(() => {
store.dispatch("resourcenode/findResourceNode", { id: `/api/resource_nodes/${nodeId}` })
loadDefaultCertificate()
onUpdateOptions(options.value)
})
@ -547,7 +569,7 @@ function btnEditOnClick(item) {
return
}
if ("file" === item.filetype) {
if ("file" === item.filetype || "certificate" === item.filetype) {
folderParams.getFile = true
if (
@ -615,31 +637,25 @@ function recordedAudioNotSaved(error) {
console.error(error)
}
function btnChangeAttachedCertificateOnClick (item) {
const folderParams = route.query;
folderParams.id = item['@id'];
if (null === item.gradebookCategory) {
axios
.get(ENTRYPOINT + 'gradebook_categories?course=' + cid)
.then(response => {
if (200 === response.status){
item.gradebookCategory = updateAttachedCertificate(response.data['hydra:member'][0]['id'], folderParams.id);
}
})
;
} else {
item.gradebookCategory = updateAttachedCertificate(item.gradebookCategory['id'], folderParams.id);
async function selectAsDefaultCertificate(certificate) {
try {
const response = await axios.patch(`/gradebook/set_default_certificate/${cid}/${certificate.iid}`);
if (response.status === 200) {
loadDefaultCertificate()
onUpdateOptions(options.value)
notification.showSuccessNotification("Certificate set as default successfully");
}
} catch (error) {
notification.showErrorNotification("Error setting certificate as default");
}
}
async function updateAttachedCertificate(gradebookCertificateId, documentId){
const { data } = await axios.patch(ENTRYPOINT + 'gradebook_categories/' + gradebookCertificateId,
{"document": documentId},
{headers: {'Content-Type': 'application/merge-patch+json'}}
);
return data;
async function loadDefaultCertificate() {
try {
const response = await axios.get(`/gradebook/default_certificate/${cid}`);
defaultCertificateId.value = response.data.certificateId;
} catch (error) {
console.error('Error to laod certificate', error);
}
}
</script>

@ -1,4 +1,12 @@
<template>
<BaseToolbar>
<BaseButton
:label="t('Back')"
icon="back"
type="black"
@click="back"
/>
</BaseToolbar>
<div class="flex flex-col justify-start">
<div class="mb-4">
<Dashboard
@ -43,7 +51,7 @@
</template>
<script setup>
import { ref, watch } from "vue"
import { computed, ref, watch } from "vue"
import "@uppy/core/dist/style.css"
import "@uppy/dashboard/dist/style.css"
import "@uppy/image-editor/dist/style.css"
@ -60,21 +68,26 @@ import { useI18n } from "vue-i18n"
import BaseCheckbox from "../../components/basecomponents/BaseCheckbox.vue"
import BaseRadioButtons from "../../components/basecomponents/BaseRadioButtons.vue"
import BaseAdvancedSettingsButton from "../../components/basecomponents/BaseAdvancedSettingsButton.vue"
import BaseButton from "../../components/basecomponents/BaseButton.vue"
import BaseToolbar from "../../components/basecomponents/BaseToolbar.vue"
import { useStore } from "vuex"
const XHRUpload = require("@uppy/xhr-upload")
const ImageEditor = require("@uppy/image-editor")
const store = useStore()
const route = useRoute()
const router = useRouter()
const { gid, sid, cid } = useCidReq()
const { onCreated, onError } = useUpload()
const { t } = useI18n()
const filetype = route.query?.cert === '1' ? 'certificate' : 'file';
const filetype = route.query.filetype === 'certificate' ? 'certificate' : 'file';
const showAdvancedSettings = ref(false)
const isUncompressZipEnabled = ref(false)
const fileExistsOption = ref("rename")
const resourceNode = computed(() => store.getters["resourcenode/getResourceNode"])
const parentResourceNodeId = ref(Number(route.params.node))
const resourceLinkList = ref(
JSON.stringify([
@ -149,4 +162,13 @@ watch(fileExistsOption, () => {
},
})
})
function back() {
if (!resourceNode.value) {
return
}
let queryParams = { cid, sid, gid, filetype }
router.push({ name: "DocumentsList", params: { node: resourceNode.value.id }, query: queryParams })
}
</script>

@ -4,6 +4,7 @@
<Toolbar
:handle-reset="resetForm"
:handle-submit="onSendFormData"
:handle-back="handleBack"
/>
<div class="documents-layout">
<div class="template-list-container">
@ -24,6 +25,12 @@
links-type="users"
/>
</DocumentsForm>
<Panel
v-if="$route.query.filetype === 'certificate' "
:header="$t('Create your certificate copy-pasting the following tags. They will be replaced in the document by their student-specific value:')"
>
<div v-html="finalTags" />
</Panel>
</div>
</div>
@ -55,8 +62,10 @@ export default {
DocumentsForm,
},
data() {
const finalTags = this.getCertificateTags();
return {
templates: [],
finalTags,
};
},
mixins: [UpdateMixin],
@ -74,6 +83,9 @@ export default {
}),
},
methods: {
handleBack() {
this.$router.back();
},
fetchTemplates() {
axios.get('/system-templates')
.then(response => {
@ -88,6 +100,38 @@ export default {
this.$refs.updateForm.updateContent(templateContent);
}
},
getCertificateTags(){
let finalTags = "";
let tags = [
'((user_firstname))',
'((user_lastname))',
'((user_username))',
'((gradebook_institution))',
'((gradebook_sitename))',
'((teacher_firstname))',
'((teacher_lastname))',
'((official_code))',
'((date_certificate))',
'((date_certificate_no_time))',
'((course_code))',
'((course_title))',
'((gradebook_grade))',
'((certificate_link))',
'((certificate_link_html))',
'((certificate_barcode))',
'((external_style))',
'((time_in_course))',
'((time_in_course_in_all_sessions))',
'((start_date_and_end_date))',
'((course_objectives))',
];
for (const tag of tags){
finalTags += "<p class=\"m-0\">"+tag+"</p>"
}
return finalTags;
},
...mapActions("documents", {
createReset: "resetCreate",
deleteItem: "del",

@ -439,8 +439,10 @@ class DisplayGradebook
$my_api_cidreq.'&origin=gradebook&selectcat='.$catobj->get_id().'">'.
Display::getMdiIcon('certificate', 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Attach certificate')).'</a>';
} else {
$actionsRight .= '<a href="'.api_get_path(WEB_COURSE_PATH).$courseId.
'/tool/document?cert=1">'.
$course = api_get_course_entity($courseId);
$resourceId = $course->resourceNode->getId();
$certificateLink = api_get_path(WEB_PATH) . 'resources/document/'.$resourceId.'/?'.api_get_cidreq().'&filetype=certificate';
$actionsRight .= '<a href="'.$certificateLink.'">'.
Display::getMdiIcon('certificate', 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Attach certificate')).'</a>';
}

@ -1200,7 +1200,7 @@ class DocumentManager
$my_content_html = $repo->getResourceFileContent($doc);
$all_user_info = self::get_all_info_to_certificate(
$user_id,
$courseInfo,
$course_id,
$is_preview
);
@ -1230,11 +1230,12 @@ class DocumentManager
*
* @return array
*/
public static function get_all_info_to_certificate($user_id, $course_info, $sessionId, $is_preview = false)
public static function get_all_info_to_certificate($user_id, $courseId, $sessionId, $is_preview = false)
{
$info_list = [];
$user_id = (int) $user_id;
$sessionId = (int) $sessionId;
$course_info = api_get_course_info_by_id($courseId);
$courseCode = $course_info['code'];
// Portal info

@ -292,7 +292,7 @@ foreach ($userList as $userInfo) {
$allUserInfo = DocumentManager::get_all_info_to_certificate(
$studentId,
$courseCode,
$courseId,
$sessionId,
false
);

@ -179,7 +179,7 @@ foreach ($userList as $userInfo) {
$allUserInfo = DocumentManager::get_all_info_to_certificate(
$studentId,
$courseCode,
$courseId,
$sessionId,
false
);

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Chamilo\CoreBundle\Controller;
use Chamilo\CoreBundle\Entity\GradebookCategory;
use Chamilo\CourseBundle\Entity\CDocument;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route('/gradebook')]
class GradebookController extends AbstractController
{
// Sets the default certificate for a gradebook category
#[Route('/set_default_certificate/{cid}/{certificateId}', name: 'chamilo_core_gradebook_set_default_certificate')]
public function setDefaultCertificate(int $cid, int $certificateId, EntityManagerInterface $entityManager): Response
{
// Find the gradebook category by course ID
$gradebookCategory = $entityManager->getRepository(GradebookCategory::class)->findOneBy(['course' => $cid]);
// Check if the category and certificate exist
if (!$gradebookCategory) {
return new Response('Gradebook category not found', Response::HTTP_NOT_FOUND);
}
$certificate = $entityManager->getRepository(CDocument::class)->find($certificateId);
if (!$certificate) {
return new Response('Certificate not found', Response::HTTP_NOT_FOUND);
}
// Set the certificate as default for the gradebook category
$gradebookCategory->setDocument($certificate);
$entityManager->flush();
// Return success response
return new JsonResponse([
'message' => 'Default certificate set successfully',
'certificateId' => $certificate->getIid(),
'gradebookCategoryId' => $gradebookCategory->getId()
]);
}
// Gets the default certificate for a gradebook category
#[Route('/default_certificate/{cid}', name: 'chamilo_core_gradebook_default_certificate')]
public function getDefaultCertificate(int $cid, EntityManagerInterface $entityManager): JsonResponse
{
// Find the gradebook category by course ID
$gradebookCategory = $entityManager->getRepository(GradebookCategory::class)->findOneBy(['course' => $cid]);
// Check if the gradebook category exists for the course
if (!$gradebookCategory) {
return new JsonResponse(['message' => 'Gradebook category not found for the course', 'certificateId' => null], Response::HTTP_NOT_FOUND);
}
// Get the default certificate if it exists
$defaultCertificate = $gradebookCategory->getDocument();
if (!$defaultCertificate) {
return new JsonResponse(['message' => 'No default certificate set', 'certificateId' => null], Response::HTTP_OK);
}
// Return success response with the default certificate ID
return new JsonResponse([
'message' => 'Default certificate found',
'certificateId' => $defaultCertificate->getIid()
]);
}
}

@ -17,6 +17,7 @@ use Chamilo\CoreBundle\Security\Authorization\Voter\ResourceNodeVoter;
use Chamilo\CoreBundle\Tool\ToolChain;
use Chamilo\CoreBundle\Traits\ControllerTrait;
use Chamilo\CoreBundle\Traits\CourseControllerTrait;
use Chamilo\CoreBundle\Traits\GradebookControllerTrait;
use Chamilo\CoreBundle\Traits\ResourceControllerTrait;
use Chamilo\CourseBundle\Controller\CourseControllerInterface;
use Chamilo\CourseBundle\Entity\CTool;
@ -50,6 +51,7 @@ class ResourceController extends AbstractResourceController implements CourseCon
use ControllerTrait;
use CourseControllerTrait;
use ResourceControllerTrait;
use GradebookControllerTrait;
/**
* @Route("/{tool}/{type}/{id}/disk_space", methods={"GET", "POST"}, name="chamilo_core_resource_disk_space")
@ -145,7 +147,17 @@ class ResourceController extends AbstractResourceController implements CourseCon
$downloadRepository = $entityManager->getRepository(TrackEDownloads::class);
$downloadId = $downloadRepository->saveDownload($user->getId(), $resourceLinkId, $url);
return $this->processFile($request, $resourceNode, 'show', $filter);
$cid = (int) $request->query->get('cid');
$sid = (int) $request->query->get('sid');
$allUserInfo = $this->getAllInfoToCertificate(
$user->getId(),
$cid,
$sid,
false
);
return $this->processFile($request, $resourceNode, 'show', $filter, $allUserInfo);
}
/**
@ -421,7 +433,7 @@ class ResourceController extends AbstractResourceController implements CourseCon
/**
* @return mixed|StreamedResponse
*/
private function processFile(Request $request, ResourceNode $resourceNode, string $mode = 'show', string $filter = '')
private function processFile(Request $request, ResourceNode $resourceNode, string $mode = 'show', string $filter = '', array $allUserInfo = [])
{
$this->denyAccessUnlessGranted(
ResourceNodeVoter::VIEW,
@ -483,6 +495,12 @@ class ResourceController extends AbstractResourceController implements CourseCon
if (str_contains($mimeType, 'html')) {
$content = $resourceNodeRepo->getResourceNodeFileContent($resourceNode);
if (null !== $allUserInfo) {
$tagsToReplace = $allUserInfo[0];
$replacementValues = $allUserInfo[1];
$content = str_replace($tagsToReplace, $replacementValues, $content);
}
$response = new Response();
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\CoreBundle\Traits;
use DocumentManager;
trait GradebookControllerTrait
{
private function getAllInfoToCertificate(int $userId, int $courseId, int $sessionId, bool $isPreview = false): array
{
return DocumentManager::get_all_info_to_certificate(
$userId,
$courseId,
$sessionId,
$isPreview
);
}
}

@ -143,7 +143,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: CDocumentRepository::class)]
#[ORM\EntityListeners([ResourceListener::class])]
#[ApiFilter(filterClass: PropertyFilter::class)]
#[ApiFilter(filterClass: SearchFilter::class, properties: ['title' => 'partial', 'resourceNode.parent' => 'exact'])]
#[ApiFilter(filterClass: SearchFilter::class, properties: ['title' => 'partial', 'resourceNode.parent' => 'exact', 'filetype' => 'exact'])]
#[ApiFilter(
filterClass: OrderFilter::class,
properties: [

Loading…
Cancel
Save