Browse Source

Merge pull request #49 from buxx/dev/_/41/radicale

Tracim 8 years ago
parent
commit
06e1bc987a

+ 4 - 1
install/requirements.txt View File

@@ -45,7 +45,10 @@ tw2.forms==2.2.2.1
45 45
 waitress==0.8.9
46 46
 zope.interface==4.1.3
47 47
 zope.sqlalchemy==0.7.6
48
-tgapp-resetpassword==0.1.3
48
+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
53
+Radicale==1.1.1
54
+icalendar==3.10

+ 9 - 0
tracim/development.ini.base View File

@@ -183,6 +183,15 @@ email.notification.smtp.port = 25
183 183
 email.notification.smtp.user = your_smtp_user
184 184
 email.notification.smtp.password = your_smtp_password
185 185
 
186
+## Radical (CalDav server) configuration
187
+# radicale.server.host = 0.0.0.0
188
+# radicale.server.port = 5232
189
+# radicale.server.ssl = false
190
+# radicale.server.filesystem.folder = ~/.config/radicale/collections
191
+## If '', current host will be used
192
+# radicale.client.host = ''
193
+# radicale.client.port = 5232
194
+# radicale.client.ssl = false
186 195
 
187 196
 #####
188 197
 #

+ 26 - 0
tracim/migration/versions/534c4594ed29_add_calendar_enabled_column_to_workspace.py View File

@@ -0,0 +1,26 @@
1
+"""Add calendar_enabled column to Workspace
2
+
3
+Revision ID: 534c4594ed29
4
+Revises: da12239d9da0
5
+Create Date: 2016-05-31 13:02:28.301984
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = '534c4594ed29'
11
+down_revision = 'da12239d9da0'
12
+
13
+from alembic import op
14
+import sqlalchemy as sa
15
+
16
+
17
+def upgrade():
18
+    ### commands auto generated by Alembic - please adjust! ###
19
+    op.add_column('workspaces', sa.Column('calendar_enabled', sa.Boolean(), nullable=False, server_default='0'))
20
+    ### end Alembic commands ###
21
+
22
+
23
+def downgrade():
24
+    ### commands auto generated by Alembic - please adjust! ###
25
+    op.drop_column('workspaces', 'calendar_enabled')
26
+    ### end Alembic commands ###

+ 30 - 0
tracim/scripts/tst_create_cal.py View File

@@ -0,0 +1,30 @@
1
+import caldav
2
+from radicale import ical
3
+
4
+#
5
+# Run it in gearbox command with app context (radicale must running)
6
+# pip package caldav==0.4.0 must be installed
7
+# run following
8
+
9
+
10
+client = caldav.DAVClient('http://127.0.0.1:5232',
11
+                          username='admin@admin.admin',
12
+                          password='admin@admin.admin')
13
+
14
+calendar = caldav.Calendar(
15
+    parent=client,
16
+    client=client,
17
+    id='/user/1.ics/',
18
+    # url='http://127.0.0.1:5232/user/1.ics/'
19
+)
20
+
21
+calendar.save()
22
+
23
+# FOR EACH EVENT IN THIS CALENDAR:
24
+
25
+coll = ical.Collection.from_path('/user/1.ics/')[0]
26
+with coll.filesystem_only():
27
+    coll.append(name='THE EVENT NAME (ID)', text='THE ICS EVENT RAW')
28
+
29
+
30
+pass

+ 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(

+ 1 - 1
tracim/test.ini View File

@@ -11,7 +11,7 @@ smtp_server = localhost
11 11
 error_email_from = turbogears@localhost
12 12
 
13 13
 [server:main]
14
-use = egg:gearbox#cherrypy
14
+use = egg:gearbox#wsgiref
15 15
 host = 127.0.0.1
16 16
 port = 8080
17 17
 

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

@@ -15,6 +15,7 @@ convert them into boolean, for example, you should use the
15 15
 
16 16
 import tg
17 17
 from paste.deploy.converters import asbool
18
+from tg.configuration.milestones import environment_loaded
18 19
 
19 20
 from tgext.pluggable import plug
20 21
 from tgext.pluggable import replace_template
@@ -27,6 +28,8 @@ from tracim.config import TracimAppConfig
27 28
 from tracim.lib import app_globals, helpers
28 29
 from tracim.lib.auth.wrapper import AuthConfigWrapper
29 30
 from tracim.lib.base import logger
31
+from tracim.lib.daemons import DaemonsManager
32
+from tracim.lib.daemons import RadicaleDaemon
30 33
 from tracim.model.data import ActionDescription
31 34
 from tracim.model.data import ContentType
32 35
 
@@ -69,6 +72,7 @@ base_config['flash.template'] = '''
69 72
 # flash.js_call -> javascript code which will be run when displaying the flash from javascript. Default is webflash.render(), you can use webflash.payload() to retrieve the message and show it with your favourite library.
70 73
 # flash.js_template -> string.Template instance used to replace full javascript support for flash messages. When rendering flash message for javascript usage the following code will be used instead of providing the standard webflash object. If you replace js_template you must also ensure cookie parsing and delete it for already displayed messages. The template will receive: $container_id, $cookie_name, $js_call variables.
71 74
 
75
+base_config['templating.genshi.name_constant_patch'] = True
72 76
 
73 77
 # Configure the authentication backend
74 78
 
@@ -82,6 +86,17 @@ plug(base_config, 'resetpassword', 'reset_password')
82 86
 replace_template(base_config, 'resetpassword.templates.index', 'tracim.templates.reset_password_index')
83 87
 replace_template(base_config, 'resetpassword.templates.change_password', 'mako:tracim.templates.reset_password_change_password')
84 88
 
89
+daemons = DaemonsManager()
90
+
91
+
92
+def start_daemons(manager: DaemonsManager):
93
+    """
94
+    Sart Tracim daemons
95
+    """
96
+    manager.run('radicale', RadicaleDaemon)
97
+
98
+environment_loaded.register(lambda: start_daemons(daemons))
99
+
85 100
 # Note: here are fake translatable strings that allow to translate messages for reset password email content
86 101
 duplicated_email_subject = l_('Password reset request')
87 102
 duplicated_email_body = l_('''
@@ -201,6 +216,20 @@ class CFG(object):
201 216
             # ContentType.Folder -- Folder is skipped
202 217
         ]
203 218
 
219
+        self.RADICALE_SERVER_HOST = tg.config.get('radicale.server.host', '0.0.0.0')
220
+        self.RADICALE_SERVER_PORT = tg.config.get('radicale.server.port', 5232)
221
+        # Note: Other parameters needed to work in SSL (cert file, etc)
222
+        self.RADICALE_SERVER_SSL = asbool(tg.config.get('radicale.server.ssl', False))
223
+        self.RADICALE_SERVER_FILE_SYSTEM_FOLDER = tg.config.get(
224
+            'radicale.server.filesystem.folder',
225
+            '~/.config/radicale/collections'
226
+        )
227
+
228
+        # If None, current host will be used
229
+        self.RADICALE_CLIENT_HOST = tg.config.get('radicale.client.host', None)
230
+        self.RADICALE_CLIENT_PORT = tg.config.get('radicale.client.port', 5232)
231
+        self.RADICALE_CLIENT_SSL = asbool(tg.config.get('radicale.client.ssl', False))
232
+
204 233
 
205 234
     def get_tracker_js_content(self, js_tracker_file_path = None):
206 235
         js_tracker_file_path = tg.config.get('js_tracker_path', None)

+ 3 - 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,5 @@ 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
+
39 40
     return app

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

@@ -14,6 +14,7 @@ from tracim.lib.user import UserApi
14 14
 from tracim.lib.userworkspace import RoleApi
15 15
 from tracim.lib.content import ContentApi
16 16
 from tracim.lib.workspace import WorkspaceApi
17
+from tracim.model import DBSession
17 18
 
18 19
 from tracim.model.auth import Group
19 20
 from tracim.model.data import NodeTreeItem
@@ -190,12 +191,14 @@ class WorkspaceRestController(TIMRestController, BaseController):
190 191
         return dict(result = dictified_workspace, fake_api = fake_api)
191 192
 
192 193
     @tg.expose()
193
-    def post(self, name, description):
194
+    def post(self, name, description, calendar_enabled=False):
194 195
         # FIXME - Check user profile
195 196
         user = tmpl_context.current_user
196 197
         workspace_api_controller = WorkspaceApi(user)
197 198
 
198 199
         workspace = workspace_api_controller.create_workspace(name, description)
200
+        workspace.calendar_enabled = calendar_enabled
201
+        DBSession.flush()
199 202
 
200 203
         tg.flash(_('{} workspace created.').format(workspace.label), CST.STATUS_OK)
201 204
         tg.redirect(self.url())
@@ -212,13 +215,14 @@ class WorkspaceRestController(TIMRestController, BaseController):
212 215
         return DictLikeClass(result = dictified_workspace)
213 216
 
214 217
     @tg.expose('tracim.templates.workspace.edit')
215
-    def put(self, id, name, description):
218
+    def put(self, id, name, description, calendar_enabled):
216 219
         user = tmpl_context.current_user
217 220
         workspace_api_controller = WorkspaceApi(user)
218 221
 
219 222
         workspace = workspace_api_controller.get_one(id)
220 223
         workspace.label = name
221 224
         workspace.description = description
225
+        workspace.calendar_enabled = calendar_enabled
222 226
         workspace_api_controller.save(workspace)
223 227
 
224 228
         tg.flash(_('{} workspace updated.').format(workspace.label), CST.STATUS_OK)

+ 5 - 0
tracim/tracim/controllers/root.py View File

@@ -24,6 +24,7 @@ from tracim.controllers.error import ErrorController
24 24
 from tracim.controllers.help import HelpController
25 25
 from tracim.controllers.user import UserRestController
26 26
 from tracim.controllers.workspace import UserWorkspaceRestController
27
+from tracim.lib.utils import replace_reset_password_templates
27 28
 
28 29
 from tracim.model.data import ContentType
29 30
 from tracim.model.serializers import DictLikeClass
@@ -55,6 +56,10 @@ class RootController(StandardController):
55 56
     workspaces = UserWorkspaceRestController()
56 57
     user = UserRestController()
57 58
 
59
+    def _render_response(self, tgl, controller, response):
60
+        replace_reset_password_templates(controller.decoration.engines)
61
+        return super()._render_response(tgl, controller, response)
62
+
58 63
     def _before(self, *args, **kw):
59 64
         super(RootController, self)._before(args, kw)
60 65
         tmpl_context.project_name = "tracim"

+ 3 - 1
tracim/tracim/controllers/workspace.py View File

@@ -58,7 +58,9 @@ class UserWorkspaceRestController(TIMRestController):
58 58
         # ,
59 59
         #                      sub_items=Context(CTX.FOLDER_CONTENT_LIST).toDict(dictified_folders))
60 60
 
61
-        fake_api.sub_items = Context(CTX.FOLDER_CONTENT_LIST).toDict(workspace.get_valid_children())
61
+        fake_api.sub_items = Context(CTX.FOLDER_CONTENT_LIST).toDict(
62
+            workspace.get_valid_children(ContentApi.DISPLAYABLE_CONTENTS)
63
+        )
62 64
 
63 65
         dictified_workspace = Context(CTX.WORKSPACE).toDict(workspace, 'workspace')
64 66
 

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

@@ -0,0 +1,235 @@
1
+import re
2
+import transaction
3
+
4
+from icalendar import Event as iCalendarEvent
5
+
6
+from tracim.lib.content import ContentApi
7
+from tracim.lib.exceptions import UnknownCalendarType
8
+from tracim.lib.exceptions import NotFound
9
+from tracim.lib.user import UserApi
10
+from tracim.lib.workspace import UnsafeWorkspaceApi
11
+from tracim.model import User
12
+from tracim.model import DBSession
13
+from tracim.model import new_revision
14
+from tracim.model.data import ActionDescription
15
+from tracim.model.data import Content
16
+from tracim.model.data import ContentType
17
+from tracim.model.organisational import Calendar
18
+from tracim.model.organisational import UserCalendar
19
+from tracim.model.organisational import WorkspaceCalendar
20
+
21
+CALENDAR_USER_PATH_RE = 'user\/([0-9]+).ics'
22
+CALENDAR_WORKSPACE_PATH_RE = 'workspace\/([0-9]+).ics'
23
+
24
+CALENDAR_TYPE_USER = UserCalendar
25
+CALENDAR_TYPE_WORKSPACE = WorkspaceCalendar
26
+
27
+CALENDAR_USER_URL_TEMPLATE = \
28
+    '{proto}://{domain}:{port}/user/{id}.ics#{slug}'
29
+CALENDAR_WORKSPACE_URL_TEMPLATE = \
30
+    '{proto}://{domain}:{port}/workspace/{id}.ics#{slug}'
31
+
32
+
33
+class CalendarManager(object):
34
+    def __init__(self, user: User):
35
+        self._user = user
36
+
37
+    def get_type_for_path(self, path: str) -> str:
38
+        """
39
+        Return calendar type for given path. Raise
40
+        tracim.lib.exceptions.UnknownCalendarType if unknown type.
41
+        :param path: path representation like user/42--foo.ics
42
+        :return: Type of calendar, can be one of CALENDAR_TYPE_USER,
43
+        CALENDAR_TYPE_WORKSPACE
44
+        """
45
+        if re.match(CALENDAR_USER_PATH_RE, path):
46
+            return CALENDAR_TYPE_USER
47
+
48
+        if re.match(CALENDAR_WORKSPACE_PATH_RE, path):
49
+            return CALENDAR_TYPE_WORKSPACE
50
+
51
+        raise UnknownCalendarType(
52
+            'No match for calendar path "{0}"'.format(path)
53
+        )
54
+
55
+    def get_id_for_path(self, path: str, type: str) -> int:
56
+        """
57
+        Return related calendar id for given path. Raise
58
+        tracim.lib.exceptions.UnknownCalendarType if unknown type.
59
+        :param path: path representation like user/42--foo.ics
60
+        :param type: Type of calendar, can be one of CALENDAR_TYPE_USER,
61
+        CALENDAR_TYPE_WORKSPACE
62
+        :return: ID of related calendar object. For UserCalendar it will be
63
+        user id, for WorkspaceCalendar it will be Workspace id.
64
+        """
65
+        if type == CALENDAR_TYPE_USER:
66
+            return re.search(CALENDAR_USER_PATH_RE, path).group(1)
67
+        elif type == CALENDAR_TYPE_WORKSPACE:
68
+            return re.search(CALENDAR_WORKSPACE_PATH_RE, path).group(1)
69
+        raise UnknownCalendarType('Type "{0}" is not implemented'.format(type))
70
+
71
+    def find_calendar_with_path(self, path: str) -> Calendar:
72
+        """
73
+        Return calendar for given path. Raise tracim.lib.exceptions.NotFound if
74
+        calendar cannot be found.
75
+        :param path: path representation like user/42--foo.ics
76
+        :return: Calendar corresponding to path
77
+        """
78
+        try:
79
+            type = self.get_type_for_path(path)
80
+            id = self.get_id_for_path(path, type)
81
+        except UnknownCalendarType as exc:
82
+            raise NotFound(str(exc))
83
+
84
+        return self.get_calendar(type, id, path)
85
+
86
+    def get_calendar(self, type: str, id: str, path: str) -> Calendar:
87
+        """
88
+        Return tracim.model.organisational.Calendar instance for given
89
+        parameters.
90
+        :param type: Type of calendar, can be one of CALENDAR_TYPE_USER,
91
+        CALENDAR_TYPE_WORKSPACE
92
+        :param id: related calendar object id
93
+        :param path: path representation like user/42--foo.ics
94
+        :return: a calendar.
95
+        """
96
+        if type == CALENDAR_TYPE_USER:
97
+            user = UserApi(self._user).get_one_by_id(id)
98
+            return UserCalendar(user, path=path)
99
+
100
+        if type == CALENDAR_TYPE_WORKSPACE:
101
+            workspace = UnsafeWorkspaceApi(self._user).get_one(id)
102
+            return WorkspaceCalendar(workspace, path=path)
103
+
104
+        raise UnknownCalendarType('Type "{0}" is not implemented'.format(type))
105
+
106
+    def add_event(
107
+            self,
108
+            calendar: Calendar,
109
+            event: iCalendarEvent,
110
+            event_name: str,
111
+            owner: User,
112
+    ) -> Content:
113
+        """
114
+        Create Content event type.
115
+        :param calendar: Event calendar owner
116
+        :param event: ICS event
117
+        :param event_name: Event name (ID) like
118
+        20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
119
+        :param owner: Event Owner
120
+        :return: Created Content
121
+        """
122
+        workspace = None
123
+        if isinstance(calendar, WorkspaceCalendar):
124
+            workspace = calendar.related_object
125
+        elif isinstance(calendar, UserCalendar):
126
+            pass
127
+        else:
128
+            raise UnknownCalendarType('Type "{0}" is not implemented'
129
+                                      .format(type(calendar)))
130
+
131
+        content = ContentApi(owner).create(
132
+            content_type=ContentType.Event,
133
+            workspace=workspace,
134
+            do_save=False
135
+        )
136
+        self.populate_content_with_event(
137
+            content,
138
+            event,
139
+            event_name
140
+        )
141
+        content.revision_type = ActionDescription.CREATION
142
+        DBSession.add(content)
143
+        DBSession.flush()
144
+        transaction.commit()
145
+
146
+        return content
147
+
148
+    def update_event(
149
+            self,
150
+            calendar: Calendar,
151
+            event: iCalendarEvent,
152
+            event_name: str,
153
+            current_user: User,
154
+    ) -> Content:
155
+        """
156
+        Update Content Event
157
+        :param calendar: Event calendar owner
158
+        :param event: ICS event
159
+        :param event_name: Event name (ID) like
160
+        20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
161
+        :param current_user: Current modification asking user
162
+        :return: Updated Content
163
+        """
164
+        workspace = None
165
+        if isinstance(calendar, WorkspaceCalendar):
166
+            workspace = calendar.related_object
167
+        elif isinstance(calendar, UserCalendar):
168
+            pass
169
+        else:
170
+            raise UnknownCalendarType('Type "{0}" is not implemented'
171
+                                      .format(type(calendar)))
172
+
173
+        content_api = ContentApi(
174
+            current_user,
175
+            force_show_all_types=True,
176
+            disable_user_workspaces_filter=True
177
+        )
178
+        content = content_api.find_one_by_unique_property(
179
+            property_name='name',
180
+            property_value=event_name,
181
+            workspace=workspace
182
+        )
183
+
184
+        with new_revision(content):
185
+            self.populate_content_with_event(
186
+                content,
187
+                event,
188
+                event_name
189
+            )
190
+            content.revision_type = ActionDescription.EDITION
191
+
192
+        DBSession.flush()
193
+        transaction.commit()
194
+
195
+        return content
196
+
197
+    def delete_event_with_name(self, event_name: str, current_user: User)\
198
+            -> Content:
199
+        """
200
+        Delete Content Event
201
+        :param event_name: Event name (ID) like
202
+        20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
203
+        :param current_user: Current deletion asking user
204
+        :return: Deleted Content
205
+        """
206
+        content_api = ContentApi(current_user, force_show_all_types=True)
207
+        content = content_api.find_one_by_unique_property(
208
+            property_name='name',
209
+            property_value=event_name,
210
+            workspace=None
211
+        )
212
+
213
+        with new_revision(content):
214
+            content_api.delete(content)
215
+
216
+        DBSession.flush()
217
+        transaction.commit()
218
+
219
+        return content
220
+
221
+    def populate_content_with_event(
222
+            self,
223
+            content: Content,
224
+            event: iCalendarEvent,
225
+            event_name: str,
226
+    ) -> Content:
227
+        content.label = event.get('summary')
228
+        content.description = event.get('description')
229
+        content.properties = {
230
+            'name': event_name,
231
+            'location': event.get('location'),
232
+            'raw': event.to_ical().decode("utf-8"),
233
+            'start': event.get('dtend').dt.strftime('%Y-%m-%d %H:%M:%S%z'),
234
+            'end': event.get('dtstart').dt.strftime('%Y-%m-%d %H:%M:%S%z'),
235
+        }

+ 56 - 3
tracim/tracim/lib/content.py View File

@@ -67,13 +67,30 @@ class ContentApi(object):
67 67
     SEARCH_SEPARATORS = ',| '
68 68
     SEARCH_DEFAULT_RESULT_NB = 50
69 69
 
70
-
71
-    def __init__(self, current_user: User, show_archived=False, show_deleted=False, all_content_in_treeview=True):
70
+    DISPLAYABLE_CONTENTS = (
71
+        ContentType.Folder,
72
+        ContentType.File,
73
+        ContentType.Comment,
74
+        ContentType.Thread,
75
+        ContentType.Page,
76
+    )
77
+
78
+    def __init__(
79
+            self,
80
+            current_user: User,
81
+            show_archived=False,
82
+            show_deleted=False,
83
+            all_content_in_treeview=True,
84
+            force_show_all_types=False,
85
+            disable_user_workspaces_filter=False,
86
+    ):
72 87
         self._user = current_user
73 88
         self._user_id = current_user.user_id if current_user else None
74 89
         self._show_archived = show_archived
75 90
         self._show_deleted = show_deleted
76 91
         self._show_all_type_of_contents_in_treeview = all_content_in_treeview
92
+        self._force_show_all_types = force_show_all_types
93
+        self._disable_user_workspaces_filter = disable_user_workspaces_filter
77 94
 
78 95
     @classmethod
79 96
     def get_revision_join(cls):
@@ -158,10 +175,14 @@ class ContentApi(object):
158 175
     def __real_base_query(self, workspace: Workspace=None):
159 176
         result = self.get_canonical_query()
160 177
 
178
+        # Exclude non displayable types
179
+        if not self._force_show_all_types:
180
+            result = result.filter(Content.type.in_(self.DISPLAYABLE_CONTENTS))
181
+
161 182
         if workspace:
162 183
             result = result.filter(Content.workspace_id==workspace.workspace_id)
163 184
 
164
-        if self._user:
185
+        if self._user and not self._disable_user_workspaces_filter:
165 186
             user = DBSession.query(User).get(self._user_id)
166 187
             # Filter according to user workspaces
167 188
             workspace_ids = [r.workspace_id for r in user.roles \
@@ -184,6 +205,10 @@ class ContentApi(object):
184 205
     def __revisions_real_base_query(self, workspace: Workspace=None):
185 206
         result = DBSession.query(ContentRevisionRO)
186 207
 
208
+        # Exclude non displayable types
209
+        if not self._force_show_all_types:
210
+            result = result.filter(Content.type.in_(self.DISPLAYABLE_CONTENTS))
211
+
187 212
         if workspace:
188 213
             result = result.filter(ContentRevisionRO.workspace_id==workspace.workspace_id)
189 214
 
@@ -698,3 +723,31 @@ class ContentApi(object):
698 723
             if content.parent.parent:
699 724
                 return self.content_under_archived(content.parent)
700 725
         return False
726
+
727
+    def find_one_by_unique_property(
728
+            self,
729
+            property_name: str,
730
+            property_value: str,
731
+            workspace: Workspace=None,
732
+    ) -> Content:
733
+        """
734
+        Return Content who contains given property.
735
+        Raise sqlalchemy.orm.exc.MultipleResultsFound if more than one Content
736
+        contains this property value.
737
+        :param property_name: Name of property
738
+        :param property_value: Value of property
739
+        :param workspace: Workspace who contains Content
740
+        :return: Found Content
741
+        """
742
+        # TODO - 20160602 - Bastien: Should be JSON type query
743
+        # see https://www.compose.io/articles/using-json-extensions-in-\
744
+        # postgresql-from-python-2/
745
+        query = self._base_query(workspace=workspace).filter(
746
+            Content._properties.like(
747
+                '%"{property_name}": "{property_value}"%'.format(
748
+                    property_name=property_name,
749
+                    property_value=property_value,
750
+                )
751
+            )
752
+        )
753
+        return query.one()

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

@@ -0,0 +1,134 @@
1
+import threading
2
+from wsgiref.simple_server import make_server
3
+
4
+import signal
5
+
6
+from radicale import Application as RadicaleApplication
7
+from radicale import HTTPServer as RadicaleHTTPServer
8
+from radicale import HTTPSServer as RadicaleHTTPSServer
9
+from radicale import RequestHandler as RadicaleRequestHandler
10
+from radicale import config as radicale_config
11
+from tg import TGApp
12
+
13
+from tracim.lib.base import logger
14
+from tracim.lib.exceptions import AlreadyRunningDaemon
15
+from tracim.lib.utils import add_signal_handler
16
+
17
+
18
+class DaemonsManager(object):
19
+    def __init__(self):
20
+        self._running_daemons = {}
21
+        add_signal_handler(signal.SIGTERM, self.stop_all)
22
+        add_signal_handler(signal.SIGINT, self.stop_all)
23
+
24
+    def run(self, name: str, daemon_class: object, **kwargs) -> None:
25
+        """
26
+        Start a daemon with given daemon class.
27
+        :param name: Name of runned daemon. It's not possible to start two
28
+        daemon with same name. In the opposite case, raise
29
+        tracim.lib.exceptions.AlreadyRunningDaemon
30
+        :param daemon_class: Daemon class to use for daemon instance.
31
+        :param kwargs: Other kwargs will be given to daemon class
32
+        instantiation.
33
+        """
34
+        if name in self._running_daemons:
35
+            raise AlreadyRunningDaemon(
36
+                'Daemon with name "{0}" already running'.format(name)
37
+            )
38
+
39
+        logger.info(self, 'Starting daemon with name "{0}" and class "{1}" ...'
40
+                          .format(name, daemon_class))
41
+        daemon = daemon_class(name=name, kwargs=kwargs, daemon=True)
42
+        daemon.start()
43
+        self._running_daemons[name] = daemon
44
+
45
+    def stop(self, name: str) -> None:
46
+        """
47
+        Stop daemon with his name and wait for him.
48
+        Where name is given name when daemon started
49
+        with run method.
50
+        :param name:
51
+        """
52
+        if name in self._running_daemons:
53
+            logger.info(self, 'Stopping daemon with name "{0}" ...'
54
+                              .format(name))
55
+            self._running_daemons[name].stop()
56
+            self._running_daemons[name].join()
57
+            del self._running_daemons[name]
58
+            logger.info(self, 'Stopping daemon with name "{0}": OK'
59
+                              .format(name))
60
+
61
+    def stop_all(self, *args, **kwargs) -> None:
62
+        """
63
+        Stop all started daemons and w<ait for them.
64
+        """
65
+        logger.info(self, 'Stopping all daemons')
66
+        for name, daemon in self._running_daemons.items():
67
+            logger.info(self, 'Stopping daemon "{0}" ...'.format(name))
68
+            daemon.stop()
69
+
70
+        for name, daemon in self._running_daemons.items():
71
+            daemon.join()
72
+            logger.info(self, 'Stopping daemon "{0}" OK'.format(name))
73
+
74
+        self._running_daemons = {}
75
+
76
+
77
+class Daemon(threading.Thread):
78
+    """
79
+    Thread who contains daemon. You must implement start and stop methods to
80
+    manage daemon life correctly.
81
+    """
82
+    def run(self):
83
+        raise NotImplementedError()
84
+
85
+    def stop(self):
86
+        raise NotImplementedError()
87
+
88
+
89
+class RadicaleDaemon(Daemon):
90
+    def __init__(self, *args, **kwargs):
91
+        super().__init__(*args, **kwargs)
92
+        self._prepare_config()
93
+        self._server = None
94
+
95
+    def run(self):
96
+        """
97
+        To see origin radical server start method, refer to
98
+        radicale.__main__.run
99
+        """
100
+        self._server = self._get_server()
101
+        self._server.serve_forever()
102
+
103
+    def stop(self):
104
+        self._server.shutdown()
105
+
106
+    def _prepare_config(self):
107
+        from tracim.config.app_cfg import CFG
108
+        cfg = CFG.get_instance()
109
+
110
+        tracim_auth = 'tracim.lib.radicale.auth'
111
+        tracim_rights = 'tracim.lib.radicale.rights'
112
+        tracim_storage = 'tracim.lib.radicale.storage'
113
+        fs_path = cfg.RADICALE_SERVER_FILE_SYSTEM_FOLDER
114
+
115
+        radicale_config.set('auth', 'type', 'custom')
116
+        radicale_config.set('auth', 'custom_handler', tracim_auth)
117
+
118
+        radicale_config.set('rights', 'type', 'custom')
119
+        radicale_config.set('rights', 'custom_handler', tracim_rights)
120
+
121
+        radicale_config.set('storage', 'type', 'custom')
122
+        radicale_config.set('storage', 'custom_handler', tracim_storage)
123
+        radicale_config.set('storage', 'filesystem_folder', fs_path)
124
+
125
+    def _get_server(self):
126
+        from tracim.config.app_cfg import CFG
127
+        cfg = CFG.get_instance()
128
+        return make_server(
129
+            cfg.RADICALE_SERVER_HOST,
130
+            cfg.RADICALE_SERVER_PORT,
131
+            RadicaleApplication(),
132
+            RadicaleHTTPSServer if cfg.RADICALE_SERVER_SSL else RadicaleHTTPServer,
133
+            RadicaleRequestHandler
134
+        )

+ 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)

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

@@ -0,0 +1,36 @@
1
+from tg import config
2
+
3
+from tracim.lib.user import UserApi
4
+
5
+
6
+class Auth(object):
7
+    """
8
+    This Auth class is designed to solve following problematic:
9
+    In tracim.lib.radicale.storage.Collection append, replace and delete
10
+    methods we don't know wich user is processing. So this Auth singleton
11
+    store last authenticated user.
12
+    """
13
+    current_user = None
14
+
15
+    @classmethod
16
+    def is_authenticated(cls, user, password) -> bool:
17
+        """
18
+        :param user: user email
19
+        :param password: user password
20
+        :return: True if auth success, False if not
21
+        """
22
+        email = config.get('sa_auth').authmetadata.authenticate({}, {
23
+            'login': user,
24
+            'password': password
25
+        })
26
+        if email:
27
+            cls.current_user = UserApi(None).get_one_by_email(email)
28
+
29
+        return bool(email)
30
+
31
+
32
+def is_authenticated(user: str, password: str) -> bool:
33
+    """
34
+    see tracim.lib.radicale.auth.Auth#is_authenticated
35
+    """
36
+    return Auth.is_authenticated(user, password)

+ 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.organisational 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)

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

@@ -0,0 +1,123 @@
1
+from contextlib import contextmanager
2
+
3
+from radicale.storage.filesystem import Collection as BaseCollection
4
+from icalendar import Event as iCalendarEvent
5
+from radicale.ical import Event as RadicaleEvent
6
+
7
+from tracim.lib.calendar import CalendarManager
8
+from tracim.lib.radicale.auth import Auth
9
+from tracim.model import Content
10
+
11
+
12
+class Collection(BaseCollection):
13
+    """
14
+    Radicale use it's own storage system but this override create, update
15
+    and delete events in our database.
16
+    """
17
+
18
+    def __init__(self, path: str, principal: bool=False):
19
+        super().__init__(path, principal)
20
+        self._replacing = False  # See ``replacing`` context manager
21
+        self._manager = CalendarManager(None)
22
+
23
+    @contextmanager
24
+    def replacing(self):
25
+        """
26
+        Radicale filesystem storage make a ``remove`` then an ``append`` to
27
+        update an item. So to know what we are in update context when
28
+        ``append`` and ``remove`` functions are called, use this context
29
+        manager in ``replace`` function.
30
+        """
31
+        try:
32
+            self._replacing = True
33
+            yield self
34
+        finally:
35
+            self._replacing = False
36
+
37
+    def replace(self, name: str, text: str) -> None:
38
+        """
39
+        Override of parent replace to manage event update action. See
40
+        ``replacing`` context manager for more informations.
41
+        :param name: Event name (ID) like
42
+        20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
43
+        :param text: ICS Event content
44
+        """
45
+        with self.replacing():
46
+            super().replace(name, text)
47
+        self._update_tracim_event(name, text)
48
+
49
+    def remove(self, name: str) -> None:
50
+        """
51
+        :param name: Event name (ID) like
52
+        20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
53
+        """
54
+        super().remove(name)
55
+        if not self._replacing:
56
+            self._remove_tracim_event(name)
57
+
58
+    def append(self, name: str, text: str) -> None:
59
+        """
60
+        :param name: Event name (ID) like
61
+        20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
62
+        :param text: ICS Event content
63
+        """
64
+        super().append(name, text)
65
+        if not self._replacing:
66
+            self._add_tracim_event(name, text)
67
+
68
+    def _add_tracim_event(self, name: str, text: str) -> Content:
69
+        """
70
+        Create tracim internal Event (Content) with Radicale given data.
71
+        :param name: Event name (ID) like
72
+        20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
73
+        :param text: ICS Event content
74
+        :return: Created Content
75
+        """
76
+        event = self._extract_event(name, text)
77
+        calendar = self._manager.find_calendar_with_path(self.path)
78
+        return self._manager.add_event(
79
+            calendar=calendar,
80
+            event=event,
81
+            event_name=name,
82
+            owner=Auth.current_user
83
+        )
84
+
85
+    def _update_tracim_event(self, name: str, text: str) -> Content:
86
+        """
87
+        Update tracim internal Event (Content) with Radicale given data.
88
+        :param name: Event name (ID) like
89
+        20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
90
+        :param text: ICS Event content
91
+        :return: Updated Content
92
+        """
93
+        event = self._extract_event(name, text)
94
+        calendar = self._manager.find_calendar_with_path(self.path)
95
+        return self._manager.update_event(
96
+            calendar=calendar,
97
+            event=event,
98
+            event_name=name,
99
+            current_user=Auth.current_user
100
+        )
101
+
102
+    def _remove_tracim_event(self, name: str) -> Content:
103
+        """
104
+        Delete internal tracim Event (Content) with given Event name.
105
+        :param name: Event name (ID) like
106
+        20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
107
+        :return: Deleted Content
108
+        """
109
+        return self._manager.delete_event_with_name(name, Auth.current_user)
110
+
111
+    def _extract_event(self, name: str, text: str) -> iCalendarEvent:
112
+        """
113
+        Return a icalendar.cal.Event construct with given Radicale ICS data.
114
+        :param name: Event name (ID) like
115
+        20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
116
+        :param text: ICS Event content
117
+        :return: ICS Event representation
118
+        """
119
+        radicale_items = self._parse(text, (RadicaleEvent,), name)
120
+        for item_name in radicale_items:
121
+            item = radicale_items[item_name]
122
+            if isinstance(item, RadicaleEvent):
123
+                return iCalendarEvent.from_ical(item.text)

+ 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

+ 52 - 1
tracim/tracim/lib/utils.py View File

@@ -1,6 +1,7 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 
3 3
 import time
4
+import signal
4 5
 
5 6
 from tracim.lib.base import logger
6 7
 
@@ -17,4 +18,54 @@ def exec_time_monitor():
17 18
 
18 19
 
19 20
 class SameValueError(ValueError):
20
-    pass
21
+    pass
22
+
23
+
24
+def replace_reset_password_templates(engines):
25
+    try:
26
+        if engines['text/html'][1] == 'resetpassword.templates.index':
27
+            engines['text/html'] = (
28
+                'mako',
29
+                'tracim.templates.reset_password_index',
30
+                engines['text/html'][2],
31
+                engines['text/html'][3]
32
+            )
33
+
34
+        if engines['text/html'][1] == 'resetpassword.templates.change_password':
35
+            engines['text/html'] = (
36
+                'mako',
37
+                'tracim.templates.reset_password_change_password',
38
+                engines['text/html'][2],
39
+                engines['text/html'][3]
40
+            )
41
+    except IndexError:
42
+        pass
43
+    except KeyError:
44
+        pass
45
+
46
+
47
+@property
48
+def NotImplemented():
49
+    raise NotImplementedError()
50
+
51
+
52
+def add_signal_handler(signal_id, handler, execute_before=True) -> None:
53
+    """
54
+    Add a callback attached to python signal.
55
+    :param signal_id: signal identifier (eg. signal.SIGTERM)
56
+    :param handler: callback to execute when signal trig
57
+    :param execute_before: If True, callback is executed before eventual
58
+    existing callback on given dignal id.
59
+    """
60
+    previous_handler = signal.getsignal(signal_id)
61
+
62
+    def call_callback(*args, **kwargs):
63
+        if not execute_before and callable(previous_handler):
64
+            previous_handler(*args, **kwargs)
65
+
66
+        handler(*args, **kwargs)
67
+
68
+        if execute_before and callable(previous_handler):
69
+            previous_handler(*args, **kwargs)
70
+
71
+    signal.signal(signal_id, call_callback)

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

@@ -121,3 +121,8 @@ class WorkspaceApi(object):
121 121
             DBSession.flush()
122 122
 
123 123
         return workspace
124
+
125
+
126
+class UnsafeWorkspaceApi(WorkspaceApi):
127
+    def _base_query(self):
128
+        return DBSession.query(Workspace).filter(Workspace.is_deleted==False)

+ 27 - 2
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,7 +27,7 @@ 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
29
-
30
+from tg import request
30 31
 from tracim.model import DeclarativeBase, metadata, DBSession
31 32
 
32 33
 # This is the association table for the many-to-many relationship between
@@ -147,6 +148,22 @@ class User(DeclarativeBase):
147 148
             profile_id = max(group.group_id for group in self.groups)
148 149
         return Profile(profile_id)
149 150
 
151
+    @property
152
+    def calendar_url(self) -> str:
153
+        # TODO - 20160531 - Bastien: Cyclic import if import in top of file
154
+        from tracim.config.app_cfg import CFG
155
+        from tracim.lib.calendar import CALENDAR_USER_URL_TEMPLATE
156
+        cfg = CFG.get_instance()
157
+        return CALENDAR_USER_URL_TEMPLATE.format(
158
+            proto='https' if cfg.RADICALE_CLIENT_SSL else 'http',
159
+            domain=cfg.RADICALE_CLIENT_HOST or request.domain,
160
+            port=cfg.RADICALE_CLIENT_PORT,
161
+            id=self.user_id,
162
+            slug=slugify(self.get_display_name(
163
+                remove_email_part=True
164
+            ), only_ascii=True)
165
+        )
166
+
150 167
     @classmethod
151 168
     def by_email_address(cls, email):
152 169
         """Return the user object whose email address is ``email``."""
@@ -206,10 +223,18 @@ class User(DeclarativeBase):
206 223
         hash.update((password + self.password[:64]).encode('utf-8'))
207 224
         return self.password[64:] == hash.hexdigest()
208 225
 
209
-    def get_display_name(self):
226
+    def get_display_name(self, remove_email_part=False):
227
+        """
228
+        :param remove_email_part: If True and display name based on email,
229
+         remove @xxx.xxx part of email in returned value
230
+        :return: display name based on user name or email.
231
+        """
210 232
         if self.display_name!=None and self.display_name!='':
211 233
             return self.display_name
212 234
         else:
235
+            if remove_email_part:
236
+                at_pos = self.email.index('@')
237
+                return self.email[0:at_pos]
213 238
             return self.email
214 239
 
215 240
     def get_role(self, workspace: 'Workspace') -> int:

+ 38 - 4
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,20 @@ 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
+        from tracim.lib.calendar import CALENDAR_WORKSPACE_URL_TEMPLATE
75
+        cfg = CFG.get_instance()
76
+        return CALENDAR_WORKSPACE_URL_TEMPLATE.format(
77
+            proto='https' if cfg.RADICALE_CLIENT_SSL else 'http',
78
+            domain=cfg.RADICALE_CLIENT_HOST or tg.request.domain,
79
+            port=cfg.RADICALE_CLIENT_PORT,
80
+            id=self.workspace_id,
81
+            slug=slugify(self.label)
82
+        )
83
+
69 84
     def get_user_role(self, user: User) -> int:
70 85
         for role in user.roles:
71 86
             if role.workspace.workspace_id==self.workspace_id:
@@ -306,6 +321,7 @@ class ContentType(object):
306 321
     Comment = 'comment'
307 322
     Thread = 'thread'
308 323
     Page = 'page'
324
+    Event = 'event'
309 325
 
310 326
     # Fake types, used for breadcrumb only
311 327
     FAKE_Dashboard = 'dashboard'
@@ -321,6 +337,7 @@ class ContentType(object):
321 337
         'page': 'fa fa-file-text-o',
322 338
         'thread': 'fa fa-comments-o',
323 339
         'comment': 'fa fa-comment-o',
340
+        'event': 'fa fa-calendar-o',
324 341
     }
325 342
 
326 343
     _CSS_ICONS = {
@@ -330,7 +347,8 @@ class ContentType(object):
330 347
         'file': 'fa fa-paperclip',
331 348
         'page': 'fa fa-file-text-o',
332 349
         'thread': 'fa fa-comments-o',
333
-        'comment': 'fa fa-comment-o'
350
+        'comment': 'fa fa-comment-o',
351
+        'event': 'fa fa-calendar-o',
334 352
     }
335 353
 
336 354
     _CSS_COLORS = {
@@ -340,7 +358,8 @@ class ContentType(object):
340 358
         'file': 't-file-color',
341 359
         'page': 't-page-color',
342 360
         'thread': 't-thread-color',
343
-        'comment': 't-thread-color'
361
+        'comment': 't-thread-color',
362
+        'event': 't-event-color',
344 363
     }
345 364
 
346 365
     _ORDER_WEIGHT = {
@@ -349,6 +368,7 @@ class ContentType(object):
349 368
         'thread': 2,
350 369
         'file': 3,
351 370
         'comment': 4,
371
+        'event': 5,
352 372
     }
353 373
 
354 374
     _LABEL = {
@@ -359,6 +379,7 @@ class ContentType(object):
359 379
         'page': l_('page'),
360 380
         'thread': l_('thread'),
361 381
         'comment': l_('comment'),
382
+        'event': l_('event'),
362 383
     }
363 384
 
364 385
     _DELETE_LABEL = {
@@ -369,6 +390,7 @@ class ContentType(object):
369 390
         'page': l_('Delete this page'),
370 391
         'thread': l_('Delete this thread'),
371 392
         'comment': l_('Delete this comment'),
393
+        'event': l_('Delete this event'),
372 394
     }
373 395
 
374 396
     @classmethod
@@ -382,7 +404,8 @@ class ContentType(object):
382 404
 
383 405
     @classmethod
384 406
     def allowed_types(cls):
385
-        return [cls.Folder, cls.File, cls.Comment, cls.Thread, cls.Page]
407
+        return [cls.Folder, cls.File, cls.Comment, cls.Thread, cls.Page,
408
+                cls.Event]
386 409
 
387 410
     @classmethod
388 411
     def allowed_types_for_folding(cls):
@@ -459,7 +482,18 @@ class ContentChecker(object):
459 482
                 return False
460 483
             if 'threads' not in properties['allowed_content']:
461 484
                 return False
485
+            return True
462 486
 
487
+        if item.type == ContentType.Event:
488
+            properties = item.properties
489
+            if 'name' not in properties.keys():
490
+                return False
491
+            if 'raw' not in properties.keys():
492
+                return False
493
+            if 'start' not in properties.keys():
494
+                return False
495
+            if 'end' not in properties.keys():
496
+                return False
463 497
             return True
464 498
 
465 499
         raise NotImplementedError

+ 53 - 0
tracim/tracim/model/organisational.py View File

@@ -0,0 +1,53 @@
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
+    @property
15
+    def related_object(self):
16
+        return self._related_object
17
+
18
+    def user_can_read(self, user: User) -> bool:
19
+        raise NotImplementedError()
20
+
21
+    def user_can_write(self, user: User) -> bool:
22
+        raise NotImplementedError()
23
+
24
+
25
+class UserCalendar(Calendar):
26
+    def user_can_write(self, user: User) -> bool:
27
+        return self._related_object.user_id == user.user_id
28
+
29
+    def user_can_read(self, user: User) -> bool:
30
+        return self._related_object.user_id == user.user_id
31
+
32
+
33
+class WorkspaceCalendar(Calendar):
34
+    _workspace_rights = {
35
+        UserRoleInWorkspace.NOT_APPLICABLE:
36
+            [],
37
+        UserRoleInWorkspace.READER:
38
+            [CALENDAR_PERMISSION_READ],
39
+        UserRoleInWorkspace.CONTRIBUTOR:
40
+            [CALENDAR_PERMISSION_READ, CALENDAR_PERMISSION_WRITE],
41
+        UserRoleInWorkspace.CONTENT_MANAGER:
42
+            [CALENDAR_PERMISSION_READ, CALENDAR_PERMISSION_WRITE],
43
+        UserRoleInWorkspace.WORKSPACE_MANAGER:
44
+            [CALENDAR_PERMISSION_READ, CALENDAR_PERMISSION_WRITE],
45
+    }
46
+
47
+    def user_can_write(self, user: User) -> bool:
48
+        role = user.get_role(self._related_object)
49
+        return CALENDAR_PERMISSION_WRITE in self._workspace_rights[role]
50
+
51
+    def user_can_read(self, user: User) -> bool:
52
+        role = user.get_role(self._related_object)
53
+        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
 

+ 4 - 0
tracim/tracim/templates/admin/workspace_getall.mak View File

@@ -46,6 +46,10 @@
46 46
                                         <textarea name="description" class="form-control" id="workspaceDescription" placeholder="${_('You may add a description of the workspace')}"></textarea>
47 47
                                     </div>
48 48
                                     <div class="form-group">
49
+                                        <label for="workspaceCalendarEnabled">${_('Calendar enabled')}</label>
50
+                                        <input id="workspaceCalendarEnabled" name="calendar_enabled" class="form-control" type="checkbox" checked />
51
+                                    </div>
52
+                                    <div class="form-group">
49 53
                                         <p class="form-control-static">${_('<u>Note</u>: members will be added during next step.')|n}</p>
50 54
                                     </div>
51 55
                                         

+ 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:

+ 5 - 1
tracim/tracim/tests/library/test_serializers.py View File

@@ -232,7 +232,11 @@ class TestSerializers(TestStandard):
232 232
         eq_(True, res['children'])
233 233
 
234 234
         for curtype in ContentType.all():
235
-            if curtype not in (ContentType.Folder, ContentType.Comment):
235
+            if curtype not in (
236
+                    ContentType.Folder,
237
+                    ContentType.Comment,
238
+                    ContentType.Event
239
+            ):
236 240
                 item = Content()
237 241
                 item.type = curtype
238 242
                 item.label = 'item'