diff --git a/UPGRADE-14.0.md b/UPGRADE-14.0.md index 1d38f0a22cb..5e760558438 100644 --- a/UPGRADE-14.0.md +++ b/UPGRADE-14.0.md @@ -141,6 +141,74 @@ Follow the instructions in relevant sections, e.g. `shopsys/coding-standards` or - see #project-base-diff to update your project - prevent duplicate color parameters in data fixtures ([#2911](https://github.com/shopsys/shopsys/pull/2911)) - see #project-base-diff to update your project +- enable crons to be run at specified times the same as crons () + - replace `HourlyFeedCronModule` and `DailyFeedCronModule` with `FeedCronModule` in `config/services/cron.yaml` and set it to be run every time crons are run to ensure that all feeds are generated + - `FeedExportCreationDataQueue` has changed, first parameter is now an array of `Shopsys\FrameworkBundle\Model\Feed\FeedModule` instances instead of module names + - method `Shopsys\FrameworkBundle\Model\Feed\FeedFacade::__construct()` changed its interface: + ```diff + public function __construct( + protected readonly FeedRegistry $feedRegistry, + protected readonly ProductVisibilityFacade $productVisibilityFacade, + protected readonly FeedExportFactory $feedExportFactory, + protected readonly FeedPathProvider $feedPathProvider, + protected readonly FilesystemOperator $filesystem, + + protected readonly FeedModuleRepository $feedModuleRepository, + + protected readonly EntityManagerInterface $em, + ) + ``` + - method `Shopsys\FrameworkBundle\Model\Feed\FeedFacade::getFeedsInfo()` changed its interface: + ```diff + - public function getFeedsInfo(?string $feedType = null): array + + public function getFeedsInfo(bool $onlyForCurrentTime = false): array + ``` + - method `Shopsys\FrameworkBundle\Model\Feed\FeedFacade::getFeedsNames()` changed its interface: + ```diff + - public function getFeedNames(?string $feedType = null): array + + public function getFeedNames(bool $onlyForCurrentTime = false): array + ``` + - method `Shopsys\FrameworkBundle\Model\Feed\FeedRegistry::__construct()` changed its interface: + ```diff + public function __construct( + - protected readonly array $knownTypes, + - protected readonly string $defaultType, + + protected readonly FeedRegistry $feedRegistry, + + protected readonly ProductVisibilityFacade $productVisibilityFacade, + + protected readonly FeedExportFactory $feedExportFactory, + + protected readonly FeedPathProvider $feedPathProvider, + + protected readonly FilesystemOperator $filesystem, + + protected readonly FeedModuleRepository $feedModuleRepository, + + protected readonly EntityManagerInterface $em, + ) + ``` + - method `Shopsys\FrameworkBundle\Model\Feed\FeedRegistry::registerFeed()` changed its interface: + ```diff + - public function registerFeed(FeedInterface $feed, ?string $type = null): void + + public function registerFeed(FeedInterface $feed, string $timeHours, string $timeMinutes, array $domainIds): void + ``` + - property `Shopsys\FrameworkBundle\Model\Feed\FeedRegistry::$feedsByType` has been replaced with new `$feedConfigsByName` property instead + - method `Shopsys\FrameworkBundle\Controller\Admin\FeedController::__construct` changed its interface: + ```diff + public function __construct( + protected readonly FeedFacade $feedFacade, + protected readonly GridFactory $gridFactory, + protected readonly Domain $domain, + + protected readonly FeedRegistry $feedRegistry, + + protected readonly FeedModuleRepository $feedModuleRepository, + ) + ``` + - method `Shopsys\FrameworkBundle\Model\Feed\Exception\FeedNotFoundException__construct` changed its interface: + ```diff + public function __construct( + string $name, + + ?int $domainId = null, + ?Exception $previous = null + ) + ``` + - method `Shopsys\FrameworkBundle\Model\Feed\FeedRegistry::getFeeds()` has been replaced with method `Shopsys\FrameworkBundle\Model\Feed\FeedRegistry::getFeedsForCurrentTime()` + - method `Shopsys\FrameworkBundle\Model\Feed\FeedRegistry::getAllFeeds()` has been replaced with method `Shopsys\FrameworkBundle\Model\Feed\FeedRegistry::getAllFeedConfigs()` + - method `Shopsys\FrameworkBundle\Model\Feed\FeedRegistry::getFeedByName()` has been replaced with method `Shopsys\FrameworkBundle\Model\Feed\FeedRegistry::getFeedConfigByName()` + - method `Shopsys\FrameworkBundle\Model\Feed\FeedRegistry::assertTypeIsKnown()` has been removed without a replacement + - see #project-base-diff to update your project ### Storefront diff --git a/docs/cookbook/basic-data-import.md b/docs/cookbook/basic-data-import.md index 5b0d8b2e1cf..fe000795d32 100644 --- a/docs/cookbook/basic-data-import.md +++ b/docs/cookbook/basic-data-import.md @@ -467,8 +467,7 @@ You will get a list of all available cron modules as an output. ```text php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Component\Error\ErrorPageCronModule" php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Cart\Item\DeleteOldCartsCronModule" -php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Feed\DailyFeedCronModule" -php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Feed\HourlyFeedCronModule" +php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Feed\FeedCronModule" php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Pricing\Vat\VatDeletionCronModule" php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Product\Availability\ProductAvailabilityCronModule" php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Product\Pricing\ProductPriceCronModule" diff --git a/docs/cookbook/working-with-multiple-cron-instances.md b/docs/cookbook/working-with-multiple-cron-instances.md index 00bf2bef1bf..233e4cf28ce 100644 --- a/docs/cookbook/working-with-multiple-cron-instances.md +++ b/docs/cookbook/working-with-multiple-cron-instances.md @@ -49,8 +49,7 @@ default php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Component\Error\ErrorPageCronModule" --instance-name=default php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Cart\Item\DeleteOldCartsCronModule" --instance-name=default - php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Feed\DailyFeedCronModule" --instance-name=default - php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Feed\HourlyFeedCronModule" --instance-name=default + php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Feed\FeedCronModule" --instance-name=default php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Pricing\Vat\VatDeletionCronModule" --instance-name=default php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Product\Availability\ProductAvailabilityCronModule" --instance-name=default php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Product\Pricing\ProductPriceCronModule" --instance-name=default diff --git a/docs/model/product-feeds.md b/docs/model/product-feeds.md index 71527c589ac..342948f2ff3 100644 --- a/docs/model/product-feeds.md +++ b/docs/model/product-feeds.md @@ -2,7 +2,7 @@ Product feeds are a way to periodically export information about your products for product search engines such as [Google Shopping](https://www.google.com/shopping). -In order to allow easy installation and removal of product feeds, they are implemented in form of plugins ([see list of current implementations](https://github.com/search?q=topic%3Aproduct-feed+org%3Ashopsys)). +To allow easy installation and removal of product feeds, they are implemented in form of plugins ([see list of current implementations](https://github.com/search?q=topic%3Aproduct-feed+org%3Ashopsys)). ## Where are the feeds? @@ -12,22 +12,55 @@ You can see all installed product feeds along with the URLs of their export in t ## When are they exported? Product feeds are usually exported using Cron modules. -The Cron modules are already implemented and registered, all that's needed is to run the [`cron` phing target](../introduction/console-commands-for-application-management-phing-targets.md#cron) every 5 minutes on your server and Shopsys Platform takes care of the rest. +The Cron modules are already implemented and registered, all that's needed is to run the [`cron` phing target](../introduction/console-commands-for-application-management-phing-targets.md#cron) every 5 minutes (can be changed in parameters) on your server and Shopsys Platform takes care of the rest. They can be also generated manually in the administration section _Marketing > XML Feeds_, if you're logged in as _superadministrator_. -There are two types of product feeds: `daily` and `hourly`. +Each feed definition in `services.yaml` includes hours and minutes in cron format, when it should be generated. -Daily feed usually contain a lot of information about the products are can take a while to export if you have a lot of products. -For this exact reason, the [`DailyFeedCronModule`](https://github.com/shopsys/shopsys/blob/master/packages/framework/src/Model/Feed/DailyFeedCronModule.php) is implemented iteratively and can export the feeds in batches as needed. +For example: -Hourly feeds contain much less information and their priority is to be as current as possible. -Typical examples are exports of current availability and stock quantities of the products. -By default, they are exported every hour and the export cannot be broken into multiple Cron executions. +```yaml +Shopsys\ProductFeed\GoogleBundle\GoogleFeed: + tags: + - { name: shopsys.product_feed, hours: '1', minutes: '0' } +``` + +Google feed will be generated every day at 1:00 AM. + +You can also set it like this: + +```yaml +Shopsys\ProductFeed\GoogleBundle\GoogleFeed: + tags: + - { name: shopsys.product_feed, hours: '*/4', minutes: '0' } +``` + +In such a case, this feed will be generated every four hours. + +Feeds have their default times set in their own `services.yaml` files, but can be easily changed in your project's `feed.yaml` file. +You only need to copy service definition from feed `services.yaml` file and change the hours and minutes to expected ones. + +## How to run feed outside scheduled time? + +If you want to run feed outside scheduled time, you can use the `php bin/console shopsys:feed-schedule` command with `--feed-name` argument to schedule one feed or `--all` argument to schedule all feeds and then run `php bin/console shopsys:cron --module="Shopsys\FrameworkBundle\Model\Feed\FeedCronModule" --instance-name=export` to generate the feeds. + +## How to limit a product feed to a specific domain? + +If you want to limit a product feed to a specific domain, you can use the `domain_ids` parameter in the feed's service definition. +Copy the service definition from the feed's `services.yaml` file and add the `domain_ids` parameter with the IDs of the domains you want to limit the feed to in your projects `feed.yaml` file. + +For example, if you want to limit Google Feed to first and third domain, you will add this to your `feed.yaml` file. + +```yaml +Shopsys\ProductFeed\GoogleBundle\GoogleFeed: + tags: + - { name: shopsys.product_feed, hours: '1', minutes: '0', domain_ids: '1,3' } +``` ## How to implement a custom product feed? The heart of a product feed plugin is a service implementing the [`FeedInterface`](https://github.com/shopsys/shopsys/blob/master/packages/framework/src/Model/Feed/FeedInterface.php) that is tagged in a DI container with `shopsys.product_feed` tag. -Optionally, the tag can have a type attribute (default is `daily`). +Tags `hours` and `minutes` are mandatory and define when the feed should be generated. The annotations in the feed interfaces ([`FeedInterface`](https://github.com/shopsys/shopsys/blob/master/packages/framework/src/Model/Feed/FeedInterface.php), [`FeedInfoInterface`](https://github.com/shopsys/shopsys/blob/master/packages/framework/src/Model/Feed/FeedInfoInterface.php) and [`FeedItemInterface`](https://github.com/shopsys/shopsys/blob/master/packages/framework/src/Model/Feed/FeedItemInterface.php)) should explain a lot. When in doubt, you can take a look at the [already implemented product feeds](https://github.com/search?q=topic%3Aproduct-feed+org%3Ashopsys) for inspiration. diff --git a/packages/framework/src/Command/CronCommand.php b/packages/framework/src/Command/CronCommand.php index 5833a9a5bde..af821859f52 100644 --- a/packages/framework/src/Command/CronCommand.php +++ b/packages/framework/src/Command/CronCommand.php @@ -4,14 +4,13 @@ namespace Shopsys\FrameworkBundle\Command; -use DateTime; -use DateTimeImmutable; use DateTimeZone; use NinjaMutex\Lock\LockInterface; use Shopsys\FrameworkBundle\Command\Exception\CronCommandException; use Shopsys\FrameworkBundle\Component\Cron\Config\CronModuleConfig; use Shopsys\FrameworkBundle\Component\Cron\CronFacade; use Shopsys\FrameworkBundle\Component\Cron\MutexFactory; +use Shopsys\FrameworkBundle\Component\DateTimeHelper\DateTimeHelper; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -182,7 +181,7 @@ private function runCron( } if ($runAllModules) { - $cronFacade->scheduleModulesByTime($this->getCurrentRoundedTime($instanceRunEveryMin)); + $cronFacade->scheduleModulesByTime(DateTimeHelper::getCurrentRoundedTimeForIntervalAndTimezone($instanceRunEveryMin, $this->getCronTimeZone())); } $mutex = $mutexFactory->getPrefixedCronMutex($instanceName); @@ -201,19 +200,6 @@ private function runCron( $mutex->releaseLock(); } - /** - * @param int $runEveryMin - * @return \DateTimeImmutable - */ - private function getCurrentRoundedTime(int $runEveryMin) - { - $time = new DateTime('now', $this->getCronTimeZone()); - $time->modify('-' . $time->format('s') . ' sec'); - $time->modify('-' . ($time->format('i') % $runEveryMin) . ' min'); - - return DateTimeImmutable::createFromMutable($time); - } - /** * @param \Symfony\Component\Console\Input\InputInterface $input * @param \Symfony\Component\Console\Output\OutputInterface $output diff --git a/packages/framework/src/Command/ScheduleFeedsCommand.php b/packages/framework/src/Command/ScheduleFeedsCommand.php new file mode 100644 index 00000000000..a32b421bc0d --- /dev/null +++ b/packages/framework/src/Command/ScheduleFeedsCommand.php @@ -0,0 +1,67 @@ +setDescription('Schedule feeds to be generated in the next cron run.') + ->addOption(self::OPTION_FEED_NAME, null, InputOption::VALUE_OPTIONAL, 'name of feed to be scheduled') + ->addOption(self::OPTION_ALL, null, InputOption::VALUE_NONE, 'schedule all feeds'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $optionAll = $input->getOption(self::OPTION_ALL); + $optionFeedName = $input->getOption(self::OPTION_FEED_NAME); + + $symfonyStyle = new SymfonyStyle($input, $output); + + if ($optionAll === true) { + $symfonyStyle->info('Scheduling all feeds...'); + $this->feedFacade->scheduleAllFeeds(); + } elseif ($optionFeedName !== null) { + $symfonyStyle->info('Scheduling feed...'); + $this->feedFacade->scheduleFeedByName($optionFeedName); + } else { + $symfonyStyle->error('You have to specify either --all or --feed-name option.'); + + return Command::FAILURE; + } + + $symfonyStyle->success('Done!'); + + return Command::SUCCESS; + } +} diff --git a/packages/framework/src/Component/Cron/Config/CronConfig.php b/packages/framework/src/Component/Cron/Config/CronConfig.php index 499fa64e6b1..94e1d823eda 100644 --- a/packages/framework/src/Component/Cron/Config/CronConfig.php +++ b/packages/framework/src/Component/Cron/Config/CronConfig.php @@ -21,8 +21,9 @@ class CronConfig /** * @param \Shopsys\FrameworkBundle\Component\Cron\CronTimeResolver $cronTimeResolver */ - public function __construct(protected readonly CronTimeResolver $cronTimeResolver) - { + public function __construct( + protected readonly CronTimeResolver $cronTimeResolver, + ) { $this->cronModuleConfigs = []; } @@ -52,7 +53,7 @@ public function registerCronModuleInstance( throw new InvalidCronModuleException($serviceId); } $this->cronTimeResolver->validateTimeString($timeHours, 23, 1); - $this->cronTimeResolver->validateTimeString($timeMinutes, 55, 5); + $this->cronTimeResolver->validateTimeString($timeMinutes, 55, 1); $cronModuleConfig = new CronModuleConfig($service, $serviceId, $timeHours, $timeMinutes, $readableName, $readableFrequency, $runEveryMin, $timeoutIteratedCronSec); $cronModuleConfig->assignToInstance($instanceName); @@ -63,7 +64,7 @@ public function registerCronModuleInstance( /** * @return \Shopsys\FrameworkBundle\Component\Cron\Config\CronModuleConfig[] */ - public function getAllCronModuleConfigs() + public function getAllCronModuleConfigs(): array { return $this->cronModuleConfigs; } @@ -72,7 +73,7 @@ public function getAllCronModuleConfigs() * @param \DateTimeInterface $roundedTime * @return \Shopsys\FrameworkBundle\Component\Cron\Config\CronModuleConfig[] */ - public function getCronModuleConfigsByTime(DateTimeInterface $roundedTime) + public function getCronModuleConfigsByTime(DateTimeInterface $roundedTime): array { $matchedCronConfigs = []; @@ -89,7 +90,7 @@ public function getCronModuleConfigsByTime(DateTimeInterface $roundedTime) * @param string $serviceId * @return \Shopsys\FrameworkBundle\Component\Cron\Config\CronModuleConfig */ - public function getCronModuleConfigByServiceId($serviceId) + public function getCronModuleConfigByServiceId(string $serviceId): CronModuleConfig { foreach ($this->cronModuleConfigs as $cronConfig) { if ($cronConfig->getServiceId() === $serviceId) { diff --git a/packages/framework/src/Component/Cron/CronTimeInterface.php b/packages/framework/src/Component/Cron/CronTimeInterface.php index 949efac5527..20125904da1 100644 --- a/packages/framework/src/Component/Cron/CronTimeInterface.php +++ b/packages/framework/src/Component/Cron/CronTimeInterface.php @@ -9,10 +9,10 @@ interface CronTimeInterface /** * @return string */ - public function getTimeMinutes(); + public function getTimeMinutes(): string; /** * @return string */ - public function getTimeHours(); + public function getTimeHours(): string; } diff --git a/packages/framework/src/Component/Cron/CronTimeResolver.php b/packages/framework/src/Component/Cron/CronTimeResolver.php index 380c782460d..5608a3807b9 100644 --- a/packages/framework/src/Component/Cron/CronTimeResolver.php +++ b/packages/framework/src/Component/Cron/CronTimeResolver.php @@ -14,7 +14,7 @@ class CronTimeResolver * @param \DateTimeInterface $dateTime * @return bool */ - public function isValidAtTime(CronTimeInterface $cronTime, DateTimeInterface $dateTime) + public function isValidAtTime(CronTimeInterface $cronTime, DateTimeInterface $dateTime): bool { $hour = (int)$dateTime->format('G'); $minute = (int)$dateTime->format('i'); @@ -28,7 +28,7 @@ public function isValidAtTime(CronTimeInterface $cronTime, DateTimeInterface $da * @param string $timeString * @return bool */ - protected function isMatchWithTimeString($value, $timeString) + protected function isMatchWithTimeString(int $value, string $timeString): bool { $timeValues = explode(',', $timeString); $matches = null; @@ -52,7 +52,7 @@ protected function isMatchWithTimeString($value, $timeString) * @param int $maxValue * @param int $divisibleBy */ - public function validateTimeString($timeString, $maxValue, $divisibleBy) + public function validateTimeString(string $timeString, int $maxValue, int $divisibleBy): void { $timeValues = explode(',', $timeString); $matches = null; diff --git a/packages/framework/src/Component/DateTimeHelper/DateTimeHelper.php b/packages/framework/src/Component/DateTimeHelper/DateTimeHelper.php index f656dea9671..7f438811612 100644 --- a/packages/framework/src/Component/DateTimeHelper/DateTimeHelper.php +++ b/packages/framework/src/Component/DateTimeHelper/DateTimeHelper.php @@ -5,27 +5,18 @@ namespace Shopsys\FrameworkBundle\Component\DateTimeHelper; use DateTime; +use DateTimeImmutable; +use DateTimeZone; use Shopsys\FrameworkBundle\Component\DateTimeHelper\Exception\CannotParseDateTimeException; class DateTimeHelper { - /** - * @return \DateTime - */ - public static function createTodayMidnightDateTime() - { - $todayMidnight = new DateTime(); - $todayMidnight->setTime(0, 0, 0); - - return $todayMidnight; - } - /** * @param string $format * @param string $time * @return \DateTime */ - public static function createFromFormat($format, $time) + public static function createFromFormat(string $format, string $time): DateTime { $dateTime = DateTime::createFromFormat($format, $time); @@ -35,4 +26,20 @@ public static function createFromFormat($format, $time) return $dateTime; } + + /** + * @param int $intervalInMinutes + * @param \DateTimeZone $dateTimeZone + * @return \DateTimeImmutable + */ + public static function getCurrentRoundedTimeForIntervalAndTimezone( + int $intervalInMinutes, + DateTimeZone $dateTimeZone, + ): DateTimeImmutable { + $time = new DateTime('now', $dateTimeZone); + $time->modify('-' . $time->format('s') . ' sec'); + $time->modify('-' . ($time->format('i') % $intervalInMinutes) . ' min'); + + return DateTimeImmutable::createFromMutable($time); + } } diff --git a/packages/framework/src/Component/Domain/Domain.php b/packages/framework/src/Component/Domain/Domain.php index 68f3c5344b5..5c2bcdec524 100644 --- a/packages/framework/src/Component/Domain/Domain.php +++ b/packages/framework/src/Component/Domain/Domain.php @@ -93,7 +93,7 @@ public function getAll() try { $this->setting->getForDomain(Setting::DOMAIN_DATA_CREATED, $domainId); - $domainConfigsWithDataCreated[] = $domainConfig; + $domainConfigsWithDataCreated[$domainId] = $domainConfig; } catch (SettingValueNotFoundException $ex) { continue; } diff --git a/packages/framework/src/Controller/Admin/FeedController.php b/packages/framework/src/Controller/Admin/FeedController.php index d9f8351bb04..cbc6b32d3f7 100644 --- a/packages/framework/src/Controller/Admin/FeedController.php +++ b/packages/framework/src/Controller/Admin/FeedController.php @@ -10,7 +10,10 @@ use Shopsys\FrameworkBundle\Component\Grid\GridFactory; use Shopsys\FrameworkBundle\Model\Feed\Exception\FeedNotFoundException; use Shopsys\FrameworkBundle\Model\Feed\FeedFacade; +use Shopsys\FrameworkBundle\Model\Feed\FeedModuleRepository; +use Shopsys\FrameworkBundle\Model\Feed\FeedRegistry; use Shopsys\FrameworkBundle\Model\Security\Roles; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\Routing\Annotation\Route; class FeedController extends AdminBaseController @@ -19,11 +22,15 @@ class FeedController extends AdminBaseController * @param \Shopsys\FrameworkBundle\Model\Feed\FeedFacade $feedFacade * @param \Shopsys\FrameworkBundle\Component\Grid\GridFactory $gridFactory * @param \Shopsys\FrameworkBundle\Component\Domain\Domain $domain + * @param \Shopsys\FrameworkBundle\Model\Feed\FeedRegistry $feedRegistry + * @param \Shopsys\FrameworkBundle\Model\Feed\FeedModuleRepository $feedModuleRepository */ public function __construct( protected readonly FeedFacade $feedFacade, protected readonly GridFactory $gridFactory, protected readonly Domain $domain, + protected readonly FeedRegistry $feedRegistry, + protected readonly FeedModuleRepository $feedModuleRepository, ) { } @@ -57,17 +64,52 @@ public function generateAction($feedName, $domainId) return $this->redirectToRoute('admin_feed_list'); } + /** + * @Route("/feed/schedule/{feedName}/{domainId}", requirements={"domainId" = "\d+"}) + * @param string $feedName + * @param int $domainId + * @return \Symfony\Component\HttpFoundation\RedirectResponse + */ + public function scheduleAction(string $feedName, int $domainId): RedirectResponse + { + try { + $this->feedFacade->scheduleFeedByNameAndDomainId($feedName, $domainId); + + $this->addSuccessFlashTwig( + t('Feed "{{ feedName }}" on domain ID {{ domainId }} successfully scheduled.'), + [ + 'feedName' => $feedName, + 'domainId' => $domainId, + ], + ); + } catch (FeedNotFoundException $ex) { + $this->addErrorFlashTwig( + t('Feed "{{ feedName }}" on domain ID {{ domainId }} not found.'), + [ + 'feedName' => $feedName, + 'domainId' => $domainId, + ], + ); + } + + return $this->redirectToRoute('admin_feed_list'); + } + /** * @Route("/feed/list/") */ public function listAction() { $feedsData = []; + $feedConfigs = $this->feedRegistry->getAllFeedConfigs(); + - $feedsInfo = $this->feedFacade->getFeedsInfo(); + foreach ($feedConfigs as $feedConfig) { + foreach ($feedConfig->getDomainIds() as $domainId) { + $domainConfig = $this->domain->getDomainConfigById($domainId); + $feedInfo = $feedConfig->getFeed()->getInfo(); + $feedModulesIndexedByDomainId = $this->feedModuleRepository->getFeedModulesByConfigIndexedByDomainId($feedConfig); - foreach ($feedsInfo as $feedInfo) { - foreach ($this->domain->getAll() as $domainConfig) { $feedTimestamp = $this->feedFacade->getFeedTimestamp($feedInfo, $domainConfig); $feedsData[] = [ 'feedLabel' => $feedInfo->getLabel(), @@ -75,7 +117,8 @@ public function listAction() 'domainConfig' => $domainConfig, 'url' => $this->feedFacade->getFeedUrl($feedInfo, $domainConfig), 'created' => $feedTimestamp === null ? null : (new DateTime())->setTimestamp($feedTimestamp), - 'actions' => null, + 'generate' => null, + 'schedule' => $feedModulesIndexedByDomainId[$domainId]->isScheduled(), 'additionalInformation' => $feedInfo->getAdditionalInformation(), ]; } @@ -90,9 +133,11 @@ public function listAction() $grid->addColumn('url', 'url', t('Url address')); if ($this->isGranted(Roles::ROLE_SUPER_ADMIN)) { - $grid->addColumn('actions', 'actions', t('Action')); + $grid->addColumn('generate', 'generate', t('Generate')); } + $grid->addColumn('schedule', 'schedule', t('Schedule')); + $grid->setTheme('@ShopsysFramework/Admin/Content/Feed/listGrid.html.twig'); return $this->render('@ShopsysFramework/Admin/Content/Feed/list.html.twig', [ diff --git a/packages/framework/src/DependencyInjection/Compiler/RegisterProductFeedConfigsCompilerPass.php b/packages/framework/src/DependencyInjection/Compiler/RegisterProductFeedConfigsCompilerPass.php index f09af7fc309..b6c8b62ccc4 100644 --- a/packages/framework/src/DependencyInjection/Compiler/RegisterProductFeedConfigsCompilerPass.php +++ b/packages/framework/src/DependencyInjection/Compiler/RegisterProductFeedConfigsCompilerPass.php @@ -22,9 +22,25 @@ public function process(ContainerBuilder $container): void foreach ($taggedServiceIds as $serviceId => $tags) { foreach ($tags as $tag) { - $type = $tag['type'] ?? null; - $feedRegistryDefinition->addMethodCall('registerFeed', [new Reference($serviceId), $type]); + $feedRegistryDefinition->addMethodCall( + 'registerFeed', + [ + new Reference($serviceId), + $tag['hours'], + $tag['minutes'], + isset($tag['domain_ids']) ? $this->splitDomainIdsFromString($tag['domain_ids']) : [], + ], + ); } } } + + /** + * @param string $domainIds + * @return int[] + */ + protected function splitDomainIdsFromString(string $domainIds): array + { + return array_map('intval', explode(',', $domainIds)); + } } diff --git a/packages/framework/src/Migrations/Version20231114121946.php b/packages/framework/src/Migrations/Version20231114121946.php new file mode 100644 index 00000000000..a1940c81e60 --- /dev/null +++ b/packages/framework/src/Migrations/Version20231114121946.php @@ -0,0 +1,32 @@ +sql(' + CREATE TABLE feed_modules ( + name VARCHAR(255) NOT NULL, + domain_id INT NOT NULL, + scheduled BOOLEAN NOT NULL, + PRIMARY KEY(name, domain_id) + )'); + } + + /** + * @param \Doctrine\DBAL\Schema\Schema $schema + */ + public function down(Schema $schema): void + { + } +} diff --git a/packages/framework/src/Model/Feed/Exception/FeedNotFoundException.php b/packages/framework/src/Model/Feed/Exception/FeedNotFoundException.php index 85dcf1f7d6d..fe2cc1ac63e 100644 --- a/packages/framework/src/Model/Feed/Exception/FeedNotFoundException.php +++ b/packages/framework/src/Model/Feed/Exception/FeedNotFoundException.php @@ -10,11 +10,19 @@ class FeedNotFoundException extends Exception implements FeedException { /** * @param string $name + * @param int|null $domainId * @param \Exception|null $previous */ - public function __construct(string $name, ?Exception $previous = null) - { - $message = 'Feed with name "' . $name . ' not found.'; + public function __construct( + string $name, + ?int $domainId = null, + ?Exception $previous = null, + ) { + $message = sprintf( + 'Feed with name "%s"%s not found.', + $name, + $domainId !== null ? sprintf(' and domain ID %d', $domainId) : '', + ); parent::__construct($message, 0, $previous); } diff --git a/packages/framework/src/Model/Feed/FeedConfig.php b/packages/framework/src/Model/Feed/FeedConfig.php new file mode 100644 index 00000000000..142fa75ab95 --- /dev/null +++ b/packages/framework/src/Model/Feed/FeedConfig.php @@ -0,0 +1,56 @@ +feed; + } + + /** + * @return string + */ + public function getTimeMinutes(): string + { + return $this->minutes; + } + + /** + * @return string + */ + public function getTimeHours(): string + { + return $this->hours; + } + + /** + * @return int[] + */ + public function getDomainIds(): array + { + return $this->domainIds; + } +} diff --git a/packages/framework/src/Model/Feed/DailyFeedCronModule.php b/packages/framework/src/Model/Feed/FeedCronModule.php similarity index 85% rename from packages/framework/src/Model/Feed/DailyFeedCronModule.php rename to packages/framework/src/Model/Feed/FeedCronModule.php index fcf4857596a..16f7ee641ba 100644 --- a/packages/framework/src/Model/Feed/DailyFeedCronModule.php +++ b/packages/framework/src/Model/Feed/FeedCronModule.php @@ -9,7 +9,7 @@ use Shopsys\Plugin\Cron\IteratedCronModuleInterface; use Symfony\Bridge\Monolog\Logger; -class DailyFeedCronModule implements IteratedCronModuleInterface +class FeedCronModule implements IteratedCronModuleInterface { protected Logger $logger; @@ -17,15 +17,19 @@ class DailyFeedCronModule implements IteratedCronModuleInterface protected ?FeedExport $currentFeedExport = null; + protected bool $areFeedsScheduled = false; + /** * @param \Shopsys\FrameworkBundle\Model\Feed\FeedFacade $feedFacade * @param \Shopsys\FrameworkBundle\Component\Domain\Domain $domain * @param \Shopsys\FrameworkBundle\Component\Setting\Setting $setting + * @param \Shopsys\FrameworkBundle\Model\Feed\FeedModuleRepository $feedModuleRepository */ public function __construct( protected readonly FeedFacade $feedFacade, protected readonly Domain $domain, protected readonly Setting $setting, + protected readonly FeedModuleRepository $feedModuleRepository, ) { } @@ -42,6 +46,11 @@ public function setLogger(Logger $logger): void */ public function iterate(): bool { + if ($this->areFeedsScheduled === false) { + $this->feedFacade->scheduleFeedsForCurrentTime(); + $this->areFeedsScheduled = true; + } + if ($this->getFeedExportCreationDataQueue()->isEmpty()) { $this->logger->debug('Queue is empty, no feeds to process.'); @@ -58,6 +67,12 @@ public function iterate(): bool $feedInfo = $this->currentFeedExport->getFeedInfo(); $domainConfig = $this->currentFeedExport->getDomainConfig(); + $currentFeedModule = $this->feedModuleRepository->getFeedModuleByNameAndDomainId( + $this->getFeedExportCreationDataQueue()->getCurrentFeedName(), + $this->getFeedExportCreationDataQueue()->getCurrentDomain()->getId(), + ); + $this->feedFacade->markFeedModuleAsUnscheduled($currentFeedModule); + $this->logger->debug(sprintf( 'Feed "%s" generated on domain "%s" into "%s".', $feedInfo->getName(), @@ -156,7 +171,7 @@ protected function getFeedExportCreationDataQueue(): FeedExportCreationDataQueue { if ($this->feedExportCreationDataQueue === null) { $this->feedExportCreationDataQueue = new FeedExportCreationDataQueue( - $this->feedFacade->getFeedNames('daily'), + $this->feedModuleRepository->getAllScheduledFeedModules(), $this->domain->getAll(), ); } diff --git a/packages/framework/src/Model/Feed/FeedExportCreationDataQueue.php b/packages/framework/src/Model/Feed/FeedExportCreationDataQueue.php index b4aac7f105c..310f0a75470 100644 --- a/packages/framework/src/Model/Feed/FeedExportCreationDataQueue.php +++ b/packages/framework/src/Model/Feed/FeedExportCreationDataQueue.php @@ -16,21 +16,13 @@ class FeedExportCreationDataQueue { /** - * @var array - */ - protected array $dataInQueue = []; - - /** - * @param string[] $feedNames + * @param \Shopsys\FrameworkBundle\Model\Feed\FeedModule[] $feedModules * @param \Shopsys\FrameworkBundle\Component\Domain\Config\DomainConfig[] $domains */ - public function __construct(array $feedNames, array $domains) - { - foreach ($feedNames as $feedName) { - foreach ($domains as $domain) { - $this->dataInQueue[] = ['feed_name' => $feedName, 'domain' => $domain]; - } - } + public function __construct( + protected array $feedModules, + protected array $domains, + ) { } /** @@ -38,7 +30,7 @@ public function __construct(array $feedNames, array $domains) */ public function getCurrentFeedName(): string { - return current($this->dataInQueue)['feed_name']; + return current($this->feedModules)->getName(); } /** @@ -46,7 +38,15 @@ public function getCurrentFeedName(): string */ public function getCurrentDomain(): DomainConfig { - return current($this->dataInQueue)['domain']; + return $this->domains[current($this->feedModules)->getDomainId()]; + } + + /** + * @return \Shopsys\FrameworkBundle\Model\Feed\FeedModule + */ + public function getCurrentFeedModule(): FeedModule + { + return current($this->feedModules); } /** @@ -54,7 +54,7 @@ public function getCurrentDomain(): DomainConfig */ public function next(): bool { - array_shift($this->dataInQueue); + array_shift($this->feedModules); return !$this->isEmpty(); } @@ -64,6 +64,6 @@ public function next(): bool */ public function isEmpty(): bool { - return count($this->dataInQueue) === 0; + return count($this->feedModules) === 0; } } diff --git a/packages/framework/src/Model/Feed/FeedFacade.php b/packages/framework/src/Model/Feed/FeedFacade.php index cbdcc71033a..8f1972eaabe 100644 --- a/packages/framework/src/Model/Feed/FeedFacade.php +++ b/packages/framework/src/Model/Feed/FeedFacade.php @@ -4,6 +4,7 @@ namespace Shopsys\FrameworkBundle\Model\Feed; +use Doctrine\ORM\EntityManagerInterface; use League\Flysystem\FilesystemOperator; use League\Flysystem\UnableToRetrieveMetadata; use Shopsys\FrameworkBundle\Component\Domain\Config\DomainConfig; @@ -17,6 +18,8 @@ class FeedFacade * @param \Shopsys\FrameworkBundle\Model\Feed\FeedExportFactory $feedExportFactory * @param \Shopsys\FrameworkBundle\Model\Feed\FeedPathProvider $feedPathProvider * @param \League\Flysystem\FilesystemOperator $filesystem + * @param \Shopsys\FrameworkBundle\Model\Feed\FeedModuleRepository $feedModuleRepository + * @param \Doctrine\ORM\EntityManagerInterface $em */ public function __construct( protected readonly FeedRegistry $feedRegistry, @@ -24,6 +27,8 @@ public function __construct( protected readonly FeedExportFactory $feedExportFactory, protected readonly FeedPathProvider $feedPathProvider, protected readonly FilesystemOperator $filesystem, + protected readonly FeedModuleRepository $feedModuleRepository, + protected readonly EntityManagerInterface $em, ) { } @@ -55,37 +60,37 @@ public function createFeedExport(string $feedName, DomainConfig $domainConfig, ? */ $this->productVisibilityFacade->refreshProductsVisibilityForMarked(); - $feed = $this->feedRegistry->getFeedByName($feedName); + $feedConfig = $this->feedRegistry->getFeedConfigByName($feedName); - return $this->feedExportFactory->create($feed, $domainConfig, $lastSeekId); + return $this->feedExportFactory->create($feedConfig->getFeed(), $domainConfig, $lastSeekId); } /** - * @param string|null $feedType + * @param bool $onlyForCurrentTime * @return \Shopsys\FrameworkBundle\Model\Feed\FeedInfoInterface[] */ - public function getFeedsInfo(?string $feedType = null): array + public function getFeedsInfo(bool $onlyForCurrentTime = false): array { - $feeds = $feedType === null ? $this->feedRegistry->getAllFeeds() : $this->feedRegistry->getFeeds($feedType); + $feedConfigs = $onlyForCurrentTime ? $this->feedRegistry->getAllFeedConfigs() : $this->feedRegistry->getFeedConfigsForCurrentTime(); $feedsInfo = []; - foreach ($feeds as $feed) { - $feedsInfo[] = $feed->getInfo(); + foreach ($feedConfigs as $feedConfig) { + $feedsInfo[] = $feedConfig->getFeed()->getInfo(); } return $feedsInfo; } /** - * @param string|null $feedType + * @param bool $onlyForCurrentTime * @return string[] */ - public function getFeedNames(?string $feedType = null): array + public function getFeedNames(bool $onlyForCurrentTime = false): array { $feedNames = []; - foreach ($this->getFeedsInfo($feedType) as $feedInfo) { + foreach ($this->getFeedsInfo($onlyForCurrentTime) as $feedInfo) { $feedNames[] = $feedInfo->getName(); } @@ -127,4 +132,64 @@ public function getFeedTimestamp(FeedInfoInterface $feedInfo, DomainConfig $doma return null; } } + + public function scheduleFeedsForCurrentTime(): void + { + $feedConfigsToSchedule = $this->feedRegistry->getFeedConfigsForCurrentTime(); + + $this->markFeedConfigsForScheduling($feedConfigsToSchedule); + } + + public function scheduleAllFeeds(): void + { + $feedConfigsToSchedule = $this->feedRegistry->getAllFeedConfigs(); + + $this->markFeedConfigsForScheduling($feedConfigsToSchedule); + } + + /** + * @param string $name + */ + public function scheduleFeedByName(string $name): void + { + $feedConfigsToSchedule = [$this->feedRegistry->getFeedConfigByName($name)]; + + $this->markFeedConfigsForScheduling($feedConfigsToSchedule); + } + + /** + * @param string $name + * @param int $domainId + */ + public function scheduleFeedByNameAndDomainId(string $name, int $domainId): void + { + $feedModule = $this->feedModuleRepository->getFeedModuleByNameAndDomainId($name, $domainId); + $feedModule->schedule(); + $this->em->flush(); + } + + /** + * @param \Shopsys\FrameworkBundle\Model\Feed\FeedModule $feedModule + */ + public function markFeedModuleAsUnscheduled(FeedModule $feedModule): void + { + $feedModule->unschedule(); + $this->em->flush(); + } + + /** + * @param array $feedConfigsToSchedule + */ + protected function markFeedConfigsForScheduling(array $feedConfigsToSchedule): void + { + foreach ($feedConfigsToSchedule as $feedConfig) { + $feedModules = $this->feedModuleRepository->getFeedModulesByConfigIndexedByDomainId($feedConfig); + + foreach ($feedModules as $feedModule) { + $feedModule->schedule(); + } + } + + $this->em->flush(); + } } diff --git a/packages/framework/src/Model/Feed/FeedModule.php b/packages/framework/src/Model/Feed/FeedModule.php new file mode 100644 index 00000000000..bb4909adb7c --- /dev/null +++ b/packages/framework/src/Model/Feed/FeedModule.php @@ -0,0 +1,79 @@ +name = $name; + $this->domainId = $domainId; + $this->scheduled = false; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return int + */ + public function getDomainId(): int + { + return $this->domainId; + } + + public function schedule(): void + { + $this->scheduled = true; + } + + public function unschedule(): void + { + $this->scheduled = false; + } + + /** + * @return bool + */ + public function isScheduled(): bool + { + return $this->scheduled; + } +} diff --git a/packages/framework/src/Model/Feed/FeedModuleFactory.php b/packages/framework/src/Model/Feed/FeedModuleFactory.php new file mode 100644 index 00000000000..2fe3cdd1864 --- /dev/null +++ b/packages/framework/src/Model/Feed/FeedModuleFactory.php @@ -0,0 +1,30 @@ +entityNameResolver->resolve(FeedModule::class); + + return new $className($name, $domainId); + } +} diff --git a/packages/framework/src/Model/Feed/FeedModuleFactoryInterface.php b/packages/framework/src/Model/Feed/FeedModuleFactoryInterface.php new file mode 100644 index 00000000000..36bfc9cb0bb --- /dev/null +++ b/packages/framework/src/Model/Feed/FeedModuleFactoryInterface.php @@ -0,0 +1,15 @@ +em->getRepository(FeedModule::class); + } + + /** + * @param \Shopsys\FrameworkBundle\Model\Feed\FeedConfig $feedConfig + * @return \Shopsys\FrameworkBundle\Model\Feed\FeedModule[] + */ + public function getFeedModulesByConfigIndexedByDomainId(FeedConfig $feedConfig): array + { + $feedName = $feedConfig->getFeed()->getInfo()->getName(); + $feedModules = []; + + foreach ($feedConfig->getDomainIds() as $domainId) { + $feedModule = $this->getFeedModuleRepository()->findOneBy([ + 'name' => $feedName, + 'domainId' => $domainId, + ]); + + if ($feedModule !== null) { + $feedModules[$domainId] = $feedModule; + + continue; + } + + $feedModule = $this->feedModuleFactory->create($feedName, $domainId); + $this->em->persist($feedModule); + + $feedModules[$domainId] = $feedModule; + } + + $this->em->flush(); + + return $feedModules; + } + + /** + * @param string $name + * @param int $domainId + * @return \Shopsys\FrameworkBundle\Model\Feed\FeedModule + */ + public function getFeedModuleByNameAndDomainId(string $name, int $domainId): FeedModule + { + $feedModule = $this->getFeedModuleRepository()->findOneBy([ + 'name' => $name, + 'domainId' => $domainId, + ]); + + if ($feedModule === null) { + throw new FeedNotFoundException($name, $domainId); + } + + return $feedModule; + } + + /** + * @return \Shopsys\FrameworkBundle\Model\Feed\FeedModule[] + */ + public function getAllScheduledFeedModules(): array + { + return $this->getFeedModuleRepository()->findBy(['scheduled' => true]); + } +} diff --git a/packages/framework/src/Model/Feed/FeedRegistry.php b/packages/framework/src/Model/Feed/FeedRegistry.php index ed904245e38..a45868299bb 100644 --- a/packages/framework/src/Model/Feed/FeedRegistry.php +++ b/packages/framework/src/Model/Feed/FeedRegistry.php @@ -4,90 +4,104 @@ namespace Shopsys\FrameworkBundle\Model\Feed; -use Shopsys\FrameworkBundle\Component\Utils\Utils; +use DateTimeZone; +use Shopsys\FrameworkBundle\Component\Cron\Config\CronConfig; +use Shopsys\FrameworkBundle\Component\Cron\CronTimeResolver; +use Shopsys\FrameworkBundle\Component\DateTimeHelper\DateTimeHelper; +use Shopsys\FrameworkBundle\Component\Domain\Domain; use Shopsys\FrameworkBundle\Model\Feed\Exception\FeedNameNotUniqueException; use Shopsys\FrameworkBundle\Model\Feed\Exception\FeedNotFoundException; -use Shopsys\FrameworkBundle\Model\Feed\Exception\UnknownFeedTypeException; class FeedRegistry { /** - * @var \Shopsys\FrameworkBundle\Model\Feed\FeedInterface[][] + * @var \Shopsys\FrameworkBundle\Model\Feed\FeedConfig[] */ - protected array $feedsByType = []; + protected array $feedConfigsByName = []; /** - * @var \Shopsys\FrameworkBundle\Model\Feed\FeedInterface[] + * @param string|null $cronTimeZone + * @param \Shopsys\FrameworkBundle\Component\Cron\CronTimeResolver $cronTimeResolver + * @param \Shopsys\FrameworkBundle\Component\Cron\Config\CronConfig $cronConfig + * @param \Shopsys\FrameworkBundle\Component\Domain\Domain $domain */ - protected array $feedsByName = []; - - /** - * @param string[] $knownTypes - * @param string $defaultType - */ - public function __construct(protected readonly array $knownTypes, protected readonly string $defaultType) - { - foreach ($knownTypes as $type) { - $this->feedsByType[$type] = []; - } + public function __construct( + protected readonly ?string $cronTimeZone, + protected readonly CronTimeResolver $cronTimeResolver, + protected readonly CronConfig $cronConfig, + protected readonly Domain $domain, + ) { } /** * @param \Shopsys\FrameworkBundle\Model\Feed\FeedInterface $feed - * @param string|null $type + * @param string $timeHours + * @param string $timeMinutes + * @param array $domainIds */ - public function registerFeed(FeedInterface $feed, ?string $type = null): void + public function registerFeed(FeedInterface $feed, string $timeHours, string $timeMinutes, array $domainIds): void { - $type = Utils::ifNull($type, $this->defaultType); - $this->assertTypeIsKnown($type); + $this->cronTimeResolver->validateTimeString($timeHours, 23, 1); + $this->cronTimeResolver->validateTimeString($timeMinutes, 55, 1); $name = $feed->getInfo()->getName(); $this->assertNameIsUnique($name); - $this->feedsByType[$type][$name] = $feed; - $this->feedsByName[$name] = $feed; + $domainIds = $domainIds === [] ? $this->domain->getAllIds() : $domainIds; + + $this->feedConfigsByName[$name] = new FeedConfig($feed, $timeHours, $timeMinutes, $domainIds); } /** - * @param string $type - * @return \Shopsys\FrameworkBundle\Model\Feed\FeedInterface[] + * @return \Shopsys\FrameworkBundle\Model\Feed\FeedConfig[] */ - public function getFeeds(string $type): array + public function getFeedConfigsForCurrentTime(): array { - $this->assertTypeIsKnown($type); + $timeZone = new DateTimeZone($this->cronTimeZone ?? date_default_timezone_get()); + $matchedFeedConfig = []; + + foreach ($this->feedConfigsByName as $feedConfig) { + if ($this->cronTimeResolver->isValidAtTime( + $feedConfig, + DateTimeHelper::getCurrentRoundedTimeForIntervalAndTimezone( + $this->getFeedCronModuleRunEveryMinuteValue(), + $timeZone, + ), + )) { + $matchedFeedConfig[] = $feedConfig; + } + } - return $this->feedsByType[$type]; + return $matchedFeedConfig; } /** * @return \Shopsys\FrameworkBundle\Model\Feed\FeedInterface[] */ - public function getAllFeeds(): array + public function getFeedsForCurrentTime(): array { - return $this->feedsByName; + return array_map(fn (FeedConfig $feedConfig) => $feedConfig->getFeed(), $this->getFeedConfigsForCurrentTime()); } /** - * @param string $name - * @return \Shopsys\FrameworkBundle\Model\Feed\FeedInterface + * @return \Shopsys\FrameworkBundle\Model\Feed\FeedConfig[] */ - public function getFeedByName(string $name): FeedInterface + public function getAllFeedConfigs(): array { - if (!array_key_exists($name, $this->feedsByName)) { - throw new FeedNotFoundException($name); - } - - return $this->feedsByName[$name]; + return $this->feedConfigsByName; } /** - * @param string $type + * @param string $name + * @return \Shopsys\FrameworkBundle\Model\Feed\FeedConfig */ - protected function assertTypeIsKnown(string $type): void + public function getFeedConfigByName(string $name): FeedConfig { - if (!in_array($type, $this->knownTypes, true)) { - throw new UnknownFeedTypeException($type, $this->knownTypes); + if (!array_key_exists($name, $this->feedConfigsByName)) { + throw new FeedNotFoundException($name); } + + return $this->feedConfigsByName[$name]; } /** @@ -95,8 +109,18 @@ protected function assertTypeIsKnown(string $type): void */ protected function assertNameIsUnique(string $name): void { - if (array_key_exists($name, $this->feedsByName)) { + if (array_key_exists($name, $this->feedConfigsByName)) { throw new FeedNameNotUniqueException($name); } } + + /** + * @return int + */ + protected function getFeedCronModuleRunEveryMinuteValue(): int + { + $feedCronModule = $this->cronConfig->getCronModuleConfigByServiceId(FeedCronModule::class); + + return $feedCronModule->getRunEveryMin(); + } } diff --git a/packages/framework/src/Model/Feed/HourlyFeedCronModule.php b/packages/framework/src/Model/Feed/HourlyFeedCronModule.php deleted file mode 100644 index 823353bea10..00000000000 --- a/packages/framework/src/Model/Feed/HourlyFeedCronModule.php +++ /dev/null @@ -1,49 +0,0 @@ -logger = $logger; - } - - public function run(): void - { - foreach ($this->feedFacade->getFeedsInfo('hourly') as $feedInfo) { - foreach ($this->domain->getAll() as $domainConfig) { - $startTime = microtime(true); - $this->feedFacade->generateFeed($feedInfo->getName(), $domainConfig); - $endTime = microtime(true); - - $this->logger->debug(sprintf( - 'Feed "%s" generated on domain "%s" into "%s" in %.3f s', - $feedInfo->getName(), - $domainConfig->getName(), - $this->feedFacade->getFeedFilepath($feedInfo, $domainConfig), - $endTime - $startTime, - )); - } - } - } -} diff --git a/packages/framework/src/Resources/config/services.yaml b/packages/framework/src/Resources/config/services.yaml index 1f91fd1564d..f9fa17f7692 100644 --- a/packages/framework/src/Resources/config/services.yaml +++ b/packages/framework/src/Resources/config/services.yaml @@ -395,8 +395,7 @@ services: Shopsys\FrameworkBundle\Model\Feed\FeedRegistry: arguments: - $knownTypes: ['daily', 'hourly'] - $defaultType: 'daily' + $cronTimeZone: '%shopsys.cron_timezone%' Shopsys\FrameworkBundle\Component\HttpFoundation\ResponseListener: tags: @@ -1039,3 +1038,6 @@ services: Shopsys\FrameworkBundle\Component\Messenger\MessageDispatcherDependency: arguments: $transportDsn: '%env(MESSENGER_TRANSPORT_DSN)%' + + Shopsys\FrameworkBundle\Model\Feed\FeedModuleFactoryInterface: + alias: Shopsys\FrameworkBundle\Model\Feed\FeedModuleFactory diff --git a/packages/framework/src/Resources/config/services_test.yaml b/packages/framework/src/Resources/config/services_test.yaml index 045d9c71d56..d7a29a93730 100644 --- a/packages/framework/src/Resources/config/services_test.yaml +++ b/packages/framework/src/Resources/config/services_test.yaml @@ -6,8 +6,7 @@ services: Shopsys\FrameworkBundle\Model\Feed\FeedRegistry: arguments: - $knownTypes: ['daily', 'hourly'] - $defaultType: 'daily' + $cronTimeZone: '%shopsys.cron_timezone%' Shopsys\FrameworkBundle\Component\Domain\Domain: factory: ['@Shopsys\FrameworkBundle\Component\Domain\DomainFactory', create] diff --git a/packages/framework/src/Resources/translations/messages.cs.po b/packages/framework/src/Resources/translations/messages.cs.po index 442190ed0b4..142c4420ff3 100644 --- a/packages/framework/src/Resources/translations/messages.cs.po +++ b/packages/framework/src/Resources/translations/messages.cs.po @@ -64,9 +64,6 @@ msgstr "Příslušenství v mezikošíku" msgid "Account" msgstr "Účet" -msgid "Action" -msgstr "Akce" - msgid "Action after sellout" msgstr "Akce při vyprodání" @@ -1075,6 +1072,12 @@ msgstr "Feed" msgid "Feed \"{{ feedName }}\" not found." msgstr "Feed s názvem \"{{ feedName }}\" nebyl nalezen." +msgid "Feed \"{{ feedName }}\" on domain ID {{ domainId }} not found." +msgstr "Feed \"{{ feedName }}\" na doméně s ID {{ domainId }} nebyl nalezen." + +msgid "Feed \"{{ feedName }}\" on domain ID {{ domainId }} successfully scheduled." +msgstr "Feed \"{{ feedName }}\" na doméně s ID {{ domainId }} byl úspěšně naplánován." + msgid "Feed \"{{ feedName }}\" successfully generated." msgstr "Feed \"{{ feedName }}\" byl úspěšně vygenerován." @@ -1996,6 +1999,9 @@ msgstr "Uložit změny v pořadí" msgid "Schedule" msgstr "Naplánovat" +msgid "Scheduled" +msgstr "Naplánován" + msgid "Script {{ name }} created" msgstr "Byl vytvořen skript {{ name }}" diff --git a/packages/framework/src/Resources/translations/messages.en.po b/packages/framework/src/Resources/translations/messages.en.po index 9c339b7495b..5d5a7f95073 100644 --- a/packages/framework/src/Resources/translations/messages.en.po +++ b/packages/framework/src/Resources/translations/messages.en.po @@ -64,9 +64,6 @@ msgstr "" msgid "Account" msgstr "" -msgid "Action" -msgstr "" - msgid "Action after sellout" msgstr "" @@ -1075,6 +1072,12 @@ msgstr "" msgid "Feed \"{{ feedName }}\" not found." msgstr "" +msgid "Feed \"{{ feedName }}\" on domain ID {{ domainId }} not found." +msgstr "" + +msgid "Feed \"{{ feedName }}\" on domain ID {{ domainId }} successfully scheduled." +msgstr "" + msgid "Feed \"{{ feedName }}\" successfully generated." msgstr "" @@ -1996,6 +1999,9 @@ msgstr "" msgid "Schedule" msgstr "" +msgid "Scheduled" +msgstr "" + msgid "Script {{ name }} created" msgstr "" diff --git a/packages/framework/src/Resources/views/Admin/Content/Feed/listGrid.html.twig b/packages/framework/src/Resources/views/Admin/Content/Feed/listGrid.html.twig index b7a91eba0d6..e9d4ed451dd 100644 --- a/packages/framework/src/Resources/views/Admin/Content/Feed/listGrid.html.twig +++ b/packages/framework/src/Resources/views/Admin/Content/Feed/listGrid.html.twig @@ -24,7 +24,7 @@ {% endif %} {% endblock %} -{% block grid_value_cell_id_actions %} +{% block grid_value_cell_id_generate %} {{ 'Generate'|trans }} {% endblock %} + +{% block grid_value_cell_id_schedule %} + {% if value is same as(false) %} + {{ 'Schedule'|trans }} + {% else %} + {{ 'Scheduled'|trans }} + {% endif %} + +{% endblock %} diff --git a/packages/framework/tests/Unit/Component/Domain/DomainTest.php b/packages/framework/tests/Unit/Component/Domain/DomainTest.php index c48db9e6cf0..ef1c3e8cf0e 100644 --- a/packages/framework/tests/Unit/Component/Domain/DomainTest.php +++ b/packages/framework/tests/Unit/Component/Domain/DomainTest.php @@ -110,7 +110,7 @@ public function testGetAll(): void $domain = new Domain($domainConfigs, $settingMock); - $this->assertSame([$domainConfigWithDataCreated], $domain->getAll()); + $this->assertSame([1 => $domainConfigWithDataCreated], $domain->getAll()); } public function testGetDomainConfigById(): void diff --git a/packages/framework/tests/Unit/Model/Feed/DailyFeedCronModuleTest.php b/packages/framework/tests/Unit/Model/Feed/FeedCronModuleTest.php similarity index 59% rename from packages/framework/tests/Unit/Model/Feed/DailyFeedCronModuleTest.php rename to packages/framework/tests/Unit/Model/Feed/FeedCronModuleTest.php index 2e478da3b64..efbfdb55f41 100644 --- a/packages/framework/tests/Unit/Model/Feed/DailyFeedCronModuleTest.php +++ b/packages/framework/tests/Unit/Model/Feed/FeedCronModuleTest.php @@ -9,14 +9,16 @@ use Shopsys\FrameworkBundle\Component\Domain\Config\DomainConfig; use Shopsys\FrameworkBundle\Component\Domain\Domain; use Shopsys\FrameworkBundle\Component\Setting\Setting; -use Shopsys\FrameworkBundle\Model\Feed\DailyFeedCronModule; +use Shopsys\FrameworkBundle\Model\Feed\FeedCronModule; use Shopsys\FrameworkBundle\Model\Feed\FeedExport; use Shopsys\FrameworkBundle\Model\Feed\FeedFacade; use Shopsys\FrameworkBundle\Model\Feed\FeedInfoInterface; +use Shopsys\FrameworkBundle\Model\Feed\FeedModule; +use Shopsys\FrameworkBundle\Model\Feed\FeedModuleRepository; use Symfony\Bridge\Monolog\Logger; use Tests\FrameworkBundle\Unit\TestCase; -class DailyFeedCronModuleTest extends TestCase +class FeedCronModuleTest extends TestCase { public function testSleepExactBetweenFeeds(): void { @@ -26,13 +28,24 @@ public function testSleepExactBetweenFeeds(): void ->disableOriginalConstructor() ->getMock(); + $feedModuleRepositoryMock = $this->getMockBuilder(FeedModuleRepository::class) + ->disableOriginalConstructor() + ->setMethods(['getAllScheduledFeedModules', 'getFeedModuleByNameAndDomainId']) + ->getMock(); + + $feedModule1 = new FeedModule('feed1', 1); + $feedModule2 = new FeedModule('feed2', 1); + + $feedModuleRepositoryMock->expects($this->any())->method('getAllScheduledFeedModules')->willReturn([$feedModule1, $feedModule2]); + $feedModuleRepositoryMock->expects($this->any())->method('getFeedModuleByNameAndDomainId')->willReturn($feedModule1); + $defaultTimeZone = new DateTimeZone('Europe/Prague'); $domainConfig = new DomainConfig(1, 'http://example.com', 'name', 'en', $defaultTimeZone); $domain = new Domain([$domainConfig], $settingMock); $feedExportMock = $this->getMockBuilder(FeedExport::class) ->disableOriginalConstructor() - ->setMethods(['isFinished', 'generateBatch', 'getFeedInfo', 'getDomainConfig', 'sleep']) + ->onlyMethods(['isFinished', 'generateBatch', 'getFeedInfo', 'getDomainConfig', 'sleep']) ->getMock(); $feedExportMock->expects($this->atLeastOnce())->method('isFinished')->willReturn(true); $feedExportMock->expects($this->any())->method('generateBatch'); @@ -46,16 +59,18 @@ public function testSleepExactBetweenFeeds(): void $feedFacadeMock = $this->getMockBuilder(FeedFacade::class) ->disableOriginalConstructor() - ->setMethods(['getFeedNames', 'createFeedExport', 'getFeedFilepath']) + ->onlyMethods(['getFeedNames', 'createFeedExport', 'getFeedFilepath', 'scheduleFeedsForCurrentTime', 'markFeedModuleAsUnscheduled']) ->getMock(); $feedFacadeMock->expects($this->any())->method('getFeedNames')->willReturn(['feed1', 'feed2']); $feedFacadeMock->expects($this->any())->method('createFeedExport')->willReturn($feedExportMock); $feedFacadeMock->expects($this->any())->method('getFeedFilepath')->willReturn('path'); + $feedFacadeMock->expects($this->any())->method('scheduleFeedsForCurrentTime'); + $feedFacadeMock->expects($this->any())->method('markFeedModuleAsUnscheduled'); - $dailyFeedCronModule = new DailyFeedCronModule($feedFacadeMock, $domain, $settingMock); - $dailyFeedCronModule->setLogger($logger); + $feedCronModule = new FeedCronModule($feedFacadeMock, $domain, $settingMock, $feedModuleRepositoryMock); + $feedCronModule->setLogger($logger); - $dailyFeedCronModule->iterate(); - $dailyFeedCronModule->sleep(); + $feedCronModule->iterate(); + $feedCronModule->sleep(); } } diff --git a/packages/product-feed-google/src/Resources/config/services.yaml b/packages/product-feed-google/src/Resources/config/services.yaml index a81f7dde056..66244dee899 100644 --- a/packages/product-feed-google/src/Resources/config/services.yaml +++ b/packages/product-feed-google/src/Resources/config/services.yaml @@ -9,7 +9,7 @@ services: Shopsys\ProductFeed\GoogleBundle\GoogleFeed: tags: - - { name: shopsys.product_feed } + - { name: shopsys.product_feed, hours: '1', minutes: '0' } Shopsys\ProductFeed\GoogleBundle\Form\GoogleProductCrudExtension: tags: diff --git a/packages/product-feed-heureka-delivery/src/Resources/config/services.yaml b/packages/product-feed-heureka-delivery/src/Resources/config/services.yaml index dd01e72a395..c86f7689d2e 100644 --- a/packages/product-feed-heureka-delivery/src/Resources/config/services.yaml +++ b/packages/product-feed-heureka-delivery/src/Resources/config/services.yaml @@ -9,4 +9,4 @@ services: Shopsys\ProductFeed\HeurekaDeliveryBundle\HeurekaDeliveryFeed: tags: - - { name: shopsys.product_feed, type: hourly } + - { name: shopsys.product_feed, hours: '*', minutes: '30' } diff --git a/packages/product-feed-heureka/src/Resources/config/services.yaml b/packages/product-feed-heureka/src/Resources/config/services.yaml index 4b59d151bd6..e04d6300c25 100644 --- a/packages/product-feed-heureka/src/Resources/config/services.yaml +++ b/packages/product-feed-heureka/src/Resources/config/services.yaml @@ -14,7 +14,7 @@ services: Shopsys\ProductFeed\HeurekaBundle\HeurekaFeed: tags: - - { name: shopsys.product_feed } + - { name: shopsys.product_feed, hours: '2', minutes: '0' } Shopsys\ProductFeed\HeurekaBundle\Form\HeurekaProductCrudExtension: tags: diff --git a/packages/product-feed-zbozi/src/Resources/config/services.yaml b/packages/product-feed-zbozi/src/Resources/config/services.yaml index 7e9d82b02ba..0c2f696393b 100644 --- a/packages/product-feed-zbozi/src/Resources/config/services.yaml +++ b/packages/product-feed-zbozi/src/Resources/config/services.yaml @@ -14,7 +14,7 @@ services: Shopsys\ProductFeed\ZboziBundle\ZboziFeed: tags: - - { name: shopsys.product_feed } + - { name: shopsys.product_feed, hours: '3', minutes: '0' } Shopsys\ProductFeed\ZboziBundle\Form\ZboziProductCrudExtension: tags: diff --git a/project-base/app/config/cron.yaml b/project-base/app/config/cron.yaml index 5acdc4da6a7..46c65733394 100644 --- a/project-base/app/config/cron.yaml +++ b/project-base/app/config/cron.yaml @@ -55,13 +55,9 @@ services: # Export - Shopsys\FrameworkBundle\Model\Feed\DailyFeedCronModule: + Shopsys\FrameworkBundle\Model\Feed\FeedCronModule: tags: - - { name: shopsys.cron, hours: '*/6', minutes: '0', instanceName: export, readableName: 'Generate daily feeds' } - - Shopsys\FrameworkBundle\Model\Feed\HourlyFeedCronModule: - tags: - - { name: shopsys.cron, hours: '*', minutes: '10', instanceName: export, readableName: 'Generate hourly feeds' } + - { name: shopsys.cron, hours: '*', minutes: '*', instanceName: export, readableName: 'Generate feeds' } Shopsys\FrameworkBundle\Model\Sitemap\SitemapCronModule: tags: diff --git a/project-base/app/config/feed.yaml b/project-base/app/config/feed.yaml new file mode 100644 index 00000000000..e0a6a97e723 --- /dev/null +++ b/project-base/app/config/feed.yaml @@ -0,0 +1,5 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false diff --git a/project-base/app/config/services.yaml b/project-base/app/config/services.yaml index 91be5719c4e..36ee4ce2686 100644 --- a/project-base/app/config/services.yaml +++ b/project-base/app/config/services.yaml @@ -4,6 +4,7 @@ imports: - { resource: directories.yaml } - { resource: cron.yaml } - { resource: services_frontend_api.yaml} + - { resource: feed.yaml } services: _defaults: @@ -693,7 +694,7 @@ services: App\ProductFeed\MergadoFeed\MergadoFeed: tags: - - { name: shopsys.product_feed } + - { name: shopsys.product_feed, hours: 5, minutes: 0 } FOS\CKEditorBundle\Config\CKEditorConfigurationInterface: alias: fos_ck_editor.configuration diff --git a/project-base/app/src/Model/Feed/FeedFacade.php b/project-base/app/src/Model/Feed/FeedFacade.php index 1cd66eb05ad..56bf556db76 100644 --- a/project-base/app/src/Model/Feed/FeedFacade.php +++ b/project-base/app/src/Model/Feed/FeedFacade.php @@ -10,7 +10,7 @@ /** * @property \App\Model\Feed\FeedExportFactory $feedExportFactory - * @method __construct(\Shopsys\FrameworkBundle\Model\Feed\FeedRegistry $feedRegistry, \App\Model\Product\ProductVisibilityFacade $productVisibilityFacade, \App\Model\Feed\FeedExportFactory $feedExportFactory, \Shopsys\FrameworkBundle\Model\Feed\FeedPathProvider $feedPathProvider, \League\Flysystem\FilesystemOperator $filesystem) + * @method __construct(\Shopsys\FrameworkBundle\Model\Feed\FeedRegistry $feedRegistry, \App\Model\Product\ProductVisibilityFacade $productVisibilityFacade, \App\Model\Feed\FeedExportFactory $feedExportFactory, \Shopsys\FrameworkBundle\Model\Feed\FeedPathProvider $feedPathProvider, \League\Flysystem\FilesystemOperator $filesystem, \Shopsys\FrameworkBundle\Model\Feed\FeedModuleRepository $feedModuleRepository, \Doctrine\ORM\EntityManagerInterface $em) */ class FeedFacade extends BaseFeedFacade { @@ -22,8 +22,8 @@ class FeedFacade extends BaseFeedFacade */ public function createFeedExport(string $feedName, DomainConfig $domainConfig, ?int $lastSeekId = null): FeedExport { - $feed = $this->feedRegistry->getFeedByName($feedName); + $feedConfig = $this->feedRegistry->getFeedConfigByName($feedName); - return $this->feedExportFactory->create($feed, $domainConfig, $lastSeekId); + return $this->feedExportFactory->create($feedConfig->getFeed(), $domainConfig, $lastSeekId); } } diff --git a/project-base/app/tests/App/Performance/Feed/AllFeedsTest.php b/project-base/app/tests/App/Performance/Feed/AllFeedsTest.php index d00fb1d5a5a..e6d061e9376 100644 --- a/project-base/app/tests/App/Performance/Feed/AllFeedsTest.php +++ b/project-base/app/tests/App/Performance/Feed/AllFeedsTest.php @@ -23,8 +23,6 @@ class AllFeedsTest extends KernelTestCase private int $maxDuration; - private int $deliveryMaxDuration; - private int $minDuration; protected function setUp(): void @@ -41,9 +39,6 @@ protected function setUp(): void ->switchDomainById(Domain::FIRST_DOMAIN_ID); $this->maxDuration = $container->getParameter('shopsys.performance_test.feed.max_duration_seconds'); - $this->deliveryMaxDuration = $container->getParameter( - 'shopsys.performance_test.feed.delivery.max_duration_seconds', - ); $this->minDuration = $container->getParameter('shopsys.performance_test.feed.min_duration_seconds'); } @@ -116,18 +111,12 @@ public function getAllFeedGenerationData() $feedRegistry = static::$container->get(FeedRegistry::class); /** @var \Shopsys\FrameworkBundle\Component\Domain\Domain $domain */ $domain = static::$container->get(Domain::class); - $dailyFeedGenerationData = $this->getFeedGenerationData( - $feedRegistry->getFeeds('daily'), + + return $this->getFeedGenerationData( + $feedRegistry->getFeedsForCurrentTime(), $domain->getAll(), $this->maxDuration, ); - $hourlyFeedGenerationData = $this->getFeedGenerationData( - $feedRegistry->getFeeds('hourly'), - $domain->getAll(), - $this->deliveryMaxDuration, - ); - - return array_merge($dailyFeedGenerationData, $hourlyFeedGenerationData); } /** diff --git a/project-base/app/tests/App/Smoke/Http/RouteConfigCustomization.php b/project-base/app/tests/App/Smoke/Http/RouteConfigCustomization.php index 611042d9784..c08a9a61f1b 100644 --- a/project-base/app/tests/App/Smoke/Http/RouteConfigCustomization.php +++ b/project-base/app/tests/App/Smoke/Http/RouteConfigCustomization.php @@ -96,6 +96,9 @@ function (RouteConfig $config) { ->customizeByRouteName('admin_feed_generate', function (RouteConfig $config) { $config->skipRoute('Do not rewrite XML feed by test products.'); }) + ->customizeByRouteName('admin_feed_schedule', function (RouteConfig $config) { + $config->skipRoute('Do not schedule XML feed by test.'); + }) ->customizeByRouteName('admin_logout', function (RouteConfig $config) { $config->skipRoute('There is different security configuration in TEST environment.'); })