File Based Locking at Document Level (#2374)
* [redesign] improved locking in documents and redesigned recent documents * [minor] patch to update doctype in existing documents
This commit is contained in:
parent
9e70ff8811
commit
4bd2285159
17 changed files with 132 additions and 90 deletions
|
|
@ -10,7 +10,12 @@ frappe.listview_settings['Error Log'] = {
|
|||
order_by: "seen asc, modified desc",
|
||||
onload: function(listview) {
|
||||
listview.page.add_menu_item(__("Clear Error Logs"), function() {
|
||||
frappe.call({method:'frappe.core.doctype.error_log.error_log.clear_error_logs'});
|
||||
frappe.call({
|
||||
method:'frappe.core.doctype.error_log.error_log.clear_error_logs',
|
||||
callback: function() {
|
||||
listview.refresh();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -712,8 +712,7 @@ class Database:
|
|||
return frappe.defaults.get_defaults(parent)
|
||||
|
||||
def begin(self):
|
||||
pass
|
||||
#self.sql("start transaction")
|
||||
self.sql("start transaction")
|
||||
|
||||
def commit(self):
|
||||
"""Commit current transaction. Calls SQL `COMMIT`."""
|
||||
|
|
|
|||
|
|
@ -62,3 +62,4 @@ class AppNotInstalledError(ValidationError): pass
|
|||
class IncorrectSitePath(NotFound): pass
|
||||
class ImplicitCommitError(ValidationError): pass
|
||||
class RetryBackgroundJobError(Exception): pass
|
||||
class DocumentLockedError(ValidationError): pass
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
import time
|
||||
from frappe import _, msgprint
|
||||
from frappe.utils import flt, cstr, now, get_datetime_str
|
||||
from frappe.utils import flt, cstr, now, get_datetime_str, file_lock
|
||||
from frappe.utils.background_jobs import enqueue
|
||||
from frappe.model.base_document import BaseDocument, get_controller
|
||||
from frappe.model.naming import set_new_name
|
||||
|
|
@ -156,23 +157,6 @@ class Document(BaseDocument):
|
|||
frappe.msgprint(msg)
|
||||
raise frappe.PermissionError(msg)
|
||||
|
||||
def lock(self):
|
||||
'''Will set docstatus to 3 + the current docstatus and mark it as queued
|
||||
|
||||
3 = queued for saving
|
||||
4 = queued for submission
|
||||
5 = queued for cancellation
|
||||
'''
|
||||
self.db_set('docstatus', 3 + self.docstatus, update_modified = False)
|
||||
|
||||
def unlock(self):
|
||||
'''set the original docstatus at the time it was locked in the controller'''
|
||||
current_docstatus = self.db_get('docstatus') - 4
|
||||
if current_docstatus < 0:
|
||||
current_docstatus = 0
|
||||
|
||||
self.db_set('docstatus', current_docstatus, update_modified = False)
|
||||
|
||||
def insert(self, ignore_permissions=None):
|
||||
"""Insert the document in the database (as a new document).
|
||||
This will check for user permissions and execute `before_insert`,
|
||||
|
|
@ -494,7 +478,9 @@ class Document(BaseDocument):
|
|||
self._action = "save"
|
||||
if not self.get('__islocal'):
|
||||
if self.meta.issingle:
|
||||
modified = frappe.db.get_value(self.doctype, self.name, "modified")
|
||||
modified = frappe.db.sql('''select value from tabSingles
|
||||
where doctype=%s and field='modified' for update''', self.doctype)
|
||||
modified = modified and modified[0][0]
|
||||
if cstr(modified) and cstr(modified) != cstr(self._original_modified):
|
||||
conflict = True
|
||||
else:
|
||||
|
|
@ -530,13 +516,7 @@ class Document(BaseDocument):
|
|||
- Submit (1) > Submit (1)
|
||||
- Submit (1) > Cancel (2)
|
||||
|
||||
If docstatus is > 2, it will throw exception as document is deemed queued
|
||||
"""
|
||||
|
||||
if self.docstatus > 2:
|
||||
frappe.throw(_('This document is currently queued for execution. Please try again'),
|
||||
title=_('Document Queued'), indicator='red')
|
||||
|
||||
if not self.docstatus:
|
||||
self.docstatus = 0
|
||||
if docstatus==0:
|
||||
|
|
@ -1014,28 +994,46 @@ class Document(BaseDocument):
|
|||
def queue_action(self, action, **kwargs):
|
||||
'''Run an action in background. If the action has an inner function,
|
||||
like _submit for submit, it will call that instead'''
|
||||
if action in ('save', 'submit', 'cancel'):
|
||||
# set docstatus explicitly again due to inconsistent action
|
||||
self.docstatus = {'save':0, 'submit':1, 'cancel': 2}[action]
|
||||
else:
|
||||
raise 'Action must be one of save, submit, cancel'
|
||||
|
||||
# call _submit instead of submit, so you can override submit to call
|
||||
# run_delayed based on some action
|
||||
# See: Stock Reconciliation
|
||||
if hasattr(self, '_' + action):
|
||||
action = '_' + action
|
||||
|
||||
if file_lock.lock_exists(self.get_signature()):
|
||||
frappe.throw(_('This document is currently queued for execution. Please try again'),
|
||||
title=_('Document Queued'), indicator='red')
|
||||
|
||||
self.lock()
|
||||
frappe.db.commit()
|
||||
enqueue('frappe.model.document.execute_action', doctype=self.doctype, name=self.name,
|
||||
action=action, **kwargs)
|
||||
|
||||
def lock(self, timeout=None):
|
||||
'''Creates a lock file for the given document. If timeout is set,
|
||||
it will retry every 1 second for acquiring the lock again
|
||||
|
||||
:param timeout: Timeout in seconds, default 0'''
|
||||
signature = self.get_signature()
|
||||
if file_lock.lock_exists(signature):
|
||||
lock_exists = True
|
||||
if timeout:
|
||||
for i in range(timeout):
|
||||
time.sleep(1)
|
||||
if not file_lock.lock_exists(signature):
|
||||
lock_exists = False
|
||||
break
|
||||
if lock_exists:
|
||||
raise frappe.DocumentLockedError
|
||||
file_lock.create_lock(signature)
|
||||
|
||||
def unlock(self):
|
||||
'''Delete the lock file for this document'''
|
||||
file_lock.delete_lock(self.get_signature())
|
||||
|
||||
def execute_action(doctype, name, action, **kwargs):
|
||||
'''Execute an action on a document (called by background worker)'''
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
doc.unlock()
|
||||
frappe.db.commit()
|
||||
try:
|
||||
getattr(doc, action)(**kwargs)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -146,3 +146,4 @@ execute:frappe.db.set_default('language', '')
|
|||
frappe.patches.v7_1.refactor_integration_broker
|
||||
frappe.patches.v7_1.set_backup_limit
|
||||
frappe.patches.v7_1.disabled_print_settings_for_custom_print_format
|
||||
frappe.patches.v7_2.set_doctype_engine
|
||||
|
|
|
|||
0
frappe/patches/v7_2/__init__.py
Normal file
0
frappe/patches/v7_2/__init__.py
Normal file
6
frappe/patches/v7_2/set_doctype_engine.py
Normal file
6
frappe/patches/v7_2/set_doctype_engine.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import frappe
|
||||
|
||||
def execute():
|
||||
for t in frappe.db.sql('show table status'):
|
||||
if t[0].startswith('tab'):
|
||||
frappe.db.sql('update tabDocType set engine=%s where name=%s', (t[1], t[0][3:]))
|
||||
|
|
@ -836,7 +836,13 @@ frappe.ui.form.GridRow = Class.extend({
|
|||
field.$input.on('keydown', function(e) {
|
||||
var values = me.frm.doc[me.grid.df.fieldname];
|
||||
var fieldname = $(this).attr('data-fieldname');
|
||||
var fieldtype = $(this).attr('data-fieldtype');
|
||||
|
||||
// TAB
|
||||
if(in_list(['Text', 'Small Text'], fieldtype)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(e.which==TAB) {
|
||||
// last column
|
||||
if(me.grid.wrapper.find('input:enabled:last').get(0)===this) {
|
||||
|
|
|
|||
|
|
@ -26,24 +26,12 @@ frappe.get_indicator = function(doc, doctype) {
|
|||
var is_submittable = frappe.model.is_submittable(doctype),
|
||||
workflow_fieldname = frappe.workflow.get_state_fieldname(doctype);
|
||||
|
||||
if(doc.docstatus==3) {
|
||||
return [__("Queued for saving"), "orange", "docstatus,=,3"];
|
||||
}
|
||||
|
||||
if(doc.docstatus==4) {
|
||||
return [__("Queued for submission"), "orange", "docstatus,=,4"];
|
||||
}
|
||||
|
||||
if(doc.docstatus==5) {
|
||||
return [__("Queued for cancellation"), "orange", "docstatus,=,5"];
|
||||
}
|
||||
|
||||
// workflow
|
||||
if(workflow_fieldname) {
|
||||
var value = doc[workflow_fieldname];
|
||||
if(value) {
|
||||
var colour = "";
|
||||
|
||||
|
||||
if(locals["Workflow State"][value] && locals["Workflow State"][value].style) {
|
||||
var colour = {
|
||||
"Success": "green",
|
||||
|
|
|
|||
|
|
@ -438,9 +438,6 @@ $.extend(frappe.model, {
|
|||
parent_doc[parentfield] = newlist;
|
||||
});
|
||||
}
|
||||
|
||||
if(frappe.ui.toolbar.recent)
|
||||
frappe.ui.toolbar.recent.remove(doctype, docname);
|
||||
},
|
||||
|
||||
get_no_copy_list: function(doctype) {
|
||||
|
|
|
|||
|
|
@ -50,8 +50,13 @@ frappe.route = function() {
|
|||
frappe.views.pageview.show(route[0]);
|
||||
}
|
||||
|
||||
|
||||
if(frappe.route_titles[window.location.hash]) {
|
||||
frappe.utils.set_title(frappe.route_titles[window.location.hash]);
|
||||
} else {
|
||||
setTimeout(function() {
|
||||
frappe.route_titles[frappe.get_route_str()] = frappe._original_title || document.title;
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,4 +150,5 @@ $(window).on('hashchange', function() {
|
|||
cur_dialog.hide();
|
||||
|
||||
frappe.route();
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -538,10 +538,7 @@ frappe.ui.Filter = Class.extend({
|
|||
df.options=[
|
||||
{value:0, label:__("Draft")},
|
||||
{value:1, label:__("Submitted")},
|
||||
{value:2, label:__("Cancelled")},
|
||||
{value:3, label:__("Queued for saving")},
|
||||
{value:4, label:__("Queued for submission")},
|
||||
{value:5, label:__("Queued for cancellation")},
|
||||
{value:2, label:__("Cancelled")}
|
||||
]
|
||||
} else if(df.fieldtype=='Check') {
|
||||
df.fieldtype='Select';
|
||||
|
|
|
|||
|
|
@ -118,27 +118,45 @@ frappe.search = {
|
|||
});
|
||||
},
|
||||
add_recent: function(txt) {
|
||||
var doctypes = frappe.utils.unique(keys(locals).concat(keys(frappe.search.recent)));
|
||||
for(var i in doctypes) {
|
||||
var doctype = doctypes[i];
|
||||
if(doctype[0]!==":" && !frappe.model.is_table(doctype)
|
||||
&& !in_list(frappe.boot.single_types, doctype)
|
||||
&& !in_list(["DocType", "DocField", "DocPerm", "Page", "Country",
|
||||
"Currency", "Page Role", "Print Format", "Report"], doctype)) {
|
||||
values = [];
|
||||
$.each(frappe.search.recent, function(i, doctype) {
|
||||
values.push([doctype[1], ['Form', doctype[0], doctype[1]]]);
|
||||
});
|
||||
|
||||
var values = frappe.utils.remove_nulls(frappe.utils.unique(
|
||||
keys(locals[doctype]).concat(frappe.search.recent[doctype] || [])
|
||||
));
|
||||
values = values.reverse();
|
||||
|
||||
var ret = frappe.search.find(values, txt, function(match) {
|
||||
return {
|
||||
label: __(doctype) + " " + match.bold(),
|
||||
value: __(doctype) + " " + match,
|
||||
route: ["Form", doctype, match]
|
||||
}
|
||||
}, true);
|
||||
$.each(frappe.route_history, function(i, route) {
|
||||
if(route[0]==='Form') {
|
||||
values.push([route[2], route]);
|
||||
}
|
||||
}
|
||||
else if(in_list(['List', 'Report', 'modules'], route[0])) {
|
||||
if(route[1]) {
|
||||
values.push([route[1], route]);
|
||||
}
|
||||
}
|
||||
else if(route[0]) {
|
||||
values.push([frappe.route_titles[route[0]] || route[0], route]);
|
||||
}
|
||||
});
|
||||
|
||||
frappe.search.find(values, txt, function(match) {
|
||||
out = {
|
||||
route: match[1]
|
||||
}
|
||||
if(match[1][0]==='Form') {
|
||||
out.label = __(match[1][1]) + " " + match[1][2].bold();
|
||||
out.value = __(match[1][1]) + " " + match[1][2];
|
||||
} else if(in_list(['List', 'Report', 'modules'], match[1][0])) {
|
||||
var type = match[1][0];
|
||||
if(type==='modules') type = 'Module';
|
||||
out.label = __(match[1][1]).bold() + " " + __(type);
|
||||
out.value = __(match[1][1]) + " " + __(type);
|
||||
} else {
|
||||
out.label = match[0].bold();
|
||||
out.value = match[0];
|
||||
}
|
||||
return out;
|
||||
}, true);
|
||||
},
|
||||
make_page_title_map: function() {
|
||||
frappe.search.pages = {};
|
||||
|
|
@ -148,21 +166,16 @@ frappe.search = {
|
|||
});
|
||||
},
|
||||
setup_recent: function() {
|
||||
var recent = JSON.parse(frappe.boot.user.recent || "[]") || [];
|
||||
frappe.search.recent = {};
|
||||
for (var i=0, l=recent.length; i < l; i++) {
|
||||
var d = recent[i];
|
||||
if (!(d[0] && d[1])) continue;
|
||||
|
||||
if (!frappe.search.recent[d[0]]) {
|
||||
frappe.search.recent[d[0]] = [];
|
||||
}
|
||||
frappe.search.recent[d[0]].push(d[1]);
|
||||
}
|
||||
frappe.search.recent = JSON.parse(frappe.boot.user.recent || "[]") || [];
|
||||
},
|
||||
find: function(list, txt, process, prepend) {
|
||||
$.each(list, function(i, item) {
|
||||
_item = __(item).toLowerCase().replace(/-/g, " ");
|
||||
if($.isArray(item)) {
|
||||
_item = item[0];
|
||||
} else {
|
||||
_item = item;
|
||||
}
|
||||
_item = __(_item).toLowerCase().replace(/-/g, " ");
|
||||
if(txt===_item || _item.indexOf(txt) !== -1) {
|
||||
var option = process(item);
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@
|
|||
{%= __("Reload") %}</a></li>
|
||||
<li><a href="/index" target="_blank">
|
||||
{%= __("View Website") %}</a></li>
|
||||
<li><a href="#background_jobs">
|
||||
{%= __("Background Jobs") %}</a></li>
|
||||
<li class="divider"></li>
|
||||
<li><a href="#" onclick="return frappe.app.logout();">
|
||||
{%= __("Logout") %}</a></li>
|
||||
|
|
|
|||
|
|
@ -334,9 +334,6 @@ _f.Frm.prototype.refresh_header = function(is_a_different_doc) {
|
|||
frappe.utils.set_title(this.meta.issingle ? this.doctype : this.docname);
|
||||
}
|
||||
|
||||
if(frappe.ui.toolbar.recent)
|
||||
frappe.ui.toolbar.recent.add(this.doctype, this.docname, 1);
|
||||
|
||||
// show / hide buttons
|
||||
if(this.toolbar) {
|
||||
if (is_a_different_doc) {
|
||||
|
|
|
|||
18
frappe/tests/test_document_locks.py
Normal file
18
frappe/tests/test_document_locks.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import frappe, unittest
|
||||
|
||||
class TestDocumentLocks(unittest.TestCase):
|
||||
def test_locking(self):
|
||||
todo = frappe.get_doc(dict(doctype='ToDo', description='test')).insert()
|
||||
todo_1 = frappe.get_doc('ToDo', todo.name)
|
||||
|
||||
todo.lock()
|
||||
self.assertRaises(frappe.DocumentLockedError, todo_1.lock)
|
||||
todo.unlock()
|
||||
|
||||
todo_1.lock()
|
||||
self.assertRaises(frappe.DocumentLockedError, todo.lock)
|
||||
todo_1.unlock()
|
||||
|
|
@ -3,8 +3,11 @@
|
|||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
'''
|
||||
File based locking utility
|
||||
'''
|
||||
|
||||
import os
|
||||
import frappe
|
||||
from time import time
|
||||
from frappe.utils import get_site_path, touch_file
|
||||
|
||||
|
|
@ -12,12 +15,17 @@ class LockTimeoutError(Exception):
|
|||
pass
|
||||
|
||||
def create_lock(name):
|
||||
'''Creates a file in the /locks folder by the given name'''
|
||||
lock_path = get_lock_path(name)
|
||||
if not check_lock(lock_path):
|
||||
return touch_file(lock_path)
|
||||
else:
|
||||
return False
|
||||
|
||||
def lock_exists(name):
|
||||
'''Returns True if lock of the given name exists'''
|
||||
return os.path.exists(get_lock_path(name))
|
||||
|
||||
def check_lock(path, timeout=600):
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue