Quellcode durchsuchen

Merge branch 'feature/V8-6053-reprendre-cleanworktodocommand-e' into develop

Olivier Massot vor 2 Monaten
Ursprung
Commit
3dd89e5527

+ 3 - 3
src/Entity/Booking/AbstractBooking.php

@@ -45,7 +45,7 @@ abstract class AbstractBooking
     protected ?int $id = null;
 
     #[ORM\Column]
-    protected string $name;
+    protected ?string $name = null;
 
     /** @var Collection<int, Access> */
     #[ORM\ManyToMany(targetEntity: Access::class, inversedBy: 'educationalProjectOrganizers')]
@@ -89,12 +89,12 @@ abstract class AbstractBooking
         return $this->id;
     }
 
-    public function getName(): string
+    public function getName(): ?string
     {
         return $this->name;
     }
 
-    public function setName(string $name): self
+    public function setName(?string $name): self
     {
         $this->name = $name;
 

+ 1 - 0
src/Enum/Core/FileStatusEnum.php

@@ -17,4 +17,5 @@ enum FileStatusEnum: string
     case READY = 'READY';
     case DELETED = 'DELETED';
     case ERROR = 'ERROR';
+    case DELETION_REQUESTED = 'DELETION_REQUESTED';
 }

+ 12 - 14
src/Service/Cron/Job/CleanTempFiles.php

@@ -12,6 +12,7 @@ use App\Service\Cron\BaseCronJob;
 use App\Service\File\Storage\LocalStorage;
 use App\Service\Utils\DatesUtils;
 use Doctrine\DBAL\Connection;
+use Doctrine\ORM\EntityManagerInterface;
 use Doctrine\ORM\QueryBuilder;
 use JetBrains\PhpStorm\Pure;
 
@@ -33,6 +34,7 @@ class CleanTempFiles extends BaseCronJob
         private Connection $connection,
         private FileRepository $fileRepository,
         private LocalStorage $storage,
+        private EntityManagerInterface $entityManager,
     ) {
         parent::__construct();
     }
@@ -100,21 +102,20 @@ class CleanTempFiles extends BaseCronJob
     }
 
     /**
-     * Delete the files.
+     * Set DELETION_REQUESTED status for all files regardless of host.
      *
      * @param array<File> $files
      */
     protected function deleteFiles(array $files): void
     {
         $total = count($files);
-        $this->logger->info($total.' temporary files to be removed');
+        $this->logger->info($total.' temporary files to be marked for deletion');
 
         $this->connection->setAutoCommit(false);
-        $queryBuilder = $this->fileRepository->createQueryBuilder('f');
 
-        $this->logger->info('Deleting files...');
+        $this->logger->info('Marking files for deletion...');
         $i = 0;
-        $deleted = 0;
+        $marked = 0;
         $this->ui->progress(0, $total);
 
         foreach ($files as $file) {
@@ -123,14 +124,13 @@ class CleanTempFiles extends BaseCronJob
             ++$i;
 
             try {
-                // Delete from disk
-                $this->storage->hardDelete($file);
-
-                // Remove from DB
-                $queryBuilder->delete()->where('f.id = :id')->setParameter('id', $file->getId());
+                // Set DELETION_REQUESTED status for all files regardless of host
+                $file->setStatus(FileStatusEnum::DELETION_REQUESTED);
+                $this->entityManager->persist($file);
+                $this->entityManager->flush();
+                ++$marked;
 
                 $this->connection->commit();
-                ++$deleted;
             } catch (\RuntimeException|\InvalidArgumentException $e) {
                 // Non blocking errors
                 $this->connection->rollback();
@@ -141,7 +141,7 @@ class CleanTempFiles extends BaseCronJob
             }
         }
 
-        $this->logger->info($deleted.' files deleted');
+        $this->logger->info($marked.' files marked for deletion');
     }
 
     protected function getQueryConditions(QueryBuilder $queryBuilder, \DateTime $maxDate): void
@@ -153,7 +153,6 @@ class CleanTempFiles extends BaseCronJob
                     $queryBuilder->expr()->eq('f.status', ':status')
                 )
             )
-            ->andWhere($queryBuilder->expr()->eq('f.host', ':host'))
             ->andWhere(
                 $queryBuilder->expr()->orX(
                     $queryBuilder->expr()->lt('f.createDate', ':maxDate'),
@@ -161,7 +160,6 @@ class CleanTempFiles extends BaseCronJob
                 )
             )
             ->setParameter('temporaryTrue', true)
-            ->setParameter('host', FileHostEnum::AP2I)
             ->setParameter('status', FileStatusEnum::DELETED)
             ->setParameter('maxDate', $maxDate->format('Y-m-d'))
         ;

+ 181 - 0
src/Service/Cron/Job/CleanWorkToDo.php

@@ -0,0 +1,181 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Cron\Job;
+
+use App\Entity\Booking\Work;
+use App\Entity\Booking\WorkByUser;
+use App\Entity\Core\File;
+use App\Enum\Core\FileHostEnum;
+use App\Enum\Core\FileStatusEnum;
+use App\Service\Cron\BaseCronJob;
+use App\Service\File\Storage\LocalStorage;
+use App\Service\Utils\DatesUtils;
+use Doctrine\DBAL\Connection;
+use Doctrine\ORM\EntityManagerInterface;
+use JetBrains\PhpStorm\Pure;
+
+/**
+ * Cronjob that delete work to do entities and associated files older than 12 months.
+ *
+ * >>> ot:cron run clean-work-to-do --preview
+ * >>> ot:cron run clean-work-to-do
+ */
+class CleanWorkToDo extends BaseCronJob
+{
+    /**
+     * Delay before removing work to do records (in months).
+     */
+    private const DELETE_OLDER_THAN = 12;
+
+    #[Pure]
+    public function __construct(
+        private Connection $connection,
+        private EntityManagerInterface $entityManager,
+        private LocalStorage $storage,
+    ) {
+        parent::__construct();
+    }
+
+    /**
+     * Preview the result of the execution, without actually deleting anything.
+     *
+     * @throws \Exception
+     */
+    public function preview(): void
+    {
+        $this->deleteWorks(true);
+    }
+
+    /**
+     * Proceed to the deletion of the works and associated files.
+     *
+     * @throws \Exception
+     */
+    public function execute(): void
+    {
+        $this->deleteWorks();
+    }
+
+    /**
+     * Delete works and associated files older than N months.
+     *
+     * @throws \Exception
+     * @throws \Doctrine\DBAL\Driver\Exception
+     * @throws \Throwable
+     */
+    protected function deleteWorks(bool $preview = false): void
+    {
+        $maxDate = DatesUtils::new();
+        $maxDate->sub(new \DateInterval('P'.self::DELETE_OLDER_THAN.'M'));
+
+        $this->logger->info('Finding works with courses created before '.$maxDate->format('c'));
+
+        // Find works to delete based on course datetime
+        $works = $this->listWorksToDelete($maxDate);
+        $total = count($works);
+
+        $this->ui->print($total.' works to be removed');
+
+        if ($total === 0) {
+            $this->logger->info('No works found to delete');
+            return;
+        }
+
+        if ($preview) {
+            $this->ui->print('> Printing the first and last 10 :');
+            $this->previewWorks($works);
+            return;
+        }
+
+        $deleted = 0;
+
+        $this->ui->progress(0, $total);
+
+        foreach ($works as $i => $work) {
+            $this->ui->progress($i + 1, $total);
+
+            $this->connection->beginTransaction();
+
+            try {
+                $workId = $work->getId();
+
+                // Set DELETION_REQUESTED status for all associated files
+                $this->connection->executeStatement(
+                    "UPDATE File SET status = ?, work_id = null WHERE work_id = ?",
+                    [FileStatusEnum::DELETION_REQUESTED->value, $workId]
+                );
+
+                // Delete WorkByUser records
+                $this->connection->executeStatement(
+                    "DELETE FROM WorkByUser WHERE work_id = ?",
+                    [$workId]
+                );
+
+                // Delete the work itself
+                $this->connection->executeStatement(
+                    "DELETE FROM Work WHERE id = ?",
+                    [$workId]
+                );
+
+                $this->connection->commit();
+
+                ++$deleted;
+            } catch (\RuntimeException|\InvalidArgumentException $e) {
+                $this->logger->error('ERROR deleting work '.$work->getId().': '.$e->getMessage());
+                $this->connection->rollback();
+            } catch (\Exception $exception) {
+                $this->connection->rollback();
+                throw $exception;
+            }
+        }
+
+        $this->logger->info('Works cleanup completed - '.$deleted.' works permanently deleted');
+    }
+
+    /**
+     * List the works to delete based on course datetime.
+     *
+     * @return array<Work>
+     *
+     * @throws \Exception
+     */
+    protected function listWorksToDelete(\DateTime $maxDate): array
+    {
+        $queryBuilder = $this->entityManager->createQueryBuilder();
+        $queryBuilder
+            ->select('w')
+            ->from(Work::class, 'w')
+            ->leftJoin('w.course', 'c')
+            ->where('c.datetimeStart <= :maxDate OR c.datetimeStart IS NULL')
+            ->setParameter('maxDate', $maxDate);
+
+        return $queryBuilder->getQuery()->getResult();
+    }
+
+    /**
+     * Preview works to be deleted.
+     *
+     * @param array<Work> $works
+     */
+    protected function previewWorks(array $works): void
+    {
+        $total = count($works);
+
+        foreach ($works as $i => $work) {
+            if ($i >= 10 && ($total - $i) > 10) {
+                continue;
+            }
+            if (($total - $i) === 10) {
+                $this->ui->print('  (...)');
+            }
+
+            $courseName = $work->getCourse() ? $work->getCourse()->getName() : 'No Course';
+            $fileCount = $work->getFiles()->count();
+            $userCount = $work->getWorkByUsers()->count();
+
+            $this->ui->print("  * Work ID {$work->getId()} - Course: {$courseName} - Files: {$fileCount} - Users: {$userCount}");
+        }
+    }
+}

+ 145 - 0
src/Service/Cron/Job/FilesGarbageCollector.php

@@ -0,0 +1,145 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Cron\Job;
+
+use App\Entity\Core\File;
+use App\Enum\Core\FileHostEnum;
+use App\Enum\Core\FileStatusEnum;
+use App\Repository\Core\FileRepository;
+use App\Service\Cron\BaseCronJob;
+use App\Service\File\Storage\LocalStorage;
+use Doctrine\DBAL\Connection;
+use Doctrine\ORM\QueryBuilder;
+use JetBrains\PhpStorm\Pure;
+
+/**
+ * Cronjob that delete files with host 'ap2i' and status DELETION_REQUESTED.
+ *
+ * >>> ot:cron run files-garbage-collector --preview
+ * >>> ot:cron run files-garbage-collector
+ */
+class FilesGarbageCollector extends BaseCronJob
+{
+    #[Pure]
+    public function __construct(
+        private Connection $connection,
+        private FileRepository $fileRepository,
+        private LocalStorage $storage,
+    ) {
+        parent::__construct();
+    }
+
+    /**
+     * Preview the result of the execution, without actually deleting anything.
+     */
+    public function preview(): void
+    {
+        $files = $this->listFilesToDelete();
+        $total = count($files);
+        $this->ui->print($total.' files to be removed');
+        $this->ui->print('> Printing the first and last 50 :');
+
+        $i = 0;
+        foreach ($files as $file) {
+            ++$i;
+            if ($i > 50 && ($total - $i) > 50) {
+                continue;
+            }
+            if (($total - $i) === 50) {
+                $this->ui->print('  (...)');
+            }
+
+            $fileExists = $this->storage->exists($file);
+
+            $this->ui->print(
+                '  * '.$file->getSlug().' (Status: '.$file->getStatus()->value.' - Exists: '.($fileExists ? 'Yes' : 'No').')'
+            );
+        }
+    }
+
+    /**
+     * Proceed to the deletion of the files and the purge of the DB.
+     */
+    public function execute(): void
+    {
+        $files = $this->listFilesToDelete();
+        $this->logger->info(count($files).' files to be removed');
+
+        $this->deleteFiles($files);
+    }
+
+    /**
+     * List the files to delete in the DB.
+     *
+     * @return array<File>
+     */
+    protected function listFilesToDelete(): array
+    {
+        $this->ui->print('List files with host ap2i and status DELETION_REQUESTED');
+
+        $queryBuilder = $this->fileRepository->createQueryBuilder('f');
+        $queryBuilder->select();
+        $this->getQueryConditions($queryBuilder);
+
+        return $queryBuilder->getQuery()->getResult();
+    }
+
+    /**
+     * Delete the files.
+     *
+     * @param array<File> $files
+     */
+    protected function deleteFiles(array $files): void
+    {
+        $total = count($files);
+        $this->logger->info($total.' files to be removed');
+
+        $queryBuilder = $this->fileRepository->createQueryBuilder('f');
+
+        $this->logger->info('Deleting files...');
+        $i = 0;
+        $deleted = 0;
+        $this->ui->progress(0, $total);
+
+        foreach ($files as $file) {
+            $this->ui->progress($i, $total);
+            ++$i;
+
+            try {
+                // Delete from disk
+                if ($this->storage->exists($file)) {
+                    $this->storage->hardDelete($file);
+                }
+
+                // Remove from DB
+                $queryBuilder
+                    ->delete()
+                    ->where('f.id = :id')
+                    ->setParameter('id', $file->getId())
+                    ->getQuery()
+                    ->execute();
+
+                ++$deleted;
+            } catch (\RuntimeException|\InvalidArgumentException $e) {
+                // Non blocking errors
+                $this->logger->error('ERROR : '.$e->getMessage());
+            } catch (\Exception $exception) {
+                throw $exception;
+            }
+        }
+
+        $this->logger->info($deleted.' files deleted');
+    }
+
+    protected function getQueryConditions(QueryBuilder $queryBuilder): void
+    {
+        $queryBuilder
+            ->andWhere($queryBuilder->expr()->eq('f.host', ':host'))
+            ->andWhere($queryBuilder->expr()->eq('f.status', ':status'))
+            ->setParameter('host', FileHostEnum::AP2I)
+            ->setParameter('status', FileStatusEnum::DELETION_REQUESTED)
+        ;
+    }
+}