Commit a0cd3709 authored by Carsten  Rose's avatar Carsten Rose
Browse files

Feature #3981 / Record Locking

First version for save - not working now.
Manual.rst: document config var SYSTEM_DIRTY_RECORD_TIMEOUT_SECONDS.
QuickFormQuery.php, Dirty.php: extend to support QFQ/save().
Client.php: fixed broken PHP Unit test
Config.php: set default for dirtyRecordTimeout.
BuildFormBootstrap.php: No dirtyUrl if dirtyMode=none.
formEditor.sql: extend definition to Form.dirtyMode, new table 'Dirty'
Fixed several unit tests for new tables.
parent 43773895
......@@ -337,6 +337,8 @@ config.qfq.ini
| | | time QFQ is called - *not* recommended! |
| | | 'never': never apply DB Updates. |
+-----------------------------+-------------------------------------------------+----------------------------------------------------------------------------+
| DIRTY_RECORD_TIMEOUT_SECONDS| DIRTY_RECORD_TIMEOUT_SECONDS = 900 | Timeout for record locking. After this time, a record will be replaced |
+-----------------------------+-------------------------------------------------+----------------------------------------------------------------------------+
| DOCUMENTATION_QFQ | DOCUMENTATION_QFQ=http://docs.typo3.org... | Link to the online documentation of QFQ. Every QFQ installation also |
| | | contains a local copy: typo3conf/ext/qfq/Documentation/html/Manual.html |
+-----------------------------+-------------------------------------------------+----------------------------------------------------------------------------+
......@@ -411,7 +413,8 @@ Example: *typo3conf/config.qfq.ini*
;NEW_BUTTON_GLYPH_ICON = glyphicon-plus
; auto | always | never
;DB_UPDATE=auto
;DB_UPDATE = auto
;DIRTY_RECORD_TIMEOUT_SECONDS_DEFAULT = 900
; Local Documentation (doc fits to installed version): typo3conf/ext/qfq/Documentation/html/Manual.html
;DOCUMENTATION_QFQ = https://docs.typo3.org/typo3cms/drafts/github/T3DocumentationStarter/Public-Info-053/Manual.html
......
......@@ -88,7 +88,9 @@ WKHTMLTOPDF = /opt/wkhtmltox/bin/wkhtmltopdf
;NEW_BUTTON_GLYPH_ICON = glyphicon-plus
; auto | always | never
;DB_UPDATE=auto
;DB_UPDATE = auto
;DIRTY_RECORD_TIMEOUT_SECONDS_DEFAULT = 900
; Local Documentation (doc fits to installed version): typo3conf/ext/qfq/Documentation/html/Manual.html
;DOCUMENTATION_QFQ = https://docs.typo3.org/typo3cms/drafts/github/T3DocumentationStarter/Public-Info-053/Manual.html
......
......@@ -492,6 +492,8 @@ class BuildFormBootstrap extends AbstractBuildForm {
$apiDir = API_DIR;
$apiDeletePhp = API_DIR . '/' . API_DELETE_PHP;
$dirtyAction = ($this->formSpec[F_DIRTY_MODE]==DIRTY_MODE_NONE) ? '' : "dirtyUrl: '$apiDir/dirty.php',";
$html .= '</form>'; // <form class="form-horizontal" ...
$html .= <<<EOF
<script type="text/javascript">
......@@ -503,7 +505,7 @@ class BuildFormBootstrap extends AbstractBuildForm {
tabsId: '$tabId',
formId: '$formId',
submitTo: '$apiDir/save.php',
dirtyUrl: '$apiDir/dirty.php',
$dirtyAction
deleteUrl: '$deleteUrl',
refreshUrl: '$apiDir/load.php',
fileUploadTo: '$apiDir/file.php?$actionUpload',
......
......@@ -256,6 +256,7 @@ const ERROR_SUBSTITUTE_FOUND = 2100;
// Dirty
const ERROR_MISSING_FORM_IN_SIP = 2200;
const ERROR_DELETE_DIRTY_RECORD = 2201;
//
// Store Names: Identifier
......@@ -427,6 +428,9 @@ const SYSTEM_DB_UPDATE_ALWAYS = 'always';
const SYSTEM_DB_UPDATE_NEVER = 'never';
const SYSTEM_DB_UPDATE_AUTO = 'auto';
const SYSTEM_DIRTY_RECORD_TIMEOUT_SECONDS = 'DIRTY_RECORD_TIMEOUT_SECONDS';
const SYSTEM_DIRTY_RECORD_TIMEOUT_SECONDS_DEFAULT = 900; // 15 mins
const DOCUMENTATION_QFQ = 'DOCUMENTATION_QFQ';
const DOCUMENTATION_QFQ_URL = 'https://docs.typo3.org/typo3cms/drafts/github/T3DocumentationStarter/Public-Info-053/Manual.html';
......@@ -1095,4 +1099,6 @@ const ACTION_ELEMENT_DELETED = -1;
const DIRTY_MODE_TIMEOUT = 'timeout';
const DIRTY_MODE_READONLY = 'readonly';
const DIRTY_MODE_OVERWRITE = 'overwrite';
const QFQ_USER_SESSION_COOKIE = 'qfqUserSessionCookie';
\ No newline at end of file
const DIRTY_MODE_NONE = 'none';
const DIRTY_QFQ_USER_SESSION_COOKIE = 'qfqUserSessionCookie';
const DIRTY_FE_USER= 'feUser';
\ No newline at end of file
......@@ -44,6 +44,7 @@ require_once(__DIR__ . '/report/Report.php');
require_once(__DIR__ . '/BodytextParser.php');
require_once(__DIR__ . '/Delete.php');
require_once(__DIR__ . '/form/FormAction.php');
require_once(__DIR__ . '/form/Dirty.php');
/*
* Form will be called
......@@ -266,6 +267,13 @@ class QuickFormQuery {
$this->fillStoreWithRecord($this->formSpec[F_TABLE_NAME], $recordId, STORE_BEFORE);
}
// Check and release dirtyRecord.
if ($formMode === FORM_DELETE || $formMode === FORM_SAVE) {
$dirty = new Dirty();
$timeOutDurationSeconds = $this->store->getVar(STORE_SYSTEM, SYSTEM_DIRTY_RECORD_TIMEOUT_SECONDS);
$dirty->releaseDirty($this->formSpec[F_DIRTY_MODE], $this->formSpec[F_TABLE_NAME], $recordId, $timeOutDurationSeconds);
}
if ($formMode === FORM_DELETE) {
$build = new Delete();
......
......@@ -91,30 +91,48 @@ class Dirty {
$feUser = $this->session->get(SESSION_FE_USER);
// Look for already existing dirty record.
$recordDirty = $this->db->sql("SELECT id, sip, feUser, qfqUserSessionCookie, dirtyMode FROM Dirty AS d WHERE d.tableName LIKE ? AND recordId=? ",
ROW_EXPECT_0_1, [$tableName, $recordId]);
$recordDirty = $this->getRecordDirty($tableName, $recordId);
if (count($recordDirty) == 0) {
// No dirty record found.
$answer = $this->writeDirty($this->client[SIP_SIP], $recordId, $tableName, $formDirtyMode, $feUser);
} else {
$answer = $this->checkConflict($recordDirty, $formDirtyMode, $feUser);
$answer = $this->conflict($recordDirty, $formDirtyMode, $feUser);
}
return $answer;
}
/**
* Load (if exist) a DirtyRecord (lock).
*
* @param $tableName
* @param $recordId
* @return array DirtyRecord or empty array.
* @throws CodeException
* @throws DbException
*/
private function getRecordDirty($tableName, $recordId) {
$recordDirty = $this->db->sql("SELECT id, sip, feUser, qfqUserSessionCookie, dirtyMode FROM Dirty AS d WHERE d.tableName LIKE ? AND recordId=? ",
ROW_EXPECT_0_1, [$tableName, $recordId]);
return $recordDirty;
}
/**
* @param array $recordDirty
* @param $currentFormDirtyMode
* @return array
*/
private function checkConflict(array $recordDirty, $currentFormDirtyMode, $feUser) {
private function conflict(array $recordDirty, $currentFormDirtyMode, $feUser) {
$status = API_ANSWER_STATUS_CONFLICT;
$at = "at " . $recordDirty[COLUMN_CREATED] . " from " . $recordDirty[CLIENT_REMOTE_ADDRESS];
if ($this->client[CLIENT_COOKIE_QFQ] == $recordDirty[QFQ_USER_SESSION_COOKIE]) {
$msg = "The record has already been tagged for editing by you (maybe in another browser tab)!";
if ($this->client[CLIENT_COOKIE_QFQ] == $recordDirty[DIRTY_QFQ_USER_SESSION_COOKIE]) {
$msg = "The record has already been tagged for editing by you (maybe in another browser tab) $at!";
$status = API_ANSWER_STATUS_CONFLICT_ALLOW_FORCE;
} else {
......@@ -125,8 +143,7 @@ class Dirty {
$msgUser = "user '$feUser'";
}
$msg = "The record has already been tagged for editing by $msgUser at " .
$recordDirty[COLUMN_CREATED] . " from " . $recordDirty[CLIENT_REMOTE_ADDRESS];
$msg = "The record has already been tagged for editing by $msgUser at $at.";
// Mandatory lock on Record or current Form?
if ($recordDirty[F_DIRTY_MODE] == DIRTY_MODE_TIMEOUT || $currentFormDirtyMode == DIRTY_MODE_TIMEOUT) {
......@@ -165,4 +182,58 @@ class Dirty {
return [API_STATUS => API_ANSWER_STATUS_SUCCESS, API_MESSAGE => ''];
}
public function releaseDirty($dirtyMode, $tableName, $recordId, $timeOutDurationSeconds) {
$recordDirty = $this->getRecordDirty($tableName, $recordId);
if (empty($recordDirty) && $dirtyMode == DIRTY_MODE_NONE) {
return; // only situation where it's ok that there is no dirtyRecord.
}
// Is the dirtyRecord mine?
if ($recordDirty[DIRTY_QFQ_USER_SESSION_COOKIE] == $this->client[CLIENT_COOKIE_QFQ]) {
// Clear the lock
$this->deleteDirtyRecord($recordDirty[COLUMN_ID]);
return;
}
//----------------------------------------
// From here: there is a foreign lock!
// Check if overwrite is allowed
if ($dirtyMode == DIRTY_MODE_OVERWRITE && $recordDirty[F_DIRTY_MODE] == DIRTY_MODE_OVERWRITE) {
$this->deleteDirtyRecord($recordDirty[COLUMN_ID]);
return;
}
$at = "at " . $recordDirty[COLUMN_CREATED] . " from " . $recordDirty[CLIENT_REMOTE_ADDRESS];
$msgUser = (empty($recordDirty[DIRTY_FE_USER])) ? "another user" : "user '$recordDirty[DIRTY_FE_USER]'";
// Check if the record is timed out
if ($timeOutDurationSeconds != 0) {
$datetimeExpire = date_add(date_create($recordDirty[COLUMN_MODIFIED]), date_interval_create_from_date_string("$timeOutDurationSeconds second"));
if ($datetimeExpire <= date_create("now")) {
$this->deleteDirtyRecord($recordDirty[COLUMN_ID]);
return;
}
}
throw new UserFormException("Save is not allowed - the record has been tagged for editing by $msgUser $at");
}
/**
* Delete the dirtyRecord with $recordDirtyId. Throw an exception if the record has not been deleted.
*
* @param $recordDirtyId
* @throws CodeException
* @throws DbException
*/
private function deleteDirtyRecord($recordDirtyId) {
$cnt = $this->db->sql('DELETE FROM Dirty WHERE id=? LIMIT 1', ROW_REGULAR, [$recordDirtyId]);
if ($cnt != 1) {
throw new CodeException("Failed to delete dirty record id=" . $recordDirtyId, ERROR_DELETE_DIRTY_RECORD);
}
}
}
\ No newline at end of file
......@@ -18,11 +18,14 @@ class Client {
public static function getParam() {
// copy GET and POST and SERVER Parameter. Priority: SERVER, POST, GET
$get = array();
$post = array();
$cookie = array();
$server = array();
$get = \qfq\Sanitize::urlDecodeArr($_GET);
if (isset($_GET)) {
$get = \qfq\Sanitize::urlDecodeArr($_GET);
}
if (isset($_POST)) {
$post = $_POST;
......
......@@ -172,6 +172,7 @@ class Config {
Support::setIfNotSet($config, SYSTEM_GFX_EXTRA_BUTTON_INFO_BELOW, '<span class="glyphicon glyphicon-info-sign text-info" aria-hidden="true"></span>');
Support::setIfNotSet($config, SYSTEM_DB_UPDATE, SYSTEM_DB_UPDATE_AUTO);
Support::setIfNotSet($config, SYSTEM_DIRTY_RECORD_TIMEOUT_SECONDS, SYSTEM_DIRTY_RECORD_TIMEOUT_SECONDS_DEFAULT);
Support::setIfNotSet($config, DOCUMENTATION_QFQ, DOCUMENTATION_QFQ_URL);
......
......@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS `Form` (
`escapeTypeDefault` VARCHAR(32) NOT NULL DEFAULT 'c',
`render` ENUM('bootstrap', 'table', 'plain') NOT NULL DEFAULT 'bootstrap',
`requiredParameter` VARCHAR(255) NOT NULL DEFAULT '',
`dirtyMode` ENUM('timeout', 'readonly', 'overwrite', 'none') NOT NULL DEFAULT 'timeout',
`showButton` SET('new', 'delete', 'close', 'save') NOT NULL DEFAULT 'new,delete,close,save',
`multiMode` ENUM('none', 'horizontal', 'vertical') NOT NULL DEFAULT 'none',
`multiSql` TEXT NOT NULL,
......@@ -128,6 +129,28 @@ CREATE TABLE IF NOT EXISTS `FormElement` (
#//
#DELIMITER ;
CREATE TABLE IF NOT EXISTS `Dirty` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`sip` VARCHAR(255) NOT NULL,
`tableName` VARCHAR(255) NOT NULL,
`recordId` INT(11) NOT NULL,
`recordModified` DATETIME NOT NULL,
`feUser` VARCHAR(255) NOT NULL,
`qfqUserSessionCookie` VARCHAR(255) NOT NULL,
`dirtyMode` ENUM('timeout', 'readonly', 'overwrite') NOT NULL,
`remoteAddress` VARCHAR(45) NOT NULL,
`modified` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `sip` (`sip`),
KEY `tableName` (`tableName`),
KEY `recordId` (`recordId`)
)
ENGINE = InnoDB
DEFAULT CHARSET = utf8
AUTO_INCREMENT = 0;
# Delete previous FormElements (if exist)
DELETE FormElement FROM FormElement, Form
WHERE
......
......@@ -255,7 +255,7 @@ class DatabaseTest extends AbstractDatabaseTest {
$rc = $this->db->sql($sql, ROW_REGULAR, $dummy, 'fake', $dummy, $stat);
// DB_NUM_ROWS | DB_INSERT_ID | DB_AFFECTED_ROWS
$this->assertEquals(7, $stat[DB_NUM_ROWS]);
$this->assertEquals(8, $stat[DB_NUM_ROWS]);
}
/**
......
......@@ -292,6 +292,8 @@ EOT;
SYSTEM_GFX_EXTRA_BUTTON_INFO_BELOW => '<span class="glyphicon glyphicon-info-sign text-info" aria-hidden="true"></span>',
SYSTEM_DB_UPDATE => SYSTEM_DB_UPDATE_AUTO,
SYSTEM_DIRTY_RECORD_TIMEOUT_SECONDS => SYSTEM_DIRTY_RECORD_TIMEOUT_SECONDS_DEFAULT,
DOCUMENTATION_QFQ => DOCUMENTATION_QFQ_URL,
];
......
......@@ -12,13 +12,14 @@ CREATE TABLE IF NOT EXISTS `Form` (
`escapeTypeDefault` VARCHAR(32) NOT NULL DEFAULT 'c',
`render` ENUM('plain', 'table', 'bootstrap') NOT NULL DEFAULT 'plain',
`requiredParameter` VARCHAR(255) NOT NULL DEFAULT '',
`dirtyMode` ENUM('timeout', 'readonly', 'overwrite', 'none') NOT NULL DEFAULT 'timeout',
`showButton` SET('new', 'delete') NOT NULL DEFAULT 'new,delete',
`multiMode` ENUM('none', 'horizontal', 'vertical') NOT NULL DEFAULT 'none',
`multiSql` TEXT NOT NULL,
`multiDetailForm` VARCHAR(255) NOT NULL DEFAULT '',
`multiDetailFormParameter` VARCHAR(255) NOT NULL DEFAULT '',
`forwardMode` ENUM('client', 'no', 'url', 'url-skip-history') NOT NULL DEFAULT 'client',
`forwardMode` ENUM('client', 'no', 'url', 'url-skip-history') NOT NULL DEFAULT 'client',
`forwardPage` VARCHAR(255) NOT NULL DEFAULT '',
`bsLabelColumns` VARCHAR(255) NOT NULL DEFAULT '',
......@@ -127,6 +128,28 @@ CREATE TABLE IF NOT EXISTS `FormElement` (
# //
# DELIMITER ;
DROP TABLE IF EXISTS `Dirty`;
CREATE TABLE IF NOT EXISTS `Dirty` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`sip` VARCHAR(255) NOT NULL,
`tableName` VARCHAR(255) NOT NULL,
`recordId` INT(11) NOT NULL,
`recordModified` DATETIME NOT NULL,
`feUser` VARCHAR(255) NOT NULL,
`qfqUserSessionCookie` VARCHAR(255) NOT NULL,
`dirtyMode` ENUM('timeout', 'readonly', 'overwrite') NOT NULL,
`remoteAddress` VARCHAR(45) NOT NULL,
`modified` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `sip` (`sip`),
KEY `tableName` (`tableName`),
KEY `recordId` (`recordId`)
)
ENGINE = InnoDB
DEFAULT CHARSET = utf8
AUTO_INCREMENT = 0;
#
# FormEditor: Form
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment