Browse Source

Radicale: Storage override to create, update an delete tracim Event (Content)

Bastien Sevajol (Algoo) 8 years ago
parent
commit
27cc9fe9d5

+ 1 - 0
install/requirements.txt View File

@@ -51,3 +51,4 @@ python-ldap-test==0.2.0
51 51
 who-ldap==3.1.0
52 52
 unicode-slugify==0.1.3
53 53
 Radicale==1.1.1
54
+icalendar==3.10

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

@@ -16,7 +16,7 @@ import sqlalchemy as sa
16 16
 
17 17
 def upgrade():
18 18
     ### commands auto generated by Alembic - please adjust! ###
19
-    op.add_column('workspaces', sa.Column('calendar_enabled', sa.Boolean(), nullable=False))
19
+    op.add_column('workspaces', sa.Column('calendar_enabled', sa.Boolean(), nullable=False, server_default='0'))
20 20
     ### end Alembic commands ###
21 21
 
22 22
 

+ 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
 

+ 131 - 4
tracim/tracim/lib/calendar.py View File

@@ -1,10 +1,19 @@
1 1
 import re
2
+import transaction
2 3
 
4
+from icalendar import Event as iCalendarEvent
5
+
6
+from tracim.lib.content import ContentApi
3 7
 from tracim.lib.exceptions import UnknownCalendarType
4 8
 from tracim.lib.exceptions import NotFound
5 9
 from tracim.lib.user import UserApi
6
-from tracim.lib.workspace import WorkspaceApi
10
+from tracim.lib.workspace import UnsafeWorkspaceApi
7 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
8 17
 from tracim.model.organizational import Calendar
9 18
 from tracim.model.organizational import UserCalendar
10 19
 from tracim.model.organizational import WorkspaceCalendar
@@ -12,8 +21,8 @@ from tracim.model.organizational import WorkspaceCalendar
12 21
 CALENDAR_USER_PATH_RE = 'user\/([0-9]+)--([a-z-]*).ics'
13 22
 CALENDAR_WORKSPACE_PATH_RE = 'workspace\/([0-9]+)--([a-z0-9-]*).ics'
14 23
 
15
-CALENDAR_TYPE_USER = 'USER'
16
-CALENDAR_TYPE_WORKSPACE = 'WORKSPACE'
24
+CALENDAR_TYPE_USER = UserCalendar
25
+CALENDAR_TYPE_WORKSPACE = WorkspaceCalendar
17 26
 
18 27
 
19 28
 class CalendarManager(object):
@@ -84,7 +93,125 @@ class CalendarManager(object):
84 93
             return UserCalendar(user, path=path)
85 94
 
86 95
         if type == CALENDAR_TYPE_WORKSPACE:
87
-            workspace = WorkspaceApi(self._user).get_one(id)
96
+            workspace = UnsafeWorkspaceApi(self._user).get_one(id)
88 97
             return WorkspaceCalendar(workspace, path=path)
89 98
 
90 99
         raise UnknownCalendarType('Type "{0}" is not implemented'.format(type))
100
+
101
+    def add_event(
102
+            self,
103
+            calendar: Calendar,
104
+            event: iCalendarEvent,
105
+            event_name: str,
106
+            owner: User,
107
+    ) -> Content:
108
+        """
109
+        Create Content event type.
110
+        :param calendar: Event calendar owner
111
+        :param event: ICS event
112
+        :param event_name: Event name (ID) like
113
+        20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
114
+        :param owner: Event Owner
115
+        :return: Created Content
116
+        """
117
+        if isinstance(calendar, WorkspaceCalendar):
118
+            content = ContentApi(owner).create(
119
+                content_type=ContentType.Event,
120
+                workspace=calendar.related_object,
121
+                label=event.get('summary'),
122
+                do_save=False
123
+            )
124
+            content.description = event.get('description')
125
+            content.properties = {
126
+                'name': event_name,
127
+                'raw': event.to_ical().decode("utf-8"),
128
+                'start': event.get('dtend').dt.strftime('%Y-%m-%d %H:%M:%S%z'),
129
+                'end': event.get('dtstart').dt.strftime('%Y-%m-%d %H:%M:%S%z'),
130
+            }
131
+            content.revision_type = ActionDescription.CREATION
132
+            DBSession.add(content)
133
+            DBSession.flush()
134
+            transaction.commit()
135
+            return content
136
+
137
+        if isinstance(calendar, UserCalendar):
138
+            # TODO - 20160531 - Bastien: UserCalendar currently not managed
139
+            raise NotImplementedError()
140
+
141
+        raise UnknownCalendarType('Type "{0}" is not implemented'
142
+                                  .format(type(calendar)))
143
+
144
+    def update_event(
145
+            self,
146
+            calendar: Calendar,
147
+            event: iCalendarEvent,
148
+            event_name: str,
149
+            current_user: User,
150
+    ) -> Content:
151
+        """
152
+        Update Content Event
153
+        :param calendar: Event calendar owner
154
+        :param event: ICS event
155
+        :param event_name: Event name (ID) like
156
+        20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
157
+        :param current_user: Current modification asking user
158
+        :return: Updated Content
159
+        """
160
+        if isinstance(calendar, WorkspaceCalendar):
161
+            content_api = ContentApi(current_user, force_show_all_types=True)
162
+            content = content_api.find_one_by_unique_property(
163
+                property_name='name',
164
+                property_value=event_name,
165
+                workspace=calendar.related_object
166
+            )
167
+
168
+            with new_revision(content):
169
+                content.label = event.get('summary')
170
+                content.description = event.get('description')
171
+                content.properties = {
172
+                    'name': event_name,
173
+                    'raw': event.to_ical().decode("utf-8"),
174
+                    'start': event.get('dtend').dt.strftime(
175
+                        '%Y-%m-%d %H:%M:%S%z'
176
+                    ),
177
+                    'end': event.get('dtstart').dt.strftime(
178
+                        '%Y-%m-%d %H:%M:%S%z'
179
+                    ),
180
+                }
181
+                content.revision_type = ActionDescription.EDITION
182
+
183
+            DBSession.flush()
184
+            transaction.commit()
185
+
186
+            return content
187
+
188
+        if isinstance(calendar, UserCalendar):
189
+            # TODO - 20160531 - Bastien: UserCalendar currently not managed
190
+            raise NotImplementedError()
191
+
192
+        raise UnknownCalendarType('Type "{0}" is not implemented'
193
+                                  .format(type(calendar)))
194
+
195
+    def delete_event_with_name(self, event_name: str, current_user: User)\
196
+            -> Content:
197
+        """
198
+        Delete Content Event
199
+        :param event_name: Event name (ID) like
200
+        20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
201
+        :param current_user: Current deletion asking user
202
+        :return: Deleted Content
203
+        """
204
+        content_api = ContentApi(current_user, force_show_all_types=True)
205
+        content = content_api.find_one_by_unique_property(
206
+            property_name='name',
207
+            property_value=event_name,
208
+            workspace=None
209
+        )
210
+
211
+        with new_revision(content):
212
+            content_api.delete(content)
213
+
214
+        DBSession.flush()
215
+        transaction.commit()
216
+
217
+        return content

+ 53 - 2
tracim/tracim/lib/content.py View File

@@ -67,13 +67,28 @@ 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
+    ):
72 86
         self._user = current_user
73 87
         self._user_id = current_user.user_id if current_user else None
74 88
         self._show_archived = show_archived
75 89
         self._show_deleted = show_deleted
76 90
         self._show_all_type_of_contents_in_treeview = all_content_in_treeview
91
+        self._force_show_all_types = force_show_all_types
77 92
 
78 93
     @classmethod
79 94
     def get_revision_join(cls):
@@ -158,6 +173,10 @@ class ContentApi(object):
158 173
     def __real_base_query(self, workspace: Workspace=None):
159 174
         result = self.get_canonical_query()
160 175
 
176
+        # Exclude non displayable types
177
+        if not self._force_show_all_types:
178
+            result = result.filter(Content.type.in_(self.DISPLAYABLE_CONTENTS))
179
+
161 180
         if workspace:
162 181
             result = result.filter(Content.workspace_id==workspace.workspace_id)
163 182
 
@@ -184,6 +203,10 @@ class ContentApi(object):
184 203
     def __revisions_real_base_query(self, workspace: Workspace=None):
185 204
         result = DBSession.query(ContentRevisionRO)
186 205
 
206
+        # Exclude non displayable types
207
+        if not self._force_show_all_types:
208
+            result = result.filter(Content.type.in_(self.DISPLAYABLE_CONTENTS))
209
+
187 210
         if workspace:
188 211
             result = result.filter(ContentRevisionRO.workspace_id==workspace.workspace_id)
189 212
 
@@ -698,3 +721,31 @@ class ContentApi(object):
698 721
             if content.parent.parent:
699 722
                 return self.content_under_archived(content.parent)
700 723
         return False
724
+
725
+    def find_one_by_unique_property(
726
+            self,
727
+            property_name: str,
728
+            property_value: str,
729
+            workspace: Workspace=None,
730
+    ) -> Content:
731
+        """
732
+        Return Content who contains given property.
733
+        Raise sqlalchemy.orm.exc.MultipleResultsFound if more than one Content
734
+        contains this property value.
735
+        :param property_name: Name of property
736
+        :param property_value: Value of property
737
+        :param workspace: Workspace who contains Content
738
+        :return: Found Content
739
+        """
740
+        # TODO - 20160602 - Bastien: Should be JSON type query
741
+        # see https://www.compose.io/articles/using-json-extensions-in-\
742
+        # postgresql-from-python-2/
743
+        query = self._base_query(workspace=workspace).filter(
744
+            Content._properties.like(
745
+                '%"{property_name}": "{property_value}"%'.format(
746
+                    property_name=property_name,
747
+                    property_value=property_value,
748
+                )
749
+            )
750
+        )
751
+        return query.one()

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

@@ -85,6 +85,7 @@ class RadicaleDaemon(Daemon):
85 85
     def _prepare_config(self):
86 86
         tracim_auth = 'tracim.lib.radicale.auth'
87 87
         tracim_rights = 'tracim.lib.radicale.rights'
88
+        tracim_storage = 'tracim.lib.radicale.storage'
88 89
 
89 90
         radicale_config.set('auth', 'type', 'custom')
90 91
         radicale_config.set('auth', 'custom_handler', tracim_auth)
@@ -92,6 +93,9 @@ class RadicaleDaemon(Daemon):
92 93
         radicale_config.set('rights', 'type', 'custom')
93 94
         radicale_config.set('rights', 'custom_handler', tracim_rights)
94 95
 
96
+        radicale_config.set('storage', 'type', 'custom')
97
+        radicale_config.set('storage', 'custom_handler', tracim_storage)
98
+
95 99
     def _get_server(self):
96 100
         from tracim.config.app_cfg import CFG
97 101
         cfg = CFG.get_instance()

+ 31 - 8
tracim/tracim/lib/radicale/auth.py View File

@@ -1,13 +1,36 @@
1 1
 from tg import config
2 2
 
3
+from tracim.lib.user import UserApi
3 4
 
4
-def is_authenticated(user, password):
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:
5 33
     """
6
-    :param user: user email
7
-    :param password: user password
8
-    :return: True if auth success, False if not
34
+    see tracim.lib.radicale.auth.Auth#is_authenticated
9 35
     """
10
-    return bool(config.get('sa_auth').authmetadata.authenticate({}, {
11
-        'login': user,
12
-        'password': password
13
-    }))
36
+    return Auth.is_authenticated(user, password)

+ 118 - 1
tracim/tracim/lib/radicale/storage.py View File

@@ -1,6 +1,123 @@
1
+from contextlib import contextmanager
2
+
1 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
2 10
 
3 11
 
4 12
 class Collection(BaseCollection):
5
-    def __init__(self, path, principal=False):
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):
6 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)

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

+ 22 - 3
tracim/tracim/model/data.py View File

@@ -320,6 +320,7 @@ class ContentType(object):
320 320
     Comment = 'comment'
321 321
     Thread = 'thread'
322 322
     Page = 'page'
323
+    Event = 'event'
323 324
 
324 325
     # Fake types, used for breadcrumb only
325 326
     FAKE_Dashboard = 'dashboard'
@@ -335,6 +336,7 @@ class ContentType(object):
335 336
         'page': 'fa fa-file-text-o',
336 337
         'thread': 'fa fa-comments-o',
337 338
         'comment': 'fa fa-comment-o',
339
+        'event': 'fa fa-calendar-o',
338 340
     }
339 341
 
340 342
     _CSS_ICONS = {
@@ -344,7 +346,8 @@ class ContentType(object):
344 346
         'file': 'fa fa-paperclip',
345 347
         'page': 'fa fa-file-text-o',
346 348
         'thread': 'fa fa-comments-o',
347
-        'comment': 'fa fa-comment-o'
349
+        'comment': 'fa fa-comment-o',
350
+        'event': 'fa fa-calendar-o',
348 351
     }
349 352
 
350 353
     _CSS_COLORS = {
@@ -354,7 +357,8 @@ class ContentType(object):
354 357
         'file': 't-file-color',
355 358
         'page': 't-page-color',
356 359
         'thread': 't-thread-color',
357
-        'comment': 't-thread-color'
360
+        'comment': 't-thread-color',
361
+        'event': 't-event-color',
358 362
     }
359 363
 
360 364
     _ORDER_WEIGHT = {
@@ -363,6 +367,7 @@ class ContentType(object):
363 367
         'thread': 2,
364 368
         'file': 3,
365 369
         'comment': 4,
370
+        'event': 5,
366 371
     }
367 372
 
368 373
     _LABEL = {
@@ -373,6 +378,7 @@ class ContentType(object):
373 378
         'page': l_('page'),
374 379
         'thread': l_('thread'),
375 380
         'comment': l_('comment'),
381
+        'event': l_('event'),
376 382
     }
377 383
 
378 384
     _DELETE_LABEL = {
@@ -383,6 +389,7 @@ class ContentType(object):
383 389
         'page': l_('Delete this page'),
384 390
         'thread': l_('Delete this thread'),
385 391
         'comment': l_('Delete this comment'),
392
+        'event': l_('Delete this event'),
386 393
     }
387 394
 
388 395
     @classmethod
@@ -396,7 +403,8 @@ class ContentType(object):
396 403
 
397 404
     @classmethod
398 405
     def allowed_types(cls):
399
-        return [cls.Folder, cls.File, cls.Comment, cls.Thread, cls.Page]
406
+        return [cls.Folder, cls.File, cls.Comment, cls.Thread, cls.Page,
407
+                cls.Event]
400 408
 
401 409
     @classmethod
402 410
     def allowed_types_for_folding(cls):
@@ -473,7 +481,18 @@ class ContentChecker(object):
473 481
                 return False
474 482
             if 'threads' not in properties['allowed_content']:
475 483
                 return False
484
+            return True
476 485
 
486
+        if item.type == ContentType.Event:
487
+            properties = item.properties
488
+            if 'name' not in properties.keys():
489
+                return False
490
+            if 'raw' not in properties.keys():
491
+                return False
492
+            if 'start' not in properties.keys():
493
+                return False
494
+            if 'end' not in properties.keys():
495
+                return False
477 496
             return True
478 497
 
479 498
         raise NotImplementedError

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

@@ -11,6 +11,10 @@ class Calendar(object):
11 11
         self._related_object = related_object
12 12
         self._path = path
13 13
 
14
+    @property
15
+    def related_object(self):
16
+        return self._related_object
17
+
14 18
     def user_can_read(self, user: User) -> bool:
15 19
         raise NotImplementedError()
16 20