فهرست منبع

add the CronCommand and the cron services

Olivier Massot 3 سال پیش
والد
کامیت
8b31f4cf6a

+ 4 - 0
config/services.yaml

@@ -60,6 +60,8 @@ services:
             tags: ['app.encoder']
         App\Service\Mailer\Builder\BuilderInterface:
             tags: [ 'app.mailer.builder' ]
+        App\Service\Cron\CronjobInterface:
+            tags: [ 'app.cronjob' ]
 
     App\Service\ServiceIterator\CurrentAccessExtensionIterator:
         - !tagged_iterator app.extensions.access
@@ -71,6 +73,8 @@ services:
         - !tagged_iterator app.encoder
     App\Service\ServiceIterator\Mailer\BuilderIterator:
         - !tagged_iterator app.mailer.builder
+    App\Service\ServiceIterator\CronjobIterator:
+        - !tagged_iterator app.cronjob
 
     App\Service\Dolibarr\DolibarrSyncService:
         tags:

+ 170 - 0
src/Commands/CronCommand.php

@@ -0,0 +1,170 @@
+<?php
+
+namespace App\Commands;
+
+use App\Service\Cron\CronJobInterface;
+use App\Service\Cron\IO\CommandLineIO;
+use App\Service\ServiceIterator\CronjobIterator;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Command\LockableTrait;
+use Symfony\Component\Console\Helper\ProgressBar;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Contracts\Service\Attribute\Required;
+
+#[AsCommand(
+    name: 'ot:cron',
+    description: 'Executes cron jobs'
+)]
+class CronCommand extends Command
+{
+    use LockableTrait;
+
+    private const ACTION_LIST = 'list';
+    private const ACTION_RUN = 'run';
+    private const ACTION_RUN_ALL = 'run-all';
+    private const ACTIONS = [self::ACTION_LIST, self::ACTION_RUN, self::ACTION_RUN_ALL];
+
+    private InputInterface $input;
+    private OutputInterface $output;
+    private bool $silent;
+    private ProgressBar $progressBar;
+    private LoggerInterface $logger;
+    private CronjobIterator $cronjobIterator;
+
+    #[Required]
+    public function setLoggerInterface(LoggerInterface $logger): void
+    {
+        $this->logger = $logger;
+    }
+
+    #[Required]
+    public function setCronjobIterator(CronjobIterator $cronjobIterator): void
+    {
+        $this->cronjobIterator = $cronjobIterator;
+    }
+
+    protected function configure(): void
+    {
+        $this->addArgument(
+            'action',
+            InputArgument::REQUIRED,
+            'Action to execute among : ' . implode(', ', self::ACTIONS)
+        );
+        $this->addArgument(
+            'jobs',
+            InputArgument::OPTIONAL,
+            "Name(s) of the cron-job(s) to execute with '" . self::ACTION_RUN . "' (comma-separated)"
+        );
+        $this->addOption(
+            'preview',
+            'p',
+            InputOption::VALUE_NONE,
+            'Only preview the operations instead of executing it'
+        );
+    }
+
+    protected function updateProgression(int $i, int $total): void
+    {
+        if (!$this->progressBar->getMaxSteps() !== $total) {
+            $this->progressBar->setMaxSteps($total);
+        }
+        $this->progressBar->setProgress($i);
+    }
+
+    final protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $this->input = $input;
+        $this->output = $output;
+
+        $action = $input->getArgument('action');
+        $jobNames = $input->getArgument('jobs');
+        $preview = $input->getOption('preview');
+
+        if (!in_array($action, self::ACTIONS, true)) {
+            $this->output->writeln('Error: unrecognized action');
+            return Command::INVALID;
+        }
+
+        if ($action === 'list') {
+            $this->listJobs();
+            return Command::SUCCESS;
+        }
+
+        $jobs = [];
+
+        if ($action === self::ACTION_RUN_ALL) {
+            $jobs = $this->cronjobIterator->getAll();
+        }
+
+        if ($action === self::ACTION_RUN) {
+            foreach (explode(',', $jobNames) as $name) {
+                try {
+                    $jobs[] = $this->cronjobIterator->getByName($name);
+                } catch (\RuntimeException $e) {
+                    $this->output->writeln($e->getMessage());
+                    $this->listJobs();
+                    return Command::INVALID;
+                }
+            }
+        }
+
+        foreach ($jobs as $job) {
+            $this->runJob($job, $preview);
+        }
+
+        return Command::SUCCESS;
+    }
+
+    private function listJobs(): void {
+        $availableJobs = $this->cronjobIterator->getAll();
+        if (empty($availableJobs)) {
+            $this->output->writeln('No cronjob found');
+        } else {
+            $this->output->writeln('Available cron jobs : ');
+            foreach ($this->cronjobIterator->getAll() as $job) {
+                $this->output->writeln('* ' . $job->name());
+            }
+        }
+    }
+
+    private function runJob(CronjobInterface $job, bool $preview = false): int
+    {
+        if (!$this->lock($job->name())) {
+            $this->output->writeln('The command ' . $job->name() . ' is already running in another process. Abort.');
+            return Command::SUCCESS;
+        }
+
+        $t0 = microtime(true);
+
+        $this->output->writeln("Started : " . $job->name());
+        if ($preview) {
+            $this->output->writeln('PREVIEW MODE');
+        }
+
+        $io = new CommandLineIO($job->name(), $this->logger, $this->output);
+        $job->setIO($io);
+
+        try {
+            if ($preview) {
+                $job->preview();
+            } else {
+                $job->execute();
+            }
+        } catch (RuntimeException $e) {
+            $this->logger->critical($e);
+            $this->output->write("An error happened while running the process : " . $e);
+            return Command::FAILURE;
+        }
+
+        $t1 = microtime(true);
+        $this->output->writeln($job->name() . " has been successfully executed [" . ($t1 - $t0) . " sec.]");
+
+        return Command::SUCCESS;
+    }
+}

+ 0 - 94
src/Commands/DolibarrSyncCommand.php

@@ -1,94 +0,0 @@
-<?php
-
-namespace App\Commands;
-
-use App\Service\Dolibarr\DolibarrSyncService;
-use Symfony\Component\Console\Attribute\AsCommand;
-use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Command\LockableTrait;
-use Symfony\Component\Console\Helper\ProgressBar;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Output\OutputInterface;
-
-#[AsCommand(
-    name: 'ot:dolibarr-sync',
-    description: 'Push the latest data from the Opentalent DB to dolibarr'
-)]
-class DolibarrSyncCommand extends Command
-{
-    use LockableTrait;
-
-    /**
-     * How many operations are shown each time the preview choice is made
-     */
-    const PREVIEW_CHUNK = 20;
-
-    public function __construct(
-        private DolibarrSyncService $dolibarrSyncService
-    ) {
-        parent::__construct();
-    }
-
-    protected function configure()
-    {
-        $this->addOption(
-            'preview',
-            'p',
-            InputOption::VALUE_NONE,
-            'Only preview the sync operations instead of executing it'
-        );
-    }
-
-    protected function execute(InputInterface $input, OutputInterface $output): int
-    {
-        if (!$this->lock()) {
-            $output->writeln('The command is already running in another process.');
-            return Command::SUCCESS;
-        }
-
-        $output->writeln("Start the synchronization");
-        $t0 = microtime(true);
-        $output->writeln("Scanning...");
-
-        $progressBar = new ProgressBar($output, 0);
-        $progressCallback = function($i, $total) use ($progressBar) {
-            if (!$progressBar->getMaxSteps() !== $total) {
-                $progressBar->setMaxSteps($total);
-            }
-            $progressBar->setProgress($i);
-        };
-
-        $operations = $this->dolibarrSyncService->scan($progressCallback);
-
-        $t1 = microtime(true);
-        $output->writeln("Scan lasted " . ($t1 - $t0) . " sec.");
-
-        $output->writeln(count($operations) . " operations to be executed");
-
-        if ($input->getOption('preview')) {
-            $output->writeln("-- Preview --");
-            foreach ($operations as $i => $iValue) {
-                $output->writeln($i . '. ' . $iValue->getLabel());
-                foreach ($iValue->getChangeLog() as $message) {
-                    $output->writeln('   ' . $message);
-                }
-            }
-        } else {
-            $t0 = microtime(true);
-            $output->writeln("Executing...");
-
-            $operations = $this->dolibarrSyncService->execute($operations, $progressCallback);
-
-            $successes = count(array_filter($operations, function ($o) { return $o->getStatus() === $o::STATUS_DONE; } ));
-            $errors = count(array_filter($operations, function ($o) { return $o->getStatus() === $o::STATUS_ERROR; } ));
-            $output->writeln($successes . " operations successfully executed");
-            $output->writeln($errors . " errors");
-
-            $t1 = microtime(true);
-            $output->writeln("Execution lasted " . ($t1 - $t0) . " sec.");
-        }
-
-        return Command::SUCCESS;
-    }
-}

+ 42 - 0
src/Service/Cron/BaseCronJob.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Service\Cron;
+
+use App\Service\Cron\IO\CronIOInterface;
+use App\Service\Cron\IO\SilentIO;
+use App\Service\Utils\StringsUtils;
+use Psr\Log\LoggerInterface;
+use Symfony\Contracts\Service\Attribute\Required;
+
+/**
+ * Base class for the Cronjobs defined in \App\Service\Cron\Job
+ *
+ * This class shouldn't implement directly the CronjobInterface because it shouldn't be injected into the
+ * CronjobIterator, but all its subclasses should.
+ */
+abstract class BaseCronJob
+{
+    protected CronIOInterface $io;
+    protected int $status = CronjobInterface::STATUS_READY;
+
+    #[Required]
+    public function setLoggerInterface(LoggerInterface $logger): void
+    {
+        $this->io = new SilentIO($this->name(), $logger);
+    }
+
+    final public function name(): string {
+        return StringsUtils::camelToSnake(
+            preg_replace('/(?:\w+\\\)*(\w+)$/', '$1', static::class),
+            '-'
+        );
+    }
+
+    final public function getStatus(): int {
+        return $this->status;
+    }
+
+    public function setIO(CronIOInterface $io): void {
+        $this->io = $io;
+    }
+}

+ 23 - 0
src/Service/Cron/CronjobInterface.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Service\Cron;
+
+use App\Service\Cron\IO\CronIOInterface;
+
+interface CronjobInterface
+{
+    public const STATUS_READY = 0;
+    public const STATUS_PENDING = 1;
+    public const STATUS_SUCCESS = 2;
+    public const STATUS_SUCCESS_WITH_WARNINGS = 3;
+    public const STATUS_ERROR = 4;
+
+    public function name(): string;
+
+    public function setIO(CronIOInterface $io): void;
+
+    public function preview(): void;
+    public function execute(): void;
+
+    public function getStatus(): int;
+}

+ 49 - 0
src/Service/Cron/IO/BaseIO.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Service\Cron\IO;
+
+use Psr\Log\LoggerInterface;
+
+abstract class BaseIO implements CronIOInterface
+{
+    public function __construct(
+        protected string $jobName,
+        private LoggerInterface $logger,
+    ) {}
+
+    private function formatLogMessage(string $message): string
+    {
+        return date('c') . ' - ' . $this->jobName . ' - ' . $message;
+    }
+    protected function debug(string $message): void {
+        $this->logger->debug($this->formatLogMessage($message));
+    }
+    protected function info(string $message): void {
+        $this->logger->info($this->formatLogMessage($message));
+    }
+    protected function warning(string $message): void {
+        $this->logger->warning($this->formatLogMessage($message));
+    }
+    protected function error(string $message): void {
+        $this->logger->error($this->formatLogMessage($message));
+    }
+    protected function critical(string $message): void {
+        $this->logger->critical($this->formatLogMessage($message));
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function print(string $message): void
+    {
+        $this->logger->info('[' . $this->jobName . '] ' . $message);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function progress(int $i, int $total): void
+    {
+        // Do nothing
+    }
+}

+ 40 - 0
src/Service/Cron/IO/CommandLineIO.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Service\Cron\IO;
+
+use Psr\Log\LoggerInterface;
+use Symfony\Component\Console\Helper\ProgressBar;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class CommandLineIO extends BaseIO
+{
+    private ProgressBar $progressBar;
+
+    public function __construct(
+        string $jobName,
+        LoggerInterface $logger,
+        private OutputInterface $output
+    ) {
+        parent::__construct($jobName, $logger);
+        $this->progressBar = new ProgressBar($output, 0);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function print(string $message): void
+    {
+        $this->output->writeln($message);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function progress(int $i, int $total): void
+    {
+        if (!$this->progressBar->getMaxSteps() !== $total) {
+            $this->progressBar->setMaxSteps($total);
+        }
+        $this->progressBar->setProgress($i);
+    }
+}

+ 21 - 0
src/Service/Cron/IO/CronIOInterface.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Service\Cron\IO;
+
+interface CronIOInterface
+{
+    /**
+     * Print a simple line of text
+     *
+     * @param string $message
+     */
+    public function print(string $message): void;
+
+    /**
+     * Update the current progress of the task
+     *
+     * @param int $i
+     * @param int $total
+     */
+    public function progress(int $i, int $total): void;
+}

+ 7 - 0
src/Service/Cron/IO/SilentIO.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Service\Cron\IO;
+
+class SilentIO extends BaseIO
+{
+}

+ 150 - 0
src/Service/Cron/Job/CleanTempFiles.php

@@ -0,0 +1,150 @@
+<?php
+
+namespace App\Service\Cron\Job;
+
+use App\Repository\Access\AccessRepository;
+use App\Repository\Core\FileRepository;
+use App\Service\Cron\BaseCronJob;
+use App\Service\Cron\CronjobInterface;
+use App\Service\Storage\LocalStorage;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\Common\Collections\Criteria;
+use Doctrine\ORM\EntityManagerInterface;
+
+/**
+ * Cronjob that delete temporary files older than N days
+ */
+class CleanTempFiles extends BaseCronJob implements CronjobInterface
+{
+    /**
+     * Delay before removing a temporary file (in days)
+     */
+    private const DELETE_OLDER_THAN = 7;
+
+    /**
+     * ID of the Access who author the file deletion
+     */
+    private const AUTHOR = 10984;
+
+    /**
+     * Delay before deleting the record of the temporary file from the DB
+     */
+    private const PURGE_RECORDS_OLDER_THAN = 365;
+
+    /**
+     * @param FileRepository $fileRepository
+     * @param AccessRepository $accessRepository
+     * @param LocalStorage $storage
+     */
+    public function __construct(
+        private FileRepository $fileRepository,
+        private EntityManagerInterface $em,
+        private AccessRepository $accessRepository,
+        private LocalStorage $storage
+    ) {}
+
+    public function preview(): void
+    {
+        $files = $this->listFilesToDelete();
+        $total = count($files);
+        $this->io->print($total . " temporary files to be removed");
+        $this->io->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->io->print('  (...)');
+            }
+            $this->io->print('  * ' . $file->getPath());
+        }
+
+        $this->purgeDb(false);
+    }
+
+    public function execute(): void
+    {
+        $files = $this->listFilesToDelete();
+
+        $this->deleteFiles($files);
+
+        $this->purgeDb();
+    }
+
+    private function listFilesToDelete(): Collection {
+
+        $maxDate = new \DateTime(self::DELETE_OLDER_THAN . ' days ago');
+
+        $this->io->print('List temporary files created before ' . $maxDate->format('c'));
+
+        $criteria = new Criteria();
+        $criteria->where(
+            Criteria::expr()?->andX(
+                Criteria::expr()?->eq('isTemporaryFile', 1),
+                Criteria::expr()?->orX(
+                    Criteria::expr()?->lt('createDate', $maxDate),
+                    Criteria::expr()?->isNull('createDate')
+                )
+            )
+        );
+
+        return $this->fileRepository->matching($criteria);
+    }
+
+    private function deleteFiles(Collection $files): void {
+        $total = count($files);
+        $this->io->print($total . " temporary files to be removed");
+
+        $author = $this->accessRepository->find(self::AUTHOR);
+
+        if ($author === null) {
+            throw new \RuntimeException('Access ' . self::AUTHOR . ' could not be found');
+        }
+
+        $this->io->print('Deleting files...');
+        $i = 0;
+        $this->io->progress(0, $total);
+        foreach ($files as $file) {
+            try {
+                $i++;
+                $this->io->progress($i, $total);
+                $this->storage->delete($file, $author);
+            } catch (\RuntimeException $e) {
+                $this->io->print('ERROR : ' . $e->getMessage());
+            }
+        }
+        $this->io->print(''); // to force a line break after the progression bar
+
+        $this->io->print($i . ' files deleted');
+    }
+
+    private function purgeDb(bool $commit = true): void {
+
+        $maxDate = new \DateTime(self::PURGE_RECORDS_OLDER_THAN . ' days ago');
+
+        $this->io->print('Purge DB from records of files deleted before ' . $maxDate->format('c'));
+
+        $this->em->beginTransaction();
+
+        $queryBuilder = $this->em->createQueryBuilder();
+        $q = $queryBuilder
+            ->delete('File', 'f')
+            ->where($queryBuilder->expr()->eq('f.isTemporaryFile', 1))
+            ->andWhere($queryBuilder->expr()->lt('f.updateDate', ':maxDate'))
+            ->setParameter('maxDate', $maxDate)
+            ->getQuery();
+
+        $purged = $q->getResult();
+
+        if ($commit) {
+            $this->em->commit();
+            $this->io->print('DB purged - ' . $purged . ' records permanently deleted');
+            return;
+        }
+
+        $this->em->rollback();
+        $this->io->print('DB purged - ' . $purged . ' records would be permanently deleted');
+    }
+
+}

+ 61 - 0
src/Service/Cron/Job/DolibarrSync.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Service\Cron\Job;
+
+use App\Repository\Access\AccessRepository;
+use App\Repository\Core\FileRepository;
+use App\Service\Cron\BaseCronJob;
+use App\Service\Cron\CronjobInterface;
+use App\Service\Dolibarr\DolibarrSyncService;
+use App\Service\Storage\LocalStorage;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\Common\Collections\Criteria;
+use Doctrine\ORM\EntityManagerInterface;
+
+/**
+ * Push the latest data from the Opentalent DB to dolibarr
+ */
+class DolibarrSync extends BaseCronJob implements CronjobInterface
+{
+    /**
+     * How many operations are shown each time the preview choice is made
+     */
+    const PREVIEW_CHUNK = 20;
+
+    public function __construct(
+        private DolibarrSyncService $dolibarrSyncService
+    ) {}
+
+    public function getProgressCallback(): \Closure
+    {
+        return function($i, $total) {
+            $this->io->progress($i, $total);
+        };
+    }
+
+    public function preview(): void
+    {
+        $operations = $this->dolibarrSyncService->scan($this->getProgressCallback());
+
+        foreach ($operations as $i => $iValue) {
+            $this->io->print($i . '. ' . $iValue->getLabel());
+            foreach ($iValue->getChangeLog() as $message) {
+                $this->io->print('   ' . $message);
+            }
+        }
+    }
+
+    public function execute(): void
+    {
+        $operations = $this->dolibarrSyncService->scan($this->getProgressCallback());
+
+        $this->io->print("Executing...");
+
+        $operations = $this->dolibarrSyncService->execute($operations, $this->getProgressCallback());
+
+        $successes = count(array_filter($operations, static function ($o) { return $o->getStatus() === $o::STATUS_DONE; } ));
+        $errors = count(array_filter($operations, static function ($o) { return $o->getStatus() === $o::STATUS_ERROR; } ));
+        $this->io->print($successes . " operations successfully executed");
+        $this->io->print($errors . " errors");
+    }
+}

+ 11 - 0
src/Service/Cron/Readme.md

@@ -0,0 +1,11 @@
+## Cronjobs
+
+This namespace group the cronjobs that can be executed with 
+
+    ot:cron [options] <action> [<jobs>]
+
+Each of the cronjobs are in App\Service\Cron\Job and shall implement the CronjobInterface.
+
+The job name is the class name, formatted in SnakeCase with hyphen `-`
+
+Ex: `MySuperJob` job's name will be `my-super-job`

+ 0 - 1
src/Service/Export/LicenceCmfExporter.php

@@ -10,7 +10,6 @@ use App\Service\Export\Model\LicenceCmf;
 use App\Enum\Access\FunctionEnum;
 use App\Repository\Organization\OrganizationRepository;
 use App\Service\Export\Model\LicenceCmfCollection;
-use App\Service\Storage\LocalStorage;
 
 /**
  * Exporte la licence CMF de la structure ou du ou des access, au format demandé

+ 34 - 0
src/Service/ServiceIterator/CronjobIterator.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Service\ServiceIterator;
+
+use App\Service\Cron\CronjobInterface;
+
+/**
+ * Permet d'itérer sur les cronjobs
+ */
+class CronjobIterator
+{
+    /**
+     * Pour l'injection des services, voir config/services.yaml, section 'TAG Services'
+     * @param iterable $cronjobServices
+     */
+    public function __construct(
+        private iterable $cronjobServices,
+    ) {}
+
+    public function getByName(string $name): CronjobInterface
+    {
+        /** @var CronjobInterface $cronService */
+        foreach ($this->cronjobServices as $cronService){
+            if ($cronService->name() === $name) {
+                return $cronService;
+            }
+        }
+        throw new \RuntimeException('no cronjob service found with this name : ' . $name);
+    }
+
+    public function getAll(): iterable {
+        return $this->cronjobServices;
+    }
+}