Passed
Pull Request — master (#7060)
by
unknown
09:02
created

SettingsController   C

Complexity

Total Complexity 56

Size/Duplication

Total Lines 544
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 319
c 1
b 0
f 0
dl 0
loc 544
rs 5.5199
wmc 56

10 Methods

Rating   Name   Duplication   Size   Complexity  
A index() 0 4 1
A __construct() 0 5 1
B toggleChangeable() 0 59 7
F updateSetting() 0 125 15
A getSearchForm() 0 7 1
A syncSettings() 0 10 1
A buildSearchDiagnostics() 0 72 3
B computeOrderedNamespacesByTranslatedLabel() 0 63 6
A getTemplateExample() 0 17 2
F searchSetting() 0 131 19

How to fix   Complexity   

Complex Class

Complex classes like SettingsController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use SettingsController, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CoreBundle\Controller\Admin;
8
9
use Chamilo\CoreBundle\Controller\BaseController;
10
use Chamilo\CoreBundle\Entity\SearchEngineField;
11
use Chamilo\CoreBundle\Entity\SettingsCurrent;
12
use Chamilo\CoreBundle\Entity\SettingsValueTemplate;
13
use Chamilo\CoreBundle\Helpers\AccessUrlHelper;
14
use Chamilo\CoreBundle\Search\Xapian\SearchIndexPathResolver;
15
use Chamilo\CoreBundle\Settings\SettingsManager;
16
use Chamilo\CoreBundle\Traits\ControllerTrait;
17
use Collator;
18
use Doctrine\ORM\EntityManagerInterface;
19
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
20
use Symfony\Component\Form\Extension\Core\Type\TextType;
21
use Symfony\Component\Form\FormInterface;
22
use Symfony\Component\HttpFoundation\JsonResponse;
23
use Symfony\Component\HttpFoundation\Request;
24
use Symfony\Component\HttpFoundation\Response;
25
use Symfony\Component\Routing\Attribute\Route;
26
use Symfony\Component\Security\Http\Attribute\IsGranted;
27
use Symfony\Component\Validator\Exception\ValidatorException;
28
use Symfony\Contracts\Translation\TranslatorInterface;
29
30
use const DIRECTORY_SEPARATOR;
31
32
#[Route('/admin')]
33
class SettingsController extends BaseController
34
{
35
    use ControllerTrait;
36
37
    public function __construct(
38
        private readonly EntityManagerInterface $entityManager,
39
        private readonly TranslatorInterface $translator,
40
        private readonly SearchIndexPathResolver $searchIndexPathResolver
41
    ) {}
42
43
    #[Route('/settings', name: 'admin_settings')]
44
    public function index(): Response
45
    {
46
        return $this->redirectToRoute('chamilo_platform_settings', ['namespace' => 'platform']);
47
    }
48
49
    /**
50
     * Toggle access_url_changeable for a given setting variable.
51
     * Only platform admins on the main URL (ID = 1) are allowed to change it,
52
     */
53
    #[IsGranted('ROLE_ADMIN')]
54
    #[Route('/settings/toggle_changeable', name: 'settings_toggle_changeable', methods: ['POST'])]
55
    public function toggleChangeable(Request $request, AccessUrlHelper $accessUrlHelper): JsonResponse
56
    {
57
        // Security: only admins.
58
        if (!$this->isGranted('ROLE_ADMIN')) {
59
            return $this->json([
60
                'error' => 'Only platform admins can modify this flag.',
61
            ], 403);
62
        }
63
64
        $currentUrl = $accessUrlHelper->getCurrent();
65
        $currentUrlId = $currentUrl->getId();
66
67
        // Only main URL (ID = 1) can toggle the flag.
68
        if (1 !== $currentUrlId) {
69
            return $this->json([
70
                'error' => 'Only the main URL (ID 1) can toggle this setting.',
71
            ], 403);
72
        }
73
74
        $payload = json_decode($request->getContent(), true);
75
76
        if (!\is_array($payload) || !isset($payload['variable'], $payload['status'])) {
77
            return $this->json([
78
                'error' => 'Invalid payload.',
79
            ], 400);
80
        }
81
82
        $variable = (string) $payload['variable'];
83
        $status = (int) $payload['status'];
84
85
        $repo = $this->entityManager->getRepository(SettingsCurrent::class);
86
87
        // We search by variable + current main AccessUrl entity.
88
        $setting = $repo->findOneBy([
89
            'variable' => $variable,
90
            'url' => $currentUrl,
91
        ]);
92
93
        if (!$setting) {
94
            return $this->json([
95
                'error' => 'Setting not found.',
96
            ], 404);
97
        }
98
99
        try {
100
            $setting->setAccessUrlChangeable($status);
101
            $this->entityManager->flush();
102
103
            return $this->json([
104
                'result' => 1,
105
                'status' => $status,
106
            ]);
107
        } catch (\Throwable $e) {
108
            return $this->json([
109
                'error' => 'Unable to update setting.',
110
                'details' => $e->getMessage(),
111
            ], 500);
112
        }
113
    }
114
115
    /**
116
     * Edit configuration with given namespace (search page).
117
     */
118
    #[IsGranted('ROLE_ADMIN')]
119
    #[Route('/settings/search_settings', name: 'chamilo_platform_settings_search')]
120
    public function searchSetting(Request $request, AccessUrlHelper $accessUrlHelper): Response
121
    {
122
        $manager = $this->getSettingsManager();
123
124
        $url = $accessUrlHelper->getCurrent();
125
        $manager->setUrl($url);
126
127
        $formList = [];
128
        $templateMap = [];
129
        $templateMapByCategory = [];
130
        $settings = [];
131
132
        $keyword = trim((string) $request->query->get('keyword', ''));
133
134
        $searchForm = $this->getSearchForm();
135
        $searchForm->handleRequest($request);
136
        if ($searchForm->isSubmitted() && $searchForm->isValid()) {
137
            $values = $searchForm->getData();
138
            $keyword = trim((string) ($values['keyword'] ?? ''));
139
        }
140
141
        $schemas = $manager->getSchemas();
142
        [$ordered, $labelMap] = $this->computeOrderedNamespacesByTranslatedLabel($schemas, $request);
143
144
        // Template map for current URL (existing behavior – JSON helper)
145
        $settingsRepo = $this->entityManager->getRepository(SettingsCurrent::class);
146
        $settingsWithTemplate = $settingsRepo->findBy(['url' => $url]);
147
148
        foreach ($settingsWithTemplate as $s) {
149
            if ($s->getValueTemplate()) {
150
                $templateMap[$s->getVariable()] = $s->getValueTemplate()->getId();
151
            }
152
        }
153
154
        // MultiURL changeable flags: read from main URL (ID = 1) only
155
        $changeableMap = [];
156
        $mainUrlRows = $settingsRepo->createQueryBuilder('sc')
157
            ->join('sc.url', 'u')
158
            ->andWhere('u.id = :mainId')
159
            ->setParameter('mainId', 1)
160
            ->getQuery()
161
            ->getResult();
162
163
        foreach ($mainUrlRows as $row) {
164
            if ($row instanceof SettingsCurrent) {
165
                $changeableMap[$row->getVariable()] = $row->getAccessUrlChangeable();
166
            }
167
        }
168
169
        $currentUrlId = $url->getId();
170
        // Only platform admins on the main URL can toggle the MultiURL flag.
171
        $canToggleMultiUrlSetting = $this->isGranted('ROLE_ADMIN') && 1 === $currentUrlId;
172
173
        if ('' === $keyword) {
174
            return $this->render('@ChamiloCore/Admin/Settings/search.html.twig', [
175
                'keyword' => $keyword,
176
                'schemas' => $schemas,
177
                'settings' => $settings,
178
                'form_list' => $formList,
179
                'search_form' => $searchForm->createView(),
180
                'template_map' => $templateMap,
181
                'template_map_by_category' => $templateMapByCategory,
182
                'ordered_namespaces' => $ordered,
183
                'namespace_labels' => $labelMap,
184
                'changeable_map' => $changeableMap,
185
                'current_url_id' => $currentUrlId,
186
                'can_toggle_multiurl_setting' => $canToggleMultiUrlSetting,
187
            ]);
188
        }
189
190
        $settingsRepo = $this->entityManager->getRepository(SettingsCurrent::class);
191
        $settingsWithTemplate = $settingsRepo->findBy(['url' => $url]);
192
        foreach ($settingsWithTemplate as $s) {
193
            if ($s->getValueTemplate()) {
194
                $templateMap[$s->getVariable()] = $s->getValueTemplate()->getId();
195
            }
196
        }
197
198
        $settingsFromKeyword = $manager->getParametersFromKeywordOrderedByCategory($keyword);
199
        if (!empty($settingsFromKeyword)) {
200
            foreach ($settingsFromKeyword as $category => $parameterList) {
201
                if (empty($category)) {
202
                    continue;
203
                }
204
205
                $variablesInCategory = [];
206
                foreach ($parameterList as $parameter) {
207
                    $var = $parameter->getVariable();
208
                    $variablesInCategory[] = $var;
209
                    if (isset($templateMap[$var])) {
210
                        $templateMapByCategory[$category][$var] = $templateMap[$var];
211
                    }
212
                }
213
214
                // Convert category to schema alias and validate it BEFORE loading/creating the form
215
                $schemaAlias = $manager->convertNameSpaceToService($category);
216
217
                // skip unknown/legacy categories (e.g., "tools")
218
                if (!isset($schemas[$schemaAlias])) {
219
                    continue;
220
                }
221
222
                $settings = $manager->load($category);
223
                $form = $this->getSettingsFormFactory()->create($schemaAlias);
224
225
                foreach (array_keys($settings->getParameters()) as $name) {
226
                    if (!\in_array($name, $variablesInCategory, true)) {
227
                        $form->remove($name);
228
                        $settings->remove($name);
229
                    }
230
                }
231
                $form->setData($settings);
232
                $formList[$category] = $form->createView();
233
            }
234
        }
235
236
        return $this->render('@ChamiloCore/Admin/Settings/search.html.twig', [
237
            'keyword' => $keyword,
238
            'schemas' => $schemas,
239
            'settings' => $settings,
240
            'form_list' => $formList,
241
            'search_form' => $searchForm->createView(),
242
            'template_map' => $templateMap,
243
            'template_map_by_category' => $templateMapByCategory,
244
            'ordered_namespaces' => $ordered,
245
            'namespace_labels' => $labelMap,
246
            'changeable_map' => $changeableMap,
247
            'current_url_id' => $currentUrlId,
248
            'can_toggle_multiurl_setting' => $canToggleMultiUrlSetting,
249
        ]);
250
    }
251
252
    /**
253
     * Edit configuration with given namespace.
254
     */
255
    #[IsGranted('ROLE_ADMIN')]
256
    #[Route('/settings/{namespace}', name: 'chamilo_platform_settings')]
257
    public function updateSetting(Request $request, AccessUrlHelper $accessUrlHelper, string $namespace): Response
258
    {
259
        $manager = $this->getSettingsManager();
260
        $url = $accessUrlHelper->getCurrent();
261
        $manager->setUrl($url);
262
        $schemaAlias = $manager->convertNameSpaceToService($namespace);
263
264
        $keyword = (string) $request->query->get('keyword', '');
265
        // Extra diagnostics used only for the "search" namespace
266
        $searchDiagnostics = null;
267
268
        // Validate schema BEFORE load/create to avoid NonExistingServiceException
269
        $schemas = $manager->getSchemas();
270
        if (!isset($schemas[$schemaAlias])) {
271
            $this->addFlash('warning', \sprintf('Unknown settings category "%s". Showing Platform settings.', $namespace));
272
273
            return $this->redirectToRoute('chamilo_platform_settings', [
274
                'namespace' => 'platform',
275
            ]);
276
        }
277
278
        $settings = $manager->load($namespace);
279
280
        $form = $this->getSettingsFormFactory()->create(
281
            $schemaAlias,
282
            null,
283
            ['allow_extra_fields' => true]
284
        );
285
286
        $form->setData($settings);
287
288
        // Build extra diagnostics for Xapian and converters when editing "search" settings
289
        if ('search' === $namespace) {
290
            $searchDiagnostics = $this->buildSearchDiagnostics($manager);
291
        }
292
293
        $isPartial =
294
            $request->isMethod('PATCH')
295
            || 'PATCH' === strtoupper((string) $request->request->get('_method'))
296
            || $request->request->getBoolean('_partial', false);
297
298
        if ($isPartial) {
299
            $payload = $request->request->all($form->getName());
300
            $form->submit($payload, false);
301
        } else {
302
            $form->handleRequest($request);
303
        }
304
305
        if ($form->isSubmitted() && $form->isValid()) {
306
            $messageType = 'success';
307
308
            try {
309
                $manager->save($form->getData());
310
                $message = $this->trans('The settings have been stored');
311
            } catch (ValidatorException $validatorException) {
312
                $message = $this->trans($validatorException->getMessage());
313
                $messageType = 'error';
314
            }
315
316
            $this->addFlash($messageType, $message);
317
318
            if ('' !== $keyword) {
319
                return $this->redirectToRoute('chamilo_platform_settings_search', [
320
                    'keyword' => $keyword,
321
                ]);
322
            }
323
324
            return $this->redirectToRoute('chamilo_platform_settings', [
325
                'namespace' => $namespace,
326
            ]);
327
        }
328
329
        [$ordered, $labelMap] = $this->computeOrderedNamespacesByTranslatedLabel($schemas, $request);
330
331
        $templateMap = [];
332
        $settingsRepo = $this->entityManager->getRepository(SettingsCurrent::class);
333
334
        // Template map for current URL (existing behavior – JSON helper)
335
        $settingsWithTemplate = $settingsRepo->findBy(['url' => $url]);
336
337
        foreach ($settingsWithTemplate as $s) {
338
            if ($s->getValueTemplate()) {
339
                $templateMap[$s->getVariable()] = $s->getValueTemplate()->getId();
340
            }
341
        }
342
343
        // MultiURL changeable flags: read from main URL (ID = 1) only
344
        $changeableMap = [];
345
        $mainUrlRows = $settingsRepo->createQueryBuilder('sc')
346
            ->join('sc.url', 'u')
347
            ->andWhere('u.id = :mainId')
348
            ->setParameter('mainId', 1)
349
            ->getQuery()
350
            ->getResult();
351
352
        foreach ($mainUrlRows as $row) {
353
            if ($row instanceof SettingsCurrent) {
354
                $changeableMap[$row->getVariable()] = $row->getAccessUrlChangeable();
355
            }
356
        }
357
358
        $platform = [
359
            'server_type' => (string) $manager->getSetting('platform.server_type', true),
360
        ];
361
362
        $currentUrlId = $url->getId();
363
        // Only platform admins on the main URL can toggle the MultiURL flag.
364
        $canToggleMultiUrlSetting = $this->isGranted('ROLE_ADMIN') && 1 === $currentUrlId;
365
366
        return $this->render('@ChamiloCore/Admin/Settings/default.html.twig', [
367
            'schemas' => $schemas,
368
            'settings' => $settings,
369
            'form' => $form->createView(),
370
            'keyword' => $keyword,
371
            'search_form' => $this->getSearchForm()->createView(),
372
            'template_map' => $templateMap,
373
            'ordered_namespaces' => $ordered,
374
            'namespace_labels' => $labelMap,
375
            'platform' => $platform,
376
            'changeable_map' => $changeableMap,
377
            'current_url_id' => $currentUrlId,
378
            'can_toggle_multiurl_setting' => $canToggleMultiUrlSetting,
379
            'search_diagnostics' => $searchDiagnostics,
380
        ]);
381
    }
382
383
    /**
384
     * Sync settings from classes with the database.
385
     */
386
    #[IsGranted('ROLE_ADMIN')]
387
    #[Route('/settings_sync', name: 'sync_settings')]
388
    public function syncSettings(AccessUrlHelper $accessUrlHelper): Response
389
    {
390
        $manager = $this->getSettingsManager();
391
        $url = $accessUrlHelper->getCurrent();
392
        $manager->setUrl($url);
393
        $manager->installSchemas($url);
394
395
        return new Response('Updated');
396
    }
397
398
    #[IsGranted('ROLE_ADMIN')]
399
    #[Route('/settings/template/{id}', name: 'chamilo_platform_settings_template')]
400
    public function getTemplateExample(int $id): JsonResponse
401
    {
402
        $repo = $this->entityManager->getRepository(SettingsValueTemplate::class);
403
        $template = $repo->find($id);
404
405
        if (!$template) {
406
            return $this->json([
407
                'error' => $this->translator->trans('Template not found.'),
408
            ], Response::HTTP_NOT_FOUND);
409
        }
410
411
        return $this->json([
412
            'variable' => $template->getVariable(),
413
            'json_example' => $template->getJsonExample(),
414
            'description' => $template->getDescription(),
415
        ]);
416
    }
417
418
    /**
419
     * @return FormInterface
420
     */
421
    private function getSearchForm()
422
    {
423
        $builder = $this->container->get('form.factory')->createNamedBuilder('search');
424
        $builder->add('keyword', TextType::class);
425
        $builder->add('search', SubmitType::class, ['attr' => ['class' => 'btn btn--primary']]);
426
427
        return $builder->getForm();
428
    }
429
430
    private function computeOrderedNamespacesByTranslatedLabel(array $schemas, Request $request): array
431
    {
432
        // Extract raw namespaces from schema service ids
433
        $namespaces = array_map(
434
            static fn ($k) => str_replace('chamilo_core.settings.', '', $k),
435
            array_keys($schemas)
436
        );
437
438
        $transform = [
439
            'announcement' => 'Announcements',
440
            'attendance' => 'Attendances',
441
            'cas' => 'CAS',
442
            'certificate' => 'Certificates',
443
            'course' => 'Courses',
444
            'document' => 'Documents',
445
            'exercise' => 'Tests',
446
            'forum' => 'Forums',
447
            'group' => 'Groups',
448
            'language' => 'Internationalization',
449
            'lp' => 'Learning paths',
450
            'mail' => 'E-mail',
451
            'message' => 'Messages',
452
            'profile' => 'User profiles',
453
            'session' => 'Sessions',
454
            'skill' => 'Skills',
455
            'social' => 'Social network',
456
            'survey' => 'Surveys',
457
            'work' => 'Assignments',
458
            'ticket' => 'Support tickets',
459
            'tracking' => 'Reporting',
460
            'webservice' => 'Webservices',
461
            'catalog' => 'Catalogue',
462
            'catalogue' => 'Catalogue',
463
            'ai_helpers' => 'AI helpers',
464
        ];
465
466
        // Build label map (translated). For keys not in $transform, use Title Case of ns.
467
        $labelMap = [];
468
        foreach ($namespaces as $ns) {
469
            if (isset($transform[$ns])) {
470
                $labelMap[$ns] = $this->translator->trans($transform[$ns]);
471
            } else {
472
                $key = ucfirst(str_replace('_', ' ', $ns));
473
                $labelMap[$ns] = $this->translator->trans($key);
474
            }
475
        }
476
477
        // Sort by translated label (locale-aware)
478
        $collator = class_exists(Collator::class) ? new Collator($request->getLocale()) : null;
479
        usort($namespaces, function ($a, $b) use ($labelMap, $collator) {
480
            return $collator
481
                ? $collator->compare($labelMap[$a], $labelMap[$b])
482
                : strcasecmp($labelMap[$a], $labelMap[$b]);
483
        });
484
485
        // Optional: keep AI helpers near the top (second position)
486
        $idx = array_search('ai_helpers', $namespaces, true);
487
        if (false !== $idx) {
488
            array_splice($namespaces, $idx, 1);
489
            array_splice($namespaces, 1, 0, ['ai_helpers']);
490
        }
491
492
        return [$namespaces, $labelMap];
493
    }
494
495
    /**
496
     * Build environment diagnostics for the "search" settings page.
497
     *
498
     * This replicates the legacy Chamilo 1 behaviour:
499
     * - Check Xapian PHP extension
500
     * - Check the index directory and permissions
501
     * - Check custom search fields
502
     * - Check external converters (pdftotext, ps2pdf, ...)
503
     */
504
    private function buildSearchDiagnostics(SettingsManager $manager): array
505
    {
506
        $searchEnabled = (string) $manager->getSetting('search.search_enabled');
507
508
        // Base status rows (Xapian extension + directory checks + custom fields)
509
        $indexDir = $this->searchIndexPathResolver->getIndexDir();
510
511
        $xapianLoaded = \extension_loaded('xapian');
512
        $dirExists = is_dir($indexDir);
513
        $dirWritable = is_writable($indexDir);
514
        $fieldsCount = $this->entityManager
515
            ->getRepository(SearchEngineField::class)
516
            ->count([])
517
        ;
518
519
        $statusRows = [
520
            [
521
                'label' => $this->translator->trans('Xapian module installed'),
522
                'ok' => $xapianLoaded,
523
            ],
524
            [
525
                'label' => $this->translator->trans('The directory exists').' - '.$indexDir,
526
                'ok' => $dirExists,
527
            ],
528
            [
529
                'label' => $this->translator->trans('Is writable').' - '.$indexDir,
530
                'ok' => $dirWritable,
531
            ],
532
            [
533
                'label' => $this->translator->trans('Available custom search fields'),
534
                'ok' => $fieldsCount > 0,
535
            ],
536
        ];
537
538
        // External converters (ps2pdf, pdftotext, ...)
539
        $tools = [];
540
        $toolsWarning = null;
541
542
        $isWindows = DIRECTORY_SEPARATOR === '\\';
543
544
        if ($isWindows) {
545
            $toolsWarning = $this->translator->trans(
546
                'You are using Chamilo on a Windows platform. Document conversion helpers are not available for full-text indexing.'
547
            );
548
        } else {
549
            $programs = ['ps2pdf', 'pdftotext', 'catdoc', 'html2text', 'unrtf', 'catppt', 'xls2csv'];
550
551
            foreach ($programs as $program) {
552
                $output = [];
553
                $returnVar = null;
554
555
                // Same behaviour as "which $program" in Chamilo 1
556
                @exec('which '.escapeshellarg($program), $output, $returnVar);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for exec(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

556
                /** @scrutinizer ignore-unhandled */ @exec('which '.escapeshellarg($program), $output, $returnVar);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
557
                $path = $output[0] ?? '';
558
                $installed = '' !== $path;
559
560
                $tools[] = [
561
                    'name' => $program,
562
                    'path' => $path,
563
                    'ok' => $installed,
564
                ];
565
            }
566
        }
567
568
        return [
569
            // Whether full-text search is enabled at all
570
            'enabled' => ('true' === $searchEnabled),
571
            // Xapian + directory + custom fields
572
            'status_rows' => $statusRows,
573
            // External converters
574
            'tools' => $tools,
575
            'tools_warning' => $toolsWarning,
576
        ];
577
    }
578
}
579