Browse Source

Merge branch 'master' of github.com:tracim/tracim

Come 8 years ago
parent
commit
b22248e431
31 changed files with 917 additions and 454 deletions
  1. 26 0
      tracim/migration/versions/2cd20ff3d23a_user_timezone.py
  2. 15 3
      tracim/tracim/command/user.py
  3. 1 2
      tracim/tracim/controllers/admin/user.py
  4. 9 14
      tracim/tracim/controllers/admin/workspace.py
  5. 13 7
      tracim/tracim/controllers/user.py
  6. BIN
      tracim/tracim/i18n/fr/LC_MESSAGES/tracim.mo
  7. 509 356
      tracim/tracim/i18n/fr/LC_MESSAGES/tracim.po
  8. 0 1
      tracim/tracim/lib/app_globals.py
  9. 43 0
      tracim/tracim/lib/calendar.py
  10. 1 1
      tracim/tracim/lib/content.py
  11. 37 13
      tracim/tracim/lib/helpers.py
  12. 2 0
      tracim/tracim/lib/radicale/auth.py
  13. 35 4
      tracim/tracim/lib/user.py
  14. 35 18
      tracim/tracim/lib/workspace.py
  15. 6 2
      tracim/tracim/model/__init__.py
  16. 3 2
      tracim/tracim/model/auth.py
  17. 5 5
      tracim/tracim/model/data.py
  18. 7 3
      tracim/tracim/model/serializers.py
  19. 2 3
      tracim/tracim/templates/admin/workspace_getone.mak
  20. 4 2
      tracim/tracim/templates/file/getone.mak
  21. 2 1
      tracim/tracim/templates/folder/getone.mak
  22. 2 1
      tracim/tracim/templates/page/getone.mak
  23. 2 1
      tracim/tracim/templates/search/display.mak
  24. 2 1
      tracim/tracim/templates/thread/getone.mak
  25. 14 0
      tracim/tracim/templates/user_workspace_forms.mak
  26. 6 3
      tracim/tracim/templates/user_workspace_widgets.mak
  27. 2 1
      tracim/tracim/templates/workspace/getone.mak
  28. 3 2
      tracim/tracim/tests/__init__.py
  29. 109 0
      tracim/tracim/tests/functional/test_calendar.py
  30. 1 0
      tracim/tracim/tests/functional/test_ldap_restrictions.py
  31. 21 8
      tracim/tracim/tests/library/test_helpers.py

+ 26 - 0
tracim/migration/versions/2cd20ff3d23a_user_timezone.py View File

@@ -0,0 +1,26 @@
1
+"""user_timezone
2
+
3
+Revision ID: 2cd20ff3d23a
4
+Revises: b4b8d57b54e5
5
+Create Date: 2016-11-08 11:32:00.903232
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = '2cd20ff3d23a'
11
+down_revision = 'b4b8d57b54e5'
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('users', sa.Column('timezone', sa.Unicode(length=255), server_default='', nullable=False))
20
+    ### end Alembic commands ###
21
+
22
+
23
+def downgrade():
24
+    ### commands auto generated by Alembic - please adjust! ###
25
+    op.drop_column('users', 'timezone')
26
+    ### end Alembic commands ###

+ 15 - 3
tracim/tracim/command/user.py View File

@@ -3,13 +3,18 @@ import transaction
3 3
 from sqlalchemy.exc import IntegrityError
4 4
 from tg import config
5 5
 
6
-from tracim.command import AppContextCommand, Extender
6
+from tracim.command import AppContextCommand
7
+from tracim.command import Extender
7 8
 from tracim.lib.auth.ldap import LDAPAuth
9
+from tracim.lib.daemons import DaemonsManager
10
+from tracim.lib.daemons import RadicaleDaemon
8 11
 from tracim.lib.email import get_email_manager
9
-from tracim.lib.exception import AlreadyExistError, CommandAbortedError
12
+from tracim.lib.exception import AlreadyExistError
13
+from tracim.lib.exception import CommandAbortedError
10 14
 from tracim.lib.group import GroupApi
11 15
 from tracim.lib.user import UserApi
12
-from tracim.model import DBSession, User
16
+from tracim.model import DBSession
17
+from tracim.model import User
13 18
 
14 19
 
15 20
 class UserCommand(AppContextCommand):
@@ -109,6 +114,13 @@ class UserCommand(AppContextCommand):
109 114
             user.update_webdav_digest_auth(password)
110 115
             self._session.add(user)
111 116
             self._session.flush()
117
+
118
+            # We need to enable radicale if it not already done
119
+            daemons = DaemonsManager()
120
+            daemons.run('radicale', RadicaleDaemon)
121
+
122
+            user_api = UserApi(user)
123
+            user_api.execute_created_user_actions(user)
112 124
         except IntegrityError:
113 125
             self._session.rollback()
114 126
             raise AlreadyExistError()

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

@@ -313,7 +313,6 @@ class UserRestController(TIMRestController):
313 313
             is_tracim_manager = False
314 314
             is_tracim_admin = False
315 315
 
316
-
317 316
         api = UserApi(current_user)
318 317
 
319 318
         if api.user_with_email_exists(email):
@@ -348,10 +347,10 @@ class UserRestController(TIMRestController):
348 347
             email_manager = get_email_manager()
349 348
             email_manager.notify_created_account(user, password=password)
350 349
 
350
+        api.execute_created_user_actions(user)
351 351
         tg.flash(_('User {} created.').format(user.get_display_name()), CST.STATUS_OK)
352 352
         tg.redirect(self.url())
353 353
 
354
-
355 354
     @tg.expose('tracim.templates.admin.user_getone')
356 355
     def get_one(self, user_id):
357 356
         current_user = tmpl_context.current_user

+ 9 - 14
tracim/tracim/controllers/admin/workspace.py View File

@@ -5,7 +5,6 @@ from tg import tmpl_context
5 5
 from tg.i18n import ugettext as _
6 6
 
7 7
 from tracim.controllers import TIMRestController
8
-from tracim.controllers import TIMRestPathContextSetup
9 8
 
10 9
 
11 10
 from tracim.lib import CST
@@ -13,23 +12,13 @@ from tracim.lib.base import BaseController
13 12
 from tracim.lib.helpers import on_off_to_boolean
14 13
 from tracim.lib.user import UserApi
15 14
 from tracim.lib.userworkspace import RoleApi
16
-from tracim.lib.content import ContentApi
17 15
 from tracim.lib.workspace import WorkspaceApi
18
-from tracim.model import DBSession
19 16
 
20 17
 from tracim.model.auth import Group
21
-from tracim.model.data import NodeTreeItem
22
-from tracim.model.data import Content
23
-from tracim.model.data import ContentType
24
-from tracim.model.data import Workspace
25 18
 from tracim.model.data import UserRoleInWorkspace
26 19
 
27 20
 from tracim.model.serializers import Context, CTX, DictLikeClass
28 21
 
29
-from tracim.controllers.content import UserWorkspaceFolderRestController
30
-
31
-
32
-
33 22
 
34 23
 class RoleInWorkspaceRestController(TIMRestController, BaseController):
35 24
 
@@ -198,9 +187,12 @@ class WorkspaceRestController(TIMRestController, BaseController):
198 187
         workspace_api_controller = WorkspaceApi(user)
199 188
         calendar_enabled = on_off_to_boolean(calendar_enabled)
200 189
 
201
-        workspace = workspace_api_controller.create_workspace(name, description)
202
-        workspace.calendar_enabled = calendar_enabled
203
-        DBSession.flush()
190
+        workspace = workspace_api_controller.create_workspace(
191
+            name,
192
+            description,
193
+            calendar_enabled=calendar_enabled,
194
+            save_now=True,
195
+        )
204 196
 
205 197
         tg.flash(_('{} workspace created.').format(workspace.label), CST.STATUS_OK)
206 198
         tg.redirect(self.url())
@@ -228,6 +220,9 @@ class WorkspaceRestController(TIMRestController, BaseController):
228 220
         workspace.calendar_enabled = calendar_enabled
229 221
         workspace_api_controller.save(workspace)
230 222
 
223
+        if calendar_enabled:
224
+            workspace_api_controller.ensure_calendar_exist(workspace)
225
+
231 226
         tg.flash(_('{} workspace updated.').format(workspace.label), CST.STATUS_OK)
232 227
         tg.redirect(self.url(workspace.workspace_id))
233 228
         return

+ 13 - 7
tracim/tracim/controllers/user.py View File

@@ -1,15 +1,16 @@
1 1
 # -*- coding: utf-8 -*-
2
-
2
+import pytz
3
+from webob.exc import HTTPForbidden
3 4
 import tg
4 5
 from tg import tmpl_context
5 6
 from tg.i18n import ugettext as _
6
-from webob.exc import HTTPForbidden
7 7
 
8 8
 from tracim.controllers import TIMRestController
9 9
 from tracim.lib.user import UserApi
10 10
 from tracim.lib.workspace import WorkspaceApi
11
-
12
-from tracim.model.serializers import Context, CTX, DictLikeClass
11
+from tracim.model.serializers import Context
12
+from tracim.model.serializers import CTX
13
+from tracim.model.serializers import DictLikeClass
13 14
 from tracim import model as pm
14 15
 
15 16
 
@@ -157,10 +158,14 @@ class UserRestController(TIMRestController):
157 158
 
158 159
         dictified_user = Context(CTX.USER).toDict(current_user, 'user')
159 160
         fake_api = DictLikeClass(next_url=next_url)
160
-        return DictLikeClass(result=dictified_user, fake_api=fake_api)
161
+        return DictLikeClass(
162
+            result=dictified_user,
163
+            fake_api=fake_api,
164
+            timezones=pytz.all_timezones,
165
+        )
161 166
 
162 167
     @tg.expose('tracim.templates.workspace.edit')
163
-    def put(self, user_id, name, email, next_url=None):
168
+    def put(self, user_id, name, email, timezone, next_url=None):
164 169
         user_id = tmpl_context.current_user.user_id
165 170
         current_user = tmpl_context.current_user
166 171
         assert user_id==current_user.user_id
@@ -168,7 +173,8 @@ class UserRestController(TIMRestController):
168 173
         # Only keep allowed field update
169 174
         updated_fields = self._clean_update_fields({
170 175
             'name': name,
171
-            'email': email
176
+            'email': email,
177
+            'timezone': timezone,
172 178
         })
173 179
 
174 180
         api = UserApi(tmpl_context.current_user)

BIN
tracim/tracim/i18n/fr/LC_MESSAGES/tracim.mo View File


File diff suppressed because it is too large
+ 509 - 356
tracim/tracim/i18n/fr/LC_MESSAGES/tracim.po


+ 0 - 1
tracim/tracim/lib/app_globals.py View File

@@ -23,7 +23,6 @@ class Globals(object):
23 23
         pass
24 24
 
25 25
     VERSION_NUMBER = '1.0.3'
26
-    LONG_DATE_FORMAT = '%A, the %d of %B %Y at %H:%M'
27 26
     SHORT_DATE_FORMAT = l_('%B %d at %I:%M%p')
28 27
 
29 28
 

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

@@ -1,3 +1,4 @@
1
+import caldav
1 2
 import os
2 3
 
3 4
 import re
@@ -5,6 +6,7 @@ import transaction
5 6
 
6 7
 from icalendar import Event as iCalendarEvent
7 8
 from sqlalchemy.orm.exc import NoResultFound
9
+from tg import tmpl_context
8 10
 from tg.i18n import ugettext as _
9 11
 
10 12
 from tracim.lib.content import ContentApi
@@ -316,3 +318,44 @@ class CalendarManager(object):
316 318
         :return: True if given collection path is an discover path
317 319
         """
318 320
         return path in ('user', 'workspace')
321
+
322
+    def create_then_remove_fake_event(
323
+            self,
324
+            calendar_class,
325
+            related_object_id,
326
+    ) -> None:
327
+        radicale_base_url = self.get_base_url()
328
+        client = caldav.DAVClient(
329
+            radicale_base_url,
330
+            username=self._user.email,
331
+            password=self._user.auth_token,
332
+        )
333
+        if calendar_class == WorkspaceCalendar:
334
+            calendar_url = self.get_workspace_calendar_url(related_object_id)
335
+        elif calendar_class == UserCalendar:
336
+            calendar_url = self.get_user_calendar_url(related_object_id)
337
+        else:
338
+            raise Exception('Unknown calendar type {0}'.format(calendar_class))
339
+
340
+        user_calendar = caldav.Calendar(
341
+            parent=client,
342
+            client=client,
343
+            url=calendar_url
344
+        )
345
+
346
+        event_ics = """BEGIN:VCALENDAR
347
+VERSION:2.0
348
+PRODID:-//Example Corp.//CalDAV Client//EN
349
+BEGIN:VEVENT
350
+UID:{uid}
351
+DTSTAMP:20100510T182145Z
352
+DTSTART:20100512T170000Z
353
+DTEND:20100512T180000Z
354
+SUMMARY:This is an event
355
+LOCATION:Here
356
+END:VEVENT
357
+END:VCALENDAR
358
+""".format(uid='{0}FAKEEVENT'.format(related_object_id))
359
+        event = user_calendar.add_event(event_ics)
360
+        event.delete()
361
+

+ 1 - 1
tracim/tracim/lib/content.py View File

@@ -184,7 +184,7 @@ class ContentApi(object):
184 184
         if workspace:
185 185
             result = result.filter(Content.workspace_id==workspace.workspace_id)
186 186
 
187
-        if self._user and not self._disable_user_workspaces_filter:
187
+        if self._user and workspace and not self._disable_user_workspaces_filter:
188 188
             user = DBSession.query(User).get(self._user_id)
189 189
             # Filter according to user workspaces
190 190
             workspace_ids = [r.workspace_id for r in user.roles \

+ 37 - 13
tracim/tracim/lib/helpers.py View File

@@ -6,11 +6,14 @@
6 6
 
7 7
 import datetime
8 8
 
9
+import pytz
9 10
 import slugify
10
-from babel.dates import format_date, format_time
11
+from babel.dates import format_date
12
+from babel.dates import format_time
11 13
 from markupsafe import Markup
12 14
 
13 15
 import tg
16
+from tg import tmpl_context
14 17
 from tg.i18n import ugettext as _
15 18
 
16 19
 from tracim.lib import app_globals as plag
@@ -20,7 +23,6 @@ from tracim.lib.base import logger
20 23
 from tracim.lib.content import ContentApi
21 24
 from tracim.lib.userworkspace import RoleApi
22 25
 from tracim.lib.workspace import WorkspaceApi
23
-from tracim.model import User
24 26
 
25 27
 from tracim.model.data import ContentStatus
26 28
 from tracim.model.data import Content
@@ -28,6 +30,39 @@ from tracim.model.data import ContentType
28 30
 from tracim.model.data import UserRoleInWorkspace
29 31
 from tracim.model.data import Workspace
30 32
 
33
+
34
+def get_with_timezone(
35
+        datetime_object: datetime.datetime,
36
+        to_timezone: str='',
37
+        default_from_timezone: str='UTC',
38
+) -> datetime.datetime:
39
+    """
40
+    Change timezone of a date
41
+    :param datetime_object: datetime to update
42
+    :param to_timezone: timezone name, if equal to '',
43
+    try to grap current user timezone. If no given timezone name and no
44
+    current user timezone, return original date time
45
+    :param default_from_timezone: datetime original timezone if datetime
46
+    object is naive
47
+    :return: datetime updated
48
+    """
49
+    # If no to_timezone, try to grab from current user
50
+    if not to_timezone and tmpl_context.current_user:
51
+        to_timezone = tmpl_context.current_user.timezone
52
+
53
+    # If no to_timezone, return original datetime
54
+    if not to_timezone:
55
+        return datetime_object
56
+
57
+    # If datetime_object have not timezone, set new from default_from_timezone
58
+    if not datetime_object.tzinfo:
59
+        from_tzinfo = pytz.timezone(default_from_timezone)
60
+        datetime_object = from_tzinfo.localize(datetime_object)
61
+
62
+    new_tzinfo = pytz.timezone(to_timezone)
63
+    return datetime_object.astimezone(new_tzinfo)
64
+
65
+
31 66
 def date_time_in_long_format(datetime_object, format=''):
32 67
 
33 68
     current_locale = tg.i18n.get_lang()[0]
@@ -63,17 +98,6 @@ def current_year():
63 98
   now = datetime.datetime.now()
64 99
   return now.strftime('%Y')
65 100
 
66
-def formatLongDateAndTime(datetime_object, format=''):
67
-    """ OBSOLETE
68
-    :param datetime_object:
69
-    :param format:
70
-    :return:
71
-    """
72
-    if not format:
73
-        format = plag.Globals.LONG_DATE_FORMAT
74
-    return datetime_object.strftime(format)
75
-
76
-
77 101
 
78 102
 def icon(icon_name, white=False):
79 103
     if (white):

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

@@ -1,6 +1,7 @@
1 1
 from tg import config
2 2
 
3 3
 from tracim.lib.user import UserApi
4
+from tracim.model import DBSession
4 5
 
5 6
 
6 7
 class Auth(object):
@@ -33,4 +34,5 @@ def is_authenticated(user: str, password: str) -> bool:
33 34
     """
34 35
     see tracim.lib.radicale.auth.Auth#is_authenticated
35 36
     """
37
+    DBSession.expire_all()
36 38
     return Auth.is_authenticated(user, password)

+ 35 - 4
tracim/tracim/lib/user.py View File

@@ -1,6 +1,5 @@
1 1
 # -*- coding: utf-8 -*-
2
-
3
-__author__ = 'damien'
2
+import transaction
4 3
 
5 4
 import tg
6 5
 
@@ -8,7 +7,9 @@ from tracim.model.auth import User
8 7
 
9 8
 from tracim.model import auth as pbma
10 9
 from tracim.model import DBSession
11
-import tracim.model.data as pmd
10
+
11
+__author__ = 'damien'
12
+
12 13
 
13 14
 class UserApi(object):
14 15
 
@@ -30,13 +31,22 @@ class UserApi(object):
30 31
     def get_one_by_id(self, id: int) -> User:
31 32
         return self._base_query().filter(User.user_id==id).one()
32 33
 
33
-    def update(self, user: User, name: str=None, email: str=None, do_save=True):
34
+    def update(
35
+            self,
36
+            user: User,
37
+            name: str=None,
38
+            email: str=None,
39
+            do_save=True,
40
+            timezone: str='',
41
+    ):
34 42
         if name is not None:
35 43
             user.display_name = name
36 44
 
37 45
         if email is not None:
38 46
             user.email = email
39 47
 
48
+        user.timezone = timezone
49
+
40 50
         if do_save:
41 51
             self.save(user)
42 52
 
@@ -70,6 +80,27 @@ class UserApi(object):
70 80
     def save(self, user: User):
71 81
         DBSession.flush()
72 82
 
83
+    def execute_created_user_actions(self, created_user: User) -> None:
84
+        """
85
+        Execute actions when user just been created
86
+        :return:
87
+        """
88
+        # NOTE: Cyclic import
89
+        from tracim.lib.calendar import CalendarManager
90
+        from tracim.model.organisational import UserCalendar
91
+
92
+        created_user.ensure_auth_token()
93
+
94
+        # Ensure database is up-to-date
95
+        DBSession.flush()
96
+        transaction.commit()
97
+
98
+        calendar_manager = CalendarManager(created_user)
99
+        calendar_manager.create_then_remove_fake_event(
100
+            calendar_class=UserCalendar,
101
+            related_object_id=created_user.user_id,
102
+        )
103
+
73 104
 
74 105
 class UserStaticApi(object):
75 106
 

+ 35 - 18
tracim/tracim/lib/workspace.py View File

@@ -1,29 +1,15 @@
1 1
 # -*- coding: utf-8 -*-
2
-
3
-__author__ = 'damien'
4
-
5
-import os
6
-from datetime import datetime
7
-from hashlib import sha256
8
-
9
-from sqlalchemy import Table, ForeignKey, Column
10
-from sqlalchemy.types import Unicode, Integer, DateTime, Text
11
-from sqlalchemy.orm import relation, synonym, contains_eager
12
-from sqlalchemy.orm import joinedload_all
13
-import sqlalchemy.orm as sqlao
14
-import sqlalchemy as sqla
15
-
16
-import tg
2
+import transaction
17 3
 
18 4
 from tracim.lib.userworkspace import RoleApi
19 5
 from tracim.model.auth import Group
20 6
 from tracim.model.auth import User
21 7
 from tracim.model.data import Workspace
22 8
 from tracim.model.data import UserRoleInWorkspace
23
-
24
-from tracim.model import auth as pbma
25 9
 from tracim.model import DBSession
26 10
 
11
+__author__ = 'damien'
12
+
27 13
 
28 14
 class WorkspaceApi(object):
29 15
 
@@ -39,10 +25,17 @@ class WorkspaceApi(object):
39 25
             filter(UserRoleInWorkspace.user_id==self._user.user_id).\
40 26
             filter(Workspace.is_deleted==False)
41 27
 
42
-    def create_workspace(self, label: str, description: str='', save_now:bool=False) -> Workspace:
28
+    def create_workspace(
29
+            self,
30
+            label: str,
31
+            description: str='',
32
+            calendar_enabled: bool=False,
33
+            save_now: bool=False,
34
+    ) -> Workspace:
43 35
         workspace = Workspace()
44 36
         workspace.label = label
45 37
         workspace.description = description
38
+        workspace.calendar_enabled = calendar_enabled
46 39
 
47 40
         # By default, we force the current user to be the workspace manager
48 41
         # And to receive email notifications
@@ -56,6 +49,9 @@ class WorkspaceApi(object):
56 49
         if save_now:
57 50
             DBSession.flush()
58 51
 
52
+        if calendar_enabled:
53
+            self.execute_created_workspace_actions(workspace)
54
+
59 55
         return workspace
60 56
 
61 57
     def get_one(self, id):
@@ -125,6 +121,27 @@ class WorkspaceApi(object):
125 121
 
126 122
         return workspace
127 123
 
124
+    def execute_created_workspace_actions(self, workspace: Workspace) -> None:
125
+        self.ensure_calendar_exist(workspace)
126
+
127
+    def ensure_calendar_exist(self, workspace: Workspace) -> None:
128
+        # Note: Cyclic imports
129
+        from tracim.lib.calendar import CalendarManager
130
+        from tracim.model.organisational import WorkspaceCalendar
131
+
132
+        if workspace.calendar_enabled:
133
+            self._user.ensure_auth_token()
134
+
135
+            # Ensure database is up-to-date
136
+            DBSession.flush()
137
+            transaction.commit()
138
+
139
+            calendar_manager = CalendarManager(self._user)
140
+            calendar_manager.create_then_remove_fake_event(
141
+                calendar_class=WorkspaceCalendar,
142
+                related_object_id=workspace.workspace_id,
143
+            )
144
+
128 145
 
129 146
 class UnsafeWorkspaceApi(WorkspaceApi):
130 147
     def _base_query(self):

+ 6 - 2
tracim/tracim/model/__init__.py View File

@@ -39,8 +39,12 @@ class RevisionsIntegrity(object):
39 39
 
40 40
 # Global session manager: DBSession() returns the Thread-local
41 41
 # session object appropriate for the current web request.
42
-maker = sessionmaker(autoflush=True, autocommit=False,
43
-                     extension=ZopeTransactionExtension())
42
+maker = sessionmaker(
43
+    autoflush=True,
44
+    autocommit=False,
45
+    extension=ZopeTransactionExtension(),
46
+    expire_on_commit=False,
47
+)
44 48
 DBSession = scoped_session(maker)
45 49
 
46 50
 # Base class for all of our model classes: By default, the data model is

+ 3 - 2
tracim/tracim/model/auth.py View File

@@ -68,7 +68,7 @@ class Group(DeclarativeBase):
68 68
     group_id = Column(Integer, Sequence('seq__groups__group_id'), autoincrement=True, primary_key=True)
69 69
     group_name = Column(Unicode(16), unique=True, nullable=False)
70 70
     display_name = Column(Unicode(255))
71
-    created = Column(DateTime, default=datetime.now)
71
+    created = Column(DateTime, default=datetime.utcnow)
72 72
 
73 73
     users = relationship('User', secondary=user_group_table, backref='groups')
74 74
 
@@ -121,9 +121,10 @@ class User(DeclarativeBase):
121 121
     email = Column(Unicode(255), unique=True, nullable=False)
122 122
     display_name = Column(Unicode(255))
123 123
     _password = Column('password', Unicode(128))
124
-    created = Column(DateTime, default=datetime.now)
124
+    created = Column(DateTime, default=datetime.utcnow)
125 125
     is_active = Column(Boolean, default=True, nullable=False)
126 126
     imported_from = Column(Unicode(32), nullable=True)
127
+    timezone = Column(Unicode(255), nullable=False, server_default='')
127 128
     _webdav_left_digest_response_hash = Column('webdav_left_digest_response_hash', Unicode(128))
128 129
     auth_token = Column(Unicode(255))
129 130
     auth_token_created = Column(DateTime)

+ 5 - 5
tracim/tracim/model/data.py View File

@@ -580,7 +580,7 @@ class ContentRevisionRO(DeclarativeBase):
580 580
             column_value = getattr(revision, column_name)
581 581
             setattr(new_rev, column_name, column_value)
582 582
 
583
-        new_rev.updated = datetime.now()
583
+        new_rev.updated = datetime.utcnow()
584 584
 
585 585
         return new_rev
586 586
 
@@ -1009,7 +1009,7 @@ class Content(DeclarativeBase):
1009 1009
 
1010 1010
     def created_as_delta(self, delta_from_datetime:datetime=None):
1011 1011
         if not delta_from_datetime:
1012
-            delta_from_datetime = datetime.now()
1012
+            delta_from_datetime = datetime.utcnow()
1013 1013
 
1014 1014
         return format_timedelta(delta_from_datetime - self.created,
1015 1015
                                 locale=tg.i18n.get_lang()[0])
@@ -1017,7 +1017,7 @@ class Content(DeclarativeBase):
1017 1017
     def datetime_as_delta(self, datetime_object,
1018 1018
                           delta_from_datetime:datetime=None):
1019 1019
         if not delta_from_datetime:
1020
-            delta_from_datetime = datetime.now()
1020
+            delta_from_datetime = datetime.utcnow()
1021 1021
         return format_timedelta(delta_from_datetime - datetime_object,
1022 1022
                                 locale=tg.i18n.get_lang()[0])
1023 1023
 
@@ -1232,7 +1232,7 @@ class VirtualEvent(object):
1232 1232
 
1233 1233
     def created_as_delta(self, delta_from_datetime:datetime=None):
1234 1234
         if not delta_from_datetime:
1235
-            delta_from_datetime = datetime.now()
1235
+            delta_from_datetime = datetime.utcnow()
1236 1236
         return format_timedelta(delta_from_datetime - self.created,
1237 1237
                                 locale=tg.i18n.get_lang()[0])
1238 1238
 
@@ -1240,7 +1240,7 @@ class VirtualEvent(object):
1240 1240
         aff = ''
1241 1241
 
1242 1242
         if not delta_from_datetime:
1243
-            delta_from_datetime = datetime.now()
1243
+            delta_from_datetime = datetime.utcnow()
1244 1244
 
1245 1245
         delta = delta_from_datetime - self.created
1246 1246
         

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

@@ -621,8 +621,10 @@ def serialize_content_for_general_list(content: Content, context: Context):
621 621
     last_activity_date = content.get_last_activity_date()
622 622
     last_activity_date_formatted = format_datetime(last_activity_date,
623 623
                                                    locale=tg.i18n.get_lang()[0])
624
-    last_activity_label = format_timedelta(datetime.now() - last_activity_date,
625
-                                           locale=tg.i18n.get_lang()[0])
624
+    last_activity_label = format_timedelta(
625
+        datetime.utcnow() - last_activity_date,
626
+        locale=tg.i18n.get_lang()[0],
627
+    )
626 628
     last_activity_label = last_activity_label.replace(' ', '\u00A0') # espace insécable
627 629
 
628 630
     return DictLikeClass(
@@ -645,7 +647,7 @@ def serialize_content_for_folder_content_list(content: Content, context: Context
645 647
     last_activity_date = content.get_last_activity_date()
646 648
     last_activity_date_formatted = format_datetime(last_activity_date,
647 649
                                                    locale=tg.i18n.get_lang()[0])
648
-    last_activity_label = format_timedelta(datetime.now() - last_activity_date,
650
+    last_activity_label = format_timedelta(datetime.utcnow() - last_activity_date,
649 651
                                            locale=tg.i18n.get_lang()[0])
650 652
     last_activity_label = last_activity_label.replace(' ', '\u00A0') # espace insécable
651 653
 
@@ -858,6 +860,7 @@ def serialize_user_list_default(user: User, context: Context):
858 860
     result['enabled'] = user.is_active
859 861
     result['profile'] = user.profile
860 862
     result['has_password'] = user.password!=None
863
+    result['timezone'] = user.timezone
861 864
     return result
862 865
 
863 866
 
@@ -880,6 +883,7 @@ def serialize_user_for_user(user: User, context: Context):
880 883
     result['enabled'] = user.is_active
881 884
     result['profile'] = user.profile
882 885
     result['calendar_url'] = user.calendar_url
886
+    result['timezone'] = user.timezone
883 887
 
884 888
     return result
885 889
 

+ 2 - 3
tracim/tracim/templates/admin/workspace_getone.mak View File

@@ -24,9 +24,8 @@
24 24
 <%def name="TITLE_ROW()">
25 25
     <div class="row-fluid">
26 26
         <div>
27
-            <%
28
-                subtitle = _('created on {}').format(h.formatLongDateAndTime(result.workspace.created))
29
-            %>
27
+            <% created_localized = h.get_with_timezone(result.workspace.created) %>
28
+            <% subtitle = _('workspace created on {date} at {time}').format(date=h.date(created_localized), time=h.time(created_localized)) %>
30 29
             ${ROW.TITLE_ROW(_('Workspace {}').format(result.workspace.label), 'fa-bank', 'col-md-offset-3 col-md-7', 't-user-color', subtitle)}
31 30
         </div>
32 31
     </div>

+ 4 - 2
tracim/tracim/templates/file/getone.mak View File

@@ -48,7 +48,8 @@
48 48
         </h1>
49 49
 
50 50
         <div style="margin: -1.5em auto -1.5em auto;" class="tracim-less-visible">
51
-          <p>${_('file created on {date} at {time} by <b>{author}</b>').format(date=h.date(result.file.created), time=h.time(result.file.created), author=result.file.owner.name)|n}</p>
51
+            <% created_localized = h.get_with_timezone(result.file.created) %>
52
+          <p>${_('file created on {date} at {time} by <b>{author}</b>').format(date=h.date(created_localized), time=h.time(created_localized), author=result.file.owner.name)|n}</p>
52 53
         </div>
53 54
     </div>
54 55
 </div>
@@ -107,7 +108,8 @@
107 108
                 </tr>
108 109
                 <tr>
109 110
                     <td class="tracim-title">${_('Modified')}</td>
110
-                    <td>${h.format_short(result.file.created)|n} ${_('by {}').format(result.file.owner.name)}</td>
111
+                    <% created_localized = h.get_with_timezone(result.file.created) %>
112
+                    <td>${h.format_short(created_localized)|n} ${_('by {}').format(result.file.owner.name)}</td>
111 113
                 </tr>
112 114
             </table>
113 115
         </div>

+ 2 - 1
tracim/tracim/templates/folder/getone.mak View File

@@ -46,7 +46,8 @@
46 46
         </h1>
47 47
 
48 48
         <div style="margin: -1.5em auto -1.5em auto;" class="tracim-less-visible">
49
-          <p>${_('folder created on {date} at {time} by <b>{author}</b>').format(date=h.date(result.folder.created), time=h.time(result.folder.created), author=result.folder.owner.name)|n}</p>
49
+            <% created_localized = h.get_with_timezone(result.folder.created) %>
50
+          <p>${_('folder created on {date} at {time} by <b>{author}</b>').format(date=h.date(created_localized), time=h.time(created_localized), author=result.folder.owner.name)|n}</p>
50 51
         </div>
51 52
     </div>
52 53
 </div>

+ 2 - 1
tracim/tracim/templates/page/getone.mak View File

@@ -47,7 +47,8 @@
47 47
         </h1>
48 48
 
49 49
         <div style="margin: -1.5em auto -1.5em auto;" class="tracim-less-visible">
50
-          <p>${_('page created on {date} at {time} by <b>{author}</b>').format(date=h.date(result.page.created), time=h.time(result.page.created), author=result.page.owner.name)|n}</p>
50
+            <% created_localized = h.get_with_timezone(result.page.created) %>
51
+          <p>${_('page created on {date} at {time} by <b>{author}</b>').format(date=h.date(created_localized), time=h.time(created_localized), author=result.page.owner.name)|n}</p>
51 52
         </div>
52 53
     </div>
53 54
 </div>

+ 2 - 1
tracim/tracim/templates/search/display.mak View File

@@ -42,7 +42,8 @@
42 42
         </h1>
43 43
 
44 44
         <div style="margin: -1.5em auto -1.5em auto;" class="tracim-less-visible">
45
-##          <p>${_('folder created on {date} at {time} by <b>{author}</b>').format(date=h.date(result.folder.created), time=h.time(result.folder.created), author=result.folder.owner.name)|n}</p>
45
+            <% created_localized = h.get_with_timezone(result.folder.created) %>
46
+##          <p>${_('folder created on {date} at {time} by <b>{author}</b>').format(date=h.date(created_localized), time=h.time(created_localized), author=result.folder.owner.name)|n}</p>
46 47
         </div>
47 48
     </div>
48 49
 </div>

+ 2 - 1
tracim/tracim/templates/thread/getone.mak View File

@@ -48,7 +48,8 @@
48 48
         </h1>
49 49
 
50 50
         <div style="margin: -1.5em auto -1.5em auto;" class="tracim-less-visible">
51
-          <p>${_('page created on {date} at {time} by <b>{author}</b>').format(date=h.date(result.thread.created), time=h.time(result.thread.created), author=result.thread.owner.name)|n}</p>
51
+            <% created_localized = h.get_with_timezone(result.thread.created) %>
52
+          <p>${_('page created on {date} at {time} by <b>{author}</b>').format(date=h.date(created_localized), time=h.time(created_localized), author=result.thread.owner.name)|n}</p>
52 53
         </div>
53 54
     </div>
54 55
 </div>

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

@@ -115,6 +115,20 @@
115 115
                 <span class="info readonly">${_('This calendar URL will work with CalDav compatibles clients')}</span>
116 116
                 <input id="calendar" type="text" class="form-control"  disabled="disabled" value="${user.calendar_url}" />
117 117
             </div>
118
+            <div class="form-group">
119
+                <label for="timezone">${_('Timezone')}</label>
120
+                <span class="info readonly">${_('Dates will be displayed with this timezone')}</span>
121
+                <select id="timezone" name="timezone" class="form-control">
122
+                    <option value=""></option>
123
+                    % for timezone in timezones:
124
+                        % if timezone == user.timezone:
125
+                            <option value="${timezone}" selected>${timezone}</option>
126
+                        % else:
127
+                            <option value="${timezone}">${timezone}</option>
128
+                        % endif
129
+                    % endfor
130
+                </select>
131
+            </div>
118 132
         </div>
119 133
         <div class="modal-footer">
120 134
             <span class="pull-right t-modal-form-submit-button">

+ 6 - 3
tracim/tracim/templates/user_workspace_widgets.mak View File

@@ -303,6 +303,7 @@
303 303
 </%def>
304 304
 
305 305
 <%def name="SECURED_TIMELINE_ITEM(user, item)">
306
+##     <% created_localized = h.get_with_timezone(item.created) %>
306 307
 ##     <div class="row t-odd-or-even t-hacky-thread-comment-border-top">
307 308
 ##         <div class="col-sm-7 col-sm-offset-3">
308 309
 ##             <div class="t-timeline-item">
@@ -312,7 +313,7 @@
312 313
 ##                 <h5 style="margin: 0;">
313 314
 ##                     <span class="tracim-less-visible">${_('<strong>{}</strong> wrote:').format(item.owner.name)|n}</span>
314 315
 ##
315
-##                     <div class="pull-right text-right t-timeline-item-moment" title="${h.date_time(item.created)|n}">
316
+##                     <div class="pull-right text-right t-timeline-item-moment" title="${h.date_time(created_localized)|n}">
316 317
 ##                         ${_('{delta} ago').format(delta=item.created_as_delta)}
317 318
 ##
318 319
 ##                         % if h.is_item_still_editable(item) and item.owner.id==user.id:
@@ -336,6 +337,7 @@
336 337
 </%def>
337 338
 
338 339
 <%def name="SECURED_HISTORY_VIRTUAL_EVENT(user, event)">
340
+    <% created_localized = h.get_with_timezone(event.created) %>
339 341
     <% is_new_css_class = 't-is-new-content' if event.is_new else '' %>
340 342
 
341 343
     <div class="row t-odd-or-even t-hacky-thread-comment-border-top ${is_new_css_class}">
@@ -353,7 +355,7 @@
353 355
                         <span class="tracim-less-visible">${_('{} by <strong>{}</strong>').format(event.label, event.owner.name)|n}</span>
354 356
                     % endif
355 357
 
356
-                    <div class="pull-right text-right t-timeline-item-moment" title="${h.date_time(event.created)|n}">
358
+                    <div class="pull-right text-right t-timeline-item-moment" title="${h.date_time(created_localized)|n}">
357 359
                         ${_('{delta} ago').format(delta=event.created_as_delta)}
358 360
 
359 361
                         % if h.is_item_still_editable(CFG, event) and event.owner.id==user.id:
@@ -374,6 +376,7 @@
374 376
 </%def>
375 377
 
376 378
 <%def name="SECURED_HISTORY_VIRTUAL_EVENT_AS_TABLE_ROW(user, event, current_revision_id)">
379
+    <% created_localized = h.get_with_timezone(event.created) %>
377 380
     <%
378 381
         warning_or_not = ('', 'warning')[current_revision_id==event.id]
379 382
         row_css = 't-is-new-content' if event.is_new else warning_or_not
@@ -382,7 +385,7 @@
382 385
         <td class="t-less-visible">
383 386
             <span class="label label-default">${ICON.FA_FW(event.type.icon)} ${event.type.label}</span>
384 387
         </td>
385
-        <td title="${h.date_time(event.created)|n}">${_('{delta} ago').format(delta=event.created_as_delta)}</td>
388
+        <td title="${h.date_time(created_localized)|n}">${_('{delta} ago').format(delta=event.created_as_delta)}</td>
386 389
         <td>${event.owner.name}</td>
387 390
 ## FIXME - REMOVE                            <td>${event}</td>
388 391
 

+ 2 - 1
tracim/tracim/templates/workspace/getone.mak View File

@@ -44,7 +44,8 @@
44 44
         </h1>
45 45
 
46 46
         <div style="margin: -1.5em auto -1.5em auto;" class="t-less-visible">
47
-          <p>${_('workspace created on {date} at {time}').format(date=h.date(result.workspace.created), time=h.time(result.workspace.created))|n}</p>
47
+            <% created_localized = h.get_with_timezone(result.workspace.created) %>
48
+          <p>${_('workspace created on {date} at {time}').format(date=h.date(created_localized), time=h.time(created_localized))|n}</p>
48 49
         </div>
49 50
     </div>
50 51
 </div>

+ 3 - 2
tracim/tracim/tests/__init__.py View File

@@ -51,7 +51,8 @@ class TestApp(BaseTestApp):
51 51
         try:
52 52
             super()._check_status(status, res)
53 53
         except AppError as exc:
54
-            dump_file_path = "/tmp/debug_%d_%s.html" % (time.time() * 1000, res.request.path_qs[1:])
54
+            escaped_page_name = res.request.path_qs[1:].replace('/', '')
55
+            dump_file_path = "/tmp/debug_%d_%s.html" % (time.time() * 1000, escaped_page_name)
55 56
             if os.path.exists("/tmp"):
56 57
                 with open(dump_file_path, 'w') as dump_file:
57 58
                     print(res.ubody, file=dump_file)
@@ -354,7 +355,7 @@ class BaseTestThread(BaseTest):
354 355
         return thread
355 356
 
356 357
 
357
-class TestCalendar(TestController):
358
+class TestCalendar(TracimTestController):
358 359
     fixtures = [BaseFixture, TestFixture]
359 360
     application_under_test = 'radicale'
360 361
 

+ 109 - 0
tracim/tracim/tests/functional/test_calendar.py View File

@@ -1,14 +1,17 @@
1
+import os
1 2
 import time
2 3
 
3 4
 import caldav
4 5
 import transaction
5 6
 from caldav.lib.error import AuthorizationError
7
+from collections import OrderedDict
6 8
 from nose.tools import eq_
7 9
 from nose.tools import ok_
8 10
 from nose.tools import raises
9 11
 import requests
10 12
 from requests.exceptions import ConnectionError
11 13
 from sqlalchemy.orm.exc import NoResultFound
14
+from tg import config
12 15
 
13 16
 from tracim.config.app_cfg import daemons
14 17
 from tracim.lib.calendar import CalendarManager
@@ -18,6 +21,7 @@ from tracim.tests import TestCalendar as BaseTestCalendar
18 21
 from tracim.tests import not_raises
19 22
 from tracim.model.auth import User
20 23
 from tracim.model.data import Content
24
+from tracim.model.data import Workspace
21 25
 
22 26
 
23 27
 class TestCalendar(BaseTestCalendar):
@@ -203,3 +207,108 @@ END:VCALENDAR
203 207
         eq_(event.properties['location'], 'Here')
204 208
         eq_(event.properties['start'], '2010-05-12 18:00:00+0000')
205 209
         eq_(event.properties['end'], '2010-05-12 17:00:00+0000')
210
+
211
+    def test_created_user_radicale_calendar(self):
212
+        self._connect_user(
213
+            'admin@admin.admin',
214
+            'admin@admin.admin',
215
+        )
216
+
217
+        user_count = DBSession.query(User)\
218
+            .filter(User.email == 'an-other-email@test.local').count()
219
+        eq_(0, user_count, 'User should not exist yet')
220
+
221
+        radicale_users_folder = '{0}/user'\
222
+            .format(config.get('radicale.server.filesystem.folder'))
223
+        eq_(
224
+            False,
225
+            os.path.isdir(radicale_users_folder),
226
+            'Radicale users folder should not exist yet',
227
+        )
228
+
229
+        # Create a new user, his calendar should be created to
230
+        try_post_user = self.app.post(
231
+            '/admin/users',
232
+            OrderedDict([
233
+                ('name', 'TEST'),
234
+                ('email', 'an-other-email@test.local'),
235
+                ('password', 'an-other-email@test.local'),
236
+                ('is_tracim_manager', 'off'),
237
+                ('is_tracim_admin', 'off'),
238
+                ('send_email', 'off'),
239
+            ])
240
+        )
241
+
242
+        eq_(try_post_user.status_code, 302,
243
+            "Code should be 302, but is %d" % try_post_user.status_code)
244
+
245
+        users_calendars = len([
246
+            name for name in os.listdir(radicale_users_folder)
247
+            if name.endswith('.ics')
248
+        ])
249
+
250
+        user = DBSession.query(User) \
251
+            .filter(User.email == 'an-other-email@test.local').one()
252
+
253
+        eq_(1, users_calendars, 'Radicale user path should list 1 calendar')
254
+        user_calendar = '{0}/{1}.ics'.format(
255
+            radicale_users_folder,
256
+            user.user_id,
257
+        )
258
+        user_calendar_exist = os.path.isfile(user_calendar)
259
+        eq_(True, user_calendar_exist, 'User calendar should be created')
260
+
261
+    def test_created_workspace_radicale_calendar(self):
262
+        self._connect_user(
263
+            'admin@admin.admin',
264
+            'admin@admin.admin',
265
+        )
266
+
267
+        workspaces_count = DBSession.query(Workspace)\
268
+            .filter(Workspace.label == 'WTESTCAL').count()
269
+        eq_(0, workspaces_count, 'Workspace should not exist yet !')
270
+
271
+        radicale_workspaces_folder = '{0}/workspace'\
272
+            .format(config.get('radicale.server.filesystem.folder'))
273
+        eq_(
274
+            False,
275
+            os.path.isdir(radicale_workspaces_folder),
276
+            'Radicale workskpaces folder should not exist yet',
277
+        )
278
+
279
+        # Create a new workspace, his calendar should be created to
280
+        try_post_workspace = self.app.post(
281
+            '/admin/workspaces',
282
+            OrderedDict([
283
+                ('name', 'WTESTCAL'),
284
+                ('description', 'WTESTCALDESCR'),
285
+                ('calendar_enabled', 'on'),
286
+            ])
287
+        )
288
+
289
+        eq_(try_post_workspace.status_code, 302,
290
+            "Code should be 302, but is %d" % try_post_workspace.status_code)
291
+
292
+        workspaces_calendars = len([
293
+            name for name in os.listdir(radicale_workspaces_folder)
294
+            if name.endswith('.ics')
295
+        ])
296
+
297
+        workspace = DBSession.query(Workspace) \
298
+            .filter(Workspace.label == 'WTESTCAL').one()
299
+
300
+        eq_(
301
+            1,
302
+            workspaces_calendars,
303
+            'Radicale workspace path should list 1 calendar',
304
+        )
305
+        workspace_calendar = '{0}/{1}.ics'.format(
306
+            radicale_workspaces_folder,
307
+            workspace.workspace_id,
308
+        )
309
+        workspace_calendar_exist = os.path.isfile(workspace_calendar)
310
+        eq_(
311
+            True,
312
+            workspace_calendar_exist,
313
+            'Workspace calendar should be created',
314
+        )

+ 1 - 0
tracim/tracim/tests/functional/test_ldap_restrictions.py View File

@@ -68,6 +68,7 @@ class TestAuthentication(LDAPTest, TracimTestController):
68 68
             OrderedDict([
69 69
                 ('name', 'Lawrence Lessig YEAH'),
70 70
                 ('email', 'An-other-email@fsf.org'),
71
+                ('timezone', ''),
71 72
             ])
72 73
         )
73 74
 

+ 21 - 8
tracim/tracim/tests/library/test_helpers.py View File

@@ -1,20 +1,18 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 
3 3
 import datetime
4
+from unittest.mock import MagicMock
4 5
 
6
+import pytz
7
+from babel.dates import get_timezone
5 8
 from nose.tools import eq_
6
-from nose.tools import ok_
9
+from tg.request_local import TurboGearsContextMember
10
+from tg.util.webtest import test_context
11
+from tg import tmpl_context
7 12
 
8 13
 import tracim.lib.helpers as h
9 14
 from tracim.config.app_cfg import CFG
10
-from tracim.model.data import Content
11
-from tracim.model.data import ContentType
12
-from tracim.model.data import Workspace
13
-
14
-from tracim.model.serializers import Context
15
-from tracim.model.serializers import CTX
16 15
 from tracim.model.serializers import DictLikeClass
17
-
18 16
 from tracim.tests import TestStandard
19 17
 
20 18
 
@@ -45,3 +43,18 @@ class TestHelpers(TestStandard):
45 43
         config.DATA_UPDATE_ALLOWED_DURATION = 8
46 44
         item.created = datetime.datetime.now() - datetime.timedelta(0, 10)
47 45
         eq_(False, h.is_item_still_editable(config, item))
46
+
47
+    def test_unit__change_datetime_timezone__ok__with_naive_and_current_user(self):  # nopep8
48
+        user_mock = MagicMock(timezone='America/Guadeloupe')
49
+
50
+        with test_context(self.app):
51
+            tmpl_context.current_user = user_mock
52
+            naive_datetime = datetime.datetime(2000, 1, 1, 0, 0, 0)
53
+
54
+            new_datetime = h.get_with_timezone(
55
+                datetime_object=naive_datetime,
56
+                default_from_timezone='UTC',
57
+                to_timezone='',  # user_mock.timezone should be used
58
+            )
59
+
60
+            eq_(str(new_datetime), '1999-12-31 20:00:00-04:00')