##// 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 '<%= dirs.js.src %>/plugins/jquery.mark.js',
32 '<%= dirs.js.src %>/plugins/jquery.mark.js',
33 '<%= dirs.js.src %>/plugins/jquery.timeago.js',
33 '<%= dirs.js.src %>/plugins/jquery.timeago.js',
34 '<%= dirs.js.src %>/plugins/jquery.timeago-extension.js',
34 '<%= dirs.js.src %>/plugins/jquery.timeago-extension.js',
35 '<%= dirs.js.src %>/plugins/toastr.js',
35
36
36 // Select2
37 // Select2
37 '<%= dirs.js.src %>/select2/select2.js',
38 '<%= dirs.js.src %>/select2/select2.js',
@@ -64,6 +65,7 b' module.exports = function(grunt) {'
64
65
65 // Rhodecode components
66 // Rhodecode components
66 '<%= dirs.js.src %>/rhodecode/init.js',
67 '<%= dirs.js.src %>/rhodecode/init.js',
68 '<%= dirs.js.src %>/rhodecode/connection_controller.js',
67 '<%= dirs.js.src %>/rhodecode/codemirror.js',
69 '<%= dirs.js.src %>/rhodecode/codemirror.js',
68 '<%= dirs.js.src %>/rhodecode/comments.js',
70 '<%= dirs.js.src %>/rhodecode/comments.js',
69 '<%= dirs.js.src %>/rhodecode/constants.js',
71 '<%= dirs.js.src %>/rhodecode/constants.js',
@@ -78,6 +80,7 b' module.exports = function(grunt) {'
78 '<%= dirs.js.src %>/rhodecode/select2_widgets.js',
80 '<%= dirs.js.src %>/rhodecode/select2_widgets.js',
79 '<%= dirs.js.src %>/rhodecode/tooltips.js',
81 '<%= dirs.js.src %>/rhodecode/tooltips.js',
80 '<%= dirs.js.src %>/rhodecode/users.js',
82 '<%= dirs.js.src %>/rhodecode/users.js',
83 '<%= dirs.js.src %>/rhodecode/utils/notifications.js',
81 '<%= dirs.js.src %>/rhodecode/appenlight.js',
84 '<%= dirs.js.src %>/rhodecode/appenlight.js',
82
85
83 // Rhodecode main module
86 // Rhodecode main module
@@ -391,6 +391,17 b' beaker.session.auto = false'
391 search.module = rhodecode.lib.index.whoosh
391 search.module = rhodecode.lib.index.whoosh
392 search.location = %(here)s/data/index
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 ## APPENLIGHT CONFIG ##
406 ## APPENLIGHT CONFIG ##
396 ###################################
407 ###################################
@@ -365,6 +365,17 b' beaker.session.auto = false'
365 search.module = rhodecode.lib.index.whoosh
365 search.module = rhodecode.lib.index.whoosh
366 search.location = %(here)s/data/index
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 ## APPENLIGHT CONFIG ##
380 ## APPENLIGHT CONFIG ##
370 ###################################
381 ###################################
@@ -166,6 +166,7 b' let'
166 ln -s ${self.supervisor}/bin/supervisor* $out/bin/
166 ln -s ${self.supervisor}/bin/supervisor* $out/bin/
167 ln -s ${self.gunicorn}/bin/gunicorn $out/bin/
167 ln -s ${self.gunicorn}/bin/gunicorn $out/bin/
168 ln -s ${self.PasteScript}/bin/paster $out/bin/
168 ln -s ${self.PasteScript}/bin/paster $out/bin/
169 ln -s ${self.channelstream}/bin/channelstream $out/bin/
169 ln -s ${self.pyramid}/bin/* $out/bin/ #*/
170 ln -s ${self.pyramid}/bin/* $out/bin/ #*/
170
171
171 # rhodecode-tools
172 # rhodecode-tools
@@ -112,6 +112,13 b' def load_environment(global_conf, app_co'
112 # sets the c attribute access when don't existing attribute are accessed
112 # sets the c attribute access when don't existing attribute are accessed
113 config['pylons.strict_tmpl_context'] = True
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 # Limit backends to "vcs.backends" from configuration
122 # Limit backends to "vcs.backends" from configuration
116 backends = config['vcs.backends'] = aslist(
123 backends = config['vcs.backends'] = aslist(
117 config.get('vcs.backends', 'hg,git'), sep=',')
124 config.get('vcs.backends', 'hg,git'), sep=',')
@@ -22,6 +22,7 b''
22 Pylons middleware initialization
22 Pylons middleware initialization
23 """
23 """
24 import logging
24 import logging
25 from collections import OrderedDict
25
26
26 from paste.registry import RegistryManager
27 from paste.registry import RegistryManager
27 from paste.gzipper import make_gzip_middleware
28 from paste.gzipper import make_gzip_middleware
@@ -68,6 +69,12 b' class SkippableRoutesMiddleware(RoutesMi'
68 def __call__(self, environ, start_response):
69 def __call__(self, environ, start_response):
69 for prefix in self.skip_prefixes:
70 for prefix in self.skip_prefixes:
70 if environ['PATH_INFO'].startswith(prefix):
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 return self.app(environ, start_response)
78 return self.app(environ, start_response)
72
79
73 return super(SkippableRoutesMiddleware, self).__call__(
80 return super(SkippableRoutesMiddleware, self).__call__(
@@ -228,7 +235,7 b' def includeme(config):'
228 settings = config.registry.settings
235 settings = config.registry.settings
229
236
230 # plugin information
237 # plugin information
231 config.registry.rhodecode_plugins = {}
238 config.registry.rhodecode_plugins = OrderedDict()
232
239
233 config.add_directive(
240 config.add_directive(
234 'register_rhodecode_plugin', register_rhodecode_plugin)
241 'register_rhodecode_plugin', register_rhodecode_plugin)
@@ -239,6 +246,7 b' def includeme(config):'
239 # Includes which are required. The application would fail without them.
246 # Includes which are required. The application would fail without them.
240 config.include('pyramid_mako')
247 config.include('pyramid_mako')
241 config.include('pyramid_beaker')
248 config.include('pyramid_beaker')
249 config.include('rhodecode.channelstream')
242 config.include('rhodecode.admin')
250 config.include('rhodecode.admin')
243 config.include('rhodecode.authentication')
251 config.include('rhodecode.authentication')
244 config.include('rhodecode.integrations')
252 config.include('rhodecode.integrations')
@@ -560,6 +560,13 b' def make_map(config):'
560 action='my_account_auth_tokens_add', conditions={'method': ['POST']})
560 action='my_account_auth_tokens_add', conditions={'method': ['POST']})
561 m.connect('my_account_auth_tokens', '/my_account/auth_tokens',
561 m.connect('my_account_auth_tokens', '/my_account/auth_tokens',
562 action='my_account_auth_tokens_delete', conditions={'method': ['DELETE']})
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 # NOTIFICATION REST ROUTES
571 # NOTIFICATION REST ROUTES
565 with rmap.submapper(path_prefix=ADMIN_PREFIX,
572 with rmap.submapper(path_prefix=ADMIN_PREFIX,
@@ -568,7 +575,6 b' def make_map(config):'
568 action='index', conditions={'method': ['GET']})
575 action='index', conditions={'method': ['GET']})
569 m.connect('notifications_mark_all_read', '/notifications/mark_all_read',
576 m.connect('notifications_mark_all_read', '/notifications/mark_all_read',
570 action='mark_all_read', conditions={'method': ['POST']})
577 action='mark_all_read', conditions={'method': ['POST']})
571
572 m.connect('/notifications/{notification_id}',
578 m.connect('/notifications/{notification_id}',
573 action='update', conditions={'method': ['PUT']})
579 action='update', conditions={'method': ['PUT']})
574 m.connect('/notifications/{notification_id}',
580 m.connect('/notifications/{notification_id}',
@@ -346,3 +346,17 b' class MyAccountController(BaseController'
346 h.flash(_("Auth token successfully deleted"), category='success')
346 h.flash(_("Auth token successfully deleted"), category='success')
347
347
348 return redirect(url('my_account_auth_tokens'))
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 return render('admin/notifications/notifications.html')
87 return render('admin/notifications/notifications.html')
88
88
89
89 @auth.CSRFRequired()
90 @auth.CSRFRequired()
90 def mark_all_read(self):
91 def mark_all_read(self):
91 if request.is_xhr:
92 if request.is_xhr:
@@ -332,6 +332,7 b' def attach_context_attributes(context, r'
332 'rhodecode_user': {
332 'rhodecode_user': {
333 'username': None,
333 'username': None,
334 'email': None,
334 'email': None,
335 'notification_status': False
335 },
336 },
336 'visual': {
337 'visual': {
337 'default_renderer': None
338 'default_renderer': None
@@ -26,10 +26,15 b' import logging'
26 import traceback
26 import traceback
27 import collections
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 from sqlalchemy.sql.expression import null
33 from sqlalchemy.sql.expression import null
30 from sqlalchemy.sql.functions import coalesce
34 from sqlalchemy.sql.functions import coalesce
31
35
32 from rhodecode.lib import helpers as h, diffs
36 from rhodecode.lib import helpers as h, diffs
37 from rhodecode.lib.channelstream import channelstream_request
33 from rhodecode.lib.utils import action_logger
38 from rhodecode.lib.utils import action_logger
34 from rhodecode.lib.utils2 import extract_mentioned_users
39 from rhodecode.lib.utils2 import extract_mentioned_users
35 from rhodecode.model import BaseModel
40 from rhodecode.model import BaseModel
@@ -134,84 +139,82 b' class ChangesetCommentsModel(BaseModel):'
134
139
135 Session().add(comment)
140 Session().add(comment)
136 Session().flush()
141 Session().flush()
137
142 kwargs = {
138 if send_email:
143 'user': user,
139 kwargs = {
144 'renderer_type': renderer,
140 'user': user,
145 'repo_name': repo.repo_name,
141 'renderer_type': renderer,
146 'status_change': status_change,
142 'repo_name': repo.repo_name,
147 'comment_body': text,
143 'status_change': status_change,
148 'comment_file': f_path,
144 'comment_body': text,
149 'comment_line': line_no,
145 'comment_file': f_path,
150 }
146 'comment_line': line_no,
147 }
148
151
149 if commit_obj:
152 if commit_obj:
150 recipients = ChangesetComment.get_users(
153 recipients = ChangesetComment.get_users(
151 revision=commit_obj.raw_id)
154 revision=commit_obj.raw_id)
152 # add commit author if it's in RhodeCode system
155 # add commit author if it's in RhodeCode system
153 cs_author = User.get_from_cs_author(commit_obj.author)
156 cs_author = User.get_from_cs_author(commit_obj.author)
154 if not cs_author:
157 if not cs_author:
155 # use repo owner if we cannot extract the author correctly
158 # use repo owner if we cannot extract the author correctly
156 cs_author = repo.user
159 cs_author = repo.user
157 recipients += [cs_author]
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(
164 target_repo_url = h.link_to(
162 repo.repo_name,
165 repo.repo_name,
163 h.url('summary_home',
166 h.url('summary_home',
164 repo_name=repo.repo_name, qualified=True))
167 repo_name=repo.repo_name, qualified=True))
165
168
166 # commit specifics
169 # commit specifics
167 kwargs.update({
170 kwargs.update({
168 'commit': commit_obj,
171 'commit': commit_obj,
169 'commit_message': commit_obj.message,
172 'commit_message': commit_obj.message,
170 'commit_target_repo': target_repo_url,
173 'commit_target_repo': target_repo_url,
171 'commit_comment_url': commit_comment_url,
174 'commit_comment_url': commit_comment_url,
172 })
175 })
173
176
174 elif pull_request_obj:
177 elif pull_request_obj:
175 # get the current participants of this pull request
178 # get the current participants of this pull request
176 recipients = ChangesetComment.get_users(
179 recipients = ChangesetComment.get_users(
177 pull_request_id=pull_request_obj.pull_request_id)
180 pull_request_id=pull_request_obj.pull_request_id)
178 # add pull request author
181 # add pull request author
179 recipients += [pull_request_obj.author]
182 recipients += [pull_request_obj.author]
180
183
181 # add the reviewers to notification
184 # add the reviewers to notification
182 recipients += [x.user for x in pull_request_obj.reviewers]
185 recipients += [x.user for x in pull_request_obj.reviewers]
183
186
184 pr_target_repo = pull_request_obj.target_repo
187 pr_target_repo = pull_request_obj.target_repo
185 pr_source_repo = pull_request_obj.source_repo
188 pr_source_repo = pull_request_obj.source_repo
186
189
187 pr_comment_url = h.url(
190 pr_comment_url = h.url(
188 'pullrequest_show',
191 'pullrequest_show',
189 repo_name=pr_target_repo.repo_name,
192 repo_name=pr_target_repo.repo_name,
190 pull_request_id=pull_request_obj.pull_request_id,
193 pull_request_id=pull_request_obj.pull_request_id,
191 anchor='comment-%s' % comment.comment_id,
194 anchor='comment-%s' % comment.comment_id,
192 qualified=True,)
195 qualified=True,)
193
196
194 # set some variables for email notification
197 # set some variables for email notification
195 pr_target_repo_url = h.url(
198 pr_target_repo_url = h.url(
196 'summary_home', repo_name=pr_target_repo.repo_name,
199 'summary_home', repo_name=pr_target_repo.repo_name,
197 qualified=True)
200 qualified=True)
198
201
199 pr_source_repo_url = h.url(
202 pr_source_repo_url = h.url(
200 'summary_home', repo_name=pr_source_repo.repo_name,
203 'summary_home', repo_name=pr_source_repo.repo_name,
201 qualified=True)
204 qualified=True)
202
205
203 # pull request specifics
206 # pull request specifics
204 kwargs.update({
207 kwargs.update({
205 'pull_request': pull_request_obj,
208 'pull_request': pull_request_obj,
206 'pr_id': pull_request_obj.pull_request_id,
209 'pr_id': pull_request_obj.pull_request_id,
207 'pr_target_repo': pr_target_repo,
210 'pr_target_repo': pr_target_repo,
208 'pr_target_repo_url': pr_target_repo_url,
211 'pr_target_repo_url': pr_target_repo_url,
209 'pr_source_repo': pr_source_repo,
212 'pr_source_repo': pr_source_repo,
210 'pr_source_repo_url': pr_source_repo_url,
213 'pr_source_repo_url': pr_source_repo_url,
211 'pr_comment_url': pr_comment_url,
214 'pr_comment_url': pr_comment_url,
212 'pr_closing': closing_pr,
215 'pr_closing': closing_pr,
213 })
216 })
214
217 if send_email:
215 # pre-generate the subject for notification itself
218 # pre-generate the subject for notification itself
216 (subject,
219 (subject,
217 _h, _e, # we don't care about those
220 _h, _e, # we don't care about those
@@ -240,6 +243,44 b' class ChangesetCommentsModel(BaseModel):'
240 )
243 )
241 action_logger(user, action, comment.repo)
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 return comment
284 return comment
244
285
245 def delete(self, comment):
286 def delete(self, comment):
@@ -25,6 +25,7 b''
25 @import 'comments';
25 @import 'comments';
26 @import 'panels-bootstrap';
26 @import 'panels-bootstrap';
27 @import 'panels';
27 @import 'panels';
28 @import 'toastr';
28 @import 'deform';
29 @import 'deform';
29
30
30
31
@@ -1615,6 +1616,10 b' BIN_FILENODE = 7'
1615 float: right;
1616 float: right;
1616 }
1617 }
1617
1618
1619 #notification-status{
1620 display: inline;
1621 }
1622
1618 // Repositories
1623 // Repositories
1619
1624
1620 #summary.fields{
1625 #summary.fields{
@@ -1,3 +1,4 b''
1 /plugins/__REGISTER__ - launched after the onDomReady() code from rhodecode.js is executed
1 /plugins/__REGISTER__ - launched after the onDomReady() code from rhodecode.js is executed
2 /ui/plugins/code/anchor_focus - launched when rc starts to scroll on load to anchor on PR/Codeview
2 /ui/plugins/code/anchor_focus - launched when rc starts to scroll on load to anchor on PR/Codeview
3 /ui/plugins/code/comment_form_built - launched when injectInlineForm() is executed and the form object is created
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 <li class="${'active' if c.active=='watched' else ''}"><a href="${h.url('my_account_watched')}">${_('Watched')}</a></li>
39 <li class="${'active' if c.active=='watched' else ''}"><a href="${h.url('my_account_watched')}">${_('Watched')}</a></li>
40 <li class="${'active' if c.active=='pullrequests' else ''}"><a href="${h.url('my_account_pullrequests')}">${_('Pull Requests')}</a></li>
40 <li class="${'active' if c.active=='pullrequests' else ''}"><a href="${h.url('my_account_pullrequests')}">${_('Pull Requests')}</a></li>
41 <li class="${'active' if c.active=='perms' else ''}"><a href="${h.url('my_account_perms')}">${_('My Permissions')}</a></li>
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 </ul>
43 </ul>
43 </div>
44 </div>
44
45
@@ -1,72 +1,56 b''
1 <%namespace name="base" file="/base/base.html"/>
2
3 <div class="panel panel-default">
1 <div class="panel panel-default">
4 <div class="panel-heading">
2 <div class="panel-heading">
5 <h3 class="panel-title">${_('Account Emails')}</h3>
3 <h3 class="panel-title">${_('Your live notification settings')}</h3>
6 </div>
4 </div>
7
5
8 <div class="panel-body">
6 <div class="panel-body">
9 <div class="emails_wrap">
7
10 <table class="rctable account_emails">
8 <p><strong>IMPORTANT:</strong> This feature requires enabled channelstream websocket server to function correctly.</p>
11 <tr>
9
12 <td class="td-user">
10 <p class="hidden">Status of browser notifications permission: <strong id="browser-notification-status"></strong></p>
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>
49
11
50 <div>
12 ${h.secure_form(url('my_account_notifications_toggle_visibility'), method='post', id='notification-status')}
51 ${h.secure_form(url('my_account_emails'), method='post')}
13 <button class="btn btn-default" type="submit">
52 <div class="form">
14 ${_('Notifications')} <strong>${_('Enabled') if c.rhodecode_user.get_instance().user_data.get('notification_status') else _('Disabled')}</strong>
53 <!-- fields -->
15 </button>
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>
69 ${h.end_form()}
16 ${h.end_form()}
70 </div>
17
18 <a class="btn btn-info" id="test-notification">Test notification</a>
19
71 </div>
20 </div>
72 </div>
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 from pyramid.threadlocal import get_current_registry, get_current_request
3 from pyramid.threadlocal import get_current_registry, get_current_request
4 pyramid_registry = get_current_registry()
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 % if config['template_hooks'].get('plugin_init_template'):
7 % if config['template_hooks'].get('plugin_init_template'):
8 ${pyramid_render(config['template_hooks'].get('plugin_init_template'),
8 ${pyramid_render(config['template_hooks'].get('plugin_init_template'),
9 {'config':config}, request=get_current_request(), package='rc_ae')|n}
9 {'config':config}, request=get_current_request(), package='rc_ae')|n}
@@ -11,6 +11,7 b" if hasattr(c, 'rhodecode_db_repo'):"
11 if getattr(c, 'rhodecode_user', None) and c.rhodecode_user.user_id:
11 if getattr(c, 'rhodecode_user', None) and c.rhodecode_user.user_id:
12 c.template_context['rhodecode_user']['username'] = c.rhodecode_user.username
12 c.template_context['rhodecode_user']['username'] = c.rhodecode_user.username
13 c.template_context['rhodecode_user']['email'] = c.rhodecode_user.email
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 c.template_context['visual']['default_renderer'] = h.get_visual_attr(c, 'default_renderer')
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 </script>
80 </script>
80
81 <!--[if lt IE 9]>
81 <!--[if lt IE 9]>
82 <script language="javascript" type="text/javascript" src="${h.asset('js/excanvas.min.js')}"></script>
82 <script language="javascript" type="text/javascript" src="${h.asset('js/excanvas.min.js')}"></script>
83 <![endif]-->
83 <![endif]-->
@@ -105,7 +105,6 b" c.template_context['visual']['default_re"
105
105
106 <%def name="head_extra()"></%def>
106 <%def name="head_extra()"></%def>
107 ${self.head_extra()}
107 ${self.head_extra()}
108
109 <%include file="/base/plugins_base.html"/>
108 <%include file="/base/plugins_base.html"/>
110
109
111 ## extra stuff
110 ## extra stuff
General Comments 0
You need to be logged in to leave comments. Login now