Merge pull request #1262 from rmehta/realtime

[realtime] cleanup & auto-refresh doc
This commit is contained in:
Anand Doshi 2015-08-18 17:44:42 +05:30
commit 86322e7ce2
21 changed files with 1353 additions and 523 deletions

View file

@ -994,3 +994,16 @@ def get_logger(module=None):
logging_setup_complete = True
return logging.getLogger(module or "frappe")
def publish_realtime(*args, **kwargs):
"""Publish real-time updates
:param event: Event name, like `task_progress` etc.
:param message: JSON message object. For async must contain `task_id`
:param room: Room in which to publish update (default entire site)
:param user: Transmit to user
:param doctype: Transmit to doctype, docname
:param docname: Transmit to doctype, docname"""
import frappe.async
return frappe.async.publish_realtime(*args, **kwargs)

View file

@ -101,7 +101,7 @@ def set_task_status(task_id, status, response=None):
"status": status,
"task_id": task_id
})
emit_via_redis("task_status_change", response, room="task:" + task_id)
publish_realtime("task_status_change", response, room="task:" + task_id)
def remove_old_task_logs():
@ -120,21 +120,50 @@ def is_file_old(file_path):
return ((time.time() - os.stat(file_path).st_mtime) > TASK_LOG_MAX_AGE)
def emit_via_redis(event, message, room=None):
def publish_realtime(event, message=None, room=None, user=None, doctype=None, docname=None):
"""Publish real-time updates
:param event: Event name, like `task_progress` etc.
:param message: JSON message object. For async must contain `task_id`
:param room: Room in which to publish update (default entire site)
:param user: Transmit to user
:param doctype: Transmit to doctype, docname
:param docname: Transmit to doctype, docname"""
if message is None:
message = {}
if not room:
if user:
room = get_user_room(user)
elif doctype and docname:
room = get_doc_room(doctype, docname)
message["doctype"] = doctype
message["name"] = docname
else:
room = get_site_room()
emit_via_redis(event, message, room)
def emit_via_redis(event, message, room):
"""Publish real-time updates via redis
:param event: Event name, like `task_progress` etc.
:param message: JSON message object. For async must contain `task_id`
:param room: name of the room"""
r = get_redis_server()
try:
r.publish('events', json.dumps({'event': event, 'message': message, 'room': room}))
except redis.exceptions.ConnectionError:
pass
def put_log(line_no, line, task_id=None):
r = get_redis_server()
if not task_id:
task_id = frappe.local.task_id
task_progress_room = "task_progress:" + frappe.local.task_id
task_log_key = "task_log:" + task_id
emit_via_redis('task_progress', {
publish_realtime('task_progress', {
"message": {
"lines": {line_no: line}
},
@ -195,19 +224,6 @@ def get_user_info(sid):
'user': session.user,
}
def new_comment(doc, event):
if not doc.comment_doctype:
return
if doc.comment_doctype == 'Message':
if doc.comment_docname == frappe.session.user:
message = doc.as_dict()
message['broadcast'] = True
emit_via_redis('new_message', message, room=get_site_room())
else:
emit_via_redis('new_message', doc.as_dict(), room=get_user_room(doc.comment_docname))
else:
emit_via_redis('new_comment', doc.as_dict(), room=get_doc_room(doc.comment_doctype, doc.comment_docname))
def get_doc_room(doctype, docname):
return ''.join([frappe.local.site, ':doc:', doctype, '/', docname])

View file

@ -32,6 +32,21 @@ class Comment(Document):
"feed_type": comment_type
}
def after_insert(self):
"""Send realtime updates"""
if not self.comment_doctype:
return
if self.comment_doctype == 'Message':
if self.comment_docname == frappe.session.user:
message = self.as_dict()
message['broadcast'] = True
frappe.publish_realtime('new_message', message)
else:
frappe.publish_realtime('new_message', self.as_dict(), user=frappe.session.user)
else:
frappe.publish_realtime('new_comment', self.as_dict(), doctype= self.comment_doctype,
docname = self.comment_docname)
def validate(self):
"""Raise exception for more than 50 comments."""
if frappe.db.sql("""select count(*) from tabComment where comment_doctype=%s

File diff suppressed because it is too large Load diff

View file

@ -33,12 +33,31 @@ frappe.desk.pages.Messages = Class.extend({
this.page.wrapper.find(".layout-main-section-wrapper").addClass("col-sm-9");
this.page.wrapper.find(".page-title").removeClass("col-xs-6").addClass("col-xs-12");
this.page.wrapper.find(".page-actions").removeClass("col-xs-6").addClass("hidden-xs");
this.setup_realtime();
},
make: function() {
this.make_sidebar();
},
setup_realtime: function() {
frappe.realtime.on('new_message', function(comment) {
if(comment.modified_by !== user) {
frappe.utils.notify(__("Message from {0}", [comment.comment_by_fullname]), comment.comment);
}
if (frappe.get_route()[0] === 'messages') {
var current_contact = $(cur_page.page).find('[data-contact]').data('contact');
var on_broadcast_page = current_contact === user;
if (current_contact == comment.owner || (on_broadcast_page && comment.broadcast)) {
var $row = $('<div class="list-row"/>');
frappe.desk.pages.messages.list.data.unshift(comment);
frappe.desk.pages.messages.list.render_row($row, comment);
frappe.desk.pages.messages.list.parent.prepend($row);
}
}
});
},
make_sidebar: function() {
var me = this;
return frappe.call({

View file

@ -8,11 +8,15 @@
class="form-control messages-textarea"></textarea>
</div>
<div style="padding-top: 15px;">
<button class="pull-right btn btn-default btn-sm btn-post" data-contact="{%= contact %}">
<button class="pull-right btn btn-primary btn-sm btn-post" data-contact="{%= contact %}">
{%= __("Post") %}
</button>
{% if (contact === user) { %}
<span class="pull-right indicator orange">{%= __("Public") %}</span>
<span class="pull-right"
style="margin-top: 4px; margin-right: 10px;">
<i class="octicon octicon-rss"></i>
<span class="text-muted small">{%= __("Public") %}</span>
</span>
{% } %}
<div class="pull-right checkbox text-muted small"
style="margin-right: 15px; margin-top: 7px;">

View file

@ -1,9 +1,12 @@
<div class="row message-row small">
{% if (data.owner==data.comment_docname && data.parenttype!="Assignment") { %}
<span class="pull-left indicator orange" title="{%= __("Public") %}"></span>
{% } %}
<div class="col-sm-9">
<div class="media">
{% if (data.owner==data.comment_docname
&& data.parenttype!="Assignment") { %}
<span class="pull-left" title="{{ __("Public") }}"><i class="octicon octicon-rss text-muted" style="margin-top: 3px;"></i></span>
{% } else { %}
<span class="pull-left" title="{{ __("Public") }}" style="width: 20px; height: 16px; display: inline-block;"></span>
{% } %}
<div class="pull-left hidden-xs">
<span class="avatar avatar-small" title="{%= frappe.user.full_name(data.owner) %} ">
<img class="media-object {{ data.is_system_message ? "grayscale" : "" }}"

View file

@ -131,9 +131,6 @@ doc_events = {
"frappe.email.doctype.email_alert.email_alert.trigger_email_alerts"
],
"on_trash": "frappe.desk.notifications.clear_doctype_notifications"
},
"Comment": {
"after_insert": "frappe.async.new_comment"
}
}

View file

@ -573,9 +573,19 @@ class Document(BaseDocument):
self.run_method("on_update_after_submit")
frappe.cache().hdel("last_modified", self.doctype)
self.notify_modified()
self.latest = None
def notify_modified(self):
"""Publish realtime that the current document is modified"""
frappe.publish_realtime("doc_update", {"modified_by": frappe.session.user},
doctype=self.doctype, docname=self.name)
if not self.meta.get("read_only") and not self.meta.get("issingle"):
frappe.publish_realtime("list_update", {"doctype": self.doctype})
def check_no_back_links_exist(self):
"""Check if document links to any active document before Cancel."""
from frappe.model.delete_doc import check_if_doc_is_linked, check_if_doc_is_dynamically_linked

View file

@ -163,7 +163,7 @@ select.form-control {
}
.form-headline .alert {
font-size: 12px;
border-color: #d1d8dd;
background-color: #fffce7;
margin-bottom: 0px;
}
.delivery-status-indicator {

View file

@ -22,6 +22,7 @@ frappe.Application = Class.extend({
this.startup();
},
startup: function() {
frappe.model.init();
this.load_bootinfo();
this.make_nav_bar();
this.set_favicon();

View file

@ -23,6 +23,7 @@ frappe.ui.form.Dashboard = Class.extend({
this.wrapper.toggle(true);
},
set_headline_alert: function(text, alert_class, icon) {
if(!alert_class) alert_class = "alert-warning";
this.set_headline(repl('<div class="alert %(alert_class)s">%(icon)s%(text)s</div>', {
"alert_class": alert_class || "",
"icon": icon ? '<i class="'+icon+'" /> ' : "",

View file

@ -294,4 +294,4 @@ frappe.ui.form.Comments = Class.extend({
return last_email;
}
})
});

View file

@ -38,12 +38,22 @@ frappe.views.ListFactory = frappe.views.Factory.extend({
});
$(document).on("save", function(event, doc) {
var list_page = "List/" + doc.doctype;
frappe.views.set_list_as_dirty(doc.doctype);
});
frappe.views.set_list_as_dirty = function(doctype) {
var list_page = "List/" + doctype;
if(frappe.pages[list_page]) {
if(frappe.pages[list_page].doclistview)
frappe.pages[list_page].doclistview.dirty = true;
}
})
var route = frappe.get_route();
if(route[0]==="List" && route[1]===doctype) {
setTimeout(function() {
frappe.pages[list_page].doclistview.run();
}, 100);
}
}
frappe.views.DocListView = frappe.ui.Listing.extend({
init: function(opts) {

View file

@ -34,6 +34,37 @@ $.extend(frappe.model, {
new_names: {},
events: {},
init: function() {
// setup refresh if the document is updated somewhere else
frappe.realtime.on("doc_update", function(data) {
// set list dirty
frappe.views.set_list_as_dirty(data.doctype);
var doc = locals[data.doctype] && locals[data.doctype][data.name];
if(doc) {
// current document is dirty, show message if its not me
if(cur_frm.doc.doctype===doc.doctype && cur_frm.doc.name===doc.name) {
if(data.modified_by!==user) {
doc.__needs_refresh = true;
cur_frm.show_if_needs_refresh();
}
} else {
if(!doc.__unsaved) {
// no local changes, remove from locals
frappe.model.remove_from_locals(doc.doctype, doc.name);
} else {
// show message when user navigates back
doc.__needs_refresh = true;
}
}
}
});
frappe.realtime.on("list_update", function(data) {
frappe.views.set_list_as_dirty(data.doctype);
});
},
is_value_type: function(fieldtype) {
// not in no-value type
return frappe.model.no_value_type.indexOf(fieldtype)===-1;
@ -122,6 +153,21 @@ $.extend(frappe.model, {
return frappe.model.docinfo[doctype] && frappe.model.docinfo[doctype][name] || null;
},
new_comment: function(comment) {
if (frappe.model.docinfo[comment.comment_doctype]
&& frappe.model.docinfo[comment.comment_doctype][comment.comment_docname]) {
var comments = frappe.model.docinfo[comment.comment_doctype][comment.comment_docname].comments;
var comment_exists = !!$.map(comments,
function(x) { return x.name == comment.name? true : undefined}).length
if (!comment_exists) {
frappe.model.docinfo[comment.comment_doctype][comment.comment_docname].comments = comments.concat([comment]);
}
}
if (cur_frm.doctype === comment.comment_doctype && cur_frm.docname === comment.comment_docname) {
cur_frm.comments.refresh();
}
},
get_shared: function(doctype, name) {
return frappe.model.get_docinfo(doctype, name).shared;
},

View file

@ -1,5 +1,6 @@
frappe.socket = {
open_tasks: {},
open_docs: [],
init: function() {
if (frappe.boot.disable_async) {
return;
@ -16,9 +17,9 @@ frappe.socket = {
frappe.socket.doc_subscribe(frm.doctype, frm.docname);
});
$(document).on('form-unload', function(e, frm) {
frappe.socket.doc_unsubscribe(frm.doctype, frm.docname);
});
// $(document).on('form-unload', function(e, frm) {
// frappe.socket.doc_unsubscribe(frm.doctype, frm.docname);
// });
},
subscribe: function(task_id, opts) {
frappe.socket.socket.emit('task_subscribe', task_id);
@ -28,11 +29,17 @@ frappe.socket = {
},
doc_subscribe: function(doctype, docname) {
frappe.socket.socket.emit('doc_subscribe', doctype, docname);
frappe.socket.open_doc = {doctype: doctype, docname: docname};
frappe.socket.open_docs.push({doctype: doctype, docname: docname});
},
doc_unsubscribe: function(doctype, docname) {
frappe.socket.socket.emit('doc_unsubscribe', doctype, docname);
frappe.socket.open_doc = null;
frappe.socket.open_docs = $.filter(frappe.socket.open_docs, function(d) {
if(d.doctype===doctype && d.name===docname) {
return null;
} else {
return d;
}
})
},
setup_listeners: function() {
frappe.socket.socket.on('task_status_change', function(data) {
@ -47,33 +54,6 @@ frappe.socket = {
frappe.socket.socket.on('task_progress', function(data) {
frappe.socket.process_response(data, "progress");
});
frappe.socket.socket.on('new_comment', function(comment) {
if (frappe.model.docinfo[comment.comment_doctype] && frappe.model.docinfo[comment.comment_doctype][comment.comment_docname]) {
var comments = frappe.model.docinfo[comment.comment_doctype][comment.comment_docname].comments
var comment_exists = !!$.map(comments, function(x) { return x.name == comment.name? true : undefined}).length
if (!comment_exists) {
frappe.model.docinfo[comment.comment_doctype][comment.comment_docname].comments = comments.concat([comment]);
}
}
if (cur_frm.doctype === comment.comment_doctype && cur_frm.docname === comment.comment_docname) {
cur_frm.comments.refresh();
}
});
frappe.socket.socket.on('new_message', function(comment) {
frappe.utils.notify(__("Message from {0}", [comment.comment_by_fullname]), comment.comment);
if ($(cur_page.page).data('page-route') === 'messages') {
var current_contact = $(cur_page.page).find('[data-contact]').data('contact');
var on_broadcast_page = current_contact === user;
if (current_contact == comment.owner || (on_broadcast_page && comment.broadcast)) {
var $row = $('<div class="list-row"/>');
frappe.desk.pages.messages.list.data.unshift(comment);
frappe.desk.pages.messages.list.render_row($row, comment);
frappe.desk.pages.messages.list.parent.prepend($row);
}
}
else {
}
});
},
setup_reconnect: function() {
// subscribe again to open_tasks
@ -81,11 +61,15 @@ frappe.socket = {
$.each(frappe.socket.open_tasks, function(task_id, opts) {
frappe.socket.subscribe(task_id, opts);
});
// re-connect open docs
$.each(frappe.socket.open_docs, function(d) {
if(locals[d.doctype] && locals[d.doctype][d.name]) {
frappe.socket.doc_subscribe(d.doctype, d.name);
}
})
});
if(frappe.socket.open_doc) {
frappe.socket.doc_subscribe(frappe.socket.open_doc.doctype, frappe.socket.open_doc.docname);
}
},
process_response: function(data, method) {
if(!data) {
@ -112,3 +96,8 @@ frappe.socket = {
}
$(frappe.socket.init);
frappe.provide("frappe.realtime");
frappe.realtime.on = function(event, callback) {
frappe.socket.socket.on(event, callback);
}

View file

@ -54,7 +54,7 @@ frappe.ui.FieldGroup = frappe.ui.form.Layout.extend({
if(f.get_parsed_value) {
var v = f.get_parsed_value();
if(f.df.reqd && !v)
if(f.df.reqd && is_null(v))
errors.push(__(f.df.label));
if(v) ret[f.df.fieldname] = v;

View file

@ -19,9 +19,20 @@ frappe.views.FormFactory = frappe.views.Factory.extend({
me.show_doc(route);
}
$(document).on("page-change", function() {
frappe.ui.form.close_grid_form();
});
if(!this.initialized) {
$(document).on("page-change", function() {
frappe.ui.form.close_grid_form();
});
frappe.realtime.on("new_comment", function(data) {
frappe.model.new_comment(data);
});
}
this.initialized = true;
},
show_doc: function(route) {
var dt = route[1],

View file

@ -406,6 +406,16 @@ _f.Frm.prototype.refresh = function(docname) {
if(this.print_preview.wrapper.is(":visible")) {
this.print_preview.preview();
}
this.show_if_needs_refresh();
}
}
_f.Frm.prototype.show_if_needs_refresh = function() {
if(this.doc.__needs_refresh) {
this.dashboard.set_headline_alert(__("This form has been modified after you have loaded it")
+ '<a class="btn btn-xs btn-primary pull-right" onclick="cur_frm.reload_doc()">'
+ __("Refresh") + '</a>', "alert-warning");
}
}

View file

@ -212,8 +212,7 @@ select.form-control {
.form-headline .alert {
font-size: @text-medium;
border-color: @border-color;
// background-color: @light-bg;
background-color: @light-yellow;
margin-bottom: 0px;
}

View file

@ -43,6 +43,7 @@ io.on('connection', function(socket){
var room = get_user_room(socket, res.body.message.user);
// console.log('joining', room);
socket.join(room);
socket.join(get_site_room(socket));
}
})
socket.on('task_subscribe', function(task_id) {
@ -64,7 +65,7 @@ io.on('connection', function(socket){
docname: docname
})
.end(function(err, res) {
console.log(err)
if(err) console.log(err);
if(res.status == 200) {
var room = get_doc_room(socket, doctype, docname);
// console.log('joining', room)
@ -89,7 +90,7 @@ function send_existing_lines(task_id, socket) {
})
}
subscriber.on("message", function(channel, message) {
message = JSON.parse(message);
io.to(message.room).emit(message.event, message.message);
@ -97,7 +98,7 @@ subscriber.on("message", function(channel, message) {
});
subscriber.subscribe("events");
http.listen(3000, function(){
console.log('listening on *:3000');
});