Skip to content

Commit 3c4fa61

Browse files
authored
Merge pull request #6351 from christianbeeznest/ras-22644
Internal: Implement course creation automation from file tree structure - refs BT#22644
2 parents 70f86d3 + 2b0c8bd commit 3c4fa61

File tree

4 files changed

+285
-17
lines changed

4 files changed

+285
-17
lines changed
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
<?php
2+
3+
/* For licensing terms, see /license.txt */
4+
5+
declare(strict_types=1);
6+
7+
namespace Chamilo\CoreBundle\Command;
8+
9+
use Chamilo\CoreBundle\Entity\Course;
10+
use Chamilo\CoreBundle\Entity\ResourceNode;
11+
use Chamilo\CoreBundle\Entity\User;
12+
use Chamilo\CoreBundle\Service\CourseService;
13+
use Chamilo\CoreBundle\Settings\SettingsManager;
14+
use Chamilo\CourseBundle\Entity\CDocument;
15+
use Chamilo\CourseBundle\Entity\CLp;
16+
use Chamilo\CourseBundle\Entity\CLpItem;
17+
use Doctrine\ORM\EntityManagerInterface;
18+
use Symfony\Component\Console\Attribute\AsCommand;
19+
use Symfony\Component\Console\Command\Command;
20+
use Symfony\Component\Console\Input\InputArgument;
21+
use Symfony\Component\Console\Input\InputInterface;
22+
use Symfony\Component\Console\Input\InputOption;
23+
use Symfony\Component\Console\Output\OutputInterface;
24+
use Symfony\Component\Console\Style\SymfonyStyle;
25+
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
26+
use Symfony\Component\Finder\Finder;
27+
28+
#[AsCommand(
29+
name: 'app:create-courses-from-structured-file',
30+
description: 'Create courses and learning paths from a folder containing files.
31+
If permissions like 0660/0770 are used, it is recommended to run this command as www-data:
32+
sudo -u www-data php bin/console app:create-courses-from-structured-file /path/to/folder',
33+
)]
34+
class CreateCoursesFromStructuredFileCommand extends Command
35+
{
36+
public function __construct(
37+
private readonly EntityManagerInterface $em,
38+
private readonly CourseService $courseService,
39+
private readonly SettingsManager $settingsManager,
40+
private readonly ParameterBagInterface $parameterBag,
41+
) {
42+
parent::__construct();
43+
}
44+
45+
protected function configure(): void
46+
{
47+
$this
48+
->addArgument(
49+
'folder',
50+
InputArgument::REQUIRED,
51+
'Absolute path to the folder that contains course files'
52+
)
53+
->addOption(
54+
'user',
55+
null,
56+
InputOption::VALUE_OPTIONAL,
57+
'Expected user owner of created files (e.g. www-data)',
58+
'www-data'
59+
);
60+
}
61+
62+
protected function execute(InputInterface $input, OutputInterface $output): int
63+
{
64+
$io = new SymfonyStyle($input, $output);
65+
66+
$expectedUser = $input->getOption('user');
67+
$realUser = get_current_user();
68+
69+
if ($realUser !== $expectedUser) {
70+
$io->warning("You are running this command as '$realUser', but expected user is '$expectedUser'.If file permissions are too restrictive (e.g. 0660), the web server may not be able to access the files.
71+
To avoid this issue, consider running the command like this: sudo -u {$expectedUser} php bin/console app:create-courses-from-structured-file /path/to/folder");
72+
}
73+
74+
$adminUser = $this->getFirstAdmin();
75+
if (!$adminUser) {
76+
$io->error('No admin user found in the system.');
77+
return Command::FAILURE;
78+
}
79+
80+
$folder = $input->getArgument('folder');
81+
if (!is_dir($folder)) {
82+
$io->error("Invalid folder: $folder");
83+
return Command::FAILURE;
84+
}
85+
86+
// Retrieve Unix permissions from platform settings
87+
$dirPermOct = octdec($this->settingsManager->getSetting('document.permissions_for_new_directories') ?? '0777');
88+
$filePermOct = octdec($this->settingsManager->getSetting('document.permissions_for_new_files') ?? '0666');
89+
90+
// Absolute base to /var/upload/resource
91+
$uploadBase = $this->parameterBag->get('kernel.project_dir') . '/var/upload/resource';
92+
93+
$finder = new Finder();
94+
$finder->files()->in($folder);
95+
96+
foreach ($finder as $file) {
97+
$basename = $file->getBasename();
98+
$courseCode = pathinfo($basename, PATHINFO_FILENAME);
99+
$filePath = $file->getRealPath();
100+
101+
// 1. Skip unsupported file extensions
102+
$allowedExtensions = ['pdf', 'html', 'htm', 'mp4'];
103+
if (!in_array(strtolower($file->getExtension()), $allowedExtensions, true)) {
104+
$io->warning("Skipping unsupported file: $basename");
105+
continue;
106+
}
107+
108+
$io->section("Creating course: $courseCode");
109+
110+
// 2. Create course
111+
$course = $this->courseService->createCourse([
112+
'title' => $courseCode,
113+
'wanted_code' => $courseCode,
114+
'add_user_as_teacher' => true,
115+
'course_language' => $this->settingsManager->getSetting('language.platform_language'),
116+
'visibility' => Course::OPEN_PLATFORM,
117+
'subscribe' => true,
118+
'unsubscribe' => true,
119+
'disk_quota' => $this->settingsManager->getSetting('document.default_document_quotum'),
120+
'expiration_date' => (new \DateTime('+1 year'))->format('Y-m-d H:i:s'),
121+
]);
122+
123+
if (!$course) {
124+
throw new \RuntimeException("Course '$courseCode' could not be created.");
125+
}
126+
127+
// 3. Create learning path
128+
$lp = (new CLp())
129+
->setLpType(1)
130+
->setTitle($courseCode)
131+
->setDescription('')
132+
->setPublishedOn(null)
133+
->setExpiredOn(null)
134+
->setCategory(null)
135+
->setParent($course)
136+
->addCourseLink($course)
137+
->setCreator($adminUser);
138+
139+
$this->em->getRepository(CLp::class)->createLp($lp);
140+
141+
// 4. Create document
142+
$document = (new CDocument())
143+
->setFiletype('file')
144+
->setTitle($basename)
145+
->setComment(null)
146+
->setReadonly(false)
147+
->setCreator($adminUser)
148+
->setParent($course)
149+
->addCourseLink($course);
150+
151+
$this->em->persist($document);
152+
$this->em->flush();
153+
154+
$documentRepo = $this->em->getRepository(CDocument::class);
155+
$resourceFile = $documentRepo->addFileFromPath($document, $basename, $filePath);
156+
157+
// 4.1 Apply permissions to the real file & its directory
158+
if ($resourceFile) {
159+
$resourceNodeRepo = $this->em->getRepository(ResourceNode::class);
160+
$relativePath = $resourceNodeRepo->getFilename($resourceFile);
161+
$fullPath = realpath($uploadBase . $relativePath);
162+
163+
if ($fullPath && is_file($fullPath)) {
164+
@chmod($fullPath, $filePermOct);
165+
}
166+
$fullDir = dirname($fullPath ?: '');
167+
if ($fullDir && is_dir($fullDir)) {
168+
@chmod($fullDir, $dirPermOct);
169+
}
170+
}
171+
172+
// 5. Ensure learning path root item exists
173+
$lpItemRepo = $this->em->getRepository(CLpItem::class);
174+
$rootItem = $lpItemRepo->getRootItem((int) $lp->getIid());
175+
176+
if (!$rootItem) {
177+
$rootItem = (new CLpItem())
178+
->setTitle('root')
179+
->setPath('root')
180+
->setLp($lp)
181+
->setItemType('root');
182+
$this->em->persist($rootItem);
183+
$this->em->flush();
184+
}
185+
186+
// 6. Create LP item linked to the document
187+
$lpItem = (new CLpItem())
188+
->setLp($lp)
189+
->setTitle($basename)
190+
->setItemType('document')
191+
->setRef((string) $document->getIid())
192+
->setPath((string) $document->getIid())
193+
->setDisplayOrder(1)
194+
->setLaunchData('')
195+
->setMinScore(0)
196+
->setMaxScore(100)
197+
->setParent($rootItem)
198+
->setLvl(1)
199+
->setRoot($rootItem);
200+
201+
$this->em->persist($lpItem);
202+
$this->em->flush();
203+
204+
$io->success("Course '$courseCode' created with LP and document '$basename'");
205+
}
206+
207+
return Command::SUCCESS;
208+
}
209+
210+
/**
211+
* Return the first user that has ROLE_ADMIN.
212+
*/
213+
private function getFirstAdmin(): ?User
214+
{
215+
return $this->em->getRepository(User::class)
216+
->createQueryBuilder('u')
217+
->where('u.roles LIKE :role')
218+
->setParameter('role', '%ROLE_ADMIN%')
219+
->setMaxResults(1)
220+
->getQuery()
221+
->getOneOrNullResult();
222+
}
223+
}

src/CoreBundle/Service/CourseService.php

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Chamilo\CoreBundle\Repository\CourseCategoryRepository;
1919
use Chamilo\CoreBundle\Repository\Node\CourseRepository;
2020
use Chamilo\CoreBundle\Repository\Node\UserRepository;
21+
use Chamilo\CoreBundle\ServiceHelper\AccessUrlHelper;
2122
use Chamilo\CoreBundle\Settings\SettingsManager;
2223
use Chamilo\CourseBundle\Component\CourseCopy\CourseBuilder;
2324
use Chamilo\CourseBundle\Component\CourseCopy\CourseRestorer;
@@ -59,7 +60,8 @@ public function __construct(
5960
private readonly MailerInterface $mailer,
6061
private readonly EventLoggerService $eventLoggerService,
6162
private readonly ParameterBagInterface $parameterBag,
62-
private readonly RequestStack $requestStack
63+
private readonly RequestStack $requestStack,
64+
private readonly AccessUrlHelper $accessUrlHelper
6365
) {}
6466

6567
public function createCourse(array $params): ?Course
@@ -90,11 +92,16 @@ public function createCourse(array $params): ?Course
9092
public function registerCourse(array $rawParams): ?Course
9193
{
9294
try {
93-
/** @var User $currentUser */
95+
/** @var User|null $currentUser */
9496
$currentUser = $this->security->getUser();
9597

96-
$params = $this->prepareAndValidateCourseData($rawParams);
98+
// Fallback admin user if running from CLI
99+
if (!$currentUser instanceof User) {
100+
$currentUser = $this->getFallbackAdminUser();
101+
}
97102

103+
$params = $this->prepareAndValidateCourseData($rawParams);
104+
$accessUrl = $this->accessUrlHelper->getCurrent();
98105
$course = new Course();
99106
$course
100107
->setTitle($params['title'])
@@ -114,6 +121,7 @@ public function registerCourse(array $rawParams): ?Course
114121
->setUnsubscribe($params['unsubscribe'])
115122
->setCreator($currentUser)
116123
;
124+
$course->addAccessUrl($accessUrl);
117125

118126
if (!empty($params['categories'])) {
119127
foreach ($params['categories'] as $categoryId) {
@@ -125,7 +133,7 @@ public function registerCourse(array $rawParams): ?Course
125133
}
126134

127135
$addTeacher = $params['add_user_as_teacher'] ?? true;
128-
$user = $currentUser;
136+
$user = $currentUser ?? $this->getFallbackAdminUser();
129137
if (!empty($params['user_id'])) {
130138
$user = $this->userRepository->find((int) $params['user_id']);
131139
}
@@ -160,7 +168,7 @@ public function registerCourse(array $rawParams): ?Course
160168

161169
$this->courseRepository->create($course);
162170

163-
if ($rawParams['exemplary_content']) {
171+
if (!empty($rawParams['exemplary_content'])) {
164172
$this->fillCourse($course, $params);
165173
}
166174

@@ -173,7 +181,7 @@ public function registerCourse(array $rawParams): ?Course
173181

174182
return $course;
175183
} catch (Exception $e) {
176-
return null;
184+
throw $e;
177185
}
178186
}
179187

@@ -339,6 +347,10 @@ private function createRootGradebook(Course $course): GradebookCategory
339347
{
340348
/** @var User $currentUser */
341349
$currentUser = $this->security->getUser();
350+
if (!$currentUser instanceof User) {
351+
$currentUser = $this->getFallbackAdminUser();
352+
}
353+
342354
if (!$currentUser) {
343355
throw new LogicException('There is no user currently authenticated..');
344356
}
@@ -366,6 +378,9 @@ private function insertExampleContent(Course $course, GradebookCategory $gradebo
366378
{
367379
/** @var User $currentUser */
368380
$currentUser = $this->security->getUser();
381+
if (!$currentUser instanceof User) {
382+
$currentUser = $this->getFallbackAdminUser();
383+
}
369384

370385
$files = [
371386
['path' => '/audio', 'title' => $this->translator->trans('Audio'), 'filetype' => 'folder', 'size' => 0],
@@ -761,13 +776,37 @@ private function handlePostCourseCreation(Course $course, array $params): void
761776
$this->sendEmailToAdmin($course);
762777
}
763778

779+
$currentUser = $this->security->getUser();
780+
if (!$currentUser instanceof User) {
781+
$currentUser = $this->getFallbackAdminUser();
782+
}
783+
764784
$this->eventLoggerService->addEvent(
765785
'course_created',
766786
'course_id',
767787
$course->getId(),
768788
null,
769-
$this->security->getUser()->getId(),
789+
$currentUser->getId(),
770790
$course->getId()
771791
);
772792
}
793+
794+
private function getFallbackAdminUser(): User
795+
{
796+
$qb = $this->entityManager->createQueryBuilder();
797+
798+
$qb->select('u')
799+
->from(User::class, 'u')
800+
->where('u.roles LIKE :role')
801+
->setParameter('role', '%ROLE_ADMIN%')
802+
->setMaxResults(1);
803+
804+
$user = $qb->getQuery()->getOneOrNullResult();
805+
806+
if (!$user instanceof User) {
807+
throw new \RuntimeException('No admin user found for fallback.');
808+
}
809+
810+
return $user;
811+
}
773812
}

0 commit comments

Comments
 (0)