Browse Source

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

Tracim 9 years ago
parent
commit
06e1bc987a

+ 4 - 1
install/requirements.txt View File

45
 waitress==0.8.9
45
 waitress==0.8.9
46
 zope.interface==4.1.3
46
 zope.interface==4.1.3
47
 zope.sqlalchemy==0.7.6
47
 zope.sqlalchemy==0.7.6
48
-tgapp-resetpassword==0.1.3
48
+git+https://github.com/algoo/tgapp-resetpassword.git@master
49
 lxml
49
 lxml
50
 python-ldap-test==0.2.0
50
 python-ldap-test==0.2.0
51
 who-ldap==3.1.0
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
 email.notification.smtp.user = your_smtp_user
183
 email.notification.smtp.user = your_smtp_user
184
 email.notification.smtp.password = your_smtp_password
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

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

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
     "repoze.who",
41
     "repoze.who",
42
     "who-ldap==3.1.0",
42
     "who-ldap==3.1.0",
43
     "python-ldap-test==0.2.0",
43
     "python-ldap-test==0.2.0",
44
+    "unicode-slugify==0.1.3",
44
     ]
45
     ]
45
 
46
 
46
 setup(
47
 setup(

+ 1 - 1
tracim/test.ini View File

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

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

15
 
15
 
16
 import tg
16
 import tg
17
 from paste.deploy.converters import asbool
17
 from paste.deploy.converters import asbool
18
+from tg.configuration.milestones import environment_loaded
18
 
19
 
19
 from tgext.pluggable import plug
20
 from tgext.pluggable import plug
20
 from tgext.pluggable import replace_template
21
 from tgext.pluggable import replace_template
27
 from tracim.lib import app_globals, helpers
28
 from tracim.lib import app_globals, helpers
28
 from tracim.lib.auth.wrapper import AuthConfigWrapper
29
 from tracim.lib.auth.wrapper import AuthConfigWrapper
29
 from tracim.lib.base import logger
30
 from tracim.lib.base import logger
31
+from tracim.lib.daemons import DaemonsManager
32
+from tracim.lib.daemons import RadicaleDaemon
30
 from tracim.model.data import ActionDescription
33
 from tracim.model.data import ActionDescription
31
 from tracim.model.data import ContentType
34
 from tracim.model.data import ContentType
32
 
35
 
69
 # 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.
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
 # 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.
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
 # Configure the authentication backend
77
 # Configure the authentication backend
74
 
78
 
82
 replace_template(base_config, 'resetpassword.templates.index', 'tracim.templates.reset_password_index')
86
 replace_template(base_config, 'resetpassword.templates.index', 'tracim.templates.reset_password_index')
83
 replace_template(base_config, 'resetpassword.templates.change_password', 'mako:tracim.templates.reset_password_change_password')
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
 # Note: here are fake translatable strings that allow to translate messages for reset password email content
100
 # Note: here are fake translatable strings that allow to translate messages for reset password email content
86
 duplicated_email_subject = l_('Password reset request')
101
 duplicated_email_subject = l_('Password reset request')
87
 duplicated_email_body = l_('''
102
 duplicated_email_body = l_('''
201
             # ContentType.Folder -- Folder is skipped
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
     def get_tracker_js_content(self, js_tracker_file_path = None):
234
     def get_tracker_js_content(self, js_tracker_file_path = None):
206
         js_tracker_file_path = tg.config.get('js_tracker_path', None)
235
         js_tracker_file_path = tg.config.get('js_tracker_path', None)

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

3
 
3
 
4
 from tracim.config.app_cfg import base_config
4
 from tracim.config.app_cfg import base_config
5
 from tracim.config.environment import load_environment
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
 __all__ = ['make_app']
9
 __all__ = ['make_app']
9
 
10
 
35
     app = make_base_app(global_conf, full_stack=True, **app_conf)
36
     app = make_base_app(global_conf, full_stack=True, **app_conf)
36
     
37
     
37
     # Wrap your base TurboGears 2 application with custom middleware here
38
     # Wrap your base TurboGears 2 application with custom middleware here
38
-    
39
+
39
     return app
40
     return app

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

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

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

24
 from tracim.controllers.help import HelpController
24
 from tracim.controllers.help import HelpController
25
 from tracim.controllers.user import UserRestController
25
 from tracim.controllers.user import UserRestController
26
 from tracim.controllers.workspace import UserWorkspaceRestController
26
 from tracim.controllers.workspace import UserWorkspaceRestController
27
+from tracim.lib.utils import replace_reset_password_templates
27
 
28
 
28
 from tracim.model.data import ContentType
29
 from tracim.model.data import ContentType
29
 from tracim.model.serializers import DictLikeClass
30
 from tracim.model.serializers import DictLikeClass
55
     workspaces = UserWorkspaceRestController()
56
     workspaces = UserWorkspaceRestController()
56
     user = UserRestController()
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
     def _before(self, *args, **kw):
63
     def _before(self, *args, **kw):
59
         super(RootController, self)._before(args, kw)
64
         super(RootController, self)._before(args, kw)
60
         tmpl_context.project_name = "tracim"
65
         tmpl_context.project_name = "tracim"

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

58
         # ,
58
         # ,
59
         #                      sub_items=Context(CTX.FOLDER_CONTENT_LIST).toDict(dictified_folders))
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
         dictified_workspace = Context(CTX.WORKSPACE).toDict(workspace, 'workspace')
65
         dictified_workspace = Context(CTX.WORKSPACE).toDict(workspace, 'workspace')
64
 
66
 

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

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
     SEARCH_SEPARATORS = ',| '
67
     SEARCH_SEPARATORS = ',| '
68
     SEARCH_DEFAULT_RESULT_NB = 50
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
         self._user = current_user
87
         self._user = current_user
73
         self._user_id = current_user.user_id if current_user else None
88
         self._user_id = current_user.user_id if current_user else None
74
         self._show_archived = show_archived
89
         self._show_archived = show_archived
75
         self._show_deleted = show_deleted
90
         self._show_deleted = show_deleted
76
         self._show_all_type_of_contents_in_treeview = all_content_in_treeview
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
     @classmethod
95
     @classmethod
79
     def get_revision_join(cls):
96
     def get_revision_join(cls):
158
     def __real_base_query(self, workspace: Workspace=None):
175
     def __real_base_query(self, workspace: Workspace=None):
159
         result = self.get_canonical_query()
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
         if workspace:
182
         if workspace:
162
             result = result.filter(Content.workspace_id==workspace.workspace_id)
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
             user = DBSession.query(User).get(self._user_id)
186
             user = DBSession.query(User).get(self._user_id)
166
             # Filter according to user workspaces
187
             # Filter according to user workspaces
167
             workspace_ids = [r.workspace_id for r in user.roles \
188
             workspace_ids = [r.workspace_id for r in user.roles \
184
     def __revisions_real_base_query(self, workspace: Workspace=None):
205
     def __revisions_real_base_query(self, workspace: Workspace=None):
185
         result = DBSession.query(ContentRevisionRO)
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
         if workspace:
212
         if workspace:
188
             result = result.filter(ContentRevisionRO.workspace_id==workspace.workspace_id)
213
             result = result.filter(ContentRevisionRO.workspace_id==workspace.workspace_id)
189
 
214
 
698
             if content.parent.parent:
723
             if content.parent.parent:
699
                 return self.content_under_archived(content.parent)
724
                 return self.content_under_archived(content.parent)
700
         return False
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

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

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
 
7
 import datetime
7
 import datetime
8
 
8
 
9
+import slugify
9
 from babel.dates import format_date, format_time
10
 from babel.dates import format_date, format_time
10
 from markupsafe import Markup
11
 from markupsafe import Markup
11
 
12
 
216
     if not tg.config.get('auth_instance').is_internal:
217
     if not tg.config.get('auth_instance').is_internal:
217
         return field_name in tg.config.get('auth_instance').managed_fields
218
         return field_name in tg.config.get('auth_instance').managed_fields
218
     return False
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

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

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

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
     def get_one_by_email(self, email: str):
27
     def get_one_by_email(self, email: str):
28
         return self._base_query().filter(User.email==email).one()
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
     def update(self, user: User, name: str=None, email: str=None, do_save=True):
33
     def update(self, user: User, name: str=None, email: str=None, do_save=True):
31
         if name is not None:
34
         if name is not None:
32
             user.display_name = name
35
             user.display_name = name

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

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 
2
 
3
 import time
3
 import time
4
+import signal
4
 
5
 
5
 from tracim.lib.base import logger
6
 from tracim.lib.base import logger
6
 
7
 
17
 
18
 
18
 
19
 
19
 class SameValueError(ValueError):
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
             DBSession.flush()
121
             DBSession.flush()
122
 
122
 
123
         return workspace
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
 import os
11
 import os
12
 from datetime import datetime
12
 from datetime import datetime
13
 from hashlib import sha256
13
 from hashlib import sha256
14
+from slugify import slugify
14
 from sqlalchemy.ext.hybrid import hybrid_property
15
 from sqlalchemy.ext.hybrid import hybrid_property
15
 from tg.i18n import lazy_ugettext as l_
16
 from tg.i18n import lazy_ugettext as l_
16
 
17
 
26
 from sqlalchemy.types import DateTime
27
 from sqlalchemy.types import DateTime
27
 from sqlalchemy.types import Boolean
28
 from sqlalchemy.types import Boolean
28
 from sqlalchemy.orm import relation, relationship, synonym
29
 from sqlalchemy.orm import relation, relationship, synonym
29
-
30
+from tg import request
30
 from tracim.model import DeclarativeBase, metadata, DBSession
31
 from tracim.model import DeclarativeBase, metadata, DBSession
31
 
32
 
32
 # This is the association table for the many-to-many relationship between
33
 # This is the association table for the many-to-many relationship between
147
             profile_id = max(group.group_id for group in self.groups)
148
             profile_id = max(group.group_id for group in self.groups)
148
         return Profile(profile_id)
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
     @classmethod
167
     @classmethod
151
     def by_email_address(cls, email):
168
     def by_email_address(cls, email):
152
         """Return the user object whose email address is ``email``."""
169
         """Return the user object whose email address is ``email``."""
206
         hash.update((password + self.password[:64]).encode('utf-8'))
223
         hash.update((password + self.password[:64]).encode('utf-8'))
207
         return self.password[64:] == hash.hexdigest()
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
         if self.display_name!=None and self.display_name!='':
232
         if self.display_name!=None and self.display_name!='':
211
             return self.display_name
233
             return self.display_name
212
         else:
234
         else:
235
+            if remove_email_part:
236
+                at_pos = self.email.index('@')
237
+                return self.email[0:at_pos]
213
             return self.email
238
             return self.email
214
 
239
 
215
     def get_role(self, workspace: 'Workspace') -> int:
240
     def get_role(self, workspace: 'Workspace') -> int:

+ 38 - 4
tracim/tracim/model/data.py View File

7
 import tg
7
 import tg
8
 from babel.dates import format_timedelta
8
 from babel.dates import format_timedelta
9
 from bs4 import BeautifulSoup
9
 from bs4 import BeautifulSoup
10
+from slugify import slugify
10
 from sqlalchemy import Column, inspect, Index
11
 from sqlalchemy import Column, inspect, Index
11
 from sqlalchemy import ForeignKey
12
 from sqlalchemy import ForeignKey
12
 from sqlalchemy import Sequence
13
 from sqlalchemy import Sequence
13
-from sqlalchemy import func
14
 from sqlalchemy.ext.associationproxy import association_proxy
14
 from sqlalchemy.ext.associationproxy import association_proxy
15
 from sqlalchemy.ext.hybrid import hybrid_property
15
 from sqlalchemy.ext.hybrid import hybrid_property
16
 from sqlalchemy.orm import backref
16
 from sqlalchemy.orm import backref
51
 
51
 
52
     label   = Column(Unicode(1024), unique=False, nullable=False, default='')
52
     label   = Column(Unicode(1024), unique=False, nullable=False, default='')
53
     description = Column(Text(), unique=False, nullable=False, default='')
53
     description = Column(Text(), unique=False, nullable=False, default='')
54
+    calendar_enabled = Column(Boolean, unique=False, nullable=False, default=False)
54
 
55
 
55
     #  Default value datetime.utcnow, see: http://stackoverflow.com/a/13370382/801924 (or http://pastebin.com/VLyWktUn)
56
     #  Default value datetime.utcnow, see: http://stackoverflow.com/a/13370382/801924 (or http://pastebin.com/VLyWktUn)
56
     created = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
57
     created = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
66
         # Return a list of unique revisions parent content
67
         # Return a list of unique revisions parent content
67
         return list(set([revision.node for revision in self.revisions]))
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
     def get_user_role(self, user: User) -> int:
84
     def get_user_role(self, user: User) -> int:
70
         for role in user.roles:
85
         for role in user.roles:
71
             if role.workspace.workspace_id==self.workspace_id:
86
             if role.workspace.workspace_id==self.workspace_id:
306
     Comment = 'comment'
321
     Comment = 'comment'
307
     Thread = 'thread'
322
     Thread = 'thread'
308
     Page = 'page'
323
     Page = 'page'
324
+    Event = 'event'
309
 
325
 
310
     # Fake types, used for breadcrumb only
326
     # Fake types, used for breadcrumb only
311
     FAKE_Dashboard = 'dashboard'
327
     FAKE_Dashboard = 'dashboard'
321
         'page': 'fa fa-file-text-o',
337
         'page': 'fa fa-file-text-o',
322
         'thread': 'fa fa-comments-o',
338
         'thread': 'fa fa-comments-o',
323
         'comment': 'fa fa-comment-o',
339
         'comment': 'fa fa-comment-o',
340
+        'event': 'fa fa-calendar-o',
324
     }
341
     }
325
 
342
 
326
     _CSS_ICONS = {
343
     _CSS_ICONS = {
330
         'file': 'fa fa-paperclip',
347
         'file': 'fa fa-paperclip',
331
         'page': 'fa fa-file-text-o',
348
         'page': 'fa fa-file-text-o',
332
         'thread': 'fa fa-comments-o',
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
     _CSS_COLORS = {
354
     _CSS_COLORS = {
340
         'file': 't-file-color',
358
         'file': 't-file-color',
341
         'page': 't-page-color',
359
         'page': 't-page-color',
342
         'thread': 't-thread-color',
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
     _ORDER_WEIGHT = {
365
     _ORDER_WEIGHT = {
349
         'thread': 2,
368
         'thread': 2,
350
         'file': 3,
369
         'file': 3,
351
         'comment': 4,
370
         'comment': 4,
371
+        'event': 5,
352
     }
372
     }
353
 
373
 
354
     _LABEL = {
374
     _LABEL = {
359
         'page': l_('page'),
379
         'page': l_('page'),
360
         'thread': l_('thread'),
380
         'thread': l_('thread'),
361
         'comment': l_('comment'),
381
         'comment': l_('comment'),
382
+        'event': l_('event'),
362
     }
383
     }
363
 
384
 
364
     _DELETE_LABEL = {
385
     _DELETE_LABEL = {
369
         'page': l_('Delete this page'),
390
         'page': l_('Delete this page'),
370
         'thread': l_('Delete this thread'),
391
         'thread': l_('Delete this thread'),
371
         'comment': l_('Delete this comment'),
392
         'comment': l_('Delete this comment'),
393
+        'event': l_('Delete this event'),
372
     }
394
     }
373
 
395
 
374
     @classmethod
396
     @classmethod
382
 
404
 
383
     @classmethod
405
     @classmethod
384
     def allowed_types(cls):
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
     @classmethod
410
     @classmethod
388
     def allowed_types_for_folding(cls):
411
     def allowed_types_for_folding(cls):
459
                 return False
482
                 return False
460
             if 'threads' not in properties['allowed_content']:
483
             if 'threads' not in properties['allowed_content']:
461
                 return False
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
             return True
497
             return True
464
 
498
 
465
         raise NotImplementedError
499
         raise NotImplementedError

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

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
     result['roles'] = context.toDict(user.roles)
876
     result['roles'] = context.toDict(user.roles)
877
     result['enabled'] = user.is_active
877
     result['enabled'] = user.is_active
878
     result['profile'] = user.profile
878
     result['profile'] = user.profile
879
+    result['calendar_url'] = user.calendar_url
879
 
880
 
880
     return result
881
     return result
881
 
882
 
979
     result['members'] = context.toDict(workspace.roles)
980
     result['members'] = context.toDict(workspace.roles)
980
     result['member_nb'] = len(workspace.roles)
981
     result['member_nb'] = len(workspace.roles)
981
     result['allowed_content_types'] = context.toDict(workspace.get_allowed_content_types())
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
     return result
986
     return result
984
 
987
 

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

46
                                         <textarea name="description" class="form-control" id="workspaceDescription" placeholder="${_('You may add a description of the workspace')}"></textarea>
46
                                         <textarea name="description" class="form-control" id="workspaceDescription" placeholder="${_('You may add a description of the workspace')}"></textarea>
47
                                     </div>
47
                                     </div>
48
                                     <div class="form-group">
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
                                         <p class="form-control-static">${_('<u>Note</u>: members will be added during next step.')|n}</p>
53
                                         <p class="form-control-static">${_('<u>Note</u>: members will be added during next step.')|n}</p>
50
                                     </div>
54
                                     </div>
51
                                         
55
                                         

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

110
                 % endif
110
                 % endif
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}>
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
             </div>
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
         </div>
118
         </div>
114
         <div class="modal-footer">
119
         <div class="modal-footer">
115
             <span class="pull-right t-modal-form-submit-button">
120
             <span class="pull-right t-modal-form-submit-button">

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

17
             <label for="workspaceDescription">${_('Description')}</label>
17
             <label for="workspaceDescription">${_('Description')}</label>
18
             <textarea name="description" class="form-control" id="workspaceDescription" placeholder="${_('You may add a description of the workspace')}">${result.workspace.description}</textarea>
18
             <textarea name="description" class="form-control" id="workspaceDescription" placeholder="${_('You may add a description of the workspace')}">${result.workspace.description}</textarea>
19
         </div>
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
     </div>
28
     </div>
21
     <div class="modal-footer">
29
     <div class="modal-footer">
22
         <span class="pull-right" style="margin-top: 0.5em;">
30
         <span class="pull-right" style="margin-top: 0.5em;">

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

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

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

232
         eq_(True, res['children'])
232
         eq_(True, res['children'])
233
 
233
 
234
         for curtype in ContentType.all():
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
                 item = Content()
240
                 item = Content()
237
                 item.type = curtype
241
                 item.type = curtype
238
                 item.label = 'item'
242
                 item.label = 'item'