From b31cc45db588cc9cf4fd130b26a64ec6420f6772 Mon Sep 17 00:00:00 2001
From: Dan Garner
Date: Tue, 30 Jan 2018 14:08:48 +0000
Subject: [PATCH 01/25] Fix issue with concurrent locking xibosignage/xibo#1400
---
lib/Widget/ModuleWidget.php | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/lib/Widget/ModuleWidget.php b/lib/Widget/ModuleWidget.php
index 5fd2ee26b4..04edfd203b 100644
--- a/lib/Widget/ModuleWidget.php
+++ b/lib/Widget/ModuleWidget.php
@@ -32,6 +32,7 @@
use Xibo\Exception\ControllerNotImplemented;
use Xibo\Exception\InvalidArgumentException;
use Xibo\Exception\NotFoundException;
+use Xibo\Exception\XiboException;
use Xibo\Factory\CommandFactory;
use Xibo\Factory\DataSetColumnFactory;
use Xibo\Factory\DataSetFactory;
@@ -371,12 +372,13 @@ public function dumpCacheForModule()
/**
* Hold a lock on concurrent requests
* blocks if the request is locked
- * @param $key
- * @param $ttl
- * @param $wait
- * @param $tries
+ * @param string|null $key
+ * @param int $ttl seconds
+ * @param int $wait milliseconds
+ * @param int $tries
+ * @throws XiboException
*/
- public function concurrentRequestLock($key = null, $ttl = 30, $wait = 5000, $tries = 3)
+ public function concurrentRequestLock($key = null, $ttl = 900, $wait = 20000, $tries = 50)
{
// If the key is null default to the widgetId
if ($key === null)
@@ -386,6 +388,13 @@ public function concurrentRequestLock($key = null, $ttl = 30, $wait = 5000, $tri
$this->lock->setInvalidationMethod(Invalidation::SLEEP, $wait, $tries);
$this->lock->get();
+
+ // Did we get a lock?
+ // if we've not been able to get a lock here - is this because we've not created this lock before?
+ // or is it because we've hit the very generous timeouts
+ if (!$this->lock->isHit() && $this->lock->getCreation() !== false)
+ throw new XiboException('Concurrent record locked, time out.');
+
$this->lock->lock($ttl);
}
From f6517c9533658e9a0d159ecdc764c8e2a5fba905 Mon Sep 17 00:00:00 2001
From: Dan Garner
Date: Wed, 7 Feb 2018 09:30:00 +0000
Subject: [PATCH 02/25] Missing weather files (they weren't moved into player
when they should have been) xibosignage/xibo#1409
---
.../{ => player}/weathericons-regular-webfont.svg | 0
.../{ => player}/weathericons-regular-webfont.ttf | Bin
.../{ => player}/weathericons-regular-webfont.woff | Bin
.../{ => player}/weathericons-regular-webfont.woff2 | Bin
4 files changed, 0 insertions(+), 0 deletions(-)
rename modules/forecastio/{ => player}/weathericons-regular-webfont.svg (100%)
rename modules/forecastio/{ => player}/weathericons-regular-webfont.ttf (100%)
rename modules/forecastio/{ => player}/weathericons-regular-webfont.woff (100%)
rename modules/forecastio/{ => player}/weathericons-regular-webfont.woff2 (100%)
diff --git a/modules/forecastio/weathericons-regular-webfont.svg b/modules/forecastio/player/weathericons-regular-webfont.svg
similarity index 100%
rename from modules/forecastio/weathericons-regular-webfont.svg
rename to modules/forecastio/player/weathericons-regular-webfont.svg
diff --git a/modules/forecastio/weathericons-regular-webfont.ttf b/modules/forecastio/player/weathericons-regular-webfont.ttf
similarity index 100%
rename from modules/forecastio/weathericons-regular-webfont.ttf
rename to modules/forecastio/player/weathericons-regular-webfont.ttf
diff --git a/modules/forecastio/weathericons-regular-webfont.woff b/modules/forecastio/player/weathericons-regular-webfont.woff
similarity index 100%
rename from modules/forecastio/weathericons-regular-webfont.woff
rename to modules/forecastio/player/weathericons-regular-webfont.woff
diff --git a/modules/forecastio/weathericons-regular-webfont.woff2 b/modules/forecastio/player/weathericons-regular-webfont.woff2
similarity index 100%
rename from modules/forecastio/weathericons-regular-webfont.woff2
rename to modules/forecastio/player/weathericons-regular-webfont.woff2
From 3ad3ddd374611f57f7bd2818ac08d6bd94d7f046 Mon Sep 17 00:00:00 2001
From: Dan Garner
Date: Thu, 8 Feb 2018 15:55:25 +0000
Subject: [PATCH 03/25] Move notification creation into the display controllers
validateDisplays. xibosignage/xibo#1399
---
lib/Controller/Display.php | 51 ++++++++++++++++++++++++------
lib/Middleware/State.php | 4 ++-
lib/XTR/MaintenanceRegularTask.php | 45 ++------------------------
3 files changed, 47 insertions(+), 53 deletions(-)
diff --git a/lib/Controller/Display.php b/lib/Controller/Display.php
index 7a6fab824d..88da3bc1d9 100644
--- a/lib/Controller/Display.php
+++ b/lib/Controller/Display.php
@@ -25,6 +25,7 @@
use Xibo\Exception\AccessDeniedException;
use Xibo\Exception\ConfigurationException;
use Xibo\Exception\NotFoundException;
+use Xibo\Exception\XiboException;
use Xibo\Factory\DisplayEventFactory;
use Xibo\Factory\DisplayFactory;
use Xibo\Factory\DisplayGroupFactory;
@@ -32,9 +33,11 @@
use Xibo\Factory\LayoutFactory;
use Xibo\Factory\LogFactory;
use Xibo\Factory\MediaFactory;
+use Xibo\Factory\NotificationFactory;
use Xibo\Factory\RequiredFileFactory;
use Xibo\Factory\ScheduleFactory;
use Xibo\Factory\TagFactory;
+use Xibo\Factory\UserGroupFactory;
use Xibo\Helper\ByteFormatter;
use Xibo\Helper\WakeOnLan;
use Xibo\Service\ConfigServiceInterface;
@@ -111,6 +114,12 @@ class Display extends Base
/** @var TagFactory */
private $tagFactory;
+ /** @var NotificationFactory */
+ private $notificationFactory;
+
+ /** @var UserGroupFactory */
+ private $userGroupFactory;
+
/**
* Set common dependencies.
* @param LogServiceInterface $log
@@ -133,8 +142,10 @@ class Display extends Base
* @param DisplayEventFactory $displayEventFactory
* @param RequiredFileFactory $requiredFileFactory
* @param TagFactory $tagFactory
+ * @param NotificationFactory $notificationFactory
+ * @param UserGroupFactory $userGroupFactory
*/
- public function __construct($log, $sanitizerService, $state, $user, $help, $date, $config, $store, $pool, $playerAction, $displayFactory, $displayGroupFactory, $logFactory, $layoutFactory, $displayProfileFactory, $mediaFactory, $scheduleFactory, $displayEventFactory, $requiredFileFactory, $tagFactory)
+ public function __construct($log, $sanitizerService, $state, $user, $help, $date, $config, $store, $pool, $playerAction, $displayFactory, $displayGroupFactory, $logFactory, $layoutFactory, $displayProfileFactory, $mediaFactory, $scheduleFactory, $displayEventFactory, $requiredFileFactory, $tagFactory, $notificationFactory, $userGroupFactory)
{
$this->setCommonDependencies($log, $sanitizerService, $state, $user, $help, $date, $config);
@@ -151,6 +162,8 @@ public function __construct($log, $sanitizerService, $state, $user, $help, $date
$this->displayEventFactory = $displayEventFactory;
$this->requiredFileFactory = $requiredFileFactory;
$this->tagFactory = $tagFactory;
+ $this->notificationFactory = $notificationFactory;
+ $this->userGroupFactory = $userGroupFactory;
}
/**
@@ -1289,14 +1302,14 @@ public function wakeOnLan($displayId)
/**
* Validate the display list
* @param array[Display] $displays
- * @return array[Display]
+ * @throws XiboException
*/
public function validateDisplays($displays)
{
- $timedOutDisplays = [];
-
// Get the global time out (overrides the alert time out on the display if 0)
$globalTimeout = $this->getConfig()->GetSetting('MAINTENANCE_ALERT_TOUT') * 60;
+ $emailAlerts = ($this->getConfig()->GetSetting("MAINTENANCE_EMAIL_ALERTS") == 'On');
+ $alwaysAlert = ($this->getConfig()->GetSetting("MAINTENANCE_ALWAYS_ALERT") == 'On');
foreach ($displays as $display) {
/* @var \Xibo\Entity\Display $display */
@@ -1316,9 +1329,11 @@ public function validateDisplays($displays)
if ($timeOut < time()) {
$this->getLog()->debug('Timed out display. Last Accessed: ' . date('Y-m-d h:i:s', $display->lastAccessed) . '. Time out: ' . date('Y-m-d h:i:s', $timeOut));
- // If this is the first switch (i.e. the row was logged in before)
- if ($display->loggedIn == 1) {
+ // Is this the first time this display has gone "off-line"
+ $displayOffline = ($display->loggedIn == 1);
+ // If this is the first switch (i.e. the row was logged in before)
+ if ($displayOffline) {
// Update the display and set it as logged out
$display->loggedIn = 0;
$display->save(\Xibo\Entity\Display::$saveOptionsMinimum);
@@ -1335,11 +1350,27 @@ public function validateDisplays($displays)
$event->save();
}
- // Store this row
- $timedOutDisplays[] = $display;
+ // Should we create a notification
+ if ($emailAlerts && $display->emailAlert == 1 && ($displayOffline || $alwaysAlert)) {
+ // Alerts enabled for this display
+ // Display just gone offline, or always alert
+ // Fields for email
+ $subject = sprintf(__("Email Alert for Display %s"), $display->display);
+ $body = sprintf(__("Display %s with ID %d was last seen at %s."), $display->display, $display->displayId, $this->getDate()->getLocalDate($display->lastAccessed));
+
+ // Add to system
+ $notification = $this->notificationFactory->createSystemNotification($subject, $body, $this->getDate()->parse());
+
+ // Add in any displayNotificationGroups, with permissions
+ foreach ($this->userGroupFactory->getDisplayNotificationGroups($display->displayGroupId) as $group) {
+ $notification->assignUserGroup($group);
+ }
+
+ $notification->save();
+ } else if ($displayOffline) {
+ $this->getLog()->info('Not sending an email for offline display - emailAlert = ' . $display->emailAlert . ', alwaysAlert = ' . $alwaysAlert);
+ }
}
}
-
- return $timedOutDisplays;
}
}
diff --git a/lib/Middleware/State.php b/lib/Middleware/State.php
index 86b3105cd6..8ad903d374 100644
--- a/lib/Middleware/State.php
+++ b/lib/Middleware/State.php
@@ -504,7 +504,9 @@ public static function registerControllersWithDi($app)
$container->scheduleFactory,
$container->displayEventFactory,
$container->requiredFileFactory,
- $container->tagFactory
+ $container->tagFactory,
+ $container->notificationFactory,
+ $container->userGroupFactory
);
});
diff --git a/lib/XTR/MaintenanceRegularTask.php b/lib/XTR/MaintenanceRegularTask.php
index 212aa89641..53f72696dc 100644
--- a/lib/XTR/MaintenanceRegularTask.php
+++ b/lib/XTR/MaintenanceRegularTask.php
@@ -40,54 +40,15 @@ public function run()
/**
* Display Down email alerts
+ * - just runs validate displays
*/
private function displayDownEmailAlerts()
{
$this->runMessage .= '## ' . __('Email Alerts') . PHP_EOL;
- $emailAlerts = ($this->config->GetSetting("MAINTENANCE_EMAIL_ALERTS") == 'On');
- $alwaysAlert = ($this->config->GetSetting("MAINTENANCE_ALWAYS_ALERT") == 'On');
-
- foreach ($this->app->container->get('\Xibo\Controller\Display')->setApp($this->app)->validateDisplays($this->displayFactory->query()) as $display) {
- /* @var \Xibo\Entity\Display $display */
- // Is this the first time this display has gone "off-line"
- $displayGoneOffline = ($display->loggedIn == 1);
-
- // Should we send an email?
- if ($emailAlerts) {
- // Alerts enabled for this display
- if ($display->emailAlert == 1) {
- // Display just gone offline, or always alert
- if ($displayGoneOffline || $alwaysAlert) {
- // Fields for email
- $subject = sprintf(__("Email Alert for Display %s"), $display->display);
- $body = sprintf(__("Display %s with ID %d was last seen at %s."), $display->display, $display->displayId, $this->date->getLocalDate($display->lastAccessed));
-
- // Add to system
- $notification = $this->notificationFactory->createSystemNotification($subject, $body, $this->date->parse());
-
- // Add in any displayNotificationGroups, with permissions
- foreach ($this->userGroupFactory->getDisplayNotificationGroups($display->displayGroupId) as $group) {
- $notification->assignUserGroup($group);
- }
-
- $notification->save();
+ $this->app->container->get('\Xibo\Controller\Display')->setApp($this->app)->validateDisplays($this->displayFactory->query());
- $this->runMessage .= ' - A' . PHP_EOL;
- } else {
- $this->runMessage .= ' - U' . PHP_EOL;
- }
- }
- else {
- // Alert disabled for this display
- $this->runMessage .= ' - D' . PHP_EOL;
- }
- }
- else {
- // Email alerts disabled globally
- $this->runMessage .= ' - X' . PHP_EOL;
- }
- }
+ $this->appendRunMessage(__('Done'));
}
/**
From c4aa95dc9bd919532eb878820d5e14e6b272737f Mon Sep 17 00:00:00 2001
From: Dan Garner
Date: Fri, 9 Feb 2018 17:08:59 +0000
Subject: [PATCH 04/25] Better (working) fix for concurrent locking.
xibosignage/xibo#1400
---
lib/Widget/ModuleWidget.php | 46 +++++++++++++++++++++++++++++--------
1 file changed, 36 insertions(+), 10 deletions(-)
diff --git a/lib/Widget/ModuleWidget.php b/lib/Widget/ModuleWidget.php
index 04edfd203b..3deb8ebe72 100644
--- a/lib/Widget/ModuleWidget.php
+++ b/lib/Widget/ModuleWidget.php
@@ -374,28 +374,54 @@ public function dumpCacheForModule()
* blocks if the request is locked
* @param string|null $key
* @param int $ttl seconds
- * @param int $wait milliseconds
+ * @param int $wait seconds
* @param int $tries
* @throws XiboException
*/
- public function concurrentRequestLock($key = null, $ttl = 900, $wait = 20000, $tries = 50)
+ public function concurrentRequestLock($key = null, $ttl = 900, $wait = 5, $tries = 100)
{
// If the key is null default to the widgetId
if ($key === null)
$key = $this->widget->widgetId;
$this->lock = $this->getPool()->getItem('locks/widget/' . $key);
- $this->lock->setInvalidationMethod(Invalidation::SLEEP, $wait, $tries);
+ // Set the invalidation method to simply return the value (not that we use it, but it gets us a miss on expiry)
+ $this->lock->setInvalidationMethod(Invalidation::VALUE);
+
+ // Get the lock
+ // other requests will wait here until we're done, or we've timed out
$this->lock->get();
// Did we get a lock?
- // if we've not been able to get a lock here - is this because we've not created this lock before?
- // or is it because we've hit the very generous timeouts
- if (!$this->lock->isHit() && $this->lock->getCreation() !== false)
- throw new XiboException('Concurrent record locked, time out.');
+ // if we're a miss, then we're not already locked
+ if ($this->lock->isMiss()) {
+ // so lock now
+ $this->lock->set(true);
+ $this->lock->expiresAfter($ttl);
+ $this->lock->save();
+
+ //sleep(30);
+ } else {
+ // We are a hit - we must be locked
+ $this->getLog()->debug('LOCK hit for ' . $key);
+
+ // Try again?
+ $tries--;
+
+ if ($tries <= 0) {
+ // We've waited long enough
+ throw new XiboException('Concurrent record locked, time out.');
+ } else {
+ $this->getLog()->debug('Unable to get a lock, trying again. Remaining retries: ' . $tries);
+
+ // Hang about waiting for the lock to be released.
+ sleep($wait);
- $this->lock->lock($ttl);
+ // Recursive request (we've decremented the number of tries)
+ $this->concurrentRequestLock($key, $ttl, $wait, $tries);
+ }
+ }
}
/**
@@ -404,10 +430,10 @@ public function concurrentRequestLock($key = null, $ttl = 900, $wait = 20000, $t
public function concurrentRequestRelease()
{
if ($this->lock !== null) {
- $this->lock->set(time());
+ // Release lock
+ $this->lock->set(false);
$this->lock->expiresAfter(1); // Expire straight away
- // Release lock
$this->getPool()->saveDeferred($this->lock);
}
}
From 2370cfaaf2298b4f19b63e70e2f495b78e042220 Mon Sep 17 00:00:00 2001
From: Dan Garner
Date: Mon, 12 Feb 2018 10:08:00 +0000
Subject: [PATCH 05/25] Fix notifyByCampaignId so that it works for layout
specific campaigns xibosignage/xibo#1407
---
lib/Service/DisplayNotifyService.php | 16 +-
.../Cache/LayoutInCampaignStatusTest.php | 138 ++++++++++++++++++
2 files changed, 152 insertions(+), 2 deletions(-)
create mode 100644 tests/integration/Cache/LayoutInCampaignStatusTest.php
diff --git a/lib/Service/DisplayNotifyService.php b/lib/Service/DisplayNotifyService.php
index 33b2c49d15..f6509b4d49 100644
--- a/lib/Service/DisplayNotifyService.php
+++ b/lib/Service/DisplayNotifyService.php
@@ -209,8 +209,20 @@ public function notifyByCampaignId($campaignId)
ON lkdisplaydg.DisplayGroupID = `lkdgdg`.childId
INNER JOIN `display`
ON lkdisplaydg.DisplayID = display.displayID
- WHERE `schedule`.campaignId = :activeCampaignId
- AND (
+ INNER JOIN (
+ SELECT campaignId
+ FROM campaign
+ WHERE campaign.campaignId = :activeCampaignId
+ UNION
+ SELECT DISTINCT parent.campaignId
+ FROM `lkcampaignlayout` child
+ INNER JOIN `lkcampaignlayout` parent
+ ON parent.layoutId = child.layoutId
+ WHERE child.campaignId = :activeCampaignId
+
+ ) campaigns
+ ON campaigns.campaignId = `schedule`.campaignId
+ WHERE (
(`schedule`.FromDT < :toDt AND IFNULL(`schedule`.toDt, `schedule`.fromDt) > :fromDt)
OR `schedule`.recurrence_range >= :fromDt
OR (
diff --git a/tests/integration/Cache/LayoutInCampaignStatusTest.php b/tests/integration/Cache/LayoutInCampaignStatusTest.php
new file mode 100644
index 0000000000..fb66518755
--- /dev/null
+++ b/tests/integration/Cache/LayoutInCampaignStatusTest.php
@@ -0,0 +1,138 @@
+
+ public function setup()
+ {
+ parent::setup();
+
+ $this->getLogger()->debug('Setup test for Cache ' . get_class() .' Test');
+
+ // Create a Campaign
+ $this->campaign = (new XiboCampaign($this->getEntityProvider()))->create(Random::generateString());
+
+ // Create a Layout
+ $this->layout = $this->createLayout();
+
+ // Create a text widget on the Layout
+ $response = $this->getEntityProvider()->post('/playlist/widget/text/' . $this->layout->regions[0]->playlists[0]['playlistId'], [
+ 'text' => 'Widget A',
+ 'duration' => 100,
+ 'useDuration' => 1
+ ]);
+
+ $this->widget = (new XiboText($this->getEntityProvider()))->hydrate($response);
+
+ // Assign the layout to our campaign
+ $this->campaign->assignLayout($this->layout->layoutId);
+
+ // Create a Display
+ $this->display = $this->createDisplay();
+
+ // Schedule the Campaign "always" onto our display
+ // deleting the layout will remove this at the end
+ $event = (new XiboSchedule($this->getEntityProvider()))->createEventLayout(
+ date('Y-m-d H:i:s', time()+3600),
+ date('Y-m-d H:i:s', time()+7200),
+ $this->campaign->campaignId,
+ [$this->display->displayGroupId],
+ 0,
+ NULL,
+ NULL,
+ NULL,
+ 0,
+ 0
+ );
+
+ $this->displaySetStatus($this->display, Display::$STATUS_DONE);
+ $this->displaySetLicensed($this->display);
+
+ $this->getLogger()->debug('Finished Setup');
+ }
+
+ public function tearDown()
+ {
+ $this->getLogger()->debug('Tear Down');
+
+ parent::tearDown();
+
+ // Delete the Layout we've been working with
+ $this->deleteLayout($this->layout);
+
+ // Delete the Display
+ $this->deleteDisplay($this->display);
+
+ // Delete the Campaign
+ $this->campaign->delete();
+ }
+ //
+
+ /**
+ * @group cacheInvalidateTests
+ */
+ public function testInvalidateCache()
+ {
+ // Make sure our Layout is already status 1
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 3), 'Layout Status isnt as expected');
+
+ // Make sure our Display is already DONE
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_DONE), 'Display Status isnt as expected');
+
+ // Build the Layout
+ $this->client->get('/layout/status/' . $this->layout->layoutId);
+
+ // Check the Layout Status
+ // Validate the layout status afterwards
+ $this->assertTrue($this->layoutStatusEquals($this->layout, 1), 'Layout Status isnt as expected');
+
+ // Validate the display status afterwards
+ $this->assertTrue($this->displayStatusEquals($this->display, Display::$STATUS_PENDING), 'Display Status isnt as expected');
+
+ // Validate that XMR has been called.
+ $this->assertTrue(in_array($this->display->displayId, $this->getPlayerActionQueue()), 'Player action not present');
+ }
+}
\ No newline at end of file
From 3996bf0c7698cdcc992f8ff9221fa23cc681118b Mon Sep 17 00:00:00 2001
From: Dan Garner
Date: Mon, 12 Feb 2018 11:44:14 +0000
Subject: [PATCH 06/25] Protect against non-RSS urls on Ticker widgets.
xibosignage/xibo#1401
---
lib/Widget/Ticker.php | 27 ++++++++++++++++++++++-----
1 file changed, 22 insertions(+), 5 deletions(-)
diff --git a/lib/Widget/Ticker.php b/lib/Widget/Ticker.php
index 14229a92d7..14142ca153 100644
--- a/lib/Widget/Ticker.php
+++ b/lib/Widget/Ticker.php
@@ -828,10 +828,28 @@ private function getRssItems($isPreview, $text)
try {
// Create a Guzzle Client to get the Feed XML
$client = new Client();
- $response = $client->get($feedUrl, $this->getConfig()->getGuzzleProxy());
+ $response = $client->get($feedUrl, $this->getConfig()->getGuzzleProxy([
+ 'headers' => [
+ 'Accept' => 'application/rss+xml, application/rdf+xml;q=0.8, application/atom+xml;q=0.6, application/xml;q=0.4, text/xml;q=0.4'
+ ],
+ 'stream' => true,
+ 'timeout' => 20 // wait no more than 20 seconds: https://github.com/xibosignage/xibo/issues/1401
+ ]));
+
+ // Pull out the content type
+ $contentType = $response->getHeaderLine('Content-Type');
+
+ $this->getLog()->debug('Feed returned content-type ' . $contentType);
+
+ // https://github.com/xibosignage/xibo/issues/1401
+ if (stripos($contentType, 'rss') === false && stripos($contentType, 'xml') === false) {
+ // The content type isn't compatible
+ $this->getLog()->error('Incompatible content type: ' . $contentType);
+ return false;
+ }
- // Pull out the content type and body
- $result = explode('charset=', $response->getHeaderLine('Content-Type'));
+ // Get the body, etc
+ $result = explode('charset=', $contentType);
$document['encoding'] = isset($result[1]) ? $result[1] : '';
$document['xml'] = $response->getBody()->getContents();
@@ -849,8 +867,7 @@ private function getRssItems($isPreview, $text)
$this->getLog()->error('Unable to get feed: ' . $requestException->getMessage());
$this->getLog()->debug($requestException->getTraceAsString());
- $document['xml'] = null;
- $document['encoding'] = null;
+ return false;
}
}
From 9fc74e63b05e0f616a91e4bf822511828a708fda Mon Sep 17 00:00:00 2001
From: Dan Garner
Date: Mon, 12 Feb 2018 14:32:13 +0000
Subject: [PATCH 07/25] Add custom/always dayparts to the system as actual
records xibosignage/xibo#1406
---
install/master/data.sql | 9 +++-
install/master/structure.sql | 4 +-
install/steps/137.json | 22 +++++++++
lib/Controller/DayPart.php | 31 ++++++++++--
lib/Controller/Library.php | 2 -
lib/Controller/Schedule.php | 16 +++---
lib/Entity/DayPart.php | 24 ++++++++-
lib/Entity/Schedule.php | 71 +++++++++++++++++++--------
lib/Factory/DayPartFactory.php | 32 ++++++------
lib/Factory/ScheduleFactory.php | 24 +++++++--
lib/Helper/Environment.php | 4 +-
lib/Middleware/State.php | 6 +--
lib/Service/DisplayNotifyService.php | 3 --
lib/Xmds/Soap.php | 2 -
views/schedule-form-add.twig | 14 +++++-
views/schedule-form-edit.twig | 14 +++++-
views/schedule-page.twig | 4 +-
web/theme/default/js/xibo-calendar.js | 11 +++--
18 files changed, 213 insertions(+), 80 deletions(-)
create mode 100644 install/steps/137.json
diff --git a/install/master/data.sql b/install/master/data.sql
index b7e7efb2a9..3cf0e1c54a 100644
--- a/install/master/data.sql
+++ b/install/master/data.sql
@@ -1,5 +1,5 @@
INSERT INTO `version` (`app_ver`, `XmdsVersion`, `XlfVersion`, `DBVersion`) VALUES
-('1.8.5', 5, 2, 136);
+('1.8.6', 5, 2, 137);
INSERT INTO `group` (`groupID`, `group`, `IsUserSpecific`, `IsEveryone`, `isSystemNotification`) VALUES
(1, 'Users', 0, 0, 0),
@@ -322,4 +322,9 @@ INSERT INTO task (taskId, name, class, status, options, schedule, isActive, conf
(3, 'Email Notifications', '\\Xibo\\XTR\\EmailNotificationsTask', 2, '[]', '*/5 * * * * *', 1, '/tasks/email-notifications.task'),
(4, 'Stats Archive', '\\Xibo\\XTR\\StatsArchiveTask', 2, '{"periodSizeInDays":"7","maxPeriods":"4"}', '0 0 * * Mon', 0, '/tasks/stats-archiver.task'),
(5, 'Remove old Notifications', '\\Xibo\\XTR\\NotificationTidyTask', 2, '{"maxAgeDays":"7","systemOnly":"1","readOnly":"0"}', '15 0 * * *', 1, '/tasks/notification-tidy.task'),
- (6, 'Fetch Remote DataSets', '\\Xibo\\XTR\\RemoteDataSetFetchTask', 2, '[]', '30 * * * * *', 1, '/tasks/remote-dataset.task');
\ No newline at end of file
+ (6, 'Fetch Remote DataSets', '\\Xibo\\XTR\\RemoteDataSetFetchTask', 2, '[]', '30 * * * * *', 1, '/tasks/remote-dataset.task');
+
+
+INSERT INTO daypart (name, description, isRetired, userid, startTime, endTime, exceptions, isAlways, isCustom) VALUES
+ ('Custom', 'User specifies the from/to date', 0, 1, '', '', '', 0, 1),
+ ('Always', 'Event runs always', 0, 1, '', '', '', 1, 0);
\ No newline at end of file
diff --git a/install/master/structure.sql b/install/master/structure.sql
index 0f013e5cbf..7eca80dcba 100644
--- a/install/master/structure.sql
+++ b/install/master/structure.sql
@@ -1127,8 +1127,10 @@ CREATE TABLE `daypart` (
`startTime` VARCHAR(8) DEFAULT '00:00:00',
`endTime` VARCHAR(8) DEFAULT '00:00:00',
`exceptions` TEXT NULL,
+ `isAlways` TINYINT(4) DEFAULT 0 NOT NULL,
+ `isCustom` TINYINT(4) DEFAULT 0 NOT NULL,
PRIMARY KEY (`dayPartId`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=2;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;
CREATE TABLE `task` (
`taskId` INT(11) NOT NULL AUTO_INCREMENT,
diff --git a/install/steps/137.json b/install/steps/137.json
new file mode 100644
index 0000000000..bd579cf1de
--- /dev/null
+++ b/install/steps/137.json
@@ -0,0 +1,22 @@
+{
+ "dbVersion": 137,
+ "appVersion": "1.8.6",
+ "steps": [
+ {
+ "step": "DayPart columns to indicate always and custom",
+ "action": "ALTER TABLE daypart ADD isAlways TINYINT DEFAULT 0 NOT NULL, ADD isCustom TINYINT DEFAULT 0 NOT NULL;"
+ },
+ {
+ "step": "Insert the always/custom day parts",
+ "action": "INSERT INTO daypart (name, description, isRetired, userid, startTime, endTime, exceptions, isAlways, isCustom) VALUES ('Custom', 'User specifies the from/to date', 0, 1, '', '', '', 0, 1),('Always', 'Event runs always', 0, 1, '', '', '', 1, 0);"
+ },
+ {
+ "step": "Back fill the existing schedules with the correct day parts - custom",
+ "action": "UPDATE `schedule` SET dayPartId = (SELECT dayPartId FROM daypart WHERE isCustom = 1) WHERE dayPartId = 0;"
+ },
+ {
+ "step": "Back fill the existing schedules with the correct day parts - always",
+ "action": "UPDATE `schedule` SET dayPartId = (SELECT dayPartId FROM daypart WHERE isAlways = 1) WHERE dayPartId = 1;"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/lib/Controller/DayPart.php b/lib/Controller/DayPart.php
index 367a619d1c..26a3bc1d8b 100644
--- a/lib/Controller/DayPart.php
+++ b/lib/Controller/DayPart.php
@@ -21,6 +21,7 @@
namespace Xibo\Controller;
use Xibo\Exception\AccessDeniedException;
+use Xibo\Exception\XiboException;
use Xibo\Factory\DayPartFactory;
use Xibo\Factory\DisplayFactory;
use Xibo\Factory\DisplayGroupFactory;
@@ -99,7 +100,9 @@ public function grid()
{
$filter = [
'dayPartId' => $this->getSanitizer()->getInt('dayPartId'),
- 'name' => $this->getSanitizer()->getString('name')
+ 'name' => $this->getSanitizer()->getString('name'),
+ 'isAlways' => 0,
+ 'isCustom' => 0
];
$dayParts = $this->dayPartFactory->query($this->gridRenderSort(), $this->gridRenderFilter($filter));
@@ -177,6 +180,9 @@ public function editForm($dayPartId)
if (!$this->getUser()->checkEditable($dayPart))
throw new AccessDeniedException();
+ if ($dayPart->isAlways === 1 || $dayPart->isCustom === 1)
+ throw new AccessDeniedException();
+
$this->getState()->template = 'daypart-form-edit';
$this->getState()->setData([
'dayPart' => $dayPart,
@@ -197,6 +203,9 @@ public function deleteForm($dayPartId)
if (!$this->getUser()->checkDeleteable($dayPart))
throw new AccessDeniedException();
+ if ($dayPart->isAlways === 1 || $dayPart->isCustom === 1)
+ throw new AccessDeniedException();
+
// Get a count of schedules for this day part
$schedules = $this->scheduleFactory->getByDayPartId($dayPartId);
@@ -284,7 +293,9 @@ public function add()
$dayPart = $this->dayPartFactory->createEmpty();
$this->handleCommonInputs($dayPart);
- $dayPart->save();
+ $dayPart
+ ->setScheduleFactory($this->scheduleFactory)
+ ->save();
// Return
$this->getState()->hydrate([
@@ -370,6 +381,8 @@ public function add()
* @SWG\Schema(ref="#/definitions/DayPart")
* )
* )
+ *
+ * @throws XiboException
*/
public function edit($dayPartId)
{
@@ -381,8 +394,13 @@ public function edit($dayPartId)
if (!$this->getUser()->checkEditable($dayPart))
throw new AccessDeniedException();
+ if ($dayPart->isAlways === 1 || $dayPart->isCustom === 1)
+ throw new AccessDeniedException();
+
$this->handleCommonInputs($dayPart);
- $dayPart->save();
+ $dayPart
+ ->setScheduleFactory($this->scheduleFactory)
+ ->save();
// Return
$this->getState()->hydrate([
@@ -471,6 +489,8 @@ private function handleCommonInputs($dayPart)
* description="successful operation"
* )
* )
+ *
+ * @throws XiboException
*/
public function delete($dayPartId)
{
@@ -479,7 +499,10 @@ public function delete($dayPartId)
if (!$this->getUser()->checkDeleteable($dayPart))
throw new AccessDeniedException();
- $dayPart->setDateService($this->getDate())->delete();
+ $dayPart
+ ->setDateService($this->getDate())
+ ->setScheduleFactory($this->scheduleFactory)
+ ->delete();
// Return
$this->getState()->hydrate([
diff --git a/lib/Controller/Library.php b/lib/Controller/Library.php
index 1b32d4767d..19810f971d 100644
--- a/lib/Controller/Library.php
+++ b/lib/Controller/Library.php
@@ -1426,8 +1426,6 @@ public function usage($mediaId)
/* @var \Xibo\Entity\Schedule $row */
// Generate this event
- $row->setDayPartFactory($this->dayPartFactory);
-
// Assess the date?
if ($mediaDate !== null) {
try {
diff --git a/lib/Controller/Schedule.php b/lib/Controller/Schedule.php
index f2f071acc6..e99b3a063b 100644
--- a/lib/Controller/Schedule.php
+++ b/lib/Controller/Schedule.php
@@ -239,8 +239,6 @@ function eventData()
/* @var \Xibo\Entity\Schedule $row */
// Generate this event
- $row->setDayPartFactory($this->dayPartFactory);
-
try {
$scheduleEvents = $row->getEvents($start, $end);
} catch (XiboException $e) {
@@ -418,9 +416,7 @@ public function eventList($displayGroupId)
// Assess schedules
$schedule = $this->scheduleFactory->createEmpty()->hydrate($event, ['intProperties' => ['isPriority', 'syncTimezone', 'displayOrder']]);
- $schedule
- ->setDayPartFactory($this->dayPartFactory)
- ->load();
+ $schedule->load();
$this->getLog()->debug('EventId ' . $schedule->eventId . ' exists in the schedule window, checking its instances for activity');
@@ -776,7 +772,7 @@ public function add()
$schedule->assignDisplayGroup($this->displayGroupFactory->getById($displayGroupId));
}
- if ($schedule->dayPartId != \Xibo\Entity\Schedule::$DAY_PART_ALWAYS) {
+ if (!$schedule->isAlwaysDayPart()) {
// Handle the dates
$fromDt = $this->getSanitizer()->getDate('fromDt');
$toDt = $this->getSanitizer()->getDate('toDt');
@@ -787,7 +783,7 @@ public function add()
$this->getLog()->debug('Times received are: FromDt=' . $this->getDate()->getLocalDate($fromDt) . '. ToDt=' . $this->getDate()->getLocalDate($toDt) . '. recurrenceRange=' . $this->getDate()->getLocalDate($recurrenceRange));
- if ($schedule->dayPartId != \Xibo\Entity\Schedule::$DAY_PART_CUSTOM && $schedule->dayPartId != \Xibo\Entity\Schedule::$DAY_PART_ALWAYS) {
+ if (!$schedule->isCustomDayPart() && !$schedule->isAlwaysDayPart()) {
// Daypart selected
// expect only a start date (no time)
$schedule->fromDt = $fromDt->startOfDay()->format('U');
@@ -847,7 +843,7 @@ function editForm($eventId)
throw new AccessDeniedException();
// Fix the event dates for display
- if ($schedule->dayPartId == \Xibo\Entity\Schedule::$DAY_PART_ALWAYS) {
+ if ($schedule->isAlwaysDayPart()) {
$schedule->fromDt = '';
$schedule->toDt = '';
} else {
@@ -1045,7 +1041,7 @@ public function edit($eventId)
$schedule->assignDisplayGroup($this->displayGroupFactory->getById($displayGroupId));
}
- if ($schedule->dayPartId != \Xibo\Entity\Schedule::$DAY_PART_ALWAYS) {
+ if (!$schedule->isAlwaysDayPart()) {
// Handle the dates
$fromDt = $this->getSanitizer()->getDate('fromDt');
$toDt = $this->getSanitizer()->getDate('toDt');
@@ -1056,7 +1052,7 @@ public function edit($eventId)
$this->getLog()->debug('Times received are: FromDt=' . $this->getDate()->getLocalDate($fromDt) . '. ToDt=' . $this->getDate()->getLocalDate($toDt) . '. recurrenceRange=' . $this->getDate()->getLocalDate($recurrenceRange));
- if ($schedule->dayPartId != \Xibo\Entity\Schedule::$DAY_PART_CUSTOM && $schedule->dayPartId != \Xibo\Entity\Schedule::$DAY_PART_ALWAYS) {
+ if (!$schedule->isCustomDayPart() && !$schedule->isAlwaysDayPart()) {
// Daypart selected
// expect only a start date (no time)
$schedule->fromDt = $fromDt->startOfDay()->format('U');
diff --git a/lib/Entity/DayPart.php b/lib/Entity/DayPart.php
index 723636817b..140855e24b 100644
--- a/lib/Entity/DayPart.php
+++ b/lib/Entity/DayPart.php
@@ -57,6 +57,18 @@ class DayPart implements \JsonSerializable
public $endTime;
public $exceptions;
+ /**
+ * @SWG\Property(description="A readonly flag determining whether this DayPart is always")
+ * @var int
+ */
+ public $isAlways = 0;
+
+ /**
+ * @SWG\Property(description="A readonly flag determining whether this DayPart is custom")
+ * @var int
+ */
+ public $isCustom = 0;
+
private $timeHash;
/** @var DateServiceInterface */
@@ -84,12 +96,20 @@ class DayPart implements \JsonSerializable
* Entity constructor.
* @param StorageServiceInterface $store
* @param LogServiceInterface $log
- * @param ScheduleFactory $scheduleFactory
*/
- public function __construct($store, $log, $scheduleFactory)
+ public function __construct($store, $log)
{
$this->setCommonDependencies($store, $log);
+ }
+
+ /**
+ * @param ScheduleFactory $scheduleFactory
+ * @return $this
+ */
+ public function setScheduleFactory($scheduleFactory)
+ {
$this->scheduleFactory = $scheduleFactory;
+ return $this;
}
/**
diff --git a/lib/Entity/Schedule.php b/lib/Entity/Schedule.php
index 6a40a81de3..2d1e59dd72 100644
--- a/lib/Entity/Schedule.php
+++ b/lib/Entity/Schedule.php
@@ -35,8 +35,6 @@ class Schedule implements \JsonSerializable
public static $LAYOUT_EVENT = 1;
public static $COMMAND_EVENT = 2;
public static $OVERLAY_EVENT = 3;
- public static $DAY_PART_CUSTOM = 0;
- public static $DAY_PART_ALWAYS = 1;
public static $DATE_MIN = 0;
public static $DATE_MAX = 2147483647;
@@ -179,6 +177,18 @@ class Schedule implements \JsonSerializable
*/
public $dayPartId;
+ /**
+ * @SWG\Property(description="Is this event an always on event?")
+ * @var int
+ */
+ public $isAlways;
+
+ /**
+ * @SWG\Property(description="Does this event have custom from/to date times?")
+ * @var int
+ */
+ public $isCustom;
+
/**
* Last Recurrence Watermark
* @var int
@@ -226,14 +236,16 @@ class Schedule implements \JsonSerializable
* @param PoolInterface $pool
* @param DateServiceInterface $date
* @param DisplayGroupFactory $displayGroupFactory
+ * @param DayPartFactory $dayPartFactory
*/
- public function __construct($store, $log, $config, $pool, $date, $displayGroupFactory)
+ public function __construct($store, $log, $config, $pool, $date, $displayGroupFactory, $dayPartFactory)
{
$this->setCommonDependencies($store, $log);
$this->config = $config;
$this->pool = $pool;
$this->dateService = $date;
$this->displayGroupFactory = $displayGroupFactory;
+ $this->dayPartFactory = $dayPartFactory;
$this->excludeProperty('lastRecurrenceWatermark');
}
@@ -253,16 +265,6 @@ public function setDisplayFactory($displayFactory)
return $this;
}
- /**
- * @param DayPartFactory $dayPartFactory
- * @return $this
- */
- public function setDayPartFactory($dayPartFactory)
- {
- $this->dayPartFactory = $dayPartFactory;
- return $this;
- }
-
/**
* @param DateServiceInterface $dateService
* @deprecated dateService is set by the factory
@@ -314,10 +316,11 @@ public function setOwner($ownerId)
/**
* Are the provided dates within the schedule look ahead
* @return bool
+ * @throws XiboException
*/
private function inScheduleLookAhead()
{
- if ($this->dayPartId == Schedule::$DAY_PART_ALWAYS)
+ if ($this->isAlwaysDayPart())
return true;
// From Date and To Date are in UNIX format
@@ -333,7 +336,7 @@ private function inScheduleLookAhead()
// If we are a recurring schedule and our recurring date is out after the required files lookahead
$this->getLog()->debug('Checking look ahead based on recurrence');
return ($this->fromDt <= $currentDate->format('U') && ($this->recurrenceRange == 0 || $this->recurrenceRange > $rfLookAhead->format('U')));
- } else if ($this->dayPartId != self::$DAY_PART_CUSTOM || $this->eventTypeId == self::$COMMAND_EVENT) {
+ } else if (!$this->isCustomDayPart() || $this->eventTypeId == self::$COMMAND_EVENT) {
// Day parting event (non recurring) or command event
// only test the from date.
$this->getLog()->debug('Checking look ahead based from date ' . $currentDate->toRssString());
@@ -391,6 +394,7 @@ public function unassignDisplayGroup($displayGroup)
/**
* Validate
+ * @throws XiboException
*/
public function validate()
{
@@ -404,7 +408,7 @@ public function validate()
if (!v::intType()->notEmpty()->validate($this->campaignId))
throw new InvalidArgumentException(__('Please select a Campaign/Layout for this event.'), 'campaignId');
- if ($this->dayPartId == Schedule::$DAY_PART_CUSTOM) {
+ if ($this->isCustomDayPart()) {
// validate the dates
if ($this->toDt <= $this->fromDt)
throw new InvalidArgumentException(__('Can not have an end time earlier than your start time'), 'start/end');
@@ -426,7 +430,7 @@ public function validate()
}
// Make sure we have a sensible recurrence setting
- if ($this->dayPartId !== self::$DAY_PART_CUSTOM && ($this->recurrenceType == 'Minute' || $this->recurrenceType == 'Hour'))
+ if (!$this->isCustomDayPart() && ($this->recurrenceType == 'Minute' || $this->recurrenceType == 'Hour'))
throw new InvalidArgumentException(__('Repeats selection is invalid for Always or Daypart events'), 'recurrencyType');
// Check display order is positive
@@ -460,7 +464,7 @@ public function save($options = [])
$this->validate();
// Handle "always" day parts
- if ($this->dayPartId == self::$DAY_PART_ALWAYS) {
+ if ($this->isAlwaysDayPart()) {
$this->fromDt = self::$DATE_MIN;
$this->toDt = self::$DATE_MAX;
}
@@ -694,7 +698,7 @@ private function generateMonth($generateFromDt)
);
// Events scheduled "always" will return one event
- if ($this->dayPartId == Schedule::$DAY_PART_ALWAYS) {
+ if ($this->isAlwaysDayPart()) {
// Create events with min/max dates
$this->addDetail(Schedule::$DATE_MIN, Schedule::$DATE_MAX);
return;
@@ -720,7 +724,7 @@ private function generateMonth($generateFromDt)
return;
// Detect invalid recurrences and quit early
- if ($this->dayPartId !== self::$DAY_PART_CUSTOM && ($this->recurrenceType == 'Minute' || $this->recurrenceType == 'Hour'))
+ if (!$this->isCustomDayPart() && ($this->recurrenceType == 'Minute' || $this->recurrenceType == 'Hour'))
return;
// Check the cache
@@ -948,12 +952,13 @@ private function dropEventCache($key = null)
* Calculate the DayPart times
* @param Date $start
* @param Date $end
+ * @throws XiboException
*/
private function calculateDayPartTimes($start, $end)
{
$dayOfWeekLookup = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
- if ($this->dayPartId != Schedule::$DAY_PART_ALWAYS && $this->dayPartId != Schedule::$DAY_PART_CUSTOM) {
+ if (!$this->isAlwaysDayPart() && !$this->isCustomDayPart()) {
// End is always based on Start
$end->setTimestamp($start->format('U'));
@@ -1052,4 +1057,28 @@ private function unlinkDisplayGroups()
$this->getStore()->update($sql, $params);
}
+
+ /**
+ * Is this event an always daypart event
+ * @return bool
+ * @throws \Xibo\Exception\NotFoundException
+ */
+ public function isAlwaysDayPart()
+ {
+ $dayPart = $this->dayPartFactory->getById($this->dayPartId);
+
+ return $dayPart->isAlways === 1;
+ }
+
+ /**
+ * Is this event a custom daypart event
+ * @return bool
+ * @throws \Xibo\Exception\NotFoundException
+ */
+ public function isCustomDayPart()
+ {
+ $dayPart = $this->dayPartFactory->getById($this->dayPartId);
+
+ return $dayPart->isCustom === 1;
+ }
}
\ No newline at end of file
diff --git a/lib/Factory/DayPartFactory.php b/lib/Factory/DayPartFactory.php
index ce87c5e47f..a7518e56c6 100644
--- a/lib/Factory/DayPartFactory.php
+++ b/lib/Factory/DayPartFactory.php
@@ -35,9 +35,6 @@
*/
class DayPartFactory extends BaseFactory
{
- /** @var ScheduleFactory */
- private $scheduleFactory;
-
/**
* Construct a factory
* @param StorageServiceInterface $store
@@ -45,13 +42,11 @@ class DayPartFactory extends BaseFactory
* @param SanitizerServiceInterface $sanitizerService
* @param User $user
* @param UserFactory $userFactory
- * @param ScheduleFactory $scheduleFactory
*/
- public function __construct($store, $log, $sanitizerService, $user, $userFactory, $scheduleFactory)
+ public function __construct($store, $log, $sanitizerService, $user, $userFactory)
{
$this->setCommonDependencies($store, $log, $sanitizerService);
$this->setAclDependencies($user, $userFactory);
- $this->scheduleFactory = $scheduleFactory;
}
/**
@@ -62,8 +57,7 @@ public function createEmpty()
{
return new DayPart(
$this->getStore(),
- $this->getLog(),
- $this->scheduleFactory
+ $this->getLog()
);
}
@@ -89,11 +83,7 @@ public function getById($dayPartId)
*/
public function allWithSystem()
{
- $dayParts = $this->query();
-
- // Add system and custom
- array_unshift($dayParts, ['dayPartId' => 1, 'name' => __('Always')]);
- array_unshift($dayParts, ['dayPartId' => 0, 'name' => __('Custom')]);
+ $dayParts = $this->query(['isAlways DESC', 'isCustom DESC', 'name']);
return $dayParts;
}
@@ -111,7 +101,7 @@ public function query($sortOrder = null, $filterBy = [])
$sortOrder = ['name'];
$params = array();
- $select = 'SELECT `daypart`.dayPartId, `name`, `description`, `isRetired`, `userId`, `startTime`, `endTime`, `exceptions`';
+ $select = 'SELECT `daypart`.dayPartId, `name`, `description`, `isRetired`, `userId`, `startTime`, `endTime`, `exceptions`, `isCustom`, `isAlways` ';
$body = ' FROM `daypart` ';
@@ -125,6 +115,16 @@ public function query($sortOrder = null, $filterBy = [])
$params['dayPartId'] = $this->getSanitizer()->getInt('dayPartId', $filterBy);
}
+ if ($this->getSanitizer()->getInt('isAlways', $filterBy) !== null) {
+ $body .= ' AND `daypart`.isAlways = :isAlways ';
+ $params['isAlways'] = $this->getSanitizer()->getInt('isAlways', $filterBy);
+ }
+
+ if ($this->getSanitizer()->getInt('isCustom', $filterBy) !== null) {
+ $body .= ' AND `daypart`.isCustom = :isCustom ';
+ $params['isCustom'] = $this->getSanitizer()->getInt('isCustom', $filterBy);
+ }
+
if ($this->getSanitizer()->getString('name', $filterBy) != null) {
$body .= ' AND `daypart`.name = :name ';
$params['name'] = $this->getSanitizer()->getString('name', $filterBy);
@@ -144,7 +144,9 @@ public function query($sortOrder = null, $filterBy = [])
$sql = $select . $body . $order . $limit;
foreach ($this->getStore()->select($sql, $params) as $row) {
- $dayPart = $this->createEmpty()->hydrate($row);
+ $dayPart = $this->createEmpty()->hydrate($row, [
+ 'intProperties' => ['isAlways', 'isCustom']
+ ]);
$dayPart->exceptions = json_decode($dayPart->exceptions, true);
$entries[] = $dayPart;
diff --git a/lib/Factory/ScheduleFactory.php b/lib/Factory/ScheduleFactory.php
index e28366328d..a3ff80e1d4 100644
--- a/lib/Factory/ScheduleFactory.php
+++ b/lib/Factory/ScheduleFactory.php
@@ -41,6 +41,9 @@ class ScheduleFactory extends BaseFactory
*/
private $displayGroupFactory;
+ /** @var DayPartFactory */
+ private $dayPartFactory;
+
/**
* Construct a factory
* @param StorageServiceInterface $store
@@ -50,14 +53,16 @@ class ScheduleFactory extends BaseFactory
* @param PoolInterface $pool
* @param DateServiceInterface $date
* @param DisplayGroupFactory $displayGroupFactory
+ * @param DayPartFactory $dayPartFactory
*/
- public function __construct($store, $log, $sanitizerService, $config, $pool, $date, $displayGroupFactory)
+ public function __construct($store, $log, $sanitizerService, $config, $pool, $date, $displayGroupFactory, $dayPartFactory)
{
$this->setCommonDependencies($store, $log, $sanitizerService);
$this->config = $config;
$this->pool = $pool;
$this->dateService = $date;
$this->displayGroupFactory = $displayGroupFactory;
+ $this->dayPartFactory = $dayPartFactory;
}
/**
@@ -72,7 +77,8 @@ public function createEmpty()
$this->config,
$this->pool,
$this->dateService,
- $this->displayGroupFactory
+ $this->displayGroupFactory,
+ $this->dayPartFactory
);
}
@@ -179,8 +185,12 @@ public function getForXmds($displayId, $fromDt, $toDt, $options = [])
schedule.syncTimezone,
`campaign`.campaign,
`command`.command,
- `lkscheduledisplaygroup`.displayGroupId
+ `lkscheduledisplaygroup`.displayGroupId,
+ `daypart`.isAlways,
+ `daypart`.isCustom
FROM `schedule`
+ INNER JOIN `daypart`
+ ON `daypart`.dayPartId = `schedule`.dayPartId
INNER JOIN `lkscheduledisplaygroup`
ON `lkscheduledisplaygroup`.eventId = `schedule`.eventId
INNER JOIN `lkdgdg`
@@ -263,8 +273,12 @@ public function query($sortOrder = null, $filterBy = [])
`command`.commandId,
`command`.command,
`schedule`.dayPartId,
- `schedule`.syncTimezone
+ `schedule`.syncTimezone,
+ `daypart`.isAlways,
+ `daypart`.isCustom
FROM `schedule`
+ INNER JOIN `daypart`
+ ON `daypart`.dayPartId = `schedule`.dayPartId
LEFT OUTER JOIN `campaign`
ON campaign.CampaignID = `schedule`.CampaignID
LEFT OUTER JOIN `command`
@@ -364,7 +378,7 @@ public function query($sortOrder = null, $filterBy = [])
$sql .= 'ORDER BY ' . implode(',', $sortOrder);
foreach ($this->getStore()->select($sql, $params) as $row) {
- $entries[] = $this->createEmpty()->hydrate($row, ['intProperties' => ['isPriority', 'syncTimezone']]);
+ $entries[] = $this->createEmpty()->hydrate($row, ['intProperties' => ['isPriority', 'syncTimezone', 'isAlways', 'isCustom']]);
}
return $entries;
diff --git a/lib/Helper/Environment.php b/lib/Helper/Environment.php
index 608b4105c5..0f32a94a31 100644
--- a/lib/Helper/Environment.php
+++ b/lib/Helper/Environment.php
@@ -11,8 +11,8 @@
class Environment
{
- public static $WEBSITE_VERSION_NAME = '1.8.5';
- public static $WEBSITE_VERSION = 136;
+ public static $WEBSITE_VERSION_NAME = '1.8.6';
+ public static $WEBSITE_VERSION = 137;
public static $VERSION_REQUIRED = '5.5';
public static $VERSION_UNSUPPORTED = '8.0';
diff --git a/lib/Middleware/State.php b/lib/Middleware/State.php
index 8ad903d374..1a28f5d0f0 100644
--- a/lib/Middleware/State.php
+++ b/lib/Middleware/State.php
@@ -1134,8 +1134,7 @@ public static function registerFactoriesWithDi($container)
$container->logService,
$container->sanitizerService,
$container->user,
- $container->userFactory,
- $container->scheduleFactory
+ $container->userFactory
);
});
@@ -1344,7 +1343,8 @@ public static function registerFactoriesWithDi($container)
$container->configService,
$container->pool,
$container->dateService,
- $container->displayGroupFactory
+ $container->displayGroupFactory,
+ $container->dayPartFactory
);
});
diff --git a/lib/Service/DisplayNotifyService.php b/lib/Service/DisplayNotifyService.php
index f6509b4d49..24f8923e46 100644
--- a/lib/Service/DisplayNotifyService.php
+++ b/lib/Service/DisplayNotifyService.php
@@ -280,7 +280,6 @@ public function notifyByCampaignId($campaignId)
if ($row['eventId'] != 0) {
$scheduleEvents = $this->scheduleFactory
->createEmpty()
- ->setDayPartFactory($this->dayPartFactory)
->hydrate($row)
->getEvents($currentDate, $rfLookAhead);
@@ -417,7 +416,6 @@ public function notifyByDataSetId($dataSetId)
if ($row['eventId'] != 0) {
$scheduleEvents = $this->scheduleFactory
->createEmpty()
- ->setDayPartFactory($this->dayPartFactory)
->hydrate($row)
->getEvents($currentDate, $rfLookAhead);
@@ -532,7 +530,6 @@ public function notifyByPlaylistId($playlistId)
if ($row['eventId'] != 0) {
$scheduleEvents = $this->scheduleFactory
->createEmpty()
- ->setDayPartFactory($this->dayPartFactory)
->hydrate($row)
->getEvents($currentDate, $rfLookAhead);
diff --git a/lib/Xmds/Soap.php b/lib/Xmds/Soap.php
index 761988814c..e7d2a8b5fa 100644
--- a/lib/Xmds/Soap.php
+++ b/lib/Xmds/Soap.php
@@ -401,7 +401,6 @@ protected function doRequiredFiles($serverKey, $hardwareKey, $httpDownloads)
if ($row['scheduleId'] != 0) {
$schedule = $this->scheduleFactory->createEmpty()->hydrate($row);
- $schedule->setDayPartFactory($this->dayPartFactory);
try {
$scheduleEvents = $schedule->getEvents($fromFilter, $toFilter);
@@ -862,7 +861,6 @@ protected function doSchedule($serverKey, $hardwareKey, $options = [])
foreach ($events as $row) {
$schedule = $this->scheduleFactory->createEmpty()->hydrate($row);
- $schedule->setDayPartFactory($this->dayPartFactory);
try {
$scheduleEvents = $schedule->getEvents($fromFilter, $toFilter);
diff --git a/views/schedule-form-add.twig b/views/schedule-form-add.twig
index 64961770bb..07b0096ae9 100644
--- a/views/schedule-form-add.twig
+++ b/views/schedule-form-add.twig
@@ -63,7 +63,19 @@
{% set title %}{% trans "Dayparting" %}{% endset %}
{% set helpText %}{% trans "Select the dayparting information for this event. To set your own times select custom and to have the event run constantly select Always." %}{% endset %}
- {{ forms.dropdown("dayPartId", "single", title, "", dayParts, "dayPartId", "name", helpText, "day-part-control") }}
+
{% set title %}{% trans "Start Time" %}{% endset %}
{% set helpText %}{% trans "Select the start time for this event" %}{% endset %}
diff --git a/views/schedule-form-edit.twig b/views/schedule-form-edit.twig
index 7161fd6d35..4a9f70b2b9 100644
--- a/views/schedule-form-edit.twig
+++ b/views/schedule-form-edit.twig
@@ -66,7 +66,19 @@
{% set title %}{% trans "Dayparting" %}{% endset %}
{% set helpText %}{% trans "Select the dayparting information for this event. To set your own times select custom and to have the event run constantly select Always." %}{% endset %}
- {{ forms.dropdown("dayPartId", "single", title, event.dayPartId, dayParts, "dayPartId", "name", helpText, "day-part-control") }}
+
{% set title %}{% trans "Start Time" %}{% endset %}
{% set helpText %}{% trans "Select the start time for this event" %}{% endset %}
diff --git a/views/schedule-page.twig b/views/schedule-page.twig
index 518bbba97f..d1c16a55a0 100644
--- a/views/schedule-page.twig
+++ b/views/schedule-page.twig
@@ -200,7 +200,7 @@
eventClass = "event-success";
}
- if (event.event.dayPartId == 1) {
+ if (event.event.isAlways == 1) {
eventIcon = "fa-retweet";
}
@@ -287,7 +287,7 @@
eventClass = "event-success";
}
- if (event.event.dayPartId == 1) {
+ if (event.event.isAlways == 1) {
eventIcon = "fa-retweet";
}
diff --git a/web/theme/default/js/xibo-calendar.js b/web/theme/default/js/xibo-calendar.js
index c77a898430..7588503b9f 100644
--- a/web/theme/default/js/xibo-calendar.js
+++ b/web/theme/default/js/xibo-calendar.js
@@ -486,9 +486,11 @@ var processScheduleFormElements = function(el) {
if (!el.is(":visible"))
return;
-
- var endTimeControlDisplay = (fieldVal != 0) ? "none" : "block";
- var startTimeControlDisplay = (fieldVal == 1) ? "none" : "block";
+
+ var meta = el.find('option[value=' + fieldVal + ']').data();
+
+ var endTimeControlDisplay = (meta.isCustom === 0) ? "none" : "block";
+ var startTimeControlDisplay = (meta.isAlways === 1) ? "none" : "block";
var $startTime = $(".starttime-control");
var $endTime = $(".endtime-control");
@@ -497,8 +499,9 @@ var processScheduleFormElements = function(el) {
$startTime.css('display', startTimeControlDisplay);
$endTime.css('display', endTimeControlDisplay);
+
// Dayparts only show the start control
- if (fieldVal != 0 && fieldVal != 1) {
+ if (meta.isAlways === 0 && meta.isCustom === 0) {
// We need to update the date/time controls to only accept the date element
$startTime.find("input[name=fromDt_Link2]").hide();
$startTime.find(".help-block").html($startTime.closest("form").data().notDaypartMessage);
From e8f688f6c871d6001d305ead7f69aa7852e3d6da Mon Sep 17 00:00:00 2001
From: Dan Garner
Date: Mon, 12 Feb 2018 15:35:18 +0000
Subject: [PATCH 08/25] Fix so that repeat until date can be removed from
Schedule Add/Edit. xibosignage/xibo#1402
---
lib/Controller/Schedule.php | 12 +++---
views/licence.twig | 4 +-
web/theme/default/js/xibo-cms.js | 65 +++++++++++++++++++-------------
3 files changed, 46 insertions(+), 35 deletions(-)
diff --git a/lib/Controller/Schedule.php b/lib/Controller/Schedule.php
index e99b3a063b..dbcff265da 100644
--- a/lib/Controller/Schedule.php
+++ b/lib/Controller/Schedule.php
@@ -1015,6 +1015,8 @@ function editForm($eventId)
* @SWG\Schema(ref="#/definitions/Schedule")
* )
* )
+ *
+ * @throws XiboException
*/
public function edit($eventId)
{
@@ -1057,9 +1059,7 @@ public function edit($eventId)
// expect only a start date (no time)
$schedule->fromDt = $fromDt->startOfDay()->format('U');
$schedule->toDt = null;
-
- if ($recurrenceRange != null)
- $schedule->recurrenceRange = $recurrenceRange->format('U');
+ $schedule->recurrenceRange = ($recurrenceRange === null) ? null : $recurrenceRange->format('U');
} else if (!($this->isApi() || str_contains($this->getConfig()->GetSetting('DATE_FORMAT'), 's'))) {
// In some circumstances we want to trim the seconds from the provided dates.
@@ -1072,16 +1072,14 @@ public function edit($eventId)
if ($toDt !== null)
$schedule->toDt = $toDt->setTime($toDt->hour, $toDt->minute, 0)->format('U');
- if ($recurrenceRange != null)
- $schedule->recurrenceRange = $recurrenceRange->setTime($recurrenceRange->hour, $recurrenceRange->minute, 0)->format('U');
+ $schedule->recurrenceRange = ($recurrenceRange === null) ? null : $recurrenceRange->setTime($recurrenceRange->hour, $recurrenceRange->minute, 0)->format('U');
} else {
$schedule->fromDt = $fromDt->format('U');
if ($toDt !== null)
$schedule->toDt = $toDt->format('U');
- if ($recurrenceRange != null)
- $schedule->recurrenceRange = $recurrenceRange->format('U');
+ $schedule->recurrenceRange = ($recurrenceRange === null) ? null : $recurrenceRange->format('U');
}
$this->getLog()->debug('Processed start is: FromDt=' . $fromDt->toRssString());
diff --git a/views/licence.twig b/views/licence.twig
index 629fbf1014..e73a828ef1 100644
--- a/views/licence.twig
+++ b/views/licence.twig
@@ -1,7 +1,7 @@
License Digital Signage for Everyone
Xibo Digital Signage - www.xibo.org.uk. Version {{ version }}
- Copyright © 2006-2016 Daniel Garner, Alex Harrington, Spring Signage Ltd and
- the Xibo Developers.
+ Copyright © 2006-2018 Spring Signage Ltd and the
+ Xibo Developers.
Xibo is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
diff --git a/web/theme/default/js/xibo-cms.js b/web/theme/default/js/xibo-cms.js
index f7716c5e8e..c13bbf9f4d 100644
--- a/web/theme/default/js/xibo-cms.js
+++ b/web/theme/default/js/xibo-cms.js
@@ -250,23 +250,31 @@ function XiboInitialise(scope) {
minView: 2,
calendarType: calendarType
}).change(function() {
- var value = moment($(this).val(), jsDateFormat);
-
- // Get the current master data
var preset = $("#" + $(this).data().linkCombined);
- var updatedMaster = (preset.val() == "") ? moment() : moment(preset.val(), systemDateFormat);
-
- if (!updatedMaster.isValid())
- updatedMaster = moment();
+ var value = $(this).val();
- updatedMaster.year(value.year());
- updatedMaster.month(value.month());
- updatedMaster.date(value.date());
-
- preset.val(updatedMaster.format(systemDateFormat));
+ // The user has wiped the value (it is empty, so we should set the hidden field to empty)
+ if (value === "") {
+ preset.val("");
+ } else {
+ // Parse the value into a moment
+ value = moment($(this).val(), jsDateFormat);
+
+ // Get the current master data (if empty, then assume now)
+ var updatedMaster = (preset.val() === "") ? moment() : moment(preset.val(), systemDateFormat);
+
+ if (!updatedMaster.isValid())
+ updatedMaster = moment();
+
+ updatedMaster.year(value.year());
+ updatedMaster.month(value.month());
+ updatedMaster.date(value.date());
+
+ preset.val(updatedMaster.format(systemDateFormat));
+ }
});
- if (preset != undefined && preset != "")
+ if (preset !== undefined && preset !== "")
$(this).find(".dateTimePickerDate").datetimepicker('update', moment(preset, systemDateFormat).format(systemDateFormat));
// Time control
@@ -274,23 +282,28 @@ function XiboInitialise(scope) {
'timeFormat': timeFormat,
'step': 15
}).change(function() {
- var value = moment($(this).val(), jsTimeFormat);
-
- // Get the current master data
+ var value = $(this).val();
var preset = $("#" + $(this).data().linkCombined);
-
- var updatedMaster = (preset.val() == "") ? moment() : moment(preset.val(), systemDateFormat);
- if (!updatedMaster.isValid())
- updatedMaster = moment();
-
- updatedMaster.hour(value.hour());
- updatedMaster.minute(value.minute());
- updatedMaster.second(value.second());
- preset.val(updatedMaster.format(systemDateFormat));
+ if (value === "") {
+ preset.val("");
+ } else {
+ value = moment($(this).val(), jsTimeFormat);
+
+ // Get the current master data
+ var updatedMaster = (preset.val() === "") ? moment() : moment(preset.val(), systemDateFormat);
+ if (!updatedMaster.isValid())
+ updatedMaster = moment();
+
+ updatedMaster.hour(value.hour());
+ updatedMaster.minute(value.minute());
+ updatedMaster.second(value.second());
+
+ preset.val(updatedMaster.format(systemDateFormat));
+ }
});
- if (preset != undefined && preset != "")
+ if (preset !== undefined && preset !== "")
$(this).find(".dateTimePickerTime").timepicker('setTime', moment(preset, systemDateFormat).toDate());
});
From 2ed110c358dbb6e99ba6c42103daf612ded15916 Mon Sep 17 00:00:00 2001
From: Dan Garner
Date: Mon, 12 Feb 2018 20:45:28 +0000
Subject: [PATCH 09/25] Possible solution to permissions issue with campaigns.
xibosignage/xibo#1405
---
lib/Controller/Campaign.php | 25 ++++++++++++++++++----
lib/Entity/Campaign.php | 36 +++++++++++++++++++++++++++++++-
lib/Factory/LayoutFactory.php | 15 +++++++------
views/campaign-form-layouts.twig | 2 +-
4 files changed, 66 insertions(+), 12 deletions(-)
diff --git a/lib/Controller/Campaign.php b/lib/Controller/Campaign.php
index 036a460eda..50ddbd75d6 100644
--- a/lib/Controller/Campaign.php
+++ b/lib/Controller/Campaign.php
@@ -444,10 +444,25 @@ public function layoutsForm($campaignId)
if (!$this->getUser()->checkEditable($campaign))
throw new AccessDeniedException();
+ $layouts = [];
+ foreach ($this->layoutFactory->getByCampaignId($campaignId, false) as $layout) {
+ if (!$this->getUser()->checkViewable($layout)) {
+ // Hide all layout details from the user
+ $emptyLayout = $this->layoutFactory->createEmpty();
+ $emptyLayout->layoutId = $layout->layoutId;
+ $emptyLayout->layout = __('Layout');
+ $emptyLayout->locked = true;
+
+ $layouts[] = $emptyLayout;
+ } else {
+ $layouts[] = $layout;
+ }
+ }
+
$this->getState()->template = 'campaign-form-layouts';
$this->getState()->setData([
'campaign' => $campaign,
- 'layouts' => $this->layoutFactory->getByCampaignId($campaignId),
+ 'layouts' => $layouts,
'help' => $this->getHelp()->link('Campaign', 'Layouts')
]);
}
@@ -540,7 +555,9 @@ public function assignLayout($campaignId)
$layout = $this->layoutFactory->getById($this->getSanitizer()->getInt('layoutId', $object));
- if (!$this->getUser()->checkViewable($layout))
+ // Check to see if this layout is already assigned
+ // if it is, then we have permission to move it around
+ if (!$this->getUser()->checkViewable($layout) && !$campaign->isLayoutAssigned($layout))
throw new AccessDeniedException(__('You do not have permission to assign the provided Layout'));
// Set the Display Order
@@ -562,7 +579,7 @@ public function assignLayout($campaignId)
$layout = $this->layoutFactory->getById($this->getSanitizer()->getInt('layoutId', $object));
- if (!$this->getUser()->checkViewable($layout))
+ if (!$this->getUser()->checkViewable($layout) && !$campaign->isLayoutAssigned($layout))
throw new AccessDeniedException(__('You do not have permission to assign the provided Layout'));
// Set the Display Order
@@ -644,7 +661,7 @@ public function unassignLayout($campaignId)
foreach ($layouts as $object) {
$layout = $this->layoutFactory->getById($this->getSanitizer()->getInt('layoutId', $object));
- if (!$this->getUser()->checkViewable($layout))
+ if (!$this->getUser()->checkViewable($layout) && !$campaign->isLayoutAssigned($layout))
throw new AccessDeniedException(__('You do not have permission to assign the provided Layout'));
// Set the Display Order
diff --git a/lib/Entity/Campaign.php b/lib/Entity/Campaign.php
index 783fd24307..32791eb474 100644
--- a/lib/Entity/Campaign.php
+++ b/lib/Entity/Campaign.php
@@ -25,6 +25,7 @@
use Respect\Validation\Validator as v;
use Xibo\Exception\InvalidArgumentException;
use Xibo\Exception\NotFoundException;
+use Xibo\Exception\XiboException;
use Xibo\Factory\DisplayFactory;
use Xibo\Factory\LayoutFactory;
use Xibo\Factory\PermissionFactory;
@@ -80,9 +81,20 @@ class Campaign implements \JsonSerializable
public $totalDuration;
public $tags = [];
-
+
+ /**
+ * @var Layout[]
+ */
private $layouts = [];
+
+ /**
+ * @var Permission[]
+ */
private $permissions = [];
+
+ /**
+ * @var Schedule[]
+ */
private $events = [];
// Private
@@ -471,6 +483,28 @@ public function unassignLayout($layout)
$this->layoutAssignmentsChanged = true;
}
+ /**
+ * Is the provided layout already assigned to this campaign
+ * @param Layout $checkLayout
+ * @return bool
+ * @throws XiboException
+ */
+ public function isLayoutAssigned($checkLayout)
+ {
+ $assigned = false;
+
+ $this->load();
+
+ foreach ($this->layouts as $layout) {
+ if ($layout->layoutId === $checkLayout->layoutId) {
+ $assigned = true;
+ break;
+ }
+ }
+
+ return $assigned;
+ }
+
private function add()
{
$this->campaignId = $this->getStore()->insert('INSERT INTO `campaign` (Campaign, IsLayoutSpecific, UserId) VALUES (:campaign, :isLayoutSpecific, :userId)', array(
diff --git a/lib/Factory/LayoutFactory.php b/lib/Factory/LayoutFactory.php
index 4cbc698583..20d28ede4e 100644
--- a/lib/Factory/LayoutFactory.php
+++ b/lib/Factory/LayoutFactory.php
@@ -946,10 +946,9 @@ public function query($sortOrder = null, $filterBy = [])
$select .= " layout.backgroundzIndex, ";
$select .= " layout.schemaVersion, ";
- if ($this->getSanitizer()->getInt('campaignId', 0, $filterBy) != 0) {
+ if ($this->getSanitizer()->getInt('campaignId', $filterBy) !== null) {
$select .= ' lkcl.displayOrder, ';
- }
- else {
+ } else {
$select .= ' NULL as displayOrder, ';
}
@@ -973,10 +972,14 @@ public function query($sortOrder = null, $filterBy = [])
$body .= " AND campaign.IsLayoutSpecific = 1";
$body .= " INNER JOIN `user` ON `user`.userId = `campaign`.userId ";
- if ($this->getSanitizer()->getInt('campaignId', 0, $filterBy) != 0) {
+ if ($this->getSanitizer()->getInt('campaignId', $filterBy) !== null) {
// Join Campaign back onto it again
- $body .= " INNER JOIN `lkcampaignlayout` lkcl ON lkcl.layoutid = layout.layoutid AND lkcl.CampaignID = :campaignId ";
- $params['campaignId'] = $this->getSanitizer()->getInt('campaignId', 0, $filterBy);
+ $body .= "
+ INNER JOIN `lkcampaignlayout` lkcl
+ ON lkcl.layoutid = layout.layoutid
+ AND lkcl.CampaignID = :campaignId
+ ";
+ $params['campaignId'] = $this->getSanitizer()->getInt('campaignId', $filterBy);
}
if ($this->getSanitizer()->getInt('displayGroupId', $filterBy) !== null) {
diff --git a/views/campaign-form-layouts.twig b/views/campaign-form-layouts.twig
index d482be9b99..8b38f40eaa 100644
--- a/views/campaign-form-layouts.twig
+++ b/views/campaign-form-layouts.twig
@@ -31,7 +31,7 @@
{% for item in layouts %}
- - {{ item.layout }}
+ - {% if item.locked %} {% endif %}{{ item.layout }}
{% endfor %}
From 27ba2e510663704e7b495bbca637b51191187566 Mon Sep 17 00:00:00 2001
From: Dan Garner
Date: Mon, 12 Feb 2018 21:18:21 +0000
Subject: [PATCH 10/25] Hook up the library selector on the ticker form (and
advanced no data message tab). xibosignage/xibo#1408
---
modules/text-form-add.twig | 2 +-
modules/text-form-edit.twig | 2 +-
modules/ticker-form-edit.twig | 93 +++++++++++++++++++++++-------
web/theme/default/css/xibo.css | 4 ++
web/theme/default/js/xibo-forms.js | 40 ++++++++-----
5 files changed, 104 insertions(+), 37 deletions(-)
diff --git a/modules/text-form-add.twig b/modules/text-form-add.twig
index d23e4cced2..7e4e46cc2c 100644
--- a/modules/text-form-add.twig
+++ b/modules/text-form-add.twig
@@ -115,7 +115,7 @@