Browse Source

Radicale: server, auth and rights integration

Bastien Sevajol (Algoo) 8 years ago
parent
commit
108d3622f6

+ 1 - 0
install/requirements.txt View File

@@ -49,3 +49,4 @@ git+https://github.com/algoo/tgapp-resetpassword.git@master
49 49
 lxml
50 50
 python-ldap-test==0.2.0
51 51
 who-ldap==3.1.0
52
+unicode-slugify==0.1.3

+ 1 - 0
tracim/setup.py View File

@@ -41,6 +41,7 @@ install_requires=[
41 41
     "repoze.who",
42 42
     "who-ldap==3.1.0",
43 43
     "python-ldap-test==0.2.0",
44
+    "unicode-slugify==0.1.3",
44 45
     ]
45 46
 
46 47
 setup(

+ 8 - 0
tracim/tracim/config/app_cfg.py View File

@@ -202,6 +202,14 @@ class CFG(object):
202 202
             # ContentType.Folder -- Folder is skipped
203 203
         ]
204 204
 
205
+        self.RADICALE_SERVER_HOST = '0.0.0.0'
206
+        self.RADICALE_SERVER_PORT = 5232
207
+        self.RADICALE_SERVER_SSL = False
208
+
209
+        self.RADICALE_CLIENT_HOST = None  # If None, current host will be used
210
+        self.RADICALE_CLIENT_PORT = 5232
211
+        self.RADICALE_CLIENT_SSL = False
212
+
205 213
 
206 214
     def get_tracker_js_content(self, js_tracker_file_path = None):
207 215
         js_tracker_file_path = tg.config.get('js_tracker_path', None)

+ 5 - 2
tracim/tracim/config/middleware.py View File

@@ -3,7 +3,8 @@
3 3
 
4 4
 from tracim.config.app_cfg import base_config
5 5
 from tracim.config.environment import load_environment
6
-
6
+from tracim.lib.daemons import DaemonsManager
7
+from tracim.lib.daemons import RadicaleDaemon
7 8
 
8 9
 __all__ = ['make_app']
9 10
 
@@ -35,5 +36,7 @@ def make_app(global_conf, full_stack=True, **app_conf):
35 36
     app = make_base_app(global_conf, full_stack=True, **app_conf)
36 37
     
37 38
     # Wrap your base TurboGears 2 application with custom middleware here
38
-    
39
+    daemons = DaemonsManager(app)
40
+    daemons.run(RadicaleDaemon.name, RadicaleDaemon)
41
+
39 42
     return app

+ 2 - 1
tracim/tracim/controllers/admin/workspace.py View File

@@ -212,13 +212,14 @@ class WorkspaceRestController(TIMRestController, BaseController):
212 212
         return DictLikeClass(result = dictified_workspace)
213 213
 
214 214
     @tg.expose('tracim.templates.workspace.edit')
215
-    def put(self, id, name, description):
215
+    def put(self, id, name, description, calendar_enabled):
216 216
         user = tmpl_context.current_user
217 217
         workspace_api_controller = WorkspaceApi(user)
218 218
 
219 219
         workspace = workspace_api_controller.get_one(id)
220 220
         workspace.label = name
221 221
         workspace.description = description
222
+        workspace.calendar_enabled = calendar_enabled
222 223
         workspace_api_controller.save(workspace)
223 224
 
224 225
         tg.flash(_('{} workspace updated.').format(workspace.label), CST.STATUS_OK)

+ 90 - 0
tracim/tracim/lib/calendar.py View File

@@ -0,0 +1,90 @@
1
+import re
2
+
3
+from tracim.lib.exceptions import UnknownCalendarType
4
+from tracim.lib.exceptions import NotFound
5
+from tracim.lib.user import UserApi
6
+from tracim.lib.workspace import WorkspaceApi
7
+from tracim.model import User
8
+from tracim.model.organizational import Calendar
9
+from tracim.model.organizational import UserCalendar
10
+from tracim.model.organizational import WorkspaceCalendar
11
+
12
+CALENDAR_USER_PATH_RE = 'user\/([0-9]+)--([a-z-]*).ics'
13
+CALENDAR_WORKSPACE_PATH_RE = 'workspace\/([0-9]+)--([a-z0-9-]*).ics'
14
+
15
+CALENDAR_TYPE_USER = 'USER'
16
+CALENDAR_TYPE_WORKSPACE = 'WORKSPACE'
17
+
18
+
19
+class CalendarManager(object):
20
+    def __init__(self, user: User):
21
+        self._user = user
22
+
23
+    def get_type_for_path(self, path: str) -> str:
24
+        """
25
+        Return calendar type for given path. Raise
26
+        tracim.lib.exceptions.UnknownCalendarType if unknown type.
27
+        :param path: path representation like user/42--foo.ics
28
+        :return: Type of calendar, can be one of CALENDAR_TYPE_USER,
29
+        CALENDAR_TYPE_WORKSPACE
30
+        """
31
+        if re.match(CALENDAR_USER_PATH_RE, path):
32
+            return CALENDAR_TYPE_USER
33
+
34
+        if re.match(CALENDAR_WORKSPACE_PATH_RE, path):
35
+            return CALENDAR_TYPE_WORKSPACE
36
+
37
+        raise UnknownCalendarType(
38
+            'No match for calendar path "{0}"'.format(path)
39
+        )
40
+
41
+    def get_id_for_path(self, path: str, type: str) -> int:
42
+        """
43
+        Return related calendar id for given path. Raise
44
+        tracim.lib.exceptions.UnknownCalendarType if unknown type.
45
+        :param path: path representation like user/42--foo.ics
46
+        :param type: Type of calendar, can be one of CALENDAR_TYPE_USER,
47
+        CALENDAR_TYPE_WORKSPACE
48
+        :return: ID of related calendar object. For UserCalendar it will be
49
+        user id, for WorkspaceCalendar it will be Workspace id.
50
+        """
51
+        if type == CALENDAR_TYPE_USER:
52
+            return re.search(CALENDAR_USER_PATH_RE, path).group(1)
53
+        elif type == CALENDAR_TYPE_WORKSPACE:
54
+            return re.search(CALENDAR_WORKSPACE_PATH_RE, path).group(1)
55
+        raise UnknownCalendarType('Type "{0}" is not implemented'.format(type))
56
+
57
+    def find_calendar_with_path(self, path: str) -> Calendar:
58
+        """
59
+        Return calendar for given path. Raise tracim.lib.exceptions.NotFound if
60
+        calendar cannot be found.
61
+        :param path: path representation like user/42--foo.ics
62
+        :return: Calendar corresponding to path
63
+        """
64
+        try:
65
+            type = self.get_type_for_path(path)
66
+            id = self.get_id_for_path(path, type)
67
+        except UnknownCalendarType as exc:
68
+            raise NotFound(str(exc))
69
+
70
+        return self.get_calendar(type, id, path)
71
+
72
+    def get_calendar(self, type: str, id: str, path: str) -> Calendar:
73
+        """
74
+        Return tracim.model.organizational.Calendar instance for given
75
+        parameters.
76
+        :param type: Type of calendar, can be one of CALENDAR_TYPE_USER,
77
+        CALENDAR_TYPE_WORKSPACE
78
+        :param id: related calendar object id
79
+        :param path: path representation like user/42--foo.ics
80
+        :return: a calendar.
81
+        """
82
+        if type == CALENDAR_TYPE_USER:
83
+            user = UserApi(self._user).get_one_by_id(id)
84
+            return UserCalendar(user, path=path)
85
+
86
+        if type == CALENDAR_TYPE_WORKSPACE:
87
+            workspace = WorkspaceApi(self._user).get_one(id)
88
+            return WorkspaceCalendar(workspace, path=path)
89
+
90
+        raise UnknownCalendarType('Type "{0}" is not implemented'.format(type))

+ 104 - 0
tracim/tracim/lib/daemons.py View File

@@ -0,0 +1,104 @@
1
+import threading
2
+from wsgiref.simple_server import make_server
3
+
4
+import signal
5
+from radicale import Application as RadicaleApplication
6
+from radicale import HTTPServer as RadicaleHTTPServer
7
+from radicale import HTTPSServer as RadicaleHTTPSServer
8
+from radicale import RequestHandler as RadicaleRequestHandler
9
+from radicale import config as radicale_config
10
+from tg import TGApp
11
+
12
+from tracim.lib.exceptions import AlreadyRunningDaemon
13
+
14
+
15
+class DaemonsManager(object):
16
+    def __init__(self, app: TGApp):
17
+        self._app = app
18
+        self._daemons = {}
19
+
20
+    def run(self, name: str, daemon_class: object, **kwargs) -> None:
21
+        """
22
+        Start a daemon with given daemon class.
23
+        :param name: Name of runned daemon. It's not possible to start two
24
+        daemon with same name. In the opposite case, raise
25
+        tracim.lib.exceptions.AlreadyRunningDaemon
26
+        :param daemon_class: Daemon class to use for daemon instance.
27
+        :param kwargs: Other kwargs will be given to daemon class
28
+        instantiation.
29
+        """
30
+        if name in self._daemons:
31
+            raise AlreadyRunningDaemon(
32
+                'Daemon with name "{0}" already running'.format(name)
33
+            )
34
+
35
+        kwargs['app'] = self._app
36
+        shutdown_program = threading.Event()
37
+        # SIGTERM and SIGINT (aka KeyboardInterrupt) should just mark this for
38
+        # shutdown
39
+        signal.signal(signal.SIGTERM, lambda *_: shutdown_program.set())
40
+        signal.signal(signal.SIGINT, lambda *_: shutdown_program.set())
41
+
42
+        try:
43
+            threading.Thread(target=daemon_class.start, kwargs=kwargs).start()
44
+            self._daemons[name] = daemon_class
45
+        finally:
46
+            shutdown_program.set()
47
+
48
+
49
+class Daemon(object):
50
+    _name = NotImplemented
51
+
52
+    def __init__(self, app: TGApp):
53
+        self._app = app
54
+
55
+    @classmethod
56
+    def start(cls, **kwargs):
57
+        return cls(**kwargs)
58
+
59
+    @classmethod
60
+    def kill(cls):
61
+        raise NotImplementedError()
62
+
63
+    @property
64
+    def name(self):
65
+        return self._name
66
+
67
+
68
+class RadicaleDaemon(Daemon):
69
+    _name = 'tracim-radicale-server'
70
+
71
+    @classmethod
72
+    def kill(cls):
73
+        pass  # TODO
74
+
75
+    def __init__(self, app: TGApp):
76
+        """
77
+        To see origin radical server start method, refer to
78
+        radicale.__main__.run
79
+        """
80
+        super().__init__(app)
81
+        self._prepare_config()
82
+        server = self._get_server()
83
+        server.serve_forever()
84
+
85
+    def _prepare_config(self):
86
+        tracim_auth = 'tracim.lib.radicale.auth'
87
+        tracim_rights = 'tracim.lib.radicale.rights'
88
+
89
+        radicale_config.set('auth', 'type', 'custom')
90
+        radicale_config.set('auth', 'custom_handler', tracim_auth)
91
+
92
+        radicale_config.set('rights', 'type', 'custom')
93
+        radicale_config.set('rights', 'custom_handler', tracim_rights)
94
+
95
+    def _get_server(self):
96
+        from tracim.config.app_cfg import CFG
97
+        cfg = CFG.get_instance()
98
+        return make_server(
99
+            cfg.RADICALE_SERVER_HOST,
100
+            cfg.RADICALE_SERVER_PORT,
101
+            RadicaleApplication(),
102
+            RadicaleHTTPServer if cfg.RADICALE_SERVER_SSL else RadicaleHTTPSServer,
103
+            RadicaleRequestHandler
104
+        )

+ 22 - 0
tracim/tracim/lib/exceptions.py View File

@@ -0,0 +1,22 @@
1
+class TracimException(Exception):
2
+    pass
3
+
4
+
5
+class DaemonException(TracimException):
6
+    pass
7
+
8
+
9
+class AlreadyRunningDaemon(DaemonException):
10
+    pass
11
+
12
+
13
+class CalendarException(TracimException):
14
+    pass
15
+
16
+
17
+class UnknownCalendarType(CalendarException):
18
+    pass
19
+
20
+
21
+class NotFound(TracimException):
22
+    pass

+ 5 - 0
tracim/tracim/lib/helpers.py View File

@@ -6,6 +6,7 @@
6 6
 
7 7
 import datetime
8 8
 
9
+import slugify
9 10
 from babel.dates import format_date, format_time
10 11
 from markupsafe import Markup
11 12
 
@@ -216,3 +217,7 @@ def is_user_externalized_field(field_name):
216 217
     if not tg.config.get('auth_instance').is_internal:
217 218
         return field_name in tg.config.get('auth_instance').managed_fields
218 219
     return False
220
+
221
+
222
+def slug(string):
223
+    return slugify.slugify(string, only_ascii=True)

+ 13 - 0
tracim/tracim/lib/radicale/auth.py View File

@@ -0,0 +1,13 @@
1
+from tg import config
2
+
3
+
4
+def is_authenticated(user, password):
5
+    """
6
+    :param user: user email
7
+    :param password: user password
8
+    :return: True if auth success, False if not
9
+    """
10
+    return bool(config.get('sa_auth').authmetadata.authenticate({}, {
11
+        'login': user,
12
+        'password': password
13
+    }))

+ 25 - 0
tracim/tracim/lib/radicale/rights.py View File

@@ -0,0 +1,25 @@
1
+from tracim.lib.calendar import CalendarManager
2
+from tracim.lib.exceptions import NotFound
3
+from tracim.lib.user import UserApi
4
+from tracim.model.organizational import CALENDAR_PERMISSION_READ
5
+
6
+
7
+def authorized(user, collection, permission):
8
+    """
9
+    :param user: radicale given user id, should be email
10
+    :param collection: Calendar representation
11
+    :param permission: 'r' or 'w'
12
+    :return: True if user can access calendar with asked permission
13
+    """
14
+    if not user:
15
+        return False
16
+    current_user = UserApi(None).get_one_by_email(user)
17
+    manager = CalendarManager(current_user)
18
+    try:
19
+        calendar = manager.find_calendar_with_path(collection.path)
20
+    except NotFound:
21
+        return False
22
+
23
+    if permission == CALENDAR_PERMISSION_READ:
24
+        return calendar.user_can_read(current_user)
25
+    return calendar.user_can_write(current_user)

+ 6 - 0
tracim/tracim/lib/radicale/storage.py View File

@@ -0,0 +1,6 @@
1
+from radicale.storage.filesystem import Collection as BaseCollection
2
+
3
+
4
+class Collection(BaseCollection):
5
+    def __init__(self, path, principal=False):
6
+        super().__init__(path, principal)

+ 3 - 0
tracim/tracim/lib/user.py View File

@@ -27,6 +27,9 @@ class UserApi(object):
27 27
     def get_one_by_email(self, email: str):
28 28
         return self._base_query().filter(User.email==email).one()
29 29
 
30
+    def get_one_by_id(self, id: int) -> User:
31
+        return self._base_query().filter(User.user_id==id).one()
32
+
30 33
     def update(self, user: User, name: str=None, email: str=None, do_save=True):
31 34
         if name is not None:
32 35
             user.display_name = name

+ 5 - 0
tracim/tracim/lib/utils.py View File

@@ -41,3 +41,8 @@ def replace_reset_password_templates(engines):
41 41
         pass
42 42
     except KeyError:
43 43
         pass
44
+
45
+
46
+@property
47
+def NotImplemented():
48
+    raise NotImplementedError()

+ 27 - 1
tracim/tracim/model/auth.py View File

@@ -11,6 +11,7 @@ though.
11 11
 import os
12 12
 from datetime import datetime
13 13
 from hashlib import sha256
14
+from slugify import slugify
14 15
 from sqlalchemy.ext.hybrid import hybrid_property
15 16
 from tg.i18n import lazy_ugettext as l_
16 17
 
@@ -26,6 +27,8 @@ from sqlalchemy.types import Integer
26 27
 from sqlalchemy.types import DateTime
27 28
 from sqlalchemy.types import Boolean
28 29
 from sqlalchemy.orm import relation, relationship, synonym
30
+from tg import request
31
+from tg import config
29 32
 
30 33
 from tracim.model import DeclarativeBase, metadata, DBSession
31 34
 
@@ -147,6 +150,21 @@ class User(DeclarativeBase):
147 150
             profile_id = max(group.group_id for group in self.groups)
148 151
         return Profile(profile_id)
149 152
 
153
+    @property
154
+    def calendar_url(self) -> str:
155
+        # TODO - 20160531 - Bastien: Cyclic import if import in top of file
156
+        from tracim.config.app_cfg import CFG
157
+        cfg = CFG.get_instance()
158
+        return '{proto}://{domain}:{port}/user/{id}--{slug_name}.ics'.format(
159
+            proto='https' if cfg.RADICALE_CLIENT_SSL else 'http',
160
+            domain=cfg.RADICALE_CLIENT_HOST or request.domain,
161
+            port=cfg.RADICALE_CLIENT_PORT,
162
+            id=self.user_id,
163
+            slug_name=slugify(self.get_display_name(
164
+                remove_email_part=True
165
+            ), only_ascii=True)
166
+        )
167
+
150 168
     @classmethod
151 169
     def by_email_address(cls, email):
152 170
         """Return the user object whose email address is ``email``."""
@@ -206,10 +224,18 @@ class User(DeclarativeBase):
206 224
         hash.update((password + self.password[:64]).encode('utf-8'))
207 225
         return self.password[64:] == hash.hexdigest()
208 226
 
209
-    def get_display_name(self):
227
+    def get_display_name(self, remove_email_part=False):
228
+        """
229
+        :param remove_email_part: If True and display name based on email,
230
+         remove @xxx.xxx part of email in returned value
231
+        :return: display name based on user name or email.
232
+        """
210 233
         if self.display_name!=None and self.display_name!='':
211 234
             return self.display_name
212 235
         else:
236
+            if remove_email_part:
237
+                at_pos = self.email.index('@')
238
+                return self.email[0:at_pos]
213 239
             return self.email
214 240
 
215 241
     def get_role(self, workspace: 'Workspace') -> int:

+ 15 - 1
tracim/tracim/model/data.py View File

@@ -7,10 +7,10 @@ from datetime import datetime
7 7
 import tg
8 8
 from babel.dates import format_timedelta
9 9
 from bs4 import BeautifulSoup
10
+from slugify import slugify
10 11
 from sqlalchemy import Column, inspect, Index
11 12
 from sqlalchemy import ForeignKey
12 13
 from sqlalchemy import Sequence
13
-from sqlalchemy import func
14 14
 from sqlalchemy.ext.associationproxy import association_proxy
15 15
 from sqlalchemy.ext.hybrid import hybrid_property
16 16
 from sqlalchemy.orm import backref
@@ -51,6 +51,7 @@ class Workspace(DeclarativeBase):
51 51
 
52 52
     label   = Column(Unicode(1024), unique=False, nullable=False, default='')
53 53
     description = Column(Text(), unique=False, nullable=False, default='')
54
+    calendar_enabled = Column(Boolean, unique=False, nullable=False, default=False)
54 55
 
55 56
     #  Default value datetime.utcnow, see: http://stackoverflow.com/a/13370382/801924 (or http://pastebin.com/VLyWktUn)
56 57
     created = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
@@ -66,6 +67,19 @@ class Workspace(DeclarativeBase):
66 67
         # Return a list of unique revisions parent content
67 68
         return list(set([revision.node for revision in self.revisions]))
68 69
 
70
+    @property
71
+    def calendar_url(self) -> str:
72
+        # TODO - 20160531 - Bastien: Cyclic import if import in top of file
73
+        from tracim.config.app_cfg import CFG
74
+        cfg = CFG.get_instance()
75
+        return '{proto}://{domain}:{port}/workspace/{id}--{slug}.ics'.format(
76
+            proto='https' if cfg.RADICALE_CLIENT_SSL else 'http',
77
+            domain=cfg.RADICALE_CLIENT_HOST or tg.request.domain,
78
+            port=cfg.RADICALE_CLIENT_PORT,
79
+            id=self.workspace_id,
80
+            slug=slugify(self.label)
81
+        )
82
+
69 83
     def get_user_role(self, user: User) -> int:
70 84
         for role in user.roles:
71 85
             if role.workspace.workspace_id==self.workspace_id:

+ 49 - 0
tracim/tracim/model/organizational.py View File

@@ -0,0 +1,49 @@
1
+from tracim.model import User
2
+from tracim.model.data import UserRoleInWorkspace
3
+
4
+
5
+CALENDAR_PERMISSION_READ = 'r'
6
+CALENDAR_PERMISSION_WRITE = 'w'
7
+
8
+
9
+class Calendar(object):
10
+    def __init__(self, related_object, path):
11
+        self._related_object = related_object
12
+        self._path = path
13
+
14
+    def user_can_read(self, user: User) -> bool:
15
+        raise NotImplementedError()
16
+
17
+    def user_can_write(self, user: User) -> bool:
18
+        raise NotImplementedError()
19
+
20
+
21
+class UserCalendar(Calendar):
22
+    def user_can_write(self, user: User) -> bool:
23
+        return self._related_object.user_id == user.user_id
24
+
25
+    def user_can_read(self, user: User) -> bool:
26
+        return self._related_object.user_id == user.user_id
27
+
28
+
29
+class WorkspaceCalendar(Calendar):
30
+    _workspace_rights = {
31
+        UserRoleInWorkspace.NOT_APPLICABLE:
32
+            [],
33
+        UserRoleInWorkspace.READER:
34
+            [CALENDAR_PERMISSION_READ],
35
+        UserRoleInWorkspace.CONTRIBUTOR:
36
+            [CALENDAR_PERMISSION_READ, CALENDAR_PERMISSION_WRITE],
37
+        UserRoleInWorkspace.CONTENT_MANAGER:
38
+            [CALENDAR_PERMISSION_READ, CALENDAR_PERMISSION_WRITE],
39
+        UserRoleInWorkspace.WORKSPACE_MANAGER:
40
+            [CALENDAR_PERMISSION_READ, CALENDAR_PERMISSION_WRITE],
41
+    }
42
+
43
+    def user_can_write(self, user: User) -> bool:
44
+        role = user.get_role(self._related_object)
45
+        return CALENDAR_PERMISSION_WRITE in self._workspace_rights[role]
46
+
47
+    def user_can_read(self, user: User) -> bool:
48
+        role = user.get_role(self._related_object)
49
+        return CALENDAR_PERMISSION_READ in self._workspace_rights[role]

+ 3 - 0
tracim/tracim/model/serializers.py View File

@@ -876,6 +876,7 @@ def serialize_user_for_user(user: User, context: Context):
876 876
     result['roles'] = context.toDict(user.roles)
877 877
     result['enabled'] = user.is_active
878 878
     result['profile'] = user.profile
879
+    result['calendar_url'] = user.calendar_url
879 880
 
880 881
     return result
881 882
 
@@ -979,6 +980,8 @@ def serialize_workspace_complete(workspace: pmd.Workspace, context: Context):
979 980
     result['members'] = context.toDict(workspace.roles)
980 981
     result['member_nb'] = len(workspace.roles)
981 982
     result['allowed_content_types'] = context.toDict(workspace.get_allowed_content_types())
983
+    result['calendar_enabled'] = workspace.calendar_enabled
984
+    result['calendar_url'] = workspace.calendar_url
982 985
 
983 986
     return result
984 987
 

+ 5 - 0
tracim/tracim/templates/user_workspace_forms.mak View File

@@ -110,6 +110,11 @@
110 110
                 % endif
111 111
                 <input name="email" type="text" class="form-control" id="email" placeholder="${_('Name')}" value="${user.email}" ${'readonly="readonly"' if h.is_user_externalized_field('email') else '' | n}>
112 112
             </div>
113
+            <div class="form-group">
114
+                <label for="calendar">${_('Personal calendar')}</label>
115
+                <span class="info readonly">${_('This calendar URL will work with CalDav compatibles clients')}</span>
116
+                <input id="calendar" type="text" class="form-control"  disabled="disabled" value="${user.calendar_url}" />
117
+            </div>
113 118
         </div>
114 119
         <div class="modal-footer">
115 120
             <span class="pull-right t-modal-form-submit-button">

+ 8 - 0
tracim/tracim/templates/workspace/edit.mak View File

@@ -17,6 +17,14 @@
17 17
             <label for="workspaceDescription">${_('Description')}</label>
18 18
             <textarea name="description" class="form-control" id="workspaceDescription" placeholder="${_('You may add a description of the workspace')}">${result.workspace.description}</textarea>
19 19
         </div>
20
+        <div class="form-group">
21
+            <label for="workspaceCalendarEnabled">${_('Calendar enabled')}</label>
22
+            <input id="workspaceCalendarEnabled" name="calendar_enabled" class="form-control" type="checkbox" ${'checked' if result.workspace.calendar_enabled else ''} />
23
+        </div>
24
+        <div class="form-group calendar-url">
25
+            <label for="workspaceCalendarUrl">${_('Calendar URL')}</label>
26
+            <input id="workspaceCalendarUrl" type="text" class="form-control"  disabled="disabled" value="${result.workspace.calendar_url}" />
27
+        </div>
20 28
     </div>
21 29
     <div class="modal-footer">
22 30
         <span class="pull-right" style="margin-top: 0.5em;">

+ 5 - 0
tracim/tracim/templates/workspace/getone.mak View File

@@ -59,6 +59,11 @@
59 59
         % else:
60 60
             <p class="t-less-visible">${_('No description available')}</p>
61 61
         % endif
62
+        % if result.workspace.calendar_enabled:
63
+            <p>
64
+                ${_('Calendar URL')}: ${result.workspace.calendar_url}
65
+            </p>
66
+        % endif
62 67
 
63 68
         <% member_nb = len(result.workspace.members) %>
64 69
         % if member_nb<=0: