##// END OF EJS Templates
notifications: support real-time notifications with websockets via channelstream
ergo -
r526:1b57d2ee default
parent child Browse files
Show More
@@ -0,0 +1,79 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2010-2016 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import os
22
23 from pyramid.settings import asbool
24
25 from rhodecode.config.routing import ADMIN_PREFIX
26 from rhodecode.lib.ext_json import json
27
28
29 def url_gen(request):
30 urls = {
31 'connect': request.route_url('channelstream_connect'),
32 'subscribe': request.route_url('channelstream_subscribe')
33 }
34 return json.dumps(urls)
35
36
37 PLUGIN_DEFINITION = {
38 'name': 'channelstream',
39 'config': {
40 'javascript': [],
41 'css': [],
42 'template_hooks': {
43 'plugin_init_template': 'rhodecode:templates/channelstream/plugin_init.html'
44 },
45 'url_gen': url_gen,
46 'static': None,
47 'enabled': False,
48 'server': '',
49 'secret': ''
50 }
51 }
52
53
54 def includeme(config):
55 settings = config.registry.settings
56 PLUGIN_DEFINITION['config']['enabled'] = asbool(
57 settings.get('channelstream.enabled'))
58 PLUGIN_DEFINITION['config']['server'] = settings.get(
59 'channelstream.server', '')
60 PLUGIN_DEFINITION['config']['secret'] = settings.get(
61 'channelstream.secret', '')
62 PLUGIN_DEFINITION['config']['history.location'] = settings.get(
63 'channelstream.history.location', '')
64 config.register_rhodecode_plugin(
65 PLUGIN_DEFINITION['name'],
66 PLUGIN_DEFINITION['config']
67 )
68 # create plugin history location
69 history_dir = PLUGIN_DEFINITION['config']['history.location']
70 if history_dir and not os.path.exists(history_dir):
71 os.makedirs(history_dir, 0750)
72
73 config.add_route(
74 name='channelstream_connect',
75 pattern=ADMIN_PREFIX + '/channelstream/connect')
76 config.add_route(
77 name='channelstream_subscribe',
78 pattern=ADMIN_PREFIX + '/channelstream/subscribe')
79 config.scan('rhodecode.channelstream')
@@ -0,0 +1,177 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2010-2016 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 """
22 Channel Stream controller for rhodecode
23
24 :created_on: Oct 10, 2015
25 :author: marcinl
26 :copyright: (c) 2013-2015 RhodeCode GmbH.
27 :license: Commercial License, see LICENSE for more details.
28 """
29
30 import logging
31 import uuid
32
33 from pylons import tmpl_context as c
34 from pyramid.settings import asbool
35 from pyramid.view import view_config
36 from webob.exc import HTTPBadRequest, HTTPForbidden, HTTPBadGateway
37
38 from rhodecode.lib.channelstream import (
39 channelstream_request,
40 ChannelstreamConnectionException,
41 ChannelstreamPermissionException,
42 check_channel_permissions,
43 get_connection_validators,
44 get_user_data,
45 parse_channels_info,
46 update_history_from_logs,
47 STATE_PUBLIC_KEYS)
48 from rhodecode.lib.auth import NotAnonymous
49 from rhodecode.lib.utils2 import str2bool
50
51 log = logging.getLogger(__name__)
52
53
54 class ChannelstreamView(object):
55 def __init__(self, context, request):
56 self.context = context
57 self.request = request
58
59 # Some of the decorators rely on this attribute to be present
60 # on the class of the decorated method.
61 self._rhodecode_user = request.user
62 registry = request.registry
63 self.channelstream_config = registry.rhodecode_plugins['channelstream']
64 if not self.channelstream_config.get('enabled'):
65 log.exception('Channelstream plugin is disabled')
66 raise HTTPBadRequest()
67
68 @NotAnonymous()
69 @view_config(route_name='channelstream_connect', renderer='json')
70 def connect(self):
71 """ handle authorization of users trying to connect """
72 try:
73 json_body = self.request.json_body
74 except Exception:
75 log.exception('Failed to decode json from request')
76 raise HTTPBadRequest()
77 try:
78 channels = check_channel_permissions(
79 json_body.get('channels'),
80 get_connection_validators(self.request.registry))
81 except ChannelstreamPermissionException:
82 log.error('Incorrect permissions for requested channels')
83 raise HTTPForbidden()
84
85 user = c.rhodecode_user
86 if user.user_id:
87 user_data = get_user_data(user.user_id)
88 else:
89 user_data = {
90 'id': None,
91 'username': None,
92 'first_name': None,
93 'last_name': None,
94 'icon_link': None,
95 'display_name': None,
96 'display_link': None,
97 }
98 payload = {
99 'username': user.username,
100 'user_state': user_data,
101 'conn_id': str(uuid.uuid4()),
102 'channels': channels,
103 'channel_configs': {},
104 'state_public_keys': STATE_PUBLIC_KEYS,
105 'info': {
106 'exclude_channels': ['broadcast']
107 }
108 }
109 filtered_channels = [channel for channel in channels
110 if channel != 'broadcast']
111 for channel in filtered_channels:
112 payload['channel_configs'][channel] = {
113 'notify_presence': True,
114 'history_size': 100,
115 'store_history': True,
116 'broadcast_presence_with_user_lists': True
117 }
118 # connect user to server
119 try:
120 connect_result = channelstream_request(self.channelstream_config,
121 payload, '/connect')
122 except ChannelstreamConnectionException:
123 log.exception('Channelstream service is down')
124 return HTTPBadGateway()
125
126 connect_result['channels'] = channels
127 connect_result['channels_info'] = parse_channels_info(
128 connect_result['channels_info'],
129 include_channel_info=filtered_channels)
130 update_history_from_logs(self.channelstream_config,
131 filtered_channels, connect_result)
132 return connect_result
133
134 @NotAnonymous()
135 @view_config(route_name='channelstream_subscribe', renderer='json')
136 def subscribe(self):
137 """ can be used to subscribe specific connection to other channels """
138 try:
139 json_body = self.request.json_body
140 except Exception:
141 log.exception('Failed to decode json from request')
142 raise HTTPBadRequest()
143 try:
144 channels = check_channel_permissions(
145 json_body.get('channels'),
146 get_connection_validators(self.request.registry))
147 except ChannelstreamPermissionException:
148 log.error('Incorrect permissions for requested channels')
149 raise HTTPForbidden()
150 payload = {'conn_id': json_body.get('conn_id', ''),
151 'channels': channels,
152 'channel_configs': {},
153 'info': {
154 'exclude_channels': ['broadcast']}
155 }
156 filtered_channels = [chan for chan in channels if chan != 'broadcast']
157 for channel in filtered_channels:
158 payload['channel_configs'][channel] = {
159 'notify_presence': True,
160 'history_size': 100,
161 'store_history': True,
162 'broadcast_presence_with_user_lists': True
163 }
164 try:
165 connect_result = channelstream_request(
166 self.channelstream_config, payload, '/subscribe')
167 except ChannelstreamConnectionException:
168 log.exception('Channelstream service is down')
169 return HTTPBadGateway()
170 # include_channel_info will limit history only to new channel
171 # to not overwrite histories on other channels in client
172 connect_result['channels_info'] = parse_channels_info(
173 connect_result['channels_info'],
174 include_channel_info=filtered_channels)
175 update_history_from_logs(self.channelstream_config,
176 filtered_channels, connect_result)
177 return connect_result
@@ -0,0 +1,219 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2016-2016 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import logging
22 import os
23
24 import itsdangerous
25 import requests
26
27 from dogpile.core import ReadWriteMutex
28
29 import rhodecode.lib.helpers as h
30
31 from rhodecode.lib.auth import HasRepoPermissionAny
32 from rhodecode.lib.ext_json import json
33 from rhodecode.model.db import User
34
35 log = logging.getLogger(__name__)
36
37 LOCK = ReadWriteMutex()
38
39 STATE_PUBLIC_KEYS = ['id', 'username', 'first_name', 'last_name',
40 'icon_link', 'display_name', 'display_link']
41
42
43 class ChannelstreamException(Exception):
44 pass
45
46
47 class ChannelstreamConnectionException(Exception):
48 pass
49
50
51 class ChannelstreamPermissionException(Exception):
52 pass
53
54
55 def channelstream_request(config, payload, endpoint, raise_exc=True):
56 signer = itsdangerous.TimestampSigner(config['secret'])
57 sig_for_server = signer.sign(endpoint)
58 secret_headers = {'x-channelstream-secret': sig_for_server,
59 'x-channelstream-endpoint': endpoint,
60 'Content-Type': 'application/json'}
61 req_url = 'http://{}{}'.format(config['server'], endpoint)
62 response = None
63 try:
64 response = requests.post(req_url, data=json.dumps(payload),
65 headers=secret_headers).json()
66 except requests.ConnectionError:
67 log.exception('ConnectionError happened')
68 if raise_exc:
69 raise ChannelstreamConnectionException()
70 except Exception:
71 log.exception('Exception related to channelstream happened')
72 if raise_exc:
73 raise ChannelstreamConnectionException()
74 return response
75
76
77 def get_user_data(user_id):
78 user = User.get(user_id)
79 return {
80 'id': user.user_id,
81 'username': user.username,
82 'first_name': user.name,
83 'last_name': user.lastname,
84 'icon_link': h.gravatar_url(user.email, 14),
85 'display_name': h.person(user, 'username_or_name_or_email'),
86 'display_link': h.link_to_user(user),
87 }
88
89
90 def broadcast_validator(channel_name):
91 """ checks if user can access the broadcast channel """
92 if channel_name == 'broadcast':
93 return True
94
95
96 def repo_validator(channel_name):
97 """ checks if user can access the broadcast channel """
98 channel_prefix = '/repo$'
99 if channel_name.startswith(channel_prefix):
100 elements = channel_name[len(channel_prefix):].split('$')
101 repo_name = elements[0]
102 can_access = HasRepoPermissionAny(
103 'repository.read',
104 'repository.write',
105 'repository.admin')(repo_name)
106 log.debug('permission check for {} channel '
107 'resulted in {}'.format(repo_name, can_access))
108 if can_access:
109 return True
110 return False
111
112
113 def check_channel_permissions(channels, plugin_validators, should_raise=True):
114 valid_channels = []
115
116 validators = [broadcast_validator, repo_validator]
117 if plugin_validators:
118 validators.extend(plugin_validators)
119 for channel_name in channels:
120 is_valid = False
121 for validator in validators:
122 if validator(channel_name):
123 is_valid = True
124 break
125 if is_valid:
126 valid_channels.append(channel_name)
127 else:
128 if should_raise:
129 raise ChannelstreamPermissionException()
130 return valid_channels
131
132
133 def get_channels_info(self, channels):
134 payload = {'channels': channels}
135 # gather persistence info
136 return channelstream_request(self._config(), payload, '/info')
137
138
139 def parse_channels_info(info_result, include_channel_info=None):
140 """
141 Returns data that contains only secure information that can be
142 presented to clients
143 """
144 include_channel_info = include_channel_info or []
145
146 user_state_dict = {}
147 for userinfo in info_result['users']:
148 user_state_dict[userinfo['user']] = {
149 k: v for k, v in userinfo['state'].items()
150 if k in STATE_PUBLIC_KEYS
151 }
152
153 channels_info = {}
154
155 for c_name, c_info in info_result['channels'].items():
156 if c_name not in include_channel_info:
157 continue
158 connected_list = []
159 for userinfo in c_info['users']:
160 connected_list.append({
161 'user': userinfo['user'],
162 'state': user_state_dict[userinfo['user']]
163 })
164 channels_info[c_name] = {'users': connected_list,
165 'history': c_info['history']}
166
167 return channels_info
168
169
170 def log_filepath(history_location, channel_name):
171 filename = '{}.log'.format(channel_name.encode('hex'))
172 filepath = os.path.join(history_location, filename)
173 return filepath
174
175
176 def read_history(history_location, channel_name):
177 filepath = log_filepath(history_location, channel_name)
178 if not os.path.exists(filepath):
179 return []
180 history_lines_limit = -100
181 history = []
182 with open(filepath, 'rb') as f:
183 for line in f.readlines()[history_lines_limit:]:
184 try:
185 history.append(json.loads(line))
186 except Exception:
187 log.exception('Failed to load history')
188 return history
189
190
191 def update_history_from_logs(config, channels, payload):
192 history_location = config.get('history.location')
193 for channel in channels:
194 history = read_history(history_location, channel)
195 payload['channels_info'][channel]['history'] = history
196
197
198 def write_history(config, message):
199 """ writes a messge to a base64encoded filename """
200 history_location = config.get('history.location')
201 if not os.path.exists(history_location):
202 return
203 try:
204 LOCK.acquire_write_lock()
205 filepath = log_filepath(history_location, message['channel'])
206 with open(filepath, 'ab') as f:
207 json.dump(message, f)
208 f.write('\n')
209 finally:
210 LOCK.release_write_lock()
211
212
213 def get_connection_validators(registry):
214 validators = []
215 for k, config in registry.rhodecode_plugins.iteritems():
216 validator = config.get('channelstream', {}).get('connect_validator')
217 if validator:
218 validators.append(validator)
219 return validators
@@ -0,0 +1,268 b''
1 // Mix-ins
2 .borderRadius(@radius) {
3 -moz-border-radius: @radius;
4 -webkit-border-radius: @radius;
5 border-radius: @radius;
6 }
7
8 .boxShadow(@boxShadow) {
9 -moz-box-shadow: @boxShadow;
10 -webkit-box-shadow: @boxShadow;
11 box-shadow: @boxShadow;
12 }
13
14 .opacity(@opacity) {
15 @opacityPercent: @opacity * 100;
16 opacity: @opacity;
17 -ms-filter: ~"progid:DXImageTransform.Microsoft.Alpha(Opacity=@{opacityPercent})";
18 filter: ~"alpha(opacity=@{opacityPercent})";
19 }
20
21 .wordWrap(@wordWrap: break-word) {
22 -ms-word-wrap: @wordWrap;
23 word-wrap: @wordWrap;
24 }
25
26 // Variables
27 @black: #000000;
28 @grey: #999999;
29 @light-grey: #CCCCCC;
30 @white: #FFFFFF;
31 @near-black: #030303;
32 @green: #51A351;
33 @red: #BD362F;
34 @blue: #2F96B4;
35 @orange: #F89406;
36 @default-container-opacity: .8;
37
38 // Styles
39 .toast-title {
40 font-weight: bold;
41 }
42
43 .toast-message {
44 .wordWrap();
45
46 a,
47 label {
48 color: @near-black;
49 }
50
51 a:hover {
52 color: @light-grey;
53 text-decoration: none;
54 }
55 }
56
57 .toast-close-button {
58 position: relative;
59 right: -0.3em;
60 top: -0.3em;
61 float: right;
62 font-size: 20px;
63 font-weight: bold;
64 color: @black;
65 -webkit-text-shadow: 0 1px 0 rgba(255,255,255,1);
66 text-shadow: 0 1px 0 rgba(255,255,255,1);
67 .opacity(0.8);
68
69 &:hover,
70 &:focus {
71 color: @black;
72 text-decoration: none;
73 cursor: pointer;
74 .opacity(0.4);
75 }
76 }
77
78 /*Additional properties for button version
79 iOS requires the button element instead of an anchor tag.
80 If you want the anchor version, it requires `href="#"`.*/
81 button.toast-close-button {
82 padding: 0;
83 cursor: pointer;
84 background: transparent;
85 border: 0;
86 -webkit-appearance: none;
87 }
88
89 //#endregion
90
91 .toast-top-center {
92 top: 0;
93 right: 0;
94 width: 100%;
95 }
96
97 .toast-bottom-center {
98 bottom: 0;
99 right: 0;
100 width: 100%;
101 }
102
103 .toast-top-full-width {
104 top: 0;
105 right: 0;
106 width: 100%;
107 }
108
109 .toast-bottom-full-width {
110 bottom: 0;
111 right: 0;
112 width: 100%;
113 }
114
115 .toast-top-left {
116 top: 12px;
117 left: 12px;
118 }
119
120 .toast-top-right {
121 top: 12px;
122 right: 12px;
123 }
124
125 .toast-bottom-right {
126 right: 12px;
127 bottom: 12px;
128 }
129
130 .toast-bottom-left {
131 bottom: 12px;
132 left: 12px;
133 }
134
135 #toast-container {
136 position: fixed;
137 z-index: 999999;
138 // The container should not be clickable.
139 pointer-events: none;
140 * {
141 -moz-box-sizing: border-box;
142 -webkit-box-sizing: border-box;
143 box-sizing: border-box;
144 }
145
146 > div {
147 position: relative;
148 // The toast itself should be clickable.
149 pointer-events: auto;
150 overflow: hidden;
151 margin: 0 0 6px;
152 padding: 15px;
153 width: 300px;
154 .borderRadius(1px 1px 1px 1px);
155 background-position: 15px center;
156 background-repeat: no-repeat;
157 color: @near-black;
158 .opacity(@default-container-opacity);
159 }
160
161 > :hover {
162 .opacity(1);
163 cursor: pointer;
164 }
165
166 > .toast-info {
167 //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGwSURBVEhLtZa9SgNBEMc9sUxxRcoUKSzSWIhXpFMhhYWFhaBg4yPYiWCXZxBLERsLRS3EQkEfwCKdjWJAwSKCgoKCcudv4O5YLrt7EzgXhiU3/4+b2ckmwVjJSpKkQ6wAi4gwhT+z3wRBcEz0yjSseUTrcRyfsHsXmD0AmbHOC9Ii8VImnuXBPglHpQ5wwSVM7sNnTG7Za4JwDdCjxyAiH3nyA2mtaTJufiDZ5dCaqlItILh1NHatfN5skvjx9Z38m69CgzuXmZgVrPIGE763Jx9qKsRozWYw6xOHdER+nn2KkO+Bb+UV5CBN6WC6QtBgbRVozrahAbmm6HtUsgtPC19tFdxXZYBOfkbmFJ1VaHA1VAHjd0pp70oTZzvR+EVrx2Ygfdsq6eu55BHYR8hlcki+n+kERUFG8BrA0BwjeAv2M8WLQBtcy+SD6fNsmnB3AlBLrgTtVW1c2QN4bVWLATaIS60J2Du5y1TiJgjSBvFVZgTmwCU+dAZFoPxGEEs8nyHC9Bwe2GvEJv2WXZb0vjdyFT4Cxk3e/kIqlOGoVLwwPevpYHT+00T+hWwXDf4AJAOUqWcDhbwAAAAASUVORK5CYII=") !important;
168 }
169
170 > .toast-error {
171 //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHOSURBVEhLrZa/SgNBEMZzh0WKCClSCKaIYOED+AAKeQQLG8HWztLCImBrYadgIdY+gIKNYkBFSwu7CAoqCgkkoGBI/E28PdbLZmeDLgzZzcx83/zZ2SSXC1j9fr+I1Hq93g2yxH4iwM1vkoBWAdxCmpzTxfkN2RcyZNaHFIkSo10+8kgxkXIURV5HGxTmFuc75B2RfQkpxHG8aAgaAFa0tAHqYFfQ7Iwe2yhODk8+J4C7yAoRTWI3w/4klGRgR4lO7Rpn9+gvMyWp+uxFh8+H+ARlgN1nJuJuQAYvNkEnwGFck18Er4q3egEc/oO+mhLdKgRyhdNFiacC0rlOCbhNVz4H9FnAYgDBvU3QIioZlJFLJtsoHYRDfiZoUyIxqCtRpVlANq0EU4dApjrtgezPFad5S19Wgjkc0hNVnuF4HjVA6C7QrSIbylB+oZe3aHgBsqlNqKYH48jXyJKMuAbiyVJ8KzaB3eRc0pg9VwQ4niFryI68qiOi3AbjwdsfnAtk0bCjTLJKr6mrD9g8iq/S/B81hguOMlQTnVyG40wAcjnmgsCNESDrjme7wfftP4P7SP4N3CJZdvzoNyGq2c/HWOXJGsvVg+RA/k2MC/wN6I2YA2Pt8GkAAAAASUVORK5CYII=") !important;
172 }
173
174 > .toast-success {
175 //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADsSURBVEhLY2AYBfQMgf///3P8+/evAIgvA/FsIF+BavYDDWMBGroaSMMBiE8VC7AZDrIFaMFnii3AZTjUgsUUWUDA8OdAH6iQbQEhw4HyGsPEcKBXBIC4ARhex4G4BsjmweU1soIFaGg/WtoFZRIZdEvIMhxkCCjXIVsATV6gFGACs4Rsw0EGgIIH3QJYJgHSARQZDrWAB+jawzgs+Q2UO49D7jnRSRGoEFRILcdmEMWGI0cm0JJ2QpYA1RDvcmzJEWhABhD/pqrL0S0CWuABKgnRki9lLseS7g2AlqwHWQSKH4oKLrILpRGhEQCw2LiRUIa4lwAAAABJRU5ErkJggg==") !important;
176 }
177
178 > .toast-warning {
179 //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGYSURBVEhL5ZSvTsNQFMbXZGICMYGYmJhAQIJAICYQPAACiSDB8AiICQQJT4CqQEwgJvYASAQCiZiYmJhAIBATCARJy+9rTsldd8sKu1M0+dLb057v6/lbq/2rK0mS/TRNj9cWNAKPYIJII7gIxCcQ51cvqID+GIEX8ASG4B1bK5gIZFeQfoJdEXOfgX4QAQg7kH2A65yQ87lyxb27sggkAzAuFhbbg1K2kgCkB1bVwyIR9m2L7PRPIhDUIXgGtyKw575yz3lTNs6X4JXnjV+LKM/m3MydnTbtOKIjtz6VhCBq4vSm3ncdrD2lk0VgUXSVKjVDJXJzijW1RQdsU7F77He8u68koNZTz8Oz5yGa6J3H3lZ0xYgXBK2QymlWWA+RWnYhskLBv2vmE+hBMCtbA7KX5drWyRT/2JsqZ2IvfB9Y4bWDNMFbJRFmC9E74SoS0CqulwjkC0+5bpcV1CZ8NMej4pjy0U+doDQsGyo1hzVJttIjhQ7GnBtRFN1UarUlH8F3xict+HY07rEzoUGPlWcjRFRr4/gChZgc3ZL2d8oAAAAASUVORK5CYII=") !important;
180 }
181
182 /*overrides*/
183 &.toast-top-center > div,
184 &.toast-bottom-center > div {
185 width: 400px;
186 margin-left: auto;
187 margin-right: auto;
188 }
189
190 &.toast-top-full-width > div,
191 &.toast-bottom-full-width > div {
192 width: 96%;
193 margin-left: auto;
194 margin-right: auto;
195 }
196 }
197
198 .toast {
199 border-color: @near-black;
200 border-style: solid;
201 border-width: 2px 2px 2px 25px;
202 background-color: @white;
203 }
204
205 .toast-success {
206 border-color: @green;
207 }
208
209 .toast-error {
210 border-color: @red;
211 }
212
213 .toast-info {
214 border-color: @blue;
215 }
216
217 .toast-warning {
218 border-color: @orange;
219 }
220
221 .toast-progress {
222 position: absolute;
223 left: 0;
224 bottom: 0;
225 height: 4px;
226 background-color: @black;
227 .opacity(0.4);
228 }
229
230 /*Responsive Design*/
231
232 @media all and (max-width: 240px) {
233 #toast-container {
234
235 > div {
236 padding: 8px;
237 width: 11em;
238 }
239
240 & .toast-close-button {
241 right: -0.2em;
242 top: -0.2em;
243 }
244 }
245 }
246
247 @media all and (min-width: 241px) and (max-width: 480px) {
248 #toast-container {
249 > div {
250 padding: 8px;
251 width: 18em;
252 }
253
254 & .toast-close-button {
255 right: -0.2em;
256 top: -0.2em;
257 }
258 }
259 }
260
261 @media all and (min-width: 481px) and (max-width: 768px) {
262 #toast-container {
263 > div {
264 padding: 15px;
265 width: 25em;
266 }
267 }
268 }
@@ -0,0 +1,435 b''
1 /*
2 * Toastr
3 * Copyright 2012-2015
4 * Authors: John Papa, Hans Fjällemark, and Tim Ferrell.
5 * All Rights Reserved.
6 * Use, reproduction, distribution, and modification of this code is subject to the terms and
7 * conditions of the MIT license, available at http://www.opensource.org/licenses/mit-license.php
8 *
9 * ARIA Support: Greta Krafsig
10 *
11 * Project: https://github.com/CodeSeven/toastr
12 */
13 /* global define */
14 (function (define) {
15 define(['jquery'], function ($) {
16 return (function () {
17 var $container;
18 var listener;
19 var toastId = 0;
20 var toastType = {
21 error: 'error',
22 info: 'info',
23 success: 'success',
24 warning: 'warning'
25 };
26
27 var toastr = {
28 clear: clear,
29 remove: remove,
30 error: error,
31 getContainer: getContainer,
32 info: info,
33 options: {},
34 subscribe: subscribe,
35 success: success,
36 version: '2.1.2',
37 warning: warning
38 };
39
40 var previousToast;
41
42 return toastr;
43
44 ////////////////
45
46 function error(message, title, optionsOverride) {
47 return notify({
48 type: toastType.error,
49 iconClass: getOptions().iconClasses.error,
50 message: message,
51 optionsOverride: optionsOverride,
52 title: title
53 });
54 }
55
56 function getContainer(options, create) {
57 if (!options) { options = getOptions(); }
58 $container = $('#' + options.containerId);
59 if ($container.length) {
60 return $container;
61 }
62 if (create) {
63 $container = createContainer(options);
64 }
65 return $container;
66 }
67
68 function info(message, title, optionsOverride) {
69 return notify({
70 type: toastType.info,
71 iconClass: getOptions().iconClasses.info,
72 message: message,
73 optionsOverride: optionsOverride,
74 title: title
75 });
76 }
77
78 function subscribe(callback) {
79 listener = callback;
80 }
81
82 function success(message, title, optionsOverride) {
83 return notify({
84 type: toastType.success,
85 iconClass: getOptions().iconClasses.success,
86 message: message,
87 optionsOverride: optionsOverride,
88 title: title
89 });
90 }
91
92 function warning(message, title, optionsOverride) {
93 return notify({
94 type: toastType.warning,
95 iconClass: getOptions().iconClasses.warning,
96 message: message,
97 optionsOverride: optionsOverride,
98 title: title
99 });
100 }
101
102 function clear($toastElement, clearOptions) {
103 var options = getOptions();
104 if (!$container) { getContainer(options); }
105 if (!clearToast($toastElement, options, clearOptions)) {
106 clearContainer(options);
107 }
108 }
109
110 function remove($toastElement) {
111 var options = getOptions();
112 if (!$container) { getContainer(options); }
113 if ($toastElement && $(':focus', $toastElement).length === 0) {
114 removeToast($toastElement);
115 return;
116 }
117 if ($container.children().length) {
118 $container.remove();
119 }
120 }
121
122 // internal functions
123
124 function clearContainer (options) {
125 var toastsToClear = $container.children();
126 for (var i = toastsToClear.length - 1; i >= 0; i--) {
127 clearToast($(toastsToClear[i]), options);
128 }
129 }
130
131 function clearToast ($toastElement, options, clearOptions) {
132 var force = clearOptions && clearOptions.force ? clearOptions.force : false;
133 if ($toastElement && (force || $(':focus', $toastElement).length === 0)) {
134 $toastElement[options.hideMethod]({
135 duration: options.hideDuration,
136 easing: options.hideEasing,
137 complete: function () { removeToast($toastElement); }
138 });
139 return true;
140 }
141 return false;
142 }
143
144 function createContainer(options) {
145 $container = $('<div/>')
146 .attr('id', options.containerId)
147 .addClass(options.positionClass)
148 .attr('aria-live', 'polite')
149 .attr('role', 'alert');
150
151 $container.appendTo($(options.target));
152 return $container;
153 }
154
155 function getDefaults() {
156 return {
157 tapToDismiss: true,
158 toastClass: 'toast',
159 containerId: 'toast-container',
160 debug: false,
161
162 showMethod: 'fadeIn', //fadeIn, slideDown, and show are built into jQuery
163 showDuration: 300,
164 showEasing: 'swing', //swing and linear are built into jQuery
165 onShown: undefined,
166 hideMethod: 'fadeOut',
167 hideDuration: 1000,
168 hideEasing: 'swing',
169 onHidden: undefined,
170 closeMethod: false,
171 closeDuration: false,
172 closeEasing: false,
173
174 extendedTimeOut: 1000,
175 iconClasses: {
176 error: 'toast-error',
177 info: 'toast-info',
178 success: 'toast-success',
179 warning: 'toast-warning'
180 },
181 iconClass: 'toast-info',
182 positionClass: 'toast-top-right',
183 timeOut: 5000, // Set timeOut and extendedTimeOut to 0 to make it sticky
184 titleClass: 'toast-title',
185 messageClass: 'toast-message',
186 escapeHtml: false,
187 target: 'body',
188 closeHtml: '<button type="button">&times;</button>',
189 newestOnTop: true,
190 preventDuplicates: false,
191 progressBar: false
192 };
193 }
194
195 function publish(args) {
196 if (!listener) { return; }
197 listener(args);
198 }
199
200 function notify(map) {
201 var options = getOptions();
202 var iconClass = map.iconClass || options.iconClass;
203
204 if (typeof (map.optionsOverride) !== 'undefined') {
205 options = $.extend(options, map.optionsOverride);
206 iconClass = map.optionsOverride.iconClass || iconClass;
207 }
208
209 if (shouldExit(options, map)) { return; }
210
211 toastId++;
212
213 $container = getContainer(options, true);
214
215 var intervalId = null;
216 var $toastElement = $('<div/>');
217 var $titleElement = $('<div/>');
218 var $messageElement = $('<div/>');
219 var $progressElement = $('<div/>');
220 var $closeElement = $(options.closeHtml);
221 var progressBar = {
222 intervalId: null,
223 hideEta: null,
224 maxHideTime: null
225 };
226 var response = {
227 toastId: toastId,
228 state: 'visible',
229 startTime: new Date(),
230 options: options,
231 map: map
232 };
233
234 personalizeToast();
235
236 displayToast();
237
238 handleEvents();
239
240 publish(response);
241
242 if (options.debug && console) {
243 console.log(response);
244 }
245
246 return $toastElement;
247
248 function escapeHtml(source) {
249 if (source == null)
250 source = "";
251
252 return new String(source)
253 .replace(/&/g, '&amp;')
254 .replace(/"/g, '&quot;')
255 .replace(/'/g, '&#39;')
256 .replace(/</g, '&lt;')
257 .replace(/>/g, '&gt;');
258 }
259
260 function personalizeToast() {
261 setIcon();
262 setTitle();
263 setMessage();
264 setCloseButton();
265 setProgressBar();
266 setSequence();
267 }
268
269 function handleEvents() {
270 $toastElement.hover(stickAround, delayedHideToast);
271 if (!options.onclick && options.tapToDismiss) {
272 $toastElement.click(hideToast);
273 }
274
275 if (options.closeButton && $closeElement) {
276 $closeElement.click(function (event) {
277 if (event.stopPropagation) {
278 event.stopPropagation();
279 } else if (event.cancelBubble !== undefined && event.cancelBubble !== true) {
280 event.cancelBubble = true;
281 }
282 hideToast(true);
283 });
284 }
285
286 if (options.onclick) {
287 $toastElement.click(function (event) {
288 options.onclick(event);
289 hideToast();
290 });
291 }
292 }
293
294 function displayToast() {
295 $toastElement.hide();
296
297 $toastElement[options.showMethod](
298 {duration: options.showDuration, easing: options.showEasing, complete: options.onShown}
299 );
300
301 if (options.timeOut > 0) {
302 intervalId = setTimeout(hideToast, options.timeOut);
303 progressBar.maxHideTime = parseFloat(options.timeOut);
304 progressBar.hideEta = new Date().getTime() + progressBar.maxHideTime;
305 if (options.progressBar) {
306 progressBar.intervalId = setInterval(updateProgress, 10);
307 }
308 }
309 }
310
311 function setIcon() {
312 if (map.iconClass) {
313 $toastElement.addClass(options.toastClass).addClass(iconClass);
314 }
315 }
316
317 function setSequence() {
318 if (options.newestOnTop) {
319 $container.prepend($toastElement);
320 } else {
321 $container.append($toastElement);
322 }
323 }
324
325 function setTitle() {
326 if (map.title) {
327 $titleElement.append(!options.escapeHtml ? map.title : escapeHtml(map.title)).addClass(options.titleClass);
328 $toastElement.append($titleElement);
329 }
330 }
331
332 function setMessage() {
333 if (map.message) {
334 $messageElement.append(!options.escapeHtml ? map.message : escapeHtml(map.message)).addClass(options.messageClass);
335 $toastElement.append($messageElement);
336 }
337 }
338
339 function setCloseButton() {
340 if (options.closeButton) {
341 $closeElement.addClass('toast-close-button').attr('role', 'button');
342 $toastElement.prepend($closeElement);
343 }
344 }
345
346 function setProgressBar() {
347 if (options.progressBar) {
348 $progressElement.addClass('toast-progress');
349 $toastElement.prepend($progressElement);
350 }
351 }
352
353 function shouldExit(options, map) {
354 if (options.preventDuplicates) {
355 if (map.message === previousToast) {
356 return true;
357 } else {
358 previousToast = map.message;
359 }
360 }
361 return false;
362 }
363
364 function hideToast(override) {
365 var method = override && options.closeMethod !== false ? options.closeMethod : options.hideMethod;
366 var duration = override && options.closeDuration !== false ?
367 options.closeDuration : options.hideDuration;
368 var easing = override && options.closeEasing !== false ? options.closeEasing : options.hideEasing;
369 if ($(':focus', $toastElement).length && !override) {
370 return;
371 }
372 clearTimeout(progressBar.intervalId);
373 return $toastElement[method]({
374 duration: duration,
375 easing: easing,
376 complete: function () {
377 removeToast($toastElement);
378 if (options.onHidden && response.state !== 'hidden') {
379 options.onHidden();
380 }
381 response.state = 'hidden';
382 response.endTime = new Date();
383 publish(response);
384 }
385 });
386 }
387
388 function delayedHideToast() {
389 if (options.timeOut > 0 || options.extendedTimeOut > 0) {
390 intervalId = setTimeout(hideToast, options.extendedTimeOut);
391 progressBar.maxHideTime = parseFloat(options.extendedTimeOut);
392 progressBar.hideEta = new Date().getTime() + progressBar.maxHideTime;
393 }
394 }
395
396 function stickAround() {
397 clearTimeout(intervalId);
398 progressBar.hideEta = 0;
399 $toastElement.stop(true, true)[options.showMethod](
400 {duration: options.showDuration, easing: options.showEasing}
401 );
402 }
403
404 function updateProgress() {
405 var percentage = ((progressBar.hideEta - (new Date().getTime())) / progressBar.maxHideTime) * 100;
406 $progressElement.width(percentage + '%');
407 }
408 }
409
410 function getOptions() {
411 return $.extend({}, getDefaults(), toastr.options);
412 }
413
414 function removeToast($toastElement) {
415 if (!$container) { $container = getContainer(); }
416 if ($toastElement.is(':visible')) {
417 return;
418 }
419 $toastElement.remove();
420 $toastElement = null;
421 if ($container.children().length === 0) {
422 $container.remove();
423 previousToast = undefined;
424 }
425 }
426
427 })();
428 });
429 }(typeof define === 'function' && define.amd ? define : function (deps, factory) {
430 if (typeof module !== 'undefined' && module.exports) { //Node
431 module.exports = factory(require('jquery'));
432 } else {
433 window.toastr = factory(window.jQuery);
434 }
435 }));
@@ -0,0 +1,219 b''
1 "use strict";
2 /** leak object to top level scope **/
3 var ccLog = undefined;
4 // global code-mirror logger;, to enable run
5 // Logger.get('ConnectionController').setLevel(Logger.DEBUG)
6 ccLog = Logger.get('ConnectionController');
7 ccLog.setLevel(Logger.OFF);
8
9 var ConnectionController;
10 var connCtrlr;
11 var registerViewChannels;
12
13 (function () {
14 ConnectionController = function (webappUrl, serverUrl, urls) {
15 var self = this;
16
17 var channels = ['broadcast'];
18 this.state = {
19 open: false,
20 webappUrl: webappUrl,
21 serverUrl: serverUrl,
22 connId: null,
23 socket: null,
24 channels: channels,
25 heartbeat: null,
26 channelsInfo: {},
27 urls: urls
28 };
29 this.channelNameParsers = [];
30
31 this.addChannelNameParser = function (fn) {
32 if (this.channelNameParsers.indexOf(fn) === -1) {
33 this.channelNameParsers.push(fn);
34 }
35 };
36
37 this.listen = function () {
38 if (window.WebSocket) {
39 ccLog.debug('attempting to create socket');
40 var socket_url = self.state.serverUrl + "/ws?conn_id=" + self.state.connId;
41 var socket_conf = {
42 url: socket_url,
43 handleAs: 'json',
44 headers: {
45 "Accept": "application/json",
46 "Content-Type": "application/json"
47 }
48 };
49 self.state.socket = new WebSocket(socket_conf.url);
50
51 self.state.socket.onopen = function (event) {
52 ccLog.debug('open event', event);
53 if (self.state.heartbeat === null) {
54 self.state.heartbeat = setInterval(function () {
55 if (self.state.socket.readyState === WebSocket.OPEN) {
56 self.state.socket.send('heartbeat');
57 }
58 }, 10000)
59 }
60 };
61 self.state.socket.onmessage = function (event) {
62 var data = $.parseJSON(event.data);
63 for (var i = 0; i < data.length; i++) {
64 if (data[i].message.topic) {
65 ccLog.debug('publishing',
66 data[i].message.topic, data[i]);
67 $.Topic(data[i].message.topic).publish(data[i])
68 }
69 else {
70 cclog.warning('unhandled message', data);
71 }
72 }
73 };
74 self.state.socket.onclose = function (event) {
75 ccLog.debug('closed event', event);
76 setTimeout(function () {
77 self.connect(true);
78 }, 5000);
79 };
80
81 self.state.socket.onerror = function (event) {
82 ccLog.debug('error event', event);
83 };
84 }
85 else {
86 ccLog.debug('attempting to create long polling connection');
87 var poolUrl = self.state.serverUrl + "/listen?conn_id=" + self.state.connId;
88 self.state.socket = $.ajax({
89 url: poolUrl
90 }).done(function (data) {
91 ccLog.debug('data', data);
92 var data = $.parseJSON(data);
93 for (var i = 0; i < data.length; i++) {
94 if (data[i].message.topic) {
95 ccLog.info('publishing',
96 data[i].message.topic, data[i]);
97 $.Topic(data[i].message.topic).publish(data[i])
98 }
99 else {
100 cclog.warning('unhandled message', data);
101 }
102 }
103 self.listen();
104 }).fail(function () {
105 ccLog.debug('longpoll error');
106 setTimeout(function () {
107 self.connect(true);
108 }, 5000);
109 });
110 }
111
112 };
113
114 this.connect = function (create_new_socket) {
115 var connReq = {'channels': self.state.channels};
116 ccLog.debug('try obtaining connection info', connReq);
117 $.ajax({
118 url: self.state.urls.connect,
119 type: "POST",
120 contentType: "application/json",
121 data: JSON.stringify(connReq),
122 dataType: "json"
123 }).done(function (data) {
124 ccLog.debug('Got connection:', data.conn_id);
125 self.state.channels = data.channels;
126 self.state.channelsInfo = data.channels_info;
127 self.state.connId = data.conn_id;
128 if (create_new_socket) {
129 self.listen();
130 }
131 self.update();
132 }).fail(function () {
133 setTimeout(function () {
134 self.connect(create_new_socket);
135 }, 5000);
136 });
137 self.update();
138 };
139
140 this.subscribeToChannels = function (channels) {
141 var new_channels = [];
142 for (var i = 0; i < channels.length; i++) {
143 var channel = channels[i];
144 if (self.state.channels.indexOf(channel)) {
145 self.state.channels.push(channel);
146 new_channels.push(channel)
147 }
148 }
149 /**
150 * only execute the request if socket is present because subscribe
151 * can actually add channels before initial app connection
152 **/
153 if (new_channels && self.state.socket !== null) {
154 var connReq = {
155 'channels': self.state.channels,
156 'conn_id': self.state.connId
157 };
158 $.ajax({
159 url: self.state.urls.subscribe,
160 type: "POST",
161 contentType: "application/json",
162 data: JSON.stringify(connReq),
163 dataType: "json"
164 }).done(function (data) {
165 self.state.channels = data.channels;
166 self.state.channelsInfo = data.channels_info;
167 self.update();
168 });
169 }
170 self.update();
171 };
172
173 this.update = function () {
174 for (var key in this.state.channelsInfo) {
175 if (this.state.channelsInfo.hasOwnProperty(key)) {
176 // update channels with latest info
177 $.Topic('/connection_controller/channel_update').publish(
178 {channel: key, state: this.state.channelsInfo[key]});
179 }
180 }
181 /**
182 * checks current channel list in state and if channel is not present
183 * converts them into executable "commands" and pushes them on topics
184 */
185 for (var i = 0; i < this.state.channels.length; i++) {
186 var channel = this.state.channels[i];
187 for (var j = 0; j < this.channelNameParsers.length; j++) {
188 this.channelNameParsers[j](channel);
189 }
190 }
191 };
192
193 this.run = function () {
194 this.connect(true);
195 };
196
197 $.Topic('/connection_controller/subscribe').subscribe(
198 self.subscribeToChannels);
199 };
200
201 $.Topic('/plugins/__REGISTER__').subscribe(function (data) {
202 // enable chat controller
203 if (window.CHANNELSTREAM_SETTINGS && window.CHANNELSTREAM_SETTINGS.enabled) {
204 $(document).ready(function () {
205 connCtrlr.run();
206 });
207 }
208 });
209
210 registerViewChannels = function (){
211 // subscribe to PR repo channel for PR's'
212 if (templateContext.pull_request_data.pull_request_id) {
213 var channelName = '/repo$' + templateContext.repo_name + '$/pr/' +
214 String(templateContext.pull_request_data.pull_request_id);
215 connCtrlr.state.channels.push(channelName);
216 }
217 }
218
219 })();
@@ -0,0 +1,60 b''
1 "use strict";
2
3 toastr.options = {
4 "closeButton": true,
5 "debug": false,
6 "newestOnTop": false,
7 "progressBar": false,
8 "positionClass": "toast-top-center",
9 "preventDuplicates": false,
10 "onclick": null,
11 "showDuration": "300",
12 "hideDuration": "300",
13 "timeOut": "0",
14 "extendedTimeOut": "0",
15 "showEasing": "swing",
16 "hideEasing": "linear",
17 "showMethod": "fadeIn",
18 "hideMethod": "fadeOut"
19 };
20
21 function notifySystem(data) {
22 var notification = new Notification(data.message.level + ': ' + data.message.message);
23 };
24
25 function notifyToaster(data){
26 toastr[data.message.level](data.message.message);
27 }
28
29 function handleNotifications(data) {
30
31 if (!templateContext.rhodecode_user.notification_status && !data.testMessage) {
32 // do not act if notifications are disabled
33 return
34 }
35 // use only js notifications for now
36 var onlyJS = true;
37 if (!("Notification" in window) || onlyJS) {
38 // use legacy notificartion
39 notifyToaster(data);
40 }
41 else {
42 // Let's check whether notification permissions have already been granted
43 if (Notification.permission === "granted") {
44 notifySystem(data);
45 }
46 // Otherwise, we need to ask the user for permission
47 else if (Notification.permission !== 'denied') {
48 Notification.requestPermission(function (permission) {
49 if (permission === "granted") {
50 notifySystem(data);
51 }
52 });
53 }
54 else{
55 notifyToaster(data);
56 }
57 }
58 };
59
60 $.Topic('/notifications').subscribe(handleNotifications);
@@ -0,0 +1,24 b''
1 <script>
2 var CHANNELSTREAM_URLS = ${config['url_gen'](request)|n};
3 %if request.registry.rhodecode_plugins['channelstream']['enabled'] and c.rhodecode_user.username != h.DEFAULT_USER:
4 var CHANNELSTREAM_SETTINGS = {
5 'enabled': true,
6 'ws_location': '${request.registry.settings.get('channelstream.ws_url')}',
7 'webapp_location': '${h.url('/', qualified=True)[:-1]}'
8 };
9 %else:
10 var CHANNELSTREAM_SETTINGS = {
11 'enabled':false,
12 'ws_location': '',
13 'webapp_location': ''};
14 %endif
15
16 if (CHANNELSTREAM_SETTINGS.enabled) {
17 connCtrlr = new ConnectionController(
18 CHANNELSTREAM_SETTINGS.webapp_location,
19 CHANNELSTREAM_SETTINGS.ws_location,
20 CHANNELSTREAM_URLS
21 );
22 registerViewChannels();
23 }
24 </script>
@@ -32,6 +32,7 b' module.exports = function(grunt) {'
32 32 '<%= dirs.js.src %>/plugins/jquery.mark.js',
33 33 '<%= dirs.js.src %>/plugins/jquery.timeago.js',
34 34 '<%= dirs.js.src %>/plugins/jquery.timeago-extension.js',
35 '<%= dirs.js.src %>/plugins/toastr.js',
35 36
36 37 // Select2
37 38 '<%= dirs.js.src %>/select2/select2.js',
@@ -64,6 +65,7 b' module.exports = function(grunt) {'
64 65
65 66 // Rhodecode components
66 67 '<%= dirs.js.src %>/rhodecode/init.js',
68 '<%= dirs.js.src %>/rhodecode/connection_controller.js',
67 69 '<%= dirs.js.src %>/rhodecode/codemirror.js',
68 70 '<%= dirs.js.src %>/rhodecode/comments.js',
69 71 '<%= dirs.js.src %>/rhodecode/constants.js',
@@ -78,6 +80,7 b' module.exports = function(grunt) {'
78 80 '<%= dirs.js.src %>/rhodecode/select2_widgets.js',
79 81 '<%= dirs.js.src %>/rhodecode/tooltips.js',
80 82 '<%= dirs.js.src %>/rhodecode/users.js',
83 '<%= dirs.js.src %>/rhodecode/utils/notifications.js',
81 84 '<%= dirs.js.src %>/rhodecode/appenlight.js',
82 85
83 86 // Rhodecode main module
@@ -391,6 +391,17 b' beaker.session.auto = false'
391 391 search.module = rhodecode.lib.index.whoosh
392 392 search.location = %(here)s/data/index
393 393
394 ########################################
395 ### CHANNELSTREAM CONFIG ####
396 ########################################
397
398 channelstream.enabled = true
399 # location of channelstream server on the backend
400 channelstream.server = 127.0.0.1:9800
401 # location of the channelstream server from outside world
402 channelstream.ws_url = ws://127.0.0.1:9800
403 channelstream.secret = secret
404
394 405 ###################################
395 406 ## APPENLIGHT CONFIG ##
396 407 ###################################
@@ -365,6 +365,17 b' beaker.session.auto = false'
365 365 search.module = rhodecode.lib.index.whoosh
366 366 search.location = %(here)s/data/index
367 367
368 ########################################
369 ### CHANNELSTREAM CONFIG ####
370 ########################################
371
372 channelstream.enabled = true
373 # location of channelstream server on the backend
374 channelstream.server = 127.0.0.1:9800
375 # location of the channelstream server from outside world
376 channelstream.ws_url = ws://127.0.0.1:9800
377 channelstream.secret = secret
378
368 379 ###################################
369 380 ## APPENLIGHT CONFIG ##
370 381 ###################################
@@ -166,6 +166,7 b' let'
166 166 ln -s ${self.supervisor}/bin/supervisor* $out/bin/
167 167 ln -s ${self.gunicorn}/bin/gunicorn $out/bin/
168 168 ln -s ${self.PasteScript}/bin/paster $out/bin/
169 ln -s ${self.channelstream}/bin/channelstream $out/bin/
169 170 ln -s ${self.pyramid}/bin/* $out/bin/ #*/
170 171
171 172 # rhodecode-tools
@@ -112,6 +112,13 b' def load_environment(global_conf, app_co'
112 112 # sets the c attribute access when don't existing attribute are accessed
113 113 config['pylons.strict_tmpl_context'] = True
114 114
115 # configure channelstream
116 config['channelstream_config'] = {
117 'enabled': asbool(config.get('channelstream.enabled', False)),
118 'server': config.get('channelstream.server'),
119 'secret': config.get('channelstream.secret')
120 }
121
115 122 # Limit backends to "vcs.backends" from configuration
116 123 backends = config['vcs.backends'] = aslist(
117 124 config.get('vcs.backends', 'hg,git'), sep=',')
@@ -22,6 +22,7 b''
22 22 Pylons middleware initialization
23 23 """
24 24 import logging
25 from collections import OrderedDict
25 26
26 27 from paste.registry import RegistryManager
27 28 from paste.gzipper import make_gzip_middleware
@@ -68,6 +69,12 b' class SkippableRoutesMiddleware(RoutesMi'
68 69 def __call__(self, environ, start_response):
69 70 for prefix in self.skip_prefixes:
70 71 if environ['PATH_INFO'].startswith(prefix):
72 # added to avoid the case when a missing /_static route falls
73 # through to pylons and causes an exception as pylons is
74 # expecting wsgiorg.routingargs to be set in the environ
75 # by RoutesMiddleware.
76 if 'wsgiorg.routing_args' not in environ:
77 environ['wsgiorg.routing_args'] = (None, {})
71 78 return self.app(environ, start_response)
72 79
73 80 return super(SkippableRoutesMiddleware, self).__call__(
@@ -228,7 +235,7 b' def includeme(config):'
228 235 settings = config.registry.settings
229 236
230 237 # plugin information
231 config.registry.rhodecode_plugins = {}
238 config.registry.rhodecode_plugins = OrderedDict()
232 239
233 240 config.add_directive(
234 241 'register_rhodecode_plugin', register_rhodecode_plugin)
@@ -239,6 +246,7 b' def includeme(config):'
239 246 # Includes which are required. The application would fail without them.
240 247 config.include('pyramid_mako')
241 248 config.include('pyramid_beaker')
249 config.include('rhodecode.channelstream')
242 250 config.include('rhodecode.admin')
243 251 config.include('rhodecode.authentication')
244 252 config.include('rhodecode.integrations')
@@ -560,6 +560,13 b' def make_map(config):'
560 560 action='my_account_auth_tokens_add', conditions={'method': ['POST']})
561 561 m.connect('my_account_auth_tokens', '/my_account/auth_tokens',
562 562 action='my_account_auth_tokens_delete', conditions={'method': ['DELETE']})
563 m.connect('my_account_notifications', '/my_account/notifications',
564 action='my_notifications',
565 conditions={'method': ['GET']})
566 m.connect('my_account_notifications_toggle_visibility',
567 '/my_account/toggle_visibility',
568 action='my_notifications_toggle_visibility',
569 conditions={'method': ['POST']})
563 570
564 571 # NOTIFICATION REST ROUTES
565 572 with rmap.submapper(path_prefix=ADMIN_PREFIX,
@@ -568,7 +575,6 b' def make_map(config):'
568 575 action='index', conditions={'method': ['GET']})
569 576 m.connect('notifications_mark_all_read', '/notifications/mark_all_read',
570 577 action='mark_all_read', conditions={'method': ['POST']})
571
572 578 m.connect('/notifications/{notification_id}',
573 579 action='update', conditions={'method': ['PUT']})
574 580 m.connect('/notifications/{notification_id}',
@@ -346,3 +346,17 b' class MyAccountController(BaseController'
346 346 h.flash(_("Auth token successfully deleted"), category='success')
347 347
348 348 return redirect(url('my_account_auth_tokens'))
349
350 def my_notifications(self):
351 c.active = 'notifications'
352 return render('admin/my_account/my_account.html')
353
354 @auth.CSRFRequired()
355 def my_notifications_toggle_visibility(self):
356 user = c.rhodecode_user.get_instance()
357 user_data = user.user_data
358 status = user_data.get('notification_status', False)
359 user_data['notification_status'] = not status
360 user.user_data = user_data
361 Session().commit()
362 return redirect(url('my_account_notifications'))
@@ -86,6 +86,7 b' class NotificationsController(BaseContro'
86 86
87 87 return render('admin/notifications/notifications.html')
88 88
89
89 90 @auth.CSRFRequired()
90 91 def mark_all_read(self):
91 92 if request.is_xhr:
@@ -332,6 +332,7 b' def attach_context_attributes(context, r'
332 332 'rhodecode_user': {
333 333 'username': None,
334 334 'email': None,
335 'notification_status': False
335 336 },
336 337 'visual': {
337 338 'default_renderer': None
@@ -26,10 +26,15 b' import logging'
26 26 import traceback
27 27 import collections
28 28
29 from datetime import datetime
30
31 from pylons.i18n.translation import _
32 from pyramid.threadlocal import get_current_registry
29 33 from sqlalchemy.sql.expression import null
30 34 from sqlalchemy.sql.functions import coalesce
31 35
32 36 from rhodecode.lib import helpers as h, diffs
37 from rhodecode.lib.channelstream import channelstream_request
33 38 from rhodecode.lib.utils import action_logger
34 39 from rhodecode.lib.utils2 import extract_mentioned_users
35 40 from rhodecode.model import BaseModel
@@ -134,84 +139,82 b' class ChangesetCommentsModel(BaseModel):'
134 139
135 140 Session().add(comment)
136 141 Session().flush()
137
138 if send_email:
139 kwargs = {
140 'user': user,
141 'renderer_type': renderer,
142 'repo_name': repo.repo_name,
143 'status_change': status_change,
144 'comment_body': text,
145 'comment_file': f_path,
146 'comment_line': line_no,
147 }
142 kwargs = {
143 'user': user,
144 'renderer_type': renderer,
145 'repo_name': repo.repo_name,
146 'status_change': status_change,
147 'comment_body': text,
148 'comment_file': f_path,
149 'comment_line': line_no,
150 }
148 151
149 if commit_obj:
150 recipients = ChangesetComment.get_users(
151 revision=commit_obj.raw_id)
152 # add commit author if it's in RhodeCode system
153 cs_author = User.get_from_cs_author(commit_obj.author)
154 if not cs_author:
155 # use repo owner if we cannot extract the author correctly
156 cs_author = repo.user
157 recipients += [cs_author]
152 if commit_obj:
153 recipients = ChangesetComment.get_users(
154 revision=commit_obj.raw_id)
155 # add commit author if it's in RhodeCode system
156 cs_author = User.get_from_cs_author(commit_obj.author)
157 if not cs_author:
158 # use repo owner if we cannot extract the author correctly
159 cs_author = repo.user
160 recipients += [cs_author]
158 161
159 commit_comment_url = self.get_url(comment)
162 commit_comment_url = self.get_url(comment)
160 163
161 target_repo_url = h.link_to(
162 repo.repo_name,
163 h.url('summary_home',
164 repo_name=repo.repo_name, qualified=True))
164 target_repo_url = h.link_to(
165 repo.repo_name,
166 h.url('summary_home',
167 repo_name=repo.repo_name, qualified=True))
165 168
166 # commit specifics
167 kwargs.update({
168 'commit': commit_obj,
169 'commit_message': commit_obj.message,
170 'commit_target_repo': target_repo_url,
171 'commit_comment_url': commit_comment_url,
172 })
169 # commit specifics
170 kwargs.update({
171 'commit': commit_obj,
172 'commit_message': commit_obj.message,
173 'commit_target_repo': target_repo_url,
174 'commit_comment_url': commit_comment_url,
175 })
173 176
174 elif pull_request_obj:
175 # get the current participants of this pull request
176 recipients = ChangesetComment.get_users(
177 pull_request_id=pull_request_obj.pull_request_id)
178 # add pull request author
179 recipients += [pull_request_obj.author]
177 elif pull_request_obj:
178 # get the current participants of this pull request
179 recipients = ChangesetComment.get_users(
180 pull_request_id=pull_request_obj.pull_request_id)
181 # add pull request author
182 recipients += [pull_request_obj.author]
180 183
181 # add the reviewers to notification
182 recipients += [x.user for x in pull_request_obj.reviewers]
184 # add the reviewers to notification
185 recipients += [x.user for x in pull_request_obj.reviewers]
183 186
184 pr_target_repo = pull_request_obj.target_repo
185 pr_source_repo = pull_request_obj.source_repo
187 pr_target_repo = pull_request_obj.target_repo
188 pr_source_repo = pull_request_obj.source_repo
186 189
187 pr_comment_url = h.url(
188 'pullrequest_show',
189 repo_name=pr_target_repo.repo_name,
190 pull_request_id=pull_request_obj.pull_request_id,
191 anchor='comment-%s' % comment.comment_id,
192 qualified=True,)
190 pr_comment_url = h.url(
191 'pullrequest_show',
192 repo_name=pr_target_repo.repo_name,
193 pull_request_id=pull_request_obj.pull_request_id,
194 anchor='comment-%s' % comment.comment_id,
195 qualified=True,)
193 196
194 # set some variables for email notification
195 pr_target_repo_url = h.url(
196 'summary_home', repo_name=pr_target_repo.repo_name,
197 qualified=True)
197 # set some variables for email notification
198 pr_target_repo_url = h.url(
199 'summary_home', repo_name=pr_target_repo.repo_name,
200 qualified=True)
198 201
199 pr_source_repo_url = h.url(
200 'summary_home', repo_name=pr_source_repo.repo_name,
201 qualified=True)
202 pr_source_repo_url = h.url(
203 'summary_home', repo_name=pr_source_repo.repo_name,
204 qualified=True)
202 205
203 # pull request specifics
204 kwargs.update({
205 'pull_request': pull_request_obj,
206 'pr_id': pull_request_obj.pull_request_id,
207 'pr_target_repo': pr_target_repo,
208 'pr_target_repo_url': pr_target_repo_url,
209 'pr_source_repo': pr_source_repo,
210 'pr_source_repo_url': pr_source_repo_url,
211 'pr_comment_url': pr_comment_url,
212 'pr_closing': closing_pr,
213 })
214
206 # pull request specifics
207 kwargs.update({
208 'pull_request': pull_request_obj,
209 'pr_id': pull_request_obj.pull_request_id,
210 'pr_target_repo': pr_target_repo,
211 'pr_target_repo_url': pr_target_repo_url,
212 'pr_source_repo': pr_source_repo,
213 'pr_source_repo_url': pr_source_repo_url,
214 'pr_comment_url': pr_comment_url,
215 'pr_closing': closing_pr,
216 })
217 if send_email:
215 218 # pre-generate the subject for notification itself
216 219 (subject,
217 220 _h, _e, # we don't care about those
@@ -240,6 +243,44 b' class ChangesetCommentsModel(BaseModel):'
240 243 )
241 244 action_logger(user, action, comment.repo)
242 245
246 registry = get_current_registry()
247 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
248 channelstream_config = rhodecode_plugins.get('channelstream')
249 msg_url = ''
250 if commit_obj:
251 msg_url = commit_comment_url
252 repo_name = repo.repo_name
253 elif pull_request_obj:
254 msg_url = pr_comment_url
255 repo_name = pr_target_repo.repo_name
256
257 if channelstream_config:
258 message = '<strong>{}</strong> {} - ' \
259 '<a onclick="window.location=\'{}\';' \
260 'window.location.reload()">' \
261 '<strong>{}</strong></a>'
262 message = message.format(
263 user.username, _('made a comment'), msg_url,
264 _('Refresh page'))
265 if channelstream_config:
266 channel = '/repo${}$/pr/{}'.format(
267 repo_name,
268 pull_request_id
269 )
270 payload = {
271 'type': 'message',
272 'timestamp': datetime.utcnow(),
273 'user': 'system',
274 'channel': channel,
275 'message': {
276 'message': message,
277 'level': 'info',
278 'topic': '/notifications'
279 }
280 }
281 channelstream_request(channelstream_config, [payload],
282 '/message', raise_exc=False)
283
243 284 return comment
244 285
245 286 def delete(self, comment):
@@ -25,6 +25,7 b''
25 25 @import 'comments';
26 26 @import 'panels-bootstrap';
27 27 @import 'panels';
28 @import 'toastr';
28 29 @import 'deform';
29 30
30 31
@@ -1615,6 +1616,10 b' BIN_FILENODE = 7'
1615 1616 float: right;
1616 1617 }
1617 1618
1619 #notification-status{
1620 display: inline;
1621 }
1622
1618 1623 // Repositories
1619 1624
1620 1625 #summary.fields{
@@ -1,3 +1,4 b''
1 1 /plugins/__REGISTER__ - launched after the onDomReady() code from rhodecode.js is executed
2 2 /ui/plugins/code/anchor_focus - launched when rc starts to scroll on load to anchor on PR/Codeview
3 3 /ui/plugins/code/comment_form_built - launched when injectInlineForm() is executed and the form object is created
4 /notifications - shows new event notifications No newline at end of file
@@ -39,6 +39,7 b''
39 39 <li class="${'active' if c.active=='watched' else ''}"><a href="${h.url('my_account_watched')}">${_('Watched')}</a></li>
40 40 <li class="${'active' if c.active=='pullrequests' else ''}"><a href="${h.url('my_account_pullrequests')}">${_('Pull Requests')}</a></li>
41 41 <li class="${'active' if c.active=='perms' else ''}"><a href="${h.url('my_account_perms')}">${_('My Permissions')}</a></li>
42 <li class="${'active' if c.active=='my_notifications' else ''}"><a href="${h.url('my_account_notifications')}">${_('My Live Notifications')}</a></li>
42 43 </ul>
43 44 </div>
44 45
@@ -1,72 +1,56 b''
1 <%namespace name="base" file="/base/base.html"/>
2
3 1 <div class="panel panel-default">
4 2 <div class="panel-heading">
5 <h3 class="panel-title">${_('Account Emails')}</h3>
3 <h3 class="panel-title">${_('Your live notification settings')}</h3>
6 4 </div>
7 5
8 6 <div class="panel-body">
9 <div class="emails_wrap">
10 <table class="rctable account_emails">
11 <tr>
12 <td class="td-user">
13 ${base.gravatar(c.user.email, 16)}
14 <span class="user email">${c.user.email}</span>
15 </td>
16 <td class="td-tags">
17 <span class="tag tag1">${_('Primary')}</span>
18 </td>
19 </tr>
20 %if c.user_email_map:
21 %for em in c.user_email_map:
22 <tr>
23 <td class="td-user">
24 ${base.gravatar(em.email, 16)}
25 <span class="user email">${em.email}</span>
26 </td>
27 <td class="td-action">
28 ${h.secure_form(url('my_account_emails'),method='delete')}
29 ${h.hidden('del_email_id',em.email_id)}
30 <button class="btn btn-link btn-danger" type="submit" id="remove_email_%s" % em.email_id
31 onclick="return confirm('${_('Confirm to delete this email: %s') % em.email}');">
32 ${_('Delete')}
33 </button>
34 ${h.end_form()}
35 </td>
36 </tr>
37 %endfor
38 %else:
39 <tr class="noborder">
40 <td colspan="3">
41 <div class="td-email">
42 ${_('No additional emails specified')}
43 </div>
44 </td>
45 </tr>
46 %endif
47 </table>
48 </div>
7
8 <p><strong>IMPORTANT:</strong> This feature requires enabled channelstream websocket server to function correctly.</p>
9
10 <p class="hidden">Status of browser notifications permission: <strong id="browser-notification-status"></strong></p>
49 11
50 <div>
51 ${h.secure_form(url('my_account_emails'), method='post')}
52 <div class="form">
53 <!-- fields -->
54 <div class="fields">
55 <div class="field">
56 <div class="label">
57 <label for="new_email">${_('New email address')}:</label>
58 </div>
59 <div class="input">
60 ${h.text('new_email', class_='medium')}
61 </div>
62 </div>
63 <div class="buttons">
64 ${h.submit('save',_('Add'),class_="btn")}
65 ${h.reset('reset',_('Reset'),class_="btn")}
66 </div>
67 </div>
68 </div>
12 ${h.secure_form(url('my_account_notifications_toggle_visibility'), method='post', id='notification-status')}
13 <button class="btn btn-default" type="submit">
14 ${_('Notifications')} <strong>${_('Enabled') if c.rhodecode_user.get_instance().user_data.get('notification_status') else _('Disabled')}</strong>
15 </button>
69 16 ${h.end_form()}
70 </div>
17
18 <a class="btn btn-info" id="test-notification">Test notification</a>
19
71 20 </div>
72 21 </div>
22
23 <script type="application/javascript">
24
25 function checkBrowserStatus(){
26 var browserStatus = 'Unknown';
27
28 if (!("Notification" in window)) {
29 browserStatus = 'Not supported'
30 }
31 else if(Notification.permission === 'denied'){
32 browserStatus = 'Denied';
33 $('.flash_msg').append('<div class="alert alert-error">Notifications are blocked on browser level - you need to enable them in your browser settings.</div>')
34 }
35 else if(Notification.permission === 'granted'){
36 browserStatus = 'Allowed';
37 }
38
39 $('#browser-notification-status').text(browserStatus);
40 };
41
42 checkBrowserStatus();
43
44 $('#test-notification').on('click', function(e){
45 var levels = ['info', 'error', 'warning', 'success'];
46 var level = levels[Math.floor(Math.random()*levels.length)];
47 var payload = {
48 message: {
49 message: 'This is a test notification.',
50 level: level,
51 testMessage: true
52 }
53 };
54 $.Topic('/notifications').publish(payload);
55 })
56 </script>
@@ -3,7 +3,7 b' from pyramid.renderers import render as '
3 3 from pyramid.threadlocal import get_current_registry, get_current_request
4 4 pyramid_registry = get_current_registry()
5 5 %>
6 % for plugin, config in pyramid_registry.rhodecode_plugins.items():
6 % for plugin, config in getattr(pyramid_registry, 'rhodecode_plugins', {}).items():
7 7 % if config['template_hooks'].get('plugin_init_template'):
8 8 ${pyramid_render(config['template_hooks'].get('plugin_init_template'),
9 9 {'config':config}, request=get_current_request(), package='rc_ae')|n}
@@ -11,6 +11,7 b" if hasattr(c, 'rhodecode_db_repo'):"
11 11 if getattr(c, 'rhodecode_user', None) and c.rhodecode_user.user_id:
12 12 c.template_context['rhodecode_user']['username'] = c.rhodecode_user.username
13 13 c.template_context['rhodecode_user']['email'] = c.rhodecode_user.email
14 c.template_context['rhodecode_user']['notification_status'] = c.rhodecode_user.get_instance().user_data.get('notification_status', True)
14 15
15 16 c.template_context['visual']['default_renderer'] = h.get_visual_attr(c, 'default_renderer')
16 17 %>
@@ -77,7 +78,6 b" c.template_context['visual']['default_re"
77 78 }
78 79 };
79 80 </script>
80
81 81 <!--[if lt IE 9]>
82 82 <script language="javascript" type="text/javascript" src="${h.asset('js/excanvas.min.js')}"></script>
83 83 <![endif]-->
@@ -105,7 +105,6 b" c.template_context['visual']['default_re"
105 105
106 106 <%def name="head_extra()"></%def>
107 107 ${self.head_extra()}
108
109 108 <%include file="/base/plugins_base.html"/>
110 109
111 110 ## extra stuff
General Comments 0
You need to be logged in to leave comments. Login now