浏览代码

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

Damien ACCORSI 7 年前
父节点
当前提交
4268b2cd42
共有 100 个文件被更改,包括 3391 次插入910 次删除
  1. 1 0
      .gitignore
  2. 151 0
      API.md
  3. 2 1
      README.md
  4. 7 4
      install/requirements.txt
  5. 17 2
      tracim/development.ini.base
  6. 1 0
      tracim/migration/env.py
  7. 77 0
      tracim/migration/versions/15305f71bfda_fill_content_file_extension_column.py
  8. 26 0
      tracim/migration/versions/2cd20ff3d23a_user_timezone.py
  9. 31 0
      tracim/migration/versions/59fc98c3c965_delete_content_file_name_column.py
  10. 78 0
      tracim/migration/versions/c1cea4bbae16_file_extentions_value_for_pages_and_.py
  11. 33 0
      tracim/migration/versions/e31ddc009b37_add_content_file_extension_column.py
  12. 2 0
      tracim/setup.py
  13. 9 0
      tracim/test.ini
  14. 16 0
      tracim/tracim/command/mail.py
  15. 17 3
      tracim/tracim/command/user.py
  16. 14 0
      tracim/tracim/config/__init__.py
  17. 99 20
      tracim/tracim/config/app_cfg.py
  18. 6 2
      tracim/tracim/config/middleware.py
  19. 34 8
      tracim/tracim/controllers/__init__.py
  20. 10 7
      tracim/tracim/controllers/admin/user.py
  21. 27 15
      tracim/tracim/controllers/admin/workspace.py
  22. 76 0
      tracim/tracim/controllers/api.py
  23. 6 1
      tracim/tracim/controllers/calendar.py
  24. 177 49
      tracim/tracim/controllers/content.py
  25. 7 3
      tracim/tracim/controllers/root.py
  26. 31 29
      tracim/tracim/controllers/user.py
  27. 26 5
      tracim/tracim/controllers/workspace.py
  28. 131 0
      tracim/tracim/fixtures/content.py
  29. 二进制
      tracim/tracim/i18n/fr/LC_MESSAGES/tracim.mo
  30. 534 329
      tracim/tracim/i18n/fr/LC_MESSAGES/tracim.po
  31. 1 1
      tracim/tracim/lib/__init__.py
  32. 1 2
      tracim/tracim/lib/app_globals.py
  33. 101 11
      tracim/tracim/lib/calendar.py
  34. 275 41
      tracim/tracim/lib/content.py
  35. 77 35
      tracim/tracim/lib/daemons.py
  36. 41 9
      tracim/tracim/lib/email.py
  37. 42 14
      tracim/tracim/lib/helpers.py
  38. 103 0
      tracim/tracim/lib/integrity.py
  39. 55 39
      tracim/tracim/lib/notifications.py
  40. 1 1
      tracim/tracim/lib/predicates.py
  41. 2 0
      tracim/tracim/lib/radicale/auth.py
  42. 1 1
      tracim/tracim/lib/radicale/storage.py
  43. 90 20
      tracim/tracim/lib/user.py
  44. 114 12
      tracim/tracim/lib/utils.py
  45. 1 1
      tracim/tracim/lib/webdav/__init__.py
  46. 144 53
      tracim/tracim/lib/webdav/design.py
  47. 24 61
      tracim/tracim/lib/webdav/sql_dav_provider.py
  48. 96 64
      tracim/tracim/lib/webdav/sql_resources.py
  49. 12 1
      tracim/tracim/lib/webdav/tracim_http_authenticator.py
  50. 285 0
      tracim/tracim/lib/webdav/utils.py
  51. 60 18
      tracim/tracim/lib/workspace.py
  52. 10 4
      tracim/tracim/model/__init__.py
  53. 13 5
      tracim/tracim/model/auth.py
  54. 119 24
      tracim/tracim/model/data.py
  55. 77 15
      tracim/tracim/model/serializers.py
  56. 0 0
      tracim/tracim/public/_caldavzap/.gitignore
  57. 0 0
      tracim/tracim/public/_caldavzap/.htaccess
  58. 0 0
      tracim/tracim/public/_caldavzap/auth/.htaccess
  59. 0 0
      tracim/tracim/public/_caldavzap/auth/common.inc
  60. 0 0
      tracim/tracim/public/_caldavzap/auth/config.inc
  61. 0 0
      tracim/tracim/public/_caldavzap/auth/cross_domain.inc
  62. 0 0
      tracim/tracim/public/_caldavzap/auth/doc/example_config_response.xml
  63. 0 0
      tracim/tracim/public/_caldavzap/auth/doc/readme.txt
  64. 0 0
      tracim/tracim/public/_caldavzap/auth/index.php
  65. 0 0
      tracim/tracim/public/_caldavzap/auth/plugins/generic.inc
  66. 0 0
      tracim/tracim/public/_caldavzap/auth/plugins/generic_conf.inc
  67. 0 0
      tracim/tracim/public/_caldavzap/auth/plugins/ldap.inc
  68. 0 0
      tracim/tracim/public/_caldavzap/auth/plugins/ldap_conf.inc
  69. 0 0
      tracim/tracim/public/_caldavzap/cache.manifest
  70. 0 0
      tracim/tracim/public/_caldavzap/cache_handler.js
  71. 0 0
      tracim/tracim/public/_caldavzap/cache_update.sh
  72. 0 0
      tracim/tracim/public/_caldavzap/changelog.txt
  73. 0 0
      tracim/tracim/public/_caldavzap/common.js
  74. 0 0
      tracim/tracim/public/_caldavzap/config.js
  75. 0 0
      tracim/tracim/public/_caldavzap/css/default.css
  76. 0 0
      tracim/tracim/public/_caldavzap/css/default_integration.css
  77. 0 0
      tracim/tracim/public/_caldavzap/css/fullcalendar.css
  78. 0 0
      tracim/tracim/public/_caldavzap/css/jquery-ui.custom.css
  79. 0 0
      tracim/tracim/public/_caldavzap/css/spectrum.custom.css
  80. 0 0
      tracim/tracim/public/_caldavzap/data_process.js
  81. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-Bold-webfont.eot
  82. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-Bold-webfont.svg
  83. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-Bold-webfont.ttf
  84. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-Bold-webfont.woff
  85. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-BoldItalic-webfont.eot
  86. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-BoldItalic-webfont.svg
  87. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-BoldItalic-webfont.ttf
  88. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-BoldItalic-webfont.woff
  89. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-Italic-webfont.eot
  90. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-Italic-webfont.svg
  91. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-Italic-webfont.ttf
  92. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-Italic-webfont.woff
  93. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-Light-webfont.eot
  94. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-Light-webfont.svg
  95. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-Light-webfont.ttf
  96. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-Light-webfont.woff
  97. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-LightItalic-webfont.eot
  98. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-LightItalic-webfont.svg
  99. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-LightItalic-webfont.ttf
  100. 0 0
      tracim/tracim/public/_caldavzap/fonts/Roboto-LightItalic-webfont.woff

+ 1 - 0
.gitignore 查看文件

@@ -63,3 +63,4 @@ track.js
63 63
 wsgidav.conf
64 64
 # Temporary files
65 65
 *~
66
+*.sqlite

+ 151 - 0
API.md 查看文件

@@ -0,0 +1,151 @@
1
+# API documentation
2
+
3
+## Authentication
4
+
5
+APi not actually implement authentication method. You must use cookies set by
6
+frontend login.
7
+
8
+## Workspaces
9
+
10
+### List
11
+
12
+    GET /api/workspaces/
13
+
14
+Return list of workspaces acessible by current connected user.
15
+
16
+#### Response
17
+
18
+    {
19
+       "value_list":[
20
+          {
21
+             "id":30,
22
+             "label":"my calendar",
23
+             "description":"blablabla",
24
+             "has_calendar":"true"
25
+          },
26
+          {
27
+             "id":230,
28
+             "label":"my calendar other",
29
+             "description":"blablabla 230",
30
+             "has_calendar":"true"
31
+          }
32
+       ]
33
+    }
34
+
35
+## Users
36
+
37
+### List
38
+
39
+    GET /api/users/
40
+
41
+Return list of all users of the tracim instance
42
+
43
+#### Response
44
+
45
+    {
46
+      "value_list": [
47
+        {
48
+          "id": 0,
49
+          "name": "Georges Abitbol",
50
+          "email": "g.abitbol@laclasse.com",
51
+          "canCreateWs": true,
52
+          "isAdmin": true,
53
+          "config": {
54
+            "sendEmailNotif": true
55
+          }
56
+        }, {
57
+          "id": 145,
58
+          "name": "Peter",
59
+          "email": "peter@laclasse.com",
60
+          "canCreateWs": false,
61
+          "isAdmin": false,
62
+          "config": {
63
+            "sendEmailNotif": false
64
+        }
65
+      ]
66
+    }
67
+
68
+## Users_Workspace (Role)
69
+
70
+### List
71
+
72
+    GET /api/users_workspace/
73
+
74
+Return list of all roles of all workspaces the connected user has access to
75
+
76
+#### Response
77
+
78
+    {
79
+      "value_list": [
80
+        {
81
+          "userId": 0,
82
+          "workspaceId": 1,
83
+          "roleId": 8,
84
+          "subscribedNotif": true
85
+        }, {
86
+          "userId": 2,
87
+          "workspaceId": 2,
88
+          "roleId": 2,
89
+          "subscribedNotif": true
90
+        }, {
91
+          "userId": 5,
92
+          "workspaceId": 3,
93
+          "roleId": 4,
94
+          "subscribedNotif": false
95
+        }
96
+      ]
97
+    }
98
+
99
+## Timezone
100
+
101
+### List
102
+
103
+    GET /api/timezone/
104
+
105
+Return list of all timezone available when creating a user
106
+
107
+#### Response
108
+
109
+    {
110
+      "value_list": [
111
+        "Africa/Abidjan",
112
+        "Africa/Accra",
113
+        "Africa/Addis_Ababa",
114
+        "Africa/Algiers",
115
+        "Africa/Asmara",
116
+        ...
117
+      ]
118
+    }
119
+
120
+## Calendars
121
+
122
+### List
123
+
124
+    GET /api/calendars/
125
+
126
+Return list of calendars accessible by current connected user.
127
+
128
+#### Response
129
+
130
+    {
131
+       "value_list":[
132
+          {
133
+             "id":30,
134
+             "label":"my calendar",
135
+             "description":"blablabla 230",
136
+             "type": "workspace"
137
+          },
138
+          {
139
+             "id":230,
140
+             "label":"my other calendar",
141
+             "description":"blablabla 230",
142
+             "type": "workspace"
143
+          },
144
+          {
145
+             "id":20,
146
+             "label":"Name of the user",
147
+             "description":"my personnal calendar",
148
+             "type": "user"
149
+          }
150
+       ]
151
+    }

+ 2 - 1
README.md 查看文件

@@ -318,7 +318,8 @@ The reset password related parameters are the follwoing ones :
318 318
 The main parameters for notifications are the following ones:
319 319
 
320 320
     email.notification.activated = true
321
-    email.notification.from = Tracim Notification <tracim@tmycompany.com>
321
+    email.notification.from.email = noreply@trac.im
322
+    email.notification.from.default_label = Tracim Notification
322 323
     email.notification.smtp.server = smtp.mycompany.com
323 324
     email.notification.smtp.port = 25
324 325
     email.notification.smtp.user = username

+ 7 - 4
install/requirements.txt 查看文件

@@ -47,11 +47,10 @@ speaklater==1.3
47 47
 sprox==0.9.4
48 48
 stevedore==1.1.0
49 49
 tg.devtools==2.3.7
50
-tgapp-resetpassword==0.1.8
50
+git+https://github.com/algoo/tgapp-resetpassword.git
51 51
 tgext.admin==0.6.4
52
-tgext.asyncjob==0.3.1
53 52
 tgext.crud==0.7.3
54
-tgext.pluggable==0.5.5
53
+tgext.pluggable==0.6.2
55 54
 transaction==1.4.4
56 55
 tw2.core==2.2.2
57 56
 tw2.forms==2.2.2.1
@@ -62,4 +61,8 @@ who-ldap==3.1.0
62 61
 -e git+https://github.com/algoo/wsgidav.git@py3#egg=wsgidav
63 62
 zope.interface==4.1.3
64 63
 zope.sqlalchemy==0.7.6
65
-
64
+PyYAML
65
+redis==2.10.5
66
+typing==3.5.3.0
67
+rq==0.7.1
68
+click==6.7

+ 17 - 2
tracim/development.ini.base 查看文件

@@ -176,7 +176,8 @@ website.base_url = http://127.0.0.1:8080
176 176
 website.server_name = 127.0.0.1
177 177
     
178 178
 email.notification.activated = False
179
-email.notification.from = Tracim Notification <noreply@trac.im>
179
+email.notification.from.email = noreply@trac.im
180
+email.notification.from.default_label = Tracim Notifications
180 181
 email.notification.content_update.template.html = ./tracim/templates/mail/content_update_body_html.mak
181 182
 email.notification.content_update.template.text = ./tracim/templates/mail/content_update_body_text.mak
182 183
 email.notification.created_account.template.html = ./tracim/templates/mail/created_account_body_html.mak
@@ -191,6 +192,14 @@ email.notification.smtp.port = 25
191 192
 email.notification.smtp.user = your_smtp_user
192 193
 email.notification.smtp.password = your_smtp_password
193 194
 
195
+## Email sending configuration
196
+# processing_mode may be sync or async,
197
+# with async, please configure redis below
198
+email.processing_mode = sync
199
+# email.async.redis.host = localhost
200
+# email.async.redis.port = 6379
201
+# email.async.redis.db = 0
202
+
194 203
 ## Radical (CalDav server) configuration
195 204
 # radicale.server.host = 0.0.0.0
196 205
 # radicale.server.port = 5232
@@ -200,10 +209,16 @@ email.notification.smtp.password = your_smtp_password
200 209
 # radicale.server.realm_message = Tracim Calendar - Password Required
201 210
 ## url can be extended like http://127.0.0.1:5232/calendar
202 211
 ## in this case, you have to create your own proxy behind this url.
203
-# radicale.client.base_url = http://127.0.0.1:5232
212
+## and update following parameters
213
+# radicale.client.base_url.host = http://127.0.0.1:5232
214
+# radicale.client.base_url.prefix = /
204 215
 
205 216
 ## WSGIDAV
206 217
 wsgidav.config_path = wsgidav.conf
218
+## url can be extended like 127.0.0.1/webdav
219
+## in this case, you have to create your own proxy behind this url.
220
+## Do not set http:// prefix.
221
+# wsgidav.client.base_url = 127.0.0.1:<WSGIDAV_PORT>
207 222
 
208 223
 #####
209 224
 #

+ 1 - 0
tracim/migration/env.py 查看文件

@@ -65,6 +65,7 @@ def run_migrations_online():
65 65
             context.run_migrations()
66 66
     finally:
67 67
         connection.close()
68
+        engine.dispose()
68 69
 
69 70
 if context.is_offline_mode():
70 71
     run_migrations_offline()

+ 77 - 0
tracim/migration/versions/15305f71bfda_fill_content_file_extension_column.py 查看文件

@@ -0,0 +1,77 @@
1
+"""fill content file_extension column
2
+
3
+Revision ID: 15305f71bfda
4
+Revises: e31ddc009b37
5
+Create Date: 2016-11-25 10:50:01.874820
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+import os
11
+
12
+revision = '15305f71bfda'
13
+down_revision = 'e31ddc009b37'
14
+
15
+from alembic import op
16
+import sqlalchemy as sa
17
+
18
+
19
+content_revision_helper = sa.Table(
20
+    'content_revisions',
21
+    sa.MetaData(),
22
+    sa.Column('revision_id', sa.Integer, primary_key=True),
23
+    sa.Column('content_id', sa.ForeignKey(u'content.id'), nullable=False),
24
+    sa.Column('owner_id', sa.ForeignKey(u'users.user_id'), index=True),
25
+    sa.Column('label', sa.String(1024), nullable=False),
26
+    sa.Column('description', sa.Text, nullable=False),
27
+    sa.Column('file_name', sa.String(255), nullable=False),
28
+    sa.Column('file_extension', sa.String(255), nullable=False),
29
+    sa.Column('file_mimetype', sa.String(255), nullable=False),
30
+    sa.Column('file_content', sa.LargeBinary),
31
+    sa.Column('properties', sa.Text, nullable=False),
32
+    sa.Column('type', sa.String(32), nullable=False),
33
+    sa.Column('status', sa.String(32), nullable=False),
34
+    sa.Column('created', sa.DateTime, nullable=False),
35
+    sa.Column('updated', sa.DateTime, nullable=False),
36
+    sa.Column('is_deleted', sa.Boolean, nullable=False),
37
+    sa.Column('is_archived', sa.Boolean, nullable=False),
38
+    sa.Column('is_temporary', sa.Boolean, nullable=False),
39
+    sa.Column('revision_type', sa.String(32), nullable=False),
40
+    sa.Column('workspace_id', sa.ForeignKey(u'workspaces.workspace_id')),
41
+    sa.Column('parent_id', sa.ForeignKey(u'content.id'), index=True),
42
+)
43
+
44
+
45
+def upgrade():
46
+    connection = op.get_bind()
47
+
48
+    for content_revision in connection.execute(
49
+            content_revision_helper.select()
50
+    ):
51
+        # On work with FILE
52
+        if content_revision.type == 'file':
53
+            file_name, file_extension = \
54
+                os.path.splitext(content_revision.file_name)
55
+
56
+            # Don't touch label if already set
57
+            if content_revision.label:
58
+                new_label = content_revision.label
59
+            # Label will be file name without extension
60
+            else:
61
+                new_label = file_name
62
+
63
+            # Update record
64
+            connection.execute(
65
+                content_revision_helper.update()
66
+                .where(
67
+                    content_revision_helper.c.revision_id ==
68
+                    content_revision.revision_id
69
+                ).values(
70
+                    label=new_label,
71
+                    file_extension=file_extension,
72
+                )
73
+            )
74
+
75
+
76
+def downgrade():
77
+    pass

+ 26 - 0
tracim/migration/versions/2cd20ff3d23a_user_timezone.py 查看文件

@@ -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 ###

+ 31 - 0
tracim/migration/versions/59fc98c3c965_delete_content_file_name_column.py 查看文件

@@ -0,0 +1,31 @@
1
+"""delete content file name column
2
+
3
+Revision ID: 59fc98c3c965
4
+Revises: 15305f71bfda
5
+Create Date: 2016-11-25 14:55:22.176175
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = '59fc98c3c965'
11
+down_revision = '15305f71bfda'
12
+
13
+from alembic import op
14
+import sqlalchemy as sa
15
+
16
+
17
+def upgrade():
18
+    op.drop_column('content_revisions', 'file_name')
19
+
20
+
21
+def downgrade():
22
+    op.add_column(
23
+        'content_revisions',
24
+        sa.Column(
25
+            'file_name',
26
+            sa.VARCHAR(length=255),
27
+            server_default=sa.text("''::character varying"),
28
+            autoincrement=False,
29
+            nullable=False
30
+        ),
31
+    )

+ 78 - 0
tracim/migration/versions/c1cea4bbae16_file_extentions_value_for_pages_and_.py 查看文件

@@ -0,0 +1,78 @@
1
+"""file_extentions value for Pages and Threads
2
+
3
+Revision ID: c1cea4bbae16
4
+Revises: 59fc98c3c965
5
+Create Date: 2016-11-30 10:41:51.893531
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+
11
+revision = 'c1cea4bbae16'
12
+down_revision = '59fc98c3c965'
13
+
14
+from alembic import op
15
+import sqlalchemy as sa
16
+
17
+content_revision_helper = sa.Table(
18
+    'content_revisions',
19
+    sa.MetaData(),
20
+    sa.Column('revision_id', sa.Integer, primary_key=True),
21
+    sa.Column('content_id', sa.ForeignKey(u'content.id'), nullable=False),
22
+    sa.Column('owner_id', sa.ForeignKey(u'users.user_id'), index=True),
23
+    sa.Column('label', sa.String(1024), nullable=False),
24
+    sa.Column('description', sa.Text, nullable=False),
25
+    sa.Column('file_extension', sa.String(255), nullable=False),
26
+    sa.Column('file_mimetype', sa.String(255), nullable=False),
27
+    sa.Column('file_content', sa.LargeBinary),
28
+    sa.Column('properties', sa.Text, nullable=False),
29
+    sa.Column('type', sa.String(32), nullable=False),
30
+    sa.Column('status', sa.String(32), nullable=False),
31
+    sa.Column('created', sa.DateTime, nullable=False),
32
+    sa.Column('updated', sa.DateTime, nullable=False),
33
+    sa.Column('is_deleted', sa.Boolean, nullable=False),
34
+    sa.Column('is_archived', sa.Boolean, nullable=False),
35
+    sa.Column('is_temporary', sa.Boolean, nullable=False),
36
+    sa.Column('revision_type', sa.String(32), nullable=False),
37
+    sa.Column('workspace_id', sa.ForeignKey(u'workspaces.workspace_id')),
38
+    sa.Column('parent_id', sa.ForeignKey(u'content.id'), index=True),
39
+)
40
+
41
+
42
+def upgrade():
43
+    connection = op.get_bind()
44
+
45
+    for content_revision in connection.execute(
46
+            content_revision_helper.select()
47
+    ):
48
+        if content_revision.type in ('page', 'thread'):
49
+            # Update record
50
+            connection.execute(
51
+                content_revision_helper.update()
52
+                    .where(
53
+                        content_revision_helper.c.revision_id ==
54
+                        content_revision.revision_id
55
+                    ).values(
56
+                        file_extension='.html',
57
+                    )
58
+                )
59
+
60
+
61
+def downgrade():
62
+    connection = op.get_bind()
63
+
64
+    for content_revision in connection.execute(
65
+            content_revision_helper.select()
66
+    ):
67
+        # On work with FILE
68
+        if content_revision.type in ('page', 'thread'):
69
+            # Update record
70
+            connection.execute(
71
+                content_revision_helper.update()
72
+                    .where(
73
+                        content_revision_helper.c.revision_id ==
74
+                        content_revision.revision_id
75
+                    ).values(
76
+                        file_extension='',
77
+                    )
78
+                )

+ 33 - 0
tracim/migration/versions/e31ddc009b37_add_content_file_extension_column.py 查看文件

@@ -0,0 +1,33 @@
1
+"""add_content_file_extension_column
2
+
3
+Revision ID: e31ddc009b37
4
+Revises: 2cd20ff3d23a
5
+Create Date: 2016-11-25 10:43:23.700867
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = 'e31ddc009b37'
11
+down_revision = '2cd20ff3d23a'
12
+
13
+from alembic import op
14
+import sqlalchemy as sa
15
+
16
+
17
+def upgrade():
18
+    op.add_column(
19
+        'content_revisions',
20
+        sa.Column(
21
+            'file_extension',
22
+            sa.Unicode(length=255),
23
+            server_default='',
24
+            nullable=False,
25
+        )
26
+    )
27
+
28
+
29
+def downgrade():
30
+    op.drop_column(
31
+        'content_revisions',
32
+        'file_extension',
33
+    )

+ 2 - 0
tracim/setup.py 查看文件

@@ -43,6 +43,7 @@ install_requires=[
43 43
     "python-ldap-test==0.2.1",
44 44
     "unicode-slugify==0.1.3",
45 45
     "pytz==2014.7",
46
+    'rq==0.7.1',
46 47
     ]
47 48
 
48 49
 setup(
@@ -76,6 +77,7 @@ setup(
76 77
             'ldap_server = tracim.command.ldap_test_server:LDAPTestServerCommand',
77 78
             'user_create = tracim.command.user:CreateUserCommand',
78 79
             'user_update = tracim.command.user:UpdateUserCommand',
80
+            'mail sender = tracim.command.mail:MailSenderCommend',
79 81
         ]
80 82
     },
81 83
     dependency_links=[

+ 9 - 0
tracim/test.ini 查看文件

@@ -12,6 +12,9 @@ error_email_from = turbogears@localhost
12 12
 radicale.server.port = 15232
13 13
 radicale.client.port = 15232
14 14
 radicale.server.filesystem.folder = /tmp/tracim_tests_radicale_fs
15
+radicale.client.base_url.host = http://localhost:15232
16
+radicale.client.base_url.prefix = /
17
+email.notification.activated = false
15 18
 
16 19
 [server:main]
17 20
 use = egg:gearbox#wsgiref
@@ -39,6 +42,12 @@ ldap_tls = False
39 42
 ldap_group_enabled = False
40 43
 use = config:development.ini
41 44
 
45
+[app:nosmtp]
46
+resetpassword.smtp_host = localhost
47
+resetpassword.smtp_login = fake
48
+resetpassword.smtp_passwd =  fake
49
+use = config:development.ini
50
+
42 51
 [app:radicale]
43 52
 sqlalchemy.url = sqlite:///tracim_test.sqlite
44 53
 

+ 16 - 0
tracim/tracim/command/mail.py 查看文件

@@ -0,0 +1,16 @@
1
+# -*- coding: utf-8 -*-
2
+from rq import Connection, Worker
3
+
4
+from tracim.command import AppContextCommand
5
+
6
+
7
+class MailSenderCommend(AppContextCommand):
8
+    def get_description(self):
9
+        return '''Run rq worker for mail sending'''
10
+
11
+    def take_action(self, parsed_args):
12
+        super().take_action(parsed_args)
13
+
14
+        with Connection():
15
+            w = Worker(['mail_sender'])
16
+            w.work()

+ 17 - 3
tracim/tracim/command/user.py 查看文件

@@ -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):
@@ -106,8 +111,16 @@ class UserCommand(AppContextCommand):
106 111
 
107 112
         try:
108 113
             user = User(email=login, password=password, **kwargs)
114
+            user.update_webdav_digest_auth(password)
109 115
             self._session.add(user)
110 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)
111 124
         except IntegrityError:
112 125
             self._session.rollback()
113 126
             raise AlreadyExistError()
@@ -117,6 +130,7 @@ class UserCommand(AppContextCommand):
117 130
     def _update_password_for_login(self, login, password):
118 131
         user = self._user_api.get_one_by_email(login)
119 132
         user.password = password
133
+        user.update_webdav_digest_auth(password)
120 134
         self._session.flush()
121 135
         transaction.commit()
122 136
 

+ 14 - 0
tracim/tracim/config/__init__.py 查看文件

@@ -1,13 +1,27 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 from tg import AppConfig
3
+from tg.appwrappers.errorpage import ErrorPageApplicationWrapper \
4
+    as BaseErrorPageApplicationWrapper
3 5
 
4 6
 from tracim.lib.auth.wrapper import AuthConfigWrapper
7
+from tracim.lib.utils import ErrorPageApplicationWrapper
5 8
 
6 9
 
7 10
 class TracimAppConfig(AppConfig):
8 11
     """
9 12
     Tracim specific config processes.
10 13
     """
14
+    def __init__(self, minimal=False, root_controller=None):
15
+        super().__init__(minimal, root_controller)
16
+        self._replace_errors_wrapper()
17
+
18
+    def _replace_errors_wrapper(self) -> None:
19
+        """
20
+        Replace tg ErrorPageApplicationWrapper by ourself
21
+        """
22
+        for index, wrapper_class in enumerate(self.application_wrappers):
23
+            if issubclass(wrapper_class, BaseErrorPageApplicationWrapper):
24
+                self.application_wrappers[index] = ErrorPageApplicationWrapper
11 25
 
12 26
     def after_init_config(self, conf):
13 27
         AuthConfigWrapper.wrap(conf)

+ 99 - 20
tracim/tracim/config/app_cfg.py 查看文件

@@ -12,6 +12,8 @@ convert them into boolean, for example, you should use the
12 12
     setting = asbool(global_conf.get('the_setting'))
13 13
  
14 14
 """
15
+import imp
16
+import importlib
15 17
 from urllib.parse import urlparse
16 18
 
17 19
 import tg
@@ -21,15 +23,14 @@ from tg.configuration.milestones import environment_loaded
21 23
 from tgext.pluggable import plug
22 24
 from tgext.pluggable import replace_template
23 25
 
24
-from tg.i18n import lazy_ugettext as l_
26
+from tracim.lib.utils import lazy_ugettext as l_
25 27
 
26 28
 import tracim
27 29
 from tracim import model
28 30
 from tracim.config import TracimAppConfig
29
-from tracim.lib import app_globals, helpers
30
-from tracim.lib.auth.wrapper import AuthConfigWrapper
31 31
 from tracim.lib.base import logger
32 32
 from tracim.lib.daemons import DaemonsManager
33
+from tracim.lib.daemons import MailSenderDaemon
33 34
 from tracim.lib.daemons import RadicaleDaemon
34 35
 from tracim.lib.daemons import WsgiDavDaemon
35 36
 from tracim.model.data import ActionDescription
@@ -102,6 +103,7 @@ def start_daemons(manager: DaemonsManager):
102 103
 
103 104
     manager.run('radicale', RadicaleDaemon)
104 105
     manager.run('webdav', WsgiDavDaemon)
106
+    manager.run('mail_sender', MailSenderDaemon)
105 107
 
106 108
 environment_loaded.register(lambda: start_daemons(daemons))
107 109
 
@@ -200,8 +202,17 @@ class CFG(object):
200 202
         self.WEBSITE_SUBTITLE = tg.config.get('website.home.subtitle', '')
201 203
         self.WEBSITE_HOME_BELOW_LOGIN_FORM = tg.config.get('website.home.below_login_form', '')
202 204
 
205
+        if tg.config.get('email.notification.from'):
206
+            raise Exception(
207
+                'email.notification.from configuration is deprecated. '
208
+                'Use instead email.notification.from.email and '
209
+                'email.notification.from.default_label.'
210
+            )
203 211
 
204
-        self.EMAIL_NOTIFICATION_FROM = tg.config.get('email.notification.from')
212
+        self.EMAIL_NOTIFICATION_FROM_EMAIL = \
213
+            tg.config.get('email.notification.from.email')
214
+        self.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL = \
215
+            tg.config.get('email.notification.from.default_label')
205 216
         self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML = tg.config.get('email.notification.content_update.template.html')
206 217
         self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT = tg.config.get('email.notification.content_update.template.text')
207 218
         self.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML = tg.config.get(
@@ -235,7 +246,8 @@ class CFG(object):
235 246
             ActionDescription.COMMENT,
236 247
             ActionDescription.CREATION,
237 248
             ActionDescription.EDITION,
238
-            ActionDescription.REVISION
249
+            ActionDescription.REVISION,
250
+            ActionDescription.STATUS_UPDATE
239 251
         ]
240 252
 
241 253
         self.EMAIL_NOTIFICATION_NOTIFIED_CONTENTS = [
@@ -274,29 +286,98 @@ class CFG(object):
274 286
             'Tracim Calendar - Password Required',
275 287
         )
276 288
 
277
-        self.RADICALE_CLIENT_BASE_URL_TEMPLATE = \
278
-            tg.config.get('radicale.client.base_url', None)
289
+        self.RADICALE_CLIENT_BASE_URL_HOST = \
290
+            tg.config.get('radicale.client.base_url.host', None)
279 291
 
280
-        if not self.RADICALE_CLIENT_BASE_URL_TEMPLATE:
281
-            self.RADICALE_CLIENT_BASE_URL_TEMPLATE = \
282
-                'http://{0}:{1}'.format(
283
-                    self.WEBSITE_SERVER_NAME,
284
-                    self.RADICALE_SERVER_PORT,
285
-                )
292
+        self.RADICALE_CLIENT_BASE_URL_PREFIX = \
293
+            tg.config.get('radicale.client.base_url.prefix', '/')
294
+        # Ensure finished by '/'
295
+        if '/' != self.RADICALE_CLIENT_BASE_URL_PREFIX[-1]:
296
+            self.RADICALE_CLIENT_BASE_URL_PREFIX += '/'
297
+        if '/' != self.RADICALE_CLIENT_BASE_URL_PREFIX[0]:
298
+            self.RADICALE_CLIENT_BASE_URL_PREFIX \
299
+                = '/' + self.RADICALE_CLIENT_BASE_URL_PREFIX
300
+
301
+        if not self.RADICALE_CLIENT_BASE_URL_HOST:
286 302
             logger.warning(
287 303
                 self,
288
-                'NOTE: Generated radicale.client.base_url parameter with '
289
-                'followings parameters: website.server_name, '
290
-                'radicale.server.port -> {0}'
291
-                .format(self.RADICALE_CLIENT_BASE_URL_TEMPLATE)
304
+                'Generated radicale.client.base_url.host parameter with '
305
+                'followings parameters: website.server_name -> {}'
306
+                .format(self.WEBSITE_SERVER_NAME)
292 307
             )
308
+            self.RADICALE_CLIENT_BASE_URL_HOST = self.WEBSITE_SERVER_NAME
309
+
310
+        self.RADICALE_CLIENT_BASE_URL_TEMPLATE = '{}{}'.format(
311
+            self.RADICALE_CLIENT_BASE_URL_HOST,
312
+            self.RADICALE_CLIENT_BASE_URL_PREFIX,
313
+        )
293 314
 
294 315
         self.USER_AUTH_TOKEN_VALIDITY = int(tg.config.get(
295 316
             'user.auth_token.validity',
296 317
             '604800',
297 318
         ))
298 319
 
299
-        self.WSGIDAV_CONFIG_PATH = tg.config.get('wsgidav.config_path')
320
+        self.WSGIDAV_CONFIG_PATH = tg.config.get(
321
+            'wsgidav.config_path',
322
+            'wsgidav.conf',
323
+        )
324
+        # TODO: Convert to importlib (cf http://stackoverflow.com/questions/41063938/use-importlib-instead-imp-for-non-py-file)
325
+        self.wsgidav_config = imp.load_source(
326
+            'wsgidav_config',
327
+            self.WSGIDAV_CONFIG_PATH,
328
+        )
329
+        self.WSGIDAV_PORT = self.wsgidav_config.port
330
+        self.WSGIDAV_CLIENT_BASE_URL = \
331
+            tg.config.get('wsgidav.client.base_url', None)
332
+
333
+        if not self.WSGIDAV_CLIENT_BASE_URL:
334
+            self.WSGIDAV_CLIENT_BASE_URL = \
335
+                '{0}:{1}'.format(
336
+                    self.WEBSITE_SERVER_NAME,
337
+                    self.WSGIDAV_PORT,
338
+                )
339
+            logger.warning(
340
+                self,
341
+                'NOTE: Generated wsgidav.client.base_url parameter with '
342
+                'followings parameters: website.server_name and '
343
+                'wsgidav.conf port'.format(
344
+                    self.WSGIDAV_CLIENT_BASE_URL,
345
+                )
346
+            )
347
+
348
+        if not self.WSGIDAV_CLIENT_BASE_URL.endswith('/'):
349
+            self.WSGIDAV_CLIENT_BASE_URL += '/'
350
+
351
+        self.EMAIL_PROCESSING_MODE = tg.config.get(
352
+            'email.processing_mode',
353
+            'sync',
354
+        ).upper()
355
+
356
+        if self.EMAIL_PROCESSING_MODE not in (
357
+                self.CST.ASYNC,
358
+                self.CST.SYNC,
359
+        ):
360
+            raise Exception(
361
+                'email.processing_mode '
362
+                'can ''be "{}" or "{}", not "{}"'.format(
363
+                    self.CST.ASYNC,
364
+                    self.CST.SYNC,
365
+                    self.EMAIL_PROCESSING_MODE,
366
+                )
367
+            )
368
+
369
+        self.EMAIL_SENDER_REDIS_HOST = tg.config.get(
370
+            'email.async.redis.host',
371
+            'localhost',
372
+        )
373
+        self.EMAIL_SENDER_REDIS_PORT = int(tg.config.get(
374
+            'email.async.redis.port',
375
+            6379,
376
+        ))
377
+        self.EMAIL_SENDER_REDIS_DB = int(tg.config.get(
378
+            'email.async.redis.db',
379
+            0,
380
+        ))
300 381
 
301 382
     def get_tracker_js_content(self, js_tracker_file_path = None):
302 383
         js_tracker_file_path = tg.config.get('js_tracker_path', None)
@@ -338,5 +419,3 @@ class CFG(object):
338 419
 base_config.variable_provider = lambda: {
339 420
     'CFG': CFG.get_instance()
340 421
 }
341
-
342
-plug(base_config, 'tgext.asyncjob')

+ 6 - 2
tracim/tracim/config/middleware.py 查看文件

@@ -3,8 +3,7 @@
3 3
 
4 4
 from tracim.config.app_cfg import base_config
5 5
 from tracim.config.environment import load_environment
6
-from tracim.lib.daemons import DaemonsManager
7
-from tracim.lib.daemons import RadicaleDaemon
6
+from sqlalchemy.pool import NullPool
8 7
 
9 8
 __all__ = ['make_app']
10 9
 
@@ -33,6 +32,11 @@ def make_app(global_conf, full_stack=True, **app_conf):
33 32
     
34 33
    
35 34
     """
35
+    # Configure NullPool for SQLAlchemy id we are in tests: unknown reason
36
+    # don't close it's connection during test and exceed postgresql limit.
37
+    if global_conf.get('test') == 'true':
38
+        global_conf['sqlalchemy.poolclass'] = NullPool
39
+
36 40
     app = make_base_app(global_conf, full_stack=True, **app_conf)
37 41
     
38 42
     # Wrap your base TurboGears 2 application with custom middleware here

+ 34 - 8
tracim/tracim/controllers/__init__.py 查看文件

@@ -1,5 +1,10 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 """Controllers for the tracim application."""
3
+from sqlalchemy.orm.exc import NoResultFound
4
+from tg import abort
5
+from tracim.lib.integrity import PathValidationManager
6
+from tracim.lib.integrity import render_invalid_integrity_chosen_path
7
+from tracim.lib.user import CurrentUserGetterApi
3 8
 from tracim.lib.workspace import WorkspaceApi
4 9
 
5 10
 import tg
@@ -24,7 +29,6 @@ from tracim.model.data import ContentType
24 29
 from tracim.model.data import Workspace
25 30
 
26 31
 from tracim.lib.content import ContentApi
27
-from tracim.lib.user import UserStaticApi
28 32
 from tracim.lib.utils import SameValueError
29 33
 
30 34
 from tracim.model.serializers import Context
@@ -34,7 +38,7 @@ class TIMRestPathContextSetup(object):
34 38
 
35 39
     @classmethod
36 40
     def current_user(cls) -> User:
37
-        user = UserStaticApi.get_current_user()
41
+        user = CurrentUserGetterApi.get_current_user()
38 42
         tmpl_context.current_user_id = user.user_id if user else None
39 43
         tmpl_context.current_user = user if user else None
40 44
         return user
@@ -69,7 +73,11 @@ class TIMRestPathContextSetup(object):
69 73
 
70 74
     @classmethod
71 75
     def current_folder(cls) -> Content:
72
-        content_api = ContentApi(tg.tmpl_context.current_user)
76
+        content_api = ContentApi(
77
+            tg.tmpl_context.current_user,
78
+            show_archived=True,
79
+            show_deleted=True,
80
+        )
73 81
         folder_id = int(tg.request.controller_state.routing_args.get('folder_id'))
74 82
         folder = content_api.get_one(folder_id, ContentType.Folder, tg.tmpl_context.workspace)
75 83
 
@@ -120,6 +128,12 @@ class TIMRestController(RestController, BaseController):
120 128
     TEMPLATE_NEW = 'unknown "template new"'
121 129
     TEMPLATE_EDIT = 'unknown "template edit"'
122 130
 
131
+    def __init__(self):
132
+        super().__init__()
133
+        self._path_validation = PathValidationManager(
134
+            is_case_sensitive=False,
135
+        )
136
+
123 137
     def _before(self, *args, **kw):
124 138
         """
125 139
         Instantiate the current workspace in tg.tmpl_context
@@ -139,7 +153,11 @@ class TIMRestControllerWithBreadcrumb(TIMRestController):
139 153
         :param item_id: an item id (item may be normal content or folder
140 154
         :return:
141 155
         """
142
-        return ContentApi(tmpl_context.current_user).build_breadcrumb(tmpl_context.workspace, item_id)
156
+        return ContentApi(
157
+            tmpl_context.current_user,
158
+            show_archived=True,
159
+            show_deleted=True,
160
+        ).build_breadcrumb(tmpl_context.workspace, item_id)
143 161
 
144 162
     def _struct_new_serialized(self, workspace_id, parent_id):
145 163
         print('values are: ', workspace_id, parent_id)
@@ -167,10 +185,12 @@ class TIMWorkspaceContentRestController(TIMRestControllerWithBreadcrumb):
167 185
     /dashboard/workspaces/{}/folders/{}/someitems/{}
168 186
     """
169 187
     def _before(self, *args, **kw):
170
-        TIMRestPathContextSetup.current_user()
171
-        TIMRestPathContextSetup.current_workspace()
172
-        TIMRestPathContextSetup.current_folder()
173
-
188
+        try:
189
+            TIMRestPathContextSetup.current_user()
190
+            TIMRestPathContextSetup.current_workspace()
191
+            TIMRestPathContextSetup.current_folder()
192
+        except NoResultFound:
193
+            abort(404)
174 194
 
175 195
     @property
176 196
     def _std_url(self):
@@ -270,6 +290,12 @@ class TIMWorkspaceContentRestController(TIMRestControllerWithBreadcrumb):
270 290
             item = api.get_one(int(item_id), self._item_type, workspace)
271 291
             with new_revision(item):
272 292
                 api.update_content(item, label, content)
293
+
294
+                if not self._path_validation.validate_new_content(item):
295
+                    return render_invalid_integrity_chosen_path(
296
+                        item.get_label(),
297
+                    )
298
+
273 299
                 api.save(item, ActionDescription.REVISION)
274 300
 
275 301
             msg = _('{} updated').format(self._item_type_label)

+ 10 - 7
tracim/tracim/controllers/admin/user.py 查看文件

@@ -1,6 +1,7 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 import uuid
3 3
 
4
+import pytz
4 5
 from tracim import model  as pm
5 6
 
6 7
 from sprox.tablebase import TableBase
@@ -10,7 +11,7 @@ from tw2 import forms as tw2f
10 11
 import tg
11 12
 from tg import predicates
12 13
 from tg import tmpl_context
13
-from tg.i18n import ugettext as _, lazy_ugettext as l_
14
+from tg.i18n import ugettext as _
14 15
 
15 16
 from sprox.widgets import PropertyMultipleSelectField
16 17
 from sprox._compat import unicode_text
@@ -27,7 +28,6 @@ from tracim.lib.base import logger
27 28
 from tracim.lib.email import get_email_manager
28 29
 from tracim.lib.user import UserApi
29 30
 from tracim.lib.group import GroupApi
30
-from tracim.lib.user import UserStaticApi
31 31
 from tracim.lib.userworkspace import RoleApi
32 32
 from tracim.lib.workspace import WorkspaceApi
33 33
 
@@ -210,6 +210,7 @@ class UserPasswordAdminRestController(TIMRestController):
210 210
             tg.redirect(next_url)
211 211
 
212 212
         user.password = new_password1
213
+        user.update_webdav_digest_auth(new_password1)
213 214
         pm.DBSession.flush()
214 215
 
215 216
         tg.flash(_('The password has been changed'), CST.STATUS_OK)
@@ -312,7 +313,6 @@ class UserRestController(TIMRestController):
312 313
             is_tracim_manager = False
313 314
             is_tracim_admin = False
314 315
 
315
-
316 316
         api = UserApi(current_user)
317 317
 
318 318
         if api.user_with_email_exists(email):
@@ -347,10 +347,10 @@ class UserRestController(TIMRestController):
347 347
             email_manager = get_email_manager()
348 348
             email_manager.notify_created_account(user, password=password)
349 349
 
350
+        api.execute_created_user_actions(user)
350 351
         tg.flash(_('User {} created.').format(user.get_display_name()), CST.STATUS_OK)
351 352
         tg.redirect(self.url())
352 353
 
353
-
354 354
     @tg.expose('tracim.templates.admin.user_getone')
355 355
     def get_one(self, user_id):
356 356
         current_user = tmpl_context.current_user
@@ -380,15 +380,18 @@ class UserRestController(TIMRestController):
380 380
         user = api.get_one(id)
381 381
 
382 382
         dictified_user = Context(CTX.USER).toDict(user, 'user')
383
-        return DictLikeClass(result = dictified_user)
383
+        return DictLikeClass(
384
+            result=dictified_user,
385
+            timezones=pytz.all_timezones,
386
+        )
384 387
 
385 388
     @tg.require(predicates.in_group(Group.TIM_MANAGER_GROUPNAME))
386 389
     @tg.expose()
387
-    def put(self, user_id, name, email, next_url=''):
390
+    def put(self, user_id, name, email, timezone: str='', next_url=''):
388 391
         api = UserApi(tmpl_context.current_user)
389 392
 
390 393
         user = api.get_one(int(user_id))
391
-        api.update(user, name, email, True)
394
+        api.update(user, name, email, True, timezone=timezone)
392 395
 
393 396
         tg.flash(_('User {} updated.').format(user.get_display_name()), CST.STATUS_OK)
394 397
         if next_url:

+ 27 - 15
tracim/tracim/controllers/admin/workspace.py 查看文件

@@ -5,31 +5,22 @@ 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
12 11
 from tracim.lib.base import BaseController
13 12
 from tracim.lib.helpers import on_off_to_boolean
13
+from tracim.lib.integrity import PathValidationManager
14
+from tracim.lib.integrity import render_invalid_integrity_chosen_path
14 15
 from tracim.lib.user import UserApi
15 16
 from tracim.lib.userworkspace import RoleApi
16
-from tracim.lib.content import ContentApi
17 17
 from tracim.lib.workspace import WorkspaceApi
18
-from tracim.model import DBSession
19 18
 
20 19
 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 20
 from tracim.model.data import UserRoleInWorkspace
26 21
 
27 22
 from tracim.model.serializers import Context, CTX, DictLikeClass
28 23
 
29
-from tracim.controllers.content import UserWorkspaceFolderRestController
30
-
31
-
32
-
33 24
 
34 25
 class RoleInWorkspaceRestController(TIMRestController, BaseController):
35 26
 
@@ -150,6 +141,12 @@ class WorkspaceRestController(TIMRestController, BaseController):
150 141
      responsible / advanced contributor. / contributor / reader
151 142
     """
152 143
 
144
+    def __init__(self):
145
+        super().__init__()
146
+        self._path_validation = PathValidationManager(
147
+            is_case_sensitive=False,
148
+        )
149
+
153 150
     @property
154 151
     def _base_url(self):
155 152
         return '/admin/workspaces'
@@ -198,9 +195,16 @@ class WorkspaceRestController(TIMRestController, BaseController):
198 195
         workspace_api_controller = WorkspaceApi(user)
199 196
         calendar_enabled = on_off_to_boolean(calendar_enabled)
200 197
 
201
-        workspace = workspace_api_controller.create_workspace(name, description)
202
-        workspace.calendar_enabled = calendar_enabled
203
-        DBSession.flush()
198
+        # Display error page to user if chosen label is in conflict
199
+        if not self._path_validation.workspace_label_is_free(name):
200
+            return render_invalid_integrity_chosen_path(name)
201
+
202
+        workspace = workspace_api_controller.create_workspace(
203
+            name,
204
+            description,
205
+            calendar_enabled=calendar_enabled,
206
+            save_now=True,
207
+        )
204 208
 
205 209
         tg.flash(_('{} workspace created.').format(workspace.label), CST.STATUS_OK)
206 210
         tg.redirect(self.url())
@@ -221,13 +225,21 @@ class WorkspaceRestController(TIMRestController, BaseController):
221 225
         user = tmpl_context.current_user
222 226
         workspace_api_controller = WorkspaceApi(user)
223 227
         calendar_enabled = on_off_to_boolean(calendar_enabled)
224
-
225 228
         workspace = workspace_api_controller.get_one(id)
229
+
230
+        # Display error page to user if chosen label is in conflict
231
+        if name != workspace.label and \
232
+                not self._path_validation.workspace_label_is_free(name):
233
+            return render_invalid_integrity_chosen_path(name)
234
+
226 235
         workspace.label = name
227 236
         workspace.description = description
228 237
         workspace.calendar_enabled = calendar_enabled
229 238
         workspace_api_controller.save(workspace)
230 239
 
240
+        if calendar_enabled:
241
+            workspace_api_controller.ensure_calendar_exist(workspace)
242
+
231 243
         tg.flash(_('{} workspace updated.').format(workspace.label), CST.STATUS_OK)
232 244
         tg.redirect(self.url(workspace.workspace_id))
233 245
         return

+ 76 - 0
tracim/tracim/controllers/api.py 查看文件

@@ -0,0 +1,76 @@
1
+# -*- coding: utf-8 -*-
2
+from tg import abort
3
+from tg import expose
4
+from tg import predicates
5
+from tg import request
6
+from tg import tmpl_context
7
+
8
+from tracim.lib.base import BaseController
9
+from tracim.lib.calendar import CalendarManager
10
+from tracim.lib.utils import api_require
11
+from tracim.lib.workspace import WorkspaceApi
12
+from tracim.model.serializers import Context, CTX
13
+
14
+"""
15
+To raise an error, use:
16
+
17
+```
18
+abort(
19
+    400,
20
+    detail={
21
+        'name': 'Parameter required'
22
+    },
23
+    comment='Missing data',
24
+)
25
+```
26
+
27
+"""
28
+
29
+
30
+class APIBaseController(BaseController):
31
+    def _before(self, *args, **kw):
32
+        # For be user friendly, we disable hard check of content_type
33
+        # if request.content_type != 'application/json':
34
+        #     abort(406, 'Only JSON requests are supported')
35
+
36
+        super()._before(*args, **kw)
37
+
38
+
39
+class WorkspaceController(APIBaseController):
40
+    @expose('json')
41
+    @api_require(predicates.not_anonymous())
42
+    def index(self):
43
+        # NOTE BS 20161025: I can't use tmpl_context.current_user,
44
+        # I d'ont know why
45
+        workspace_api = WorkspaceApi(tmpl_context.identity.get('user'))
46
+        workspaces = workspace_api.get_all()
47
+        serialized_workspaces = Context(CTX.API_WORKSPACE).toDict(workspaces)
48
+
49
+        return {
50
+            'value_list': serialized_workspaces
51
+        }
52
+
53
+
54
+class CalendarsController(APIBaseController):
55
+    @expose('json')
56
+    @api_require(predicates.not_anonymous())
57
+    def index(self):
58
+        # NOTE BS 20161025: I can't use tmpl_context.current_user,
59
+        # I d'ont know why
60
+        user = tmpl_context.identity.get('user')
61
+        calendar_workspaces = CalendarManager\
62
+            .get_workspace_readable_calendars_for_user(user)
63
+        calendars = Context(CTX.API_CALENDAR_WORKSPACE)\
64
+            .toDict(calendar_workspaces)
65
+
66
+        # Manually add information about user calendar
67
+        calendars.append(Context(CTX.API_CALENDAR_USER).toDict(user))
68
+
69
+        return {
70
+            'value_list': calendars
71
+        }
72
+
73
+
74
+class APIController(BaseController):
75
+    workspaces = WorkspaceController()
76
+    calendars = CalendarsController()

+ 6 - 1
tracim/tracim/controllers/calendar.py 查看文件

@@ -2,6 +2,7 @@
2 2
 import re
3 3
 import tg
4 4
 from tg import tmpl_context
5
+from tg.predicates import not_anonymous
5 6
 
6 7
 from tracim.lib.base import BaseController
7 8
 from tracim.lib.calendar import CalendarManager
@@ -16,6 +17,7 @@ class CalendarController(BaseController):
16 17
     """
17 18
 
18 19
     @tg.expose('tracim.templates.calendar.iframe_container')
20
+    @tg.require(not_anonymous())
19 21
     def index(self):
20 22
         user = tmpl_context.identity.get('user')
21 23
         dictified_current_user = Context(CTX.CURRENT_USER).toDict(user)
@@ -34,6 +36,9 @@ class CalendarConfigController(BaseController):
34 36
 
35 37
     @tg.expose('tracim.templates.calendar.config')
36 38
     def index(self):
39
+        from tracim.config.app_cfg import CFG
40
+        cfg = CFG.get_instance()
41
+
37 42
         # TODO BS 20160720: S'assurer d'être identifié !
38 43
         user = tmpl_context.identity.get('user')
39 44
         dictified_current_user = Context(CTX.CURRENT_USER).toDict(user)
@@ -46,7 +51,7 @@ class CalendarConfigController(BaseController):
46 51
         workspace_calendar_urls = CalendarManager\
47 52
             .get_workspace_readable_calendars_urls_for_user(user)
48 53
         base_href_url = \
49
-            re.sub(r"^http[s]?://", '', CalendarManager.get_base_url())
54
+            re.sub(r"^http[s]?://", '', cfg.RADICALE_CLIENT_BASE_URL_HOST)
50 55
 
51 56
         # Template will use User.auth_token, ensure it's validity
52 57
         user.ensure_auth_token()

+ 177 - 49
tracim/tracim/controllers/content.py 查看文件

@@ -1,40 +1,41 @@
1 1
 # -*- coding: utf-8 -*-
2
-import sys
3
-
4 2
 __author__ = 'damien'
5 3
 
6
-from cgi import FieldStorage
4
+import sys
5
+import traceback
7 6
 
7
+from cgi import FieldStorage
8 8
 import tg
9 9
 from tg import tmpl_context
10 10
 from tg.i18n import ugettext as _
11 11
 from tg.predicates import not_anonymous
12 12
 
13
-import traceback
14
-
15 13
 from tracim.controllers import TIMRestController
16 14
 from tracim.controllers import TIMRestPathContextSetup
17 15
 from tracim.controllers import TIMRestControllerWithBreadcrumb
18 16
 from tracim.controllers import TIMWorkspaceContentRestController
19
-
20 17
 from tracim.lib import CST
21 18
 from tracim.lib.base import BaseController
22 19
 from tracim.lib.base import logger
23 20
 from tracim.lib.utils import SameValueError
21
+from tracim.lib.utils import get_valid_header_file_name
22
+from tracim.lib.utils import str_as_bool
24 23
 from tracim.lib.content import ContentApi
25 24
 from tracim.lib.helpers import convert_id_into_instances
26 25
 from tracim.lib.predicates import current_user_is_reader
27 26
 from tracim.lib.predicates import current_user_is_contributor
28 27
 from tracim.lib.predicates import current_user_is_content_manager
29 28
 from tracim.lib.predicates import require_current_user_is_owner
30
-
31 29
 from tracim.model.serializers import Context, CTX, DictLikeClass
32 30
 from tracim.model.data import ActionDescription
33 31
 from tracim.model import new_revision
32
+from tracim.model import DBSession
34 33
 from tracim.model.data import Content
35 34
 from tracim.model.data import ContentType
36 35
 from tracim.model.data import UserRoleInWorkspace
37 36
 from tracim.model.data import Workspace
37
+from tracim.lib.integrity import render_invalid_integrity_chosen_path
38
+
38 39
 
39 40
 class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
40 41
 
@@ -154,6 +155,10 @@ class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
154 155
         return tg.url('/workspaces/{}/folders/{}')
155 156
 
156 157
     @property
158
+    def _err_url(self):
159
+        return tg.url('/dashboard/workspaces/{}/folders/{}/file/{}')
160
+
161
+    @property
157 162
     def _item_type(self):
158 163
         return ContentType.File
159 164
 
@@ -175,13 +180,16 @@ class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
175 180
         file_id = int(file_id)
176 181
         user = tmpl_context.current_user
177 182
         workspace = tmpl_context.workspace
178
-        workspace_id = tmpl_context.workspace_id
179 183
 
180 184
         current_user_content = Context(CTX.CURRENT_USER,
181 185
                                        current_user=user).toDict(user)
182 186
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
183 187
 
184
-        content_api = ContentApi(user)
188
+        content_api = ContentApi(
189
+            user,
190
+            show_archived=True,
191
+            show_deleted=True,
192
+        )
185 193
         if revision_id:
186 194
             file = content_api.get_one_from_revision(file_id,  self._item_type, workspace, revision_id)
187 195
         else:
@@ -231,7 +239,9 @@ class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
231 239
             tg.response.headers['Content-type'] = str(revision_to_send.file_mimetype)
232 240
 
233 241
         tg.response.headers['Content-Type'] = content_type
234
-        tg.response.headers['Content-Disposition'] = str('attachment; filename="{}"'.format(revision_to_send.file_name))
242
+        file_name = get_valid_header_file_name(revision_to_send.file_name)
243
+        tg.response.headers['Content-Disposition'] = \
244
+            str('attachment; filename="{}"'.format(file_name))
235 245
         return revision_to_send.file_content
236 246
 
237 247
 
@@ -257,11 +267,18 @@ class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
257 267
     def post(self, label='', file_data=None):
258 268
         # TODO - SECURE THIS
259 269
         workspace = tmpl_context.workspace
270
+        folder = tmpl_context.folder
260 271
 
261 272
         api = ContentApi(tmpl_context.current_user)
262
-
263
-        file = api.create(ContentType.File, workspace, tmpl_context.folder, label)
264
-        api.update_file_data(file, file_data.filename, file_data.type, file_data.file.read())
273
+        with DBSession.no_autoflush:
274
+            file = api.create(ContentType.File, workspace, folder, label)
275
+            api.update_file_data(file, file_data.filename, file_data.type, file_data.file.read())
276
+
277
+            # Display error page to user if chosen label is in conflict
278
+            if not self._path_validation.validate_new_content(file):
279
+                return render_invalid_integrity_chosen_path(
280
+                    file.get_label_as_file(),
281
+                )
265 282
         api.save(file, ActionDescription.CREATION)
266 283
 
267 284
         tg.flash(_('File created'), CST.STATUS_OK)
@@ -270,25 +287,39 @@ class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
270 287
 
271 288
     @tg.require(current_user_is_contributor())
272 289
     @tg.expose()
273
-    def put(self, item_id, file_data=None, comment=None, label=''):
290
+    def put(self, item_id, file_data=None, comment=None, label=None):
274 291
         # TODO - SECURE THIS
275 292
         workspace = tmpl_context.workspace
276 293
 
277 294
         try:
278
-            item_saved = False
279 295
             api = ContentApi(tmpl_context.current_user)
280 296
             item = api.get_one(int(item_id), self._item_type, workspace)
297
+            label_changed = False
298
+            if label is not None and label != item.label:
299
+                label_changed = True
300
+
301
+            if label is None:
302
+                label = ''
281 303
 
282 304
             # TODO - D.A. - 2015-03-19
283 305
             # refactor this method in order to make code easier to understand
284 306
 
285 307
             with new_revision(item):
286 308
 
287
-                if comment and label:
309
+                if (comment and label) or (not comment and label_changed):
288 310
                     updated_item = api.update_content(
289 311
                         item, label if label else item.label,
290 312
                         comment if comment else ''
291 313
                     )
314
+
315
+                    # Display error page to user if chosen label is in conflict
316
+                    if not self._path_validation.validate_new_content(
317
+                        updated_item,
318
+                    ):
319
+                        return render_invalid_integrity_chosen_path(
320
+                            updated_item.get_label_as_file(),
321
+                        )
322
+
292 323
                     api.save(updated_item, ActionDescription.EDITION)
293 324
 
294 325
                     # This case is the default "file title and description update"
@@ -315,6 +346,16 @@ class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
315 346
 
316 347
                     if isinstance(file_data, FieldStorage):
317 348
                         api.update_file_data(item, file_data.filename, file_data.type, file_data.file.read())
349
+
350
+                        # Display error page to user if chosen label is in
351
+                        # conflict
352
+                        if not self._path_validation.validate_new_content(
353
+                            item,
354
+                        ):
355
+                            return render_invalid_integrity_chosen_path(
356
+                                item.get_label_as_file(),
357
+                            )
358
+
318 359
                         api.save(item, ActionDescription.REVISION)
319 360
 
320 361
             msg = _('{} updated').format(self._item_type_label)
@@ -375,7 +416,11 @@ class UserWorkspaceFolderPageRestController(TIMWorkspaceContentRestController):
375 416
         current_user_content = Context(CTX.CURRENT_USER).toDict(user)
376 417
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
377 418
 
378
-        content_api = ContentApi(user)
419
+        content_api = ContentApi(
420
+            user,
421
+            show_deleted=True,
422
+            show_archived=True,
423
+        )
379 424
         if revision_id:
380 425
             page = content_api.get_one_from_revision(page_id, ContentType.Page, workspace, revision_id)
381 426
         else:
@@ -413,8 +458,15 @@ class UserWorkspaceFolderPageRestController(TIMWorkspaceContentRestController):
413 458
 
414 459
         api = ContentApi(tmpl_context.current_user)
415 460
 
416
-        page = api.create(ContentType.Page, workspace, tmpl_context.folder, label)
417
-        page.description = content
461
+        with DBSession.no_autoflush:
462
+            page = api.create(ContentType.Page, workspace, tmpl_context.folder, label)
463
+            page.description = content
464
+
465
+            if not self._path_validation.validate_new_content(page):
466
+                return render_invalid_integrity_chosen_path(
467
+                    page.get_label(),
468
+                )
469
+
418 470
         api.save(page, ActionDescription.CREATION, do_notify=True)
419 471
 
420 472
         tg.flash(_('Page created'), CST.STATUS_OK)
@@ -433,6 +485,12 @@ class UserWorkspaceFolderPageRestController(TIMWorkspaceContentRestController):
433 485
             item = api.get_one(int(item_id), self._item_type, workspace)
434 486
             with new_revision(item):
435 487
                 api.update_content(item, label, content)
488
+
489
+                if not self._path_validation.validate_new_content(item):
490
+                    return render_invalid_integrity_chosen_path(
491
+                        item.get_label(),
492
+                    )
493
+
436 494
                 api.save(item, ActionDescription.REVISION)
437 495
 
438 496
             msg = _('{} updated').format(self._item_type_label)
@@ -509,13 +567,20 @@ class UserWorkspaceFolderThreadRestController(TIMWorkspaceContentRestController)
509 567
 
510 568
         api = ContentApi(tmpl_context.current_user)
511 569
 
512
-        thread = api.create(ContentType.Thread, workspace, tmpl_context.folder, label)
513
-        # FIXME - DO NOT DUPLCIATE FIRST MESSAGE thread.description = content
514
-        api.save(thread, ActionDescription.CREATION, do_notify=False)
570
+        with DBSession.no_autoflush:
571
+            thread = api.create(ContentType.Thread, workspace, tmpl_context.folder, label)
572
+            # FIXME - DO NOT DUPLCIATE FIRST MESSAGE thread.description = content
573
+            api.save(thread, ActionDescription.CREATION, do_notify=False)
574
+
575
+            comment = api.create(ContentType.Comment, workspace, thread, label)
576
+            comment.label = ''
577
+            comment.description = content
578
+
579
+            if not self._path_validation.validate_new_content(thread):
580
+                return render_invalid_integrity_chosen_path(
581
+                    thread.get_label(),
582
+                )
515 583
 
516
-        comment = api.create(ContentType.Comment, workspace, thread, label)
517
-        comment.label = ''
518
-        comment.description = content
519 584
         api.save(comment, ActionDescription.COMMENT, do_notify=False)
520 585
         api.do_notify(thread)
521 586
 
@@ -525,7 +590,14 @@ class UserWorkspaceFolderThreadRestController(TIMWorkspaceContentRestController)
525 590
 
526 591
     @tg.require(current_user_is_reader())
527 592
     @tg.expose('tracim.templates.thread.getone')
528
-    def get_one(self, thread_id):
593
+    def get_one(self, thread_id, **kwargs):
594
+        """
595
+        :param thread_id: content_id of Thread
596
+        :param inverted: fill with True equivalent to invert order of comments
597
+                         NOTE: This parameter is in kwargs because prevent URL
598
+                         changes.
599
+        """
600
+        inverted = kwargs.get('inverted')
529 601
         thread_id = int(thread_id)
530 602
         user = tmpl_context.current_user
531 603
         workspace = tmpl_context.workspace
@@ -533,7 +605,11 @@ class UserWorkspaceFolderThreadRestController(TIMWorkspaceContentRestController)
533 605
         current_user_content = Context(CTX.CURRENT_USER).toDict(user)
534 606
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
535 607
 
536
-        content_api = ContentApi(user)
608
+        content_api = ContentApi(
609
+            user,
610
+            show_deleted=True,
611
+            show_archived=True,
612
+        )
537 613
         thread = content_api.get_one(thread_id, ContentType.Thread, workspace)
538 614
 
539 615
         fake_api_breadcrumb = self.get_breadcrumb(thread_id)
@@ -541,7 +617,15 @@ class UserWorkspaceFolderThreadRestController(TIMWorkspaceContentRestController)
541 617
         fake_api = Context(CTX.FOLDER).toDict(fake_api_content)
542 618
 
543 619
         dictified_thread = Context(CTX.THREAD).toDict(thread, 'thread')
544
-        return DictLikeClass(result = dictified_thread, fake_api=fake_api)
620
+
621
+        if inverted:
622
+          dictified_thread.thread.history = reversed(dictified_thread.thread.history)
623
+
624
+        return DictLikeClass(
625
+            result=dictified_thread,
626
+            fake_api=fake_api,
627
+            inverted=inverted,
628
+        )
545 629
 
546 630
 
547 631
 
@@ -682,18 +766,35 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
682 766
 
683 767
     @tg.require(current_user_is_reader())
684 768
     @tg.expose('tracim.templates.folder.getone')
685
-    def get_one(self, folder_id):
769
+    def get_one(self, folder_id, **kwargs):
770
+        """
771
+        :param folder_id: Displayed folder id
772
+        :param kwargs:
773
+          * show_deleted: bool: Display deleted contents or hide them if False
774
+          * show_archived: bool: Display archived contents or hide them
775
+            if False
776
+        """
777
+        show_deleted = str_as_bool(kwargs.get('show_deleted', ''))
778
+        show_archived = str_as_bool(kwargs.get('show_archived', ''))
686 779
         folder_id = int(folder_id)
687 780
         user = tmpl_context.current_user
688 781
         workspace = tmpl_context.workspace
689
-        workspace_id = tmpl_context.workspace_id
690 782
 
691 783
         current_user_content = Context(CTX.CURRENT_USER,
692 784
                                        current_user=user).toDict(user)
693 785
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
694 786
 
695
-        content_api = ContentApi(user)
696
-        folder = content_api.get_one(folder_id, ContentType.Folder, workspace)
787
+        content_api = ContentApi(
788
+            user,
789
+            show_deleted=show_deleted,
790
+            show_archived=show_archived,
791
+        )
792
+        with content_api.show(show_deleted=True, show_archived=True):
793
+            folder = content_api.get_one(
794
+                folder_id,
795
+                ContentType.Folder,
796
+                workspace,
797
+            )
697 798
 
698 799
         fake_api_breadcrumb = self.get_breadcrumb(folder_id)
699 800
         fake_api_subfolders = self.get_all_fake(workspace, folder.content_id).result
@@ -712,17 +813,29 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
712 813
 
713 814
         fake_api = Context(CTX.FOLDER).toDict(fake_api_content)
714 815
 
715
-        fake_api.sub_items = Context(CTX.FOLDER_CONTENT_LIST).toDict(
716
-            folder.get_valid_children([ContentType.Folder,
717
-                                       ContentType.File,
718
-                                       ContentType.Page,
719
-                                       ContentType.Thread]))
816
+        sub_items = content_api.get_children(
817
+            parent_id=folder.content_id,
818
+            content_types=[
819
+                ContentType.Folder,
820
+                ContentType.File,
821
+                ContentType.Page,
822
+                ContentType.Thread,
823
+            ],
824
+
825
+        )
826
+        fake_api.sub_items = Context(CTX.FOLDER_CONTENT_LIST).toDict(sub_items)
720 827
 
721 828
         fake_api.content_types = Context(CTX.DEFAULT).toDict(
722
-            content_api.get_all_types())
829
+            content_api.get_all_types()
830
+        )
723 831
 
724 832
         dictified_folder = Context(CTX.FOLDER).toDict(folder, 'folder')
725
-        return DictLikeClass(result = dictified_folder, fake_api=fake_api)
833
+        return DictLikeClass(
834
+            result=dictified_folder,
835
+            fake_api=fake_api,
836
+            show_deleted=show_deleted,
837
+            show_archived=show_archived,
838
+        )
726 839
 
727 840
 
728 841
     def get_all_fake(self, context_workspace: Workspace, parent_id=None):
@@ -736,7 +849,8 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
736 849
         """
737 850
         workspace = context_workspace
738 851
         content_api = ContentApi(tmpl_context.current_user)
739
-        parent_folder = content_api.get_one(parent_id, ContentType.Folder)
852
+        with content_api.show(show_deleted=True, show_archived=True):
853
+            parent_folder = content_api.get_one(parent_id, ContentType.Folder)
740 854
         folders = content_api.get_child_folders(parent_folder, workspace)
741 855
 
742 856
         folders = Context(CTX.FOLDERS).toDict(folders)
@@ -759,15 +873,23 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
759 873
             parent = None
760 874
             if parent_id:
761 875
                 parent = api.get_one(int(parent_id), ContentType.Folder, workspace)
762
-            folder = api.create(ContentType.Folder, workspace, parent, label)
763 876
 
764
-            subcontent = dict(
765
-                folder = True if can_contain_folders=='on' else False,
766
-                thread = True if can_contain_threads=='on' else False,
767
-                file = True if can_contain_files=='on' else False,
768
-                page = True if can_contain_pages=='on' else False
769
-            )
770
-            api.set_allowed_content(folder, subcontent)
877
+            with DBSession.no_autoflush:
878
+                folder = api.create(ContentType.Folder, workspace, parent, label)
879
+
880
+                subcontent = dict(
881
+                    folder = True if can_contain_folders=='on' else False,
882
+                    thread = True if can_contain_threads=='on' else False,
883
+                    file = True if can_contain_files=='on' else False,
884
+                    page = True if can_contain_pages=='on' else False
885
+                )
886
+                api.set_allowed_content(folder, subcontent)
887
+
888
+                if not self._path_validation.validate_new_content(folder):
889
+                    return render_invalid_integrity_chosen_path(
890
+                        folder.get_label(),
891
+                    )
892
+
771 893
             api.save(folder)
772 894
 
773 895
             tg.flash(_('Folder created'), CST.STATUS_OK)
@@ -813,6 +935,12 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
813 935
                     # TODO - D.A. - 2015-05-25 - Allow to set folder description
814 936
                     api.update_content(folder, label, folder.description)
815 937
                 api.set_allowed_content(folder, subcontent)
938
+
939
+                if not self._path_validation.validate_new_content(folder):
940
+                    return render_invalid_integrity_chosen_path(
941
+                        folder.get_label(),
942
+                    )
943
+
816 944
                 api.save(folder)
817 945
 
818 946
             tg.flash(_('Folder updated'), CST.STATUS_OK)
@@ -941,4 +1069,4 @@ class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
941 1069
             back_url = self._parent_url.format(item.workspace_id, item.parent_id)
942 1070
             msg = _('{} not un-deleted: {}').format(self._item_type_label, str(e))
943 1071
             tg.flash(msg, CST.STATUS_ERROR)
944
-            tg.redirect(back_url)
1072
+            tg.redirect(back_url)

+ 7 - 3
tracim/tracim/controllers/root.py 查看文件

@@ -1,5 +1,5 @@
1 1
 # -*- coding: utf-8 -*-
2
-
2
+import tg
3 3
 from tg import expose
4 4
 from tg import flash
5 5
 from tg import lurl
@@ -11,10 +11,11 @@ from tg import tmpl_context
11 11
 from tg import url
12 12
 
13 13
 from tg.i18n import ugettext as _
14
+from tracim.controllers.api import APIController
14 15
 
15 16
 from tracim.lib import CST
16 17
 from tracim.lib.base import logger
17
-from tracim.lib.user import UserStaticApi
18
+from tracim.lib.user import CurrentUserGetterApi
18 19
 from tracim.lib.content import ContentApi
19 20
 
20 21
 from tracim.controllers import StandardController
@@ -60,6 +61,9 @@ class RootController(StandardController):
60 61
     workspaces = UserWorkspaceRestController()
61 62
     user = UserRestController()
62 63
 
64
+    # api
65
+    api = APIController()
66
+
63 67
     def _render_response(self, tgl, controller, response):
64 68
         replace_reset_password_templates(controller.decoration.engines)
65 69
         return super()._render_response(tgl, controller, response)
@@ -111,7 +115,7 @@ class RootController(StandardController):
111 115
             redirect(url('/login'),
112 116
                 params=dict(came_from=came_from, __logins=login_counter))
113 117
 
114
-        user = UserStaticApi.get_current_user()
118
+        user = CurrentUserGetterApi.get_current_user()
115 119
 
116 120
         flash(_('Welcome back, %s!') % user.get_display_name())
117 121
         redirect(came_from)

+ 31 - 29
tracim/tracim/controllers/user.py 查看文件

@@ -1,33 +1,19 @@
1 1
 # -*- coding: utf-8 -*-
2
+import pytz
3
+from sqlalchemy.orm.exc import NoResultFound
4
+from tracim.lib import CST
2 5
 from webob.exc import HTTPForbidden
3
-
4
-from tracim import model  as pm
5
-
6
-from sprox.tablebase import TableBase
7
-from sprox.formbase import EditableForm, AddRecordForm
8
-from sprox.fillerbase import TableFiller, EditFormFiller
9
-from tw2 import forms as tw2f
10 6
 import tg
11 7
 from tg import tmpl_context
12
-from tg.i18n import ugettext as _, lazy_ugettext as l_
13
-
14
-from sprox.widgets import PropertyMultipleSelectField
15
-from sprox._compat import unicode_text
16
-
17
-from formencode import Schema
18
-from formencode.validators import FieldsMatch
8
+from tg.i18n import ugettext as _
19 9
 
20 10
 from tracim.controllers import TIMRestController
21
-from tracim.lib import helpers as h
22 11
 from tracim.lib.user import UserApi
23
-from tracim.lib.group import GroupApi
24
-from tracim.lib.user import UserStaticApi
25
-from tracim.lib.userworkspace import RoleApi
26 12
 from tracim.lib.workspace import WorkspaceApi
27
-
28
-from tracim.model import DBSession
29
-from tracim.model.auth import Group, User
30
-from tracim.model.serializers import Context, CTX, DictLikeClass
13
+from tracim.model.serializers import Context
14
+from tracim.model.serializers import CTX
15
+from tracim.model.serializers import DictLikeClass
16
+from tracim import model as pm
31 17
 
32 18
 
33 19
 class UserWorkspaceRestController(TIMRestController):
@@ -123,7 +109,7 @@ class UserPasswordRestController(TIMRestController):
123 109
             tg.redirect(redirect_url)
124 110
 
125 111
         current_user.password = new_password1
126
-        current_user.webdav_left_digest_response_hash = '%s:/:%s' % (current_user.email, new_password1)
112
+        current_user.update_webdav_digest_auth(new_password1)
127 113
         pm.DBSession.flush()
128 114
 
129 115
         tg.flash(_('Your password has been changed'))
@@ -174,26 +160,42 @@ class UserRestController(TIMRestController):
174 160
 
175 161
         dictified_user = Context(CTX.USER).toDict(current_user, 'user')
176 162
         fake_api = DictLikeClass(next_url=next_url)
177
-        return DictLikeClass(result=dictified_user, fake_api=fake_api)
163
+        return DictLikeClass(
164
+            result=dictified_user,
165
+            fake_api=fake_api,
166
+            timezones=pytz.all_timezones,
167
+        )
178 168
 
179 169
     @tg.expose('tracim.templates.workspace.edit')
180
-    def put(self, user_id, name, email, next_url=None):
170
+    def put(self, user_id, name, email, timezone, next_url=None):
181 171
         user_id = tmpl_context.current_user.user_id
182 172
         current_user = tmpl_context.current_user
173
+        user_api = UserApi(current_user)
183 174
         assert user_id==current_user.user_id
175
+        if next_url:
176
+            next = tg.url(next_url)
177
+        else:
178
+            next = self.url()
179
+
180
+        try:
181
+            email_user = user_api.get_one_by_email(email)
182
+            if email_user != current_user:
183
+                tg.flash(_('Email already in use'), CST.STATUS_ERROR)
184
+                tg.redirect(next)
185
+        except NoResultFound:
186
+            pass
184 187
 
185 188
         # Only keep allowed field update
186 189
         updated_fields = self._clean_update_fields({
187 190
             'name': name,
188
-            'email': email
191
+            'email': email,
192
+            'timezone': timezone,
189 193
         })
190 194
 
191 195
         api = UserApi(tmpl_context.current_user)
192 196
         api.update(current_user, do_save=True, **updated_fields)
193 197
         tg.flash(_('profile updated.'))
194
-        if next_url:
195
-            tg.redirect(tg.url(next_url))
196
-        tg.redirect(self.url())
198
+        tg.redirect(next)
197 199
 
198 200
     def _clean_update_fields(self, fields: dict):
199 201
         """

+ 26 - 5
tracim/tracim/controllers/workspace.py 查看文件

@@ -12,6 +12,7 @@ from tracim.controllers.content import UserWorkspaceFolderRestController
12 12
 
13 13
 from tracim.lib.helpers import convert_id_into_instances
14 14
 from tracim.lib.content import ContentApi
15
+from tracim.lib.utils import str_as_bool
15 16
 from tracim.lib.workspace import WorkspaceApi
16 17
 
17 18
 from tracim.model.data import NodeTreeItem
@@ -42,7 +43,16 @@ class UserWorkspaceRestController(TIMRestController):
42 43
         tg.redirect(tg.url('/home'))
43 44
 
44 45
     @tg.expose('tracim.templates.workspace.getone')
45
-    def get_one(self, workspace_id):
46
+    def get_one(self, workspace_id, **kwargs):
47
+        """
48
+        :param workspace_id: Displayed workspace id
49
+        :param kwargs:
50
+          * show_deleted: bool: Display deleted contents or hide them if False
51
+          * show_archived: bool: Display archived contents or hide them
52
+            if False
53
+        """
54
+        show_deleted = str_as_bool(kwargs.get('show_deleted', False))
55
+        show_archived = str_as_bool(kwargs.get('show_archived', ''))
46 56
         user = tmpl_context.current_user
47 57
 
48 58
         current_user_content = Context(CTX.CURRENT_USER).toDict(user)
@@ -60,13 +70,24 @@ class UserWorkspaceRestController(TIMRestController):
60 70
         )
61 71
 
62 72
         fake_api.sub_items = Context(CTX.FOLDER_CONTENT_LIST).toDict(
63
-            workspace.get_valid_children(ContentApi.DISPLAYABLE_CONTENTS)
73
+            # TODO BS 20161209: Is the correct way to grab folders? No use API?
74
+            workspace.get_valid_children(
75
+                ContentApi.DISPLAYABLE_CONTENTS,
76
+                show_deleted=show_deleted,
77
+                show_archived=show_archived,
78
+            )
64 79
         )
65 80
 
66 81
         dictified_workspace = Context(CTX.WORKSPACE).toDict(workspace, 'workspace')
67
-
68
-        return DictLikeClass(result = dictified_workspace, fake_api=fake_api)
69
-
82
+        webdav_url = CFG.get_instance().WSGIDAV_CLIENT_BASE_URL
83
+
84
+        return DictLikeClass(
85
+            result=dictified_workspace,
86
+            fake_api=fake_api,
87
+            webdav_url=webdav_url,
88
+            show_deleted=show_deleted,
89
+            show_archived=show_archived,
90
+        )
70 91
 
71 92
     @tg.expose('json')
72 93
     def treeview_root(self, id='#',

+ 131 - 0
tracim/tracim/fixtures/content.py 查看文件

@@ -0,0 +1,131 @@
1
+# -*- coding: utf-8 -*-
2
+from tracim import model
3
+from tracim.fixtures import Fixture
4
+from tracim.fixtures.users_and_groups import Test
5
+from tracim.lib.content import ContentApi
6
+from tracim.lib.userworkspace import RoleApi
7
+from tracim.lib.workspace import WorkspaceApi
8
+from tracim.model.data import ContentType
9
+from tracim.model.data import UserRoleInWorkspace
10
+
11
+
12
+class Content(Fixture):
13
+    require = [Test]
14
+
15
+    def insert(self):
16
+        admin = self._session.query(model.User) \
17
+            .filter(model.User.email == 'admin@admin.admin') \
18
+            .one()
19
+        bob = self._session.query(model.User) \
20
+            .filter(model.User.email == 'bob@fsf.local') \
21
+            .one()
22
+        workspace_api = WorkspaceApi(admin)
23
+        content_api = ContentApi(admin)
24
+        role_api = RoleApi(admin)
25
+
26
+        # Workspaces
27
+        w1 = workspace_api.create_workspace('w1', save_now=True)
28
+        w2 = workspace_api.create_workspace('w2', save_now=True)
29
+        w3 = workspace_api.create_workspace('w3', save_now=True)
30
+
31
+        # Workspaces roles
32
+        role_api.create_one(
33
+            user=bob,
34
+            workspace=w1,
35
+            role_level=UserRoleInWorkspace.CONTENT_MANAGER,
36
+            with_notif=False,
37
+        )
38
+        role_api.create_one(
39
+            user=bob,
40
+            workspace=w2,
41
+            role_level=UserRoleInWorkspace.CONTENT_MANAGER,
42
+            with_notif=False,
43
+        )
44
+
45
+        # Folders
46
+        w1f1 = content_api.create(
47
+            content_type=ContentType.Folder,
48
+            workspace=w1,
49
+            label='w1f1',
50
+            do_save=True,
51
+        )
52
+        w1f2 = content_api.create(
53
+            content_type=ContentType.Folder,
54
+            workspace=w1,
55
+            label='w1f2',
56
+            do_save=True,
57
+        )
58
+
59
+        w2f1 = content_api.create(
60
+            content_type=ContentType.Folder,
61
+            workspace=w2,
62
+            label='w2f1',
63
+            do_save=True,
64
+        )
65
+        w2f2 = content_api.create(
66
+            content_type=ContentType.Folder,
67
+            workspace=w2,
68
+            label='w2f2',
69
+            do_save=True,
70
+        )
71
+
72
+        w3f1 = content_api.create(
73
+            content_type=ContentType.Folder,
74
+            workspace=w3,
75
+            label='w3f3',
76
+            do_save=True,
77
+        )
78
+
79
+        # Pages, threads, ..
80
+        w1f1p1 = content_api.create(
81
+            content_type=ContentType.Page,
82
+            workspace=w1,
83
+            parent=w1f1,
84
+            label='w1f1p1',
85
+            do_save=True,
86
+        )
87
+        w1f1t1 = content_api.create(
88
+            content_type=ContentType.Thread,
89
+            workspace=w1,
90
+            parent=w1f1,
91
+            label='w1f1t1',
92
+            do_save=False,
93
+        )
94
+        w1f1t1.description = 'w1f1t1 description'
95
+        self._session.add(w1f1t1)
96
+        w1f1d1_txt = content_api.create(
97
+            content_type=ContentType.File,
98
+            workspace=w1,
99
+            parent=w1f1,
100
+            label='w1f1d1',
101
+            do_save=False,
102
+        )
103
+        w1f1d1_txt.file_extension = '.txt'
104
+        w1f1d1_txt.file_content = b'w1f1d1 content'
105
+        self._session.add(w1f1d1_txt)
106
+        w1f1d2_html = content_api.create(
107
+            content_type=ContentType.File,
108
+            workspace=w1,
109
+            parent=w1f1,
110
+            label='w1f1d2',
111
+            do_save=False,
112
+        )
113
+        w1f1d2_html.file_extension = '.html'
114
+        w1f1d2_html.file_content = b'<p>w1f1d2 content</p>'
115
+        self._session.add(w1f1d2_html)
116
+        w1f1f1 = content_api.create(
117
+            content_type=ContentType.Folder,
118
+            workspace=w1,
119
+            label='w1f1f1',
120
+            parent=w1f1,
121
+            do_save=True,
122
+        )
123
+
124
+        w2f1p1 = content_api.create(
125
+            content_type=ContentType.Page,
126
+            workspace=w2,
127
+            parent=w2f1,
128
+            label='w2f1p1',
129
+            do_save=True,
130
+        )
131
+        self._session.flush()

二进制
tracim/tracim/i18n/fr/LC_MESSAGES/tracim.mo 查看文件


文件差异内容过多而无法显示
+ 534 - 329
tracim/tracim/i18n/fr/LC_MESSAGES/tracim.po


+ 1 - 1
tracim/tracim/lib/__init__.py 查看文件

@@ -1,5 +1,5 @@
1 1
 # -*- coding: utf-8 -*-
2
-from tg.i18n import lazy_ugettext as l_
2
+
3 3
 
4 4
 class NotFoundError(Exception):
5 5
     pass

+ 1 - 2
tracim/tracim/lib/app_globals.py 查看文件

@@ -3,7 +3,7 @@
3 3
 from markupsafe import escape_silent as escape
4 4
 
5 5
 import tg
6
-from tg.i18n import ugettext as _, lazy_ugettext as l_
6
+from tracim.lib.utils import lazy_ugettext as l_
7 7
 from tg.flash import TGFlash
8 8
 
9 9
 """The application's Globals object"""
@@ -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
 

+ 101 - 11
tracim/tracim/lib/calendar.py 查看文件

@@ -1,10 +1,14 @@
1
+import caldav
1 2
 import os
2 3
 
3 4
 import re
4 5
 import transaction
6
+from caldav.lib.error import PutError
5 7
 
6 8
 from icalendar import Event as iCalendarEvent
7 9
 from sqlalchemy.orm.exc import NoResultFound
10
+from tg import tmpl_context
11
+from tg.i18n import ugettext as _
8 12
 
9 13
 from tracim.lib.content import ContentApi
10 14
 from tracim.lib.exceptions import UnknownCalendarType
@@ -37,10 +41,22 @@ CALENDAR_WORKSPACE_BASE_URL = '/workspace/'
37 41
 
38 42
 class CalendarManager(object):
39 43
     @classmethod
40
-    def get_base_url(cls):
44
+    def get_personal_calendar_description(cls) -> str:
45
+        return _('My personal calendar')
46
+
47
+    @classmethod
48
+    def get_base_url(cls, low_level: bool=False) -> str:
49
+        """
50
+        :param low_level: If True, use local ip address with radicale port.
51
+        :return: Radical address base url.
52
+        """
41 53
         from tracim.config.app_cfg import CFG
42 54
         cfg = CFG.get_instance()
43
-        return cfg.RADICALE_CLIENT_BASE_URL_TEMPLATE
55
+
56
+        if not low_level:
57
+            return cfg.RADICALE_CLIENT_BASE_URL_TEMPLATE
58
+
59
+        return 'http://127.0.0.1:{0}'.format(cfg.RADICALE_SERVER_PORT)
44 60
 
45 61
     @classmethod
46 62
     def get_user_base_url(cls):
@@ -55,16 +71,30 @@ class CalendarManager(object):
55 71
         return os.path.join(cfg.RADICALE_CLIENT_BASE_URL_TEMPLATE, 'workspace/')
56 72
 
57 73
     @classmethod
58
-    def get_user_calendar_url(cls, user_id: int):
74
+    def get_user_calendar_url(
75
+            cls,
76
+            user_id: int,
77
+            low_level: bool=False,
78
+    ):
59 79
         user_path = CALENDAR_USER_URL_TEMPLATE.format(id=str(user_id))
60
-        return os.path.join(cls.get_base_url(), user_path)
80
+        return os.path.join(
81
+            cls.get_base_url(low_level=low_level),
82
+            user_path,
83
+        )
61 84
 
62 85
     @classmethod
63
-    def get_workspace_calendar_url(cls, workspace_id: int):
86
+    def get_workspace_calendar_url(
87
+            cls,
88
+            workspace_id: int,
89
+            low_level: bool=False,
90
+    ):
64 91
         workspace_path = CALENDAR_WORKSPACE_URL_TEMPLATE.format(
65 92
             id=str(workspace_id)
66 93
         )
67
-        return os.path.join(cls.get_base_url(), workspace_path)
94
+        return os.path.join(
95
+            cls.get_base_url(low_level=low_level),
96
+            workspace_path,
97
+        )
68 98
 
69 99
     def __init__(self, user: User):
70 100
         self._user = user
@@ -284,14 +314,24 @@ class CalendarManager(object):
284 314
     def get_workspace_readable_calendars_urls_for_user(cls, user: User)\
285 315
             -> [str]:
286 316
         calendar_urls = []
317
+        for workspace in cls.get_workspace_readable_calendars_for_user(user):
318
+            calendar_urls.append(cls.get_workspace_calendar_url(
319
+                workspace_id=workspace.workspace_id,
320
+            ))
321
+
322
+        return calendar_urls
323
+
324
+    @classmethod
325
+    def get_workspace_readable_calendars_for_user(cls, user: User)\
326
+            -> ['Workspace']:
327
+        workspaces = []
287 328
         workspace_api = WorkspaceApi(user)
288
-        for workspace in workspace_api.get_all_for_user(user):
329
+
330
+        for workspace in workspace_api.get_all():
289 331
             if workspace.calendar_enabled:
290
-                calendar_urls.append(cls.get_workspace_calendar_url(
291
-                    workspace_id=workspace.workspace_id,
292
-                ))
332
+                workspaces.append(workspace)
293 333
 
294
-        return calendar_urls
334
+        return workspaces
295 335
 
296 336
     def is_discovery_path(self, path: str) -> bool:
297 337
         """
@@ -301,3 +341,53 @@ class CalendarManager(object):
301 341
         :return: True if given collection path is an discover path
302 342
         """
303 343
         return path in ('user', 'workspace')
344
+
345
+    def create_then_remove_fake_event(
346
+            self,
347
+            calendar_class,
348
+            related_object_id,
349
+    ) -> None:
350
+        radicale_base_url = self.get_base_url(low_level=True)
351
+        client = caldav.DAVClient(
352
+            radicale_base_url,
353
+            username=self._user.email,
354
+            password=self._user.auth_token,
355
+        )
356
+        if calendar_class == WorkspaceCalendar:
357
+            calendar_url = self.get_workspace_calendar_url(
358
+                related_object_id,
359
+                low_level=True,
360
+            )
361
+        elif calendar_class == UserCalendar:
362
+            calendar_url = self.get_user_calendar_url(
363
+                related_object_id,
364
+                low_level=True,
365
+            )
366
+        else:
367
+            raise Exception('Unknown calendar type {0}'.format(calendar_class))
368
+
369
+        user_calendar = caldav.Calendar(
370
+            parent=client,
371
+            client=client,
372
+            url=calendar_url
373
+        )
374
+
375
+        event_ics = """BEGIN:VCALENDAR
376
+VERSION:2.0
377
+PRODID:-//Example Corp.//CalDAV Client//EN
378
+BEGIN:VEVENT
379
+UID:{uid}
380
+DTSTAMP:20100510T182145Z
381
+DTSTART:20100512T170000Z
382
+DTEND:20100512T180000Z
383
+SUMMARY:This is an event
384
+LOCATION:Here
385
+END:VEVENT
386
+END:VCALENDAR
387
+""".format(uid='{0}FAKEEVENT'.format(related_object_id))
388
+        try:
389
+            event = user_calendar.add_event(event_ics)
390
+            event.delete()
391
+        except PutError:
392
+            pass  # TODO BS 20161128: Radicale is down. Record this event ?
393
+

+ 275 - 41
tracim/tracim/lib/content.py 查看文件

@@ -1,4 +1,12 @@
1 1
 # -*- coding: utf-8 -*-
2
+from contextlib import contextmanager
3
+
4
+import os
5
+
6
+from operator import itemgetter
7
+from sqlalchemy import func
8
+from sqlalchemy.orm import Query
9
+
2 10
 __author__ = 'damien'
3 11
 
4 12
 import datetime
@@ -94,6 +102,34 @@ class ContentApi(object):
94 102
         self._force_show_all_types = force_show_all_types
95 103
         self._disable_user_workspaces_filter = disable_user_workspaces_filter
96 104
 
105
+    @contextmanager
106
+    def show(
107
+            self,
108
+            show_archived: bool=False,
109
+            show_deleted: bool=False,
110
+            show_temporary: bool=False,
111
+    ):
112
+        """
113
+        Use this method as context manager to update show_archived,
114
+        show_deleted and show_temporary properties during context.
115
+        :param show_archived: show archived contents
116
+        :param show_deleted:  show deleted contents
117
+        :param show_temporary:  show temporary contents
118
+        """
119
+        previous_show_archived = self._show_archived
120
+        previous_show_deleted = self._show_deleted
121
+        previous_show_temporary = self._show_temporary
122
+
123
+        try:
124
+            self._show_archived = show_archived
125
+            self._show_deleted = show_deleted
126
+            self._show_temporary = show_temporary
127
+            yield self
128
+        finally:
129
+            self._show_archived = previous_show_archived
130
+            self._show_deleted = previous_show_deleted
131
+            self._show_temporary = previous_show_temporary
132
+
97 133
     @classmethod
98 134
     def get_revision_join(cls):
99 135
         """
@@ -184,12 +220,21 @@ class ContentApi(object):
184 220
         if workspace:
185 221
             result = result.filter(Content.workspace_id==workspace.workspace_id)
186 222
 
223
+        # Security layer: if user provided, filter
224
+        # with user workspaces privileges
187 225
         if self._user and not self._disable_user_workspaces_filter:
188 226
             user = DBSession.query(User).get(self._user_id)
189 227
             # Filter according to user workspaces
190 228
             workspace_ids = [r.workspace_id for r in user.roles \
191 229
                              if r.role>=UserRoleInWorkspace.READER]
192
-            result = result.filter(Content.workspace_id.in_(workspace_ids))
230
+            result = result.filter(or_(
231
+                Content.workspace_id.in_(workspace_ids),
232
+                # And allow access to non workspace document when he is owner
233
+                and_(
234
+                    Content.workspace_id == None,
235
+                    Content.owner_id == self._user_id,
236
+                )
237
+            ))
193 238
 
194 239
         return result
195 240
 
@@ -275,6 +320,9 @@ class ContentApi(object):
275 320
 
276 321
         return result
277 322
 
323
+    def get_base_query(self, workspace: Workspace) -> Query:
324
+        return self._base_query(workspace)
325
+
278 326
     def get_child_folders(self, parent: Content=None, workspace: Workspace=None, filter_by_allowed_content_types: list=[], removed_item_ids: list=[], allowed_node_types=None) -> [Content]:
279 327
         """
280 328
         This method returns child items (folders or items) for left bar treeview.
@@ -321,6 +369,10 @@ class ContentApi(object):
321 369
 
322 370
     def create(self, content_type: str, workspace: Workspace, parent: Content=None, label:str ='', do_save=False, is_temporary: bool=False) -> Content:
323 371
         assert content_type in ContentType.allowed_types()
372
+
373
+        if content_type == ContentType.Folder and not label:
374
+            label = self.generate_folder_label(workspace, parent)
375
+
324 376
         content = Content()
325 377
         content.owner = self._user
326 378
         content.parent = parent
@@ -330,6 +382,12 @@ class ContentApi(object):
330 382
         content.is_temporary = is_temporary
331 383
         content.revision_type = ActionDescription.CREATION
332 384
 
385
+        if content.type in (
386
+                ContentType.Page,
387
+                ContentType.Thread,
388
+        ):
389
+            content.file_extension = '.html'
390
+
333 391
         if do_save:
334 392
             DBSession.add(content)
335 393
             self.save(content, ActionDescription.CREATION)
@@ -394,8 +452,11 @@ class ContentApi(object):
394 452
 
395 453
         return revision
396 454
 
397
-    def get_one_by_label_and_parent(self, content_label: str, content_parent: Content = None,
398
-                                    workspace: Workspace = None) -> Content:
455
+    def get_one_by_label_and_parent(
456
+            self,
457
+            content_label: str,
458
+            content_parent: Content=None,
459
+    ) -> Content:
399 460
         """
400 461
         This method let us request the database to obtain a Content with its name and parent
401 462
         :param content_label: Either the content's label or the content's filename if the label is None
@@ -403,52 +464,180 @@ class ContentApi(object):
403 464
         :param workspace: The workspace's content
404 465
         :return The corresponding Content
405 466
         """
406
-        assert content_label is not None# DYN_REMOVE
407
-
408
-        resultset = self._base_query(workspace)
409
-
467
+        workspace = content_parent.workspace if content_parent else None
468
+        query = self._base_query(workspace)
410 469
         parent_id = content_parent.content_id if content_parent else None
470
+        query = query.filter(Content.parent_id == parent_id)
471
+
472
+        file_name, file_extension = os.path.splitext(content_label)
473
+
474
+        return query.filter(
475
+            or_(
476
+                and_(
477
+                    Content.type == ContentType.File,
478
+                    Content.label == file_name,
479
+                    Content.file_extension == file_extension,
480
+                ),
481
+                and_(
482
+                    Content.type == ContentType.Thread,
483
+                    Content.label == file_name,
484
+                ),
485
+                and_(
486
+                    Content.type == ContentType.Page,
487
+                    Content.label == file_name,
488
+                ),
489
+                and_(
490
+                    Content.type == ContentType.Folder,
491
+                    Content.label == content_label,
492
+                ),
493
+            )
494
+        ).one()
411 495
 
412
-        resultset = resultset.filter(Content.parent_id == parent_id)
496
+    def get_one_by_label_and_parent_labels(
497
+            self,
498
+            content_label: str,
499
+            workspace: Workspace,
500
+            content_parent_labels: [str]=None,
501
+    ):
502
+        """
503
+        Return content with it's label, workspace and parents labels (optional)
504
+        :param content_label: label of content (label or file_name)
505
+        :param workspace: workspace containing all of this
506
+        :param content_parent_labels: Ordered list of labels representing path
507
+            of folder (without workspace label).
508
+        E.g.: ['foo', 'bar'] for complete path /Workspace1/foo/bar folder
509
+        :return: Found Content
510
+        """
511
+        query = self._base_query(workspace)
512
+        parent_folder = None
513
+
514
+        # Grab content parent folder if parent path given
515
+        if content_parent_labels:
516
+            parent_folder = self.get_folder_with_workspace_path_labels(
517
+                content_parent_labels,
518
+                workspace,
519
+            )
413 520
 
414
-        return resultset.filter(or_(
415
-            Content.label == content_label,
416
-            Content.file_name == content_label,
417
-            Content.label == re.sub(r'\.[^.]+$', '', content_label)
418
-        )).one()
521
+        # Build query for found content by label
522
+        content_query = self.filter_query_for_content_label_as_path(
523
+            query=query,
524
+            content_label_as_file=content_label,
525
+        )
419 526
 
420
-    def get_one_by_label_and_parent_label(self, content_label: str, content_parent_label: [str]=None, workspace: Workspace=None):
421
-        assert content_label is not None  # DYN_REMOVE
422
-        resultset = self._base_query(workspace)
527
+        # Modify query to apply parent folder filter if any
528
+        if parent_folder:
529
+            content_query = content_query.filter(
530
+                Content.parent_id == parent_folder.content_id,
531
+            )
532
+        else:
533
+            content_query = content_query.filter(
534
+                Content.parent_id == None,
535
+            )
423 536
 
424
-        res =  resultset.filter(or_(
425
-            Content.label == content_label,
426
-            Content.file_name == content_label,
427
-            Content.label == re.sub(r'\.[^.]+$', '', content_label)
428
-        )).all()
537
+        # Filter with workspace
538
+        content_query = content_query.filter(
539
+            Content.workspace_id == workspace.workspace_id,
540
+        )
429 541
 
430
-        if content_parent_label:
431
-            tmp = dict()
432
-            for content in res:
433
-                tmp[content] = content.parent
542
+        # Return the content
543
+        return content_query\
544
+            .order_by(
545
+                Content.revision_id.desc(),
546
+            )\
547
+            .one()
434 548
 
435
-            for parent_label in reversed(content_parent_label):
436
-                a = []
437
-                tmp = {content: parent.parent for content, parent in tmp.items()
438
-                       if parent and parent.label == parent_label}
549
+    def get_folder_with_workspace_path_labels(
550
+            self,
551
+            path_labels: [str],
552
+            workspace: Workspace,
553
+    ) -> Content:
554
+        """
555
+        Return a Content folder for given relative path.
556
+        TODO BS 20161124: Not safe if web interface allow folder duplicate names
557
+        :param path_labels: List of labels representing path of folder
558
+        (without workspace label).
559
+        E.g.: ['foo', 'bar'] for complete path /Workspace1/foo/bar folder
560
+        :param workspace: workspace of folders
561
+        :return: Content folder
562
+        """
563
+        query = self._base_query(workspace)
564
+        folder = None
565
+
566
+        for label in path_labels:
567
+            # Filter query on label
568
+            folder_query = query \
569
+                .filter(
570
+                    Content.type == ContentType.Folder,
571
+                    Content.label == label,
572
+                    Content.workspace_id == workspace.workspace_id,
573
+                )
439 574
 
440
-                if len(tmp) == 1:
441
-                    content, last_parent = tmp.popitem()
442
-                    return content
443
-                elif len(tmp) == 0:
444
-                    return None
575
+            # Search into parent folder (if already deep)
576
+            if folder:
577
+                folder_query = folder_query\
578
+                    .filter(
579
+                        Content.parent_id == folder.content_id,
580
+                    )
581
+            else:
582
+                folder_query = folder_query \
583
+                    .filter(Content.parent_id == None)
445 584
 
446
-            for content, parent_content in tmp.items():
447
-                if not parent_content:
448
-                    return content
585
+            # Get thirst corresponding folder
586
+            folder = folder_query \
587
+                .order_by(Content.revision_id.desc()) \
588
+                .one()
449 589
 
450
-            return None
451
-        return res[0]
590
+        return folder
591
+
592
+    def filter_query_for_content_label_as_path(
593
+            self,
594
+            query: Query,
595
+            content_label_as_file: str,
596
+            is_case_sensitive: bool = False,
597
+    ) -> Query:
598
+        """
599
+        Apply normalised filters to found Content corresponding as given label.
600
+        :param query: query to modify
601
+        :param content_label_as_file: label in this
602
+        FILE version, use Content.get_label_as_file().
603
+        :param is_case_sensitive: Take care about case or not
604
+        :return: modified query
605
+        """
606
+        file_name, file_extension = os.path.splitext(content_label_as_file)
607
+
608
+        label_filter = Content.label == content_label_as_file
609
+        file_name_filter = Content.label == file_name
610
+        file_extension_filter = Content.file_extension == file_extension
611
+
612
+        if not is_case_sensitive:
613
+            label_filter = func.lower(Content.label) == \
614
+                           func.lower(content_label_as_file)
615
+            file_name_filter = func.lower(Content.label) == \
616
+                               func.lower(file_name)
617
+            file_extension_filter = func.lower(Content.file_extension) == \
618
+                                    func.lower(file_extension)
619
+
620
+        return query.filter(or_(
621
+            and_(
622
+                Content.type == ContentType.File,
623
+                file_name_filter,
624
+                file_extension_filter,
625
+            ),
626
+            and_(
627
+                Content.type == ContentType.Thread,
628
+                file_name_filter,
629
+                file_extension_filter,
630
+            ),
631
+            and_(
632
+                Content.type == ContentType.Page,
633
+                file_name_filter,
634
+                file_extension_filter,
635
+            ),
636
+            and_(
637
+                Content.type == ContentType.Folder,
638
+                label_filter,
639
+            ),
640
+        ))
452 641
 
453 642
     def get_all(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> [Content]:
454 643
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
@@ -467,6 +656,24 @@ class ContentApi(object):
467 656
 
468 657
         return resultset.all()
469 658
 
659
+    def get_children(self, parent_id: int, content_types: list, workspace: Workspace=None) -> [Content]:
660
+        """
661
+        Return parent_id childs of given content_types
662
+        :param parent_id: parent id
663
+        :param content_types: list of types
664
+        :param workspace: workspace filter
665
+        :return: list of content
666
+        """
667
+        resultset = self._base_query(workspace)
668
+        resultset = resultset.filter(Content.type.in_(content_types))
669
+
670
+        if parent_id:
671
+            resultset = resultset.filter(Content.parent_id==parent_id)
672
+        if parent_id is False:
673
+            resultset = resultset.filter(Content.parent_id == None)
674
+
675
+        return resultset.all()
676
+
470 677
     # TODO find an other name to filter on is_deleted / is_archived
471 678
     def get_all_with_filter(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> [Content]:
472 679
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
@@ -541,8 +748,13 @@ class ContentApi(object):
541 748
             .filter(~ContentRevisionRO.revision_id.in_(read_revision_ids)) \
542 749
             .subquery()
543 750
 
544
-        not_read_content_ids = DBSession.query(
545
-            distinct(not_read_revisions.c.content_id)).all()
751
+        not_read_content_ids_query = DBSession.query(
752
+            distinct(not_read_revisions.c.content_id)
753
+        )
754
+        not_read_content_ids = list(map(
755
+            itemgetter(0),
756
+            not_read_content_ids_query,
757
+        ))
546 758
 
547 759
         not_read_contents = self._base_query(workspace) \
548 760
             .filter(Content.content_id.in_(not_read_content_ids)) \
@@ -868,3 +1080,25 @@ class ContentApi(object):
868 1080
             )
869 1081
         )
870 1082
         return query.one()
1083
+
1084
+    def generate_folder_label(
1085
+            self,
1086
+            workspace: Workspace,
1087
+            parent: Content=None,
1088
+    ) -> str:
1089
+        """
1090
+        Generate a folder label
1091
+        :param workspace: Future folder workspace
1092
+        :param parent: Parent of foture folder (can be None)
1093
+        :return: Generated folder name
1094
+        """
1095
+        query = self._base_query(workspace=workspace)\
1096
+            .filter(Content.label.ilike('{0}%'.format(
1097
+                _('New folder'),
1098
+            )))
1099
+        if parent:
1100
+            query = query.filter(Content.parent == parent)
1101
+
1102
+        return _('New folder {0}').format(
1103
+            query.count() + 1,
1104
+        )

+ 77 - 35
tracim/tracim/lib/daemons.py 查看文件

@@ -4,13 +4,15 @@ from wsgiref.simple_server import make_server
4 4
 import signal
5 5
 
6 6
 import collections
7
-import transaction
8 7
 
9 8
 from radicale import Application as RadicaleApplication
10 9
 from radicale import HTTPServer as BaseRadicaleHTTPServer
11 10
 from radicale import HTTPSServer as BaseRadicaleHTTPSServer
12 11
 from radicale import RequestHandler as RadicaleRequestHandler
13 12
 from radicale import config as radicale_config
13
+from rq import Connection as RQConnection
14
+from rq import Worker as BaseRQWorker
15
+from redis import Redis
14 16
 
15 17
 from tracim.lib.base import logger
16 18
 from tracim.lib.exceptions import AlreadyRunningDaemon
@@ -21,7 +23,6 @@ class DaemonsManager(object):
21 23
     def __init__(self):
22 24
         self._running_daemons = {}
23 25
         add_signal_handler(signal.SIGTERM, self.stop_all)
24
-        add_signal_handler(signal.SIGINT, self.stop_all)
25 26
 
26 27
     def run(self, name: str, daemon_class: object, **kwargs) -> None:
27 28
         """
@@ -145,6 +146,43 @@ class Daemon(threading.Thread):
145 146
         raise NotImplementedError()
146 147
 
147 148
 
149
+class MailSenderDaemon(Daemon):
150
+    # NOTE: use *args and **kwargs because parent __init__ use strange
151
+    # * parameter
152
+    def __init__(self, *args, **kwargs):
153
+        super().__init__(*args, **kwargs)
154
+        self.worker = None  # type: RQWorker
155
+
156
+    def append_thread_callback(self, callback: collections.Callable) -> None:
157
+        logger.warning('MailSenderDaemon not implement append_thread_callback')
158
+        pass
159
+
160
+    def stop(self) -> None:
161
+        self.worker.request_stop('TRACIM STOP', None)
162
+
163
+    def run(self) -> None:
164
+        from tracim.config.app_cfg import CFG
165
+        cfg = CFG.get_instance()
166
+
167
+        with RQConnection(Redis(
168
+            host=cfg.EMAIL_SENDER_REDIS_HOST,
169
+            port=cfg.EMAIL_SENDER_REDIS_PORT,
170
+            db=cfg.EMAIL_SENDER_REDIS_DB,
171
+        )):
172
+            self.worker = RQWorker(['mail_sender'])
173
+            self.worker.work()
174
+
175
+
176
+class RQWorker(BaseRQWorker):
177
+    def _install_signal_handlers(self):
178
+        # TODO BS 20170126: RQ WWorker is designed to work in main thread
179
+        # So we have to disable these signals (we implement server stop in
180
+        # MailSenderDaemon.stop method). When bug
181
+        # https://github.com/tracim/tracim/issues/166 will be fixed, ensure
182
+        # This worker terminate correctly.
183
+        pass
184
+
185
+
148 186
 class RadicaleHTTPSServer(TracimSocketServerMixin, BaseRadicaleHTTPSServer):
149 187
     pass
150 188
 
@@ -192,6 +230,11 @@ class RadicaleDaemon(Daemon):
192 230
         radicale_config.set('storage', 'filesystem_folder', fs_path)
193 231
 
194 232
         radicale_config.set('server', 'realm', realm_message)
233
+        radicale_config.set(
234
+            'server',
235
+            'base_prefix',
236
+            cfg.RADICALE_CLIENT_BASE_URL_PREFIX,
237
+        )
195 238
 
196 239
         try:
197 240
             radicale_config.add_section('headers')
@@ -255,6 +298,9 @@ from tracim.lib.webdav.sql_domain_controller import TracimDomainController
255 298
 from inspect import isfunction
256 299
 import traceback
257 300
 
301
+from wsgidav.server.cherrypy import wsgiserver
302
+from wsgidav.server.cherrypy.wsgiserver.wsgiserver3 import CherryPyWSGIServer
303
+
258 304
 DEFAULT_CONFIG_FILE = "wsgidav.conf"
259 305
 PYTHON_VERSION = "%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])
260 306
 
@@ -284,17 +330,23 @@ class WsgiDavDaemon(Daemon):
284 330
             print(
285 331
                 "WARNING: Could not import lxml: using xml instead (slower). Consider installing lxml from http://codespeak.net/lxml/.")
286 332
         from wsgidav.dir_browser import WsgiDavDirBrowser
287
-        from wsgidav.debug_filter import WsgiDavDebugFilter
288 333
         from tracim.lib.webdav.tracim_http_authenticator import TracimHTTPAuthenticator
289 334
         from wsgidav.error_printer import ErrorPrinter
335
+        from tracim.lib.webdav.utils import TracimWsgiDavDebugFilter
290 336
 
291
-        config['middleware_stack'] = [ WsgiDavDirBrowser, TracimHTTPAuthenticator, ErrorPrinter, WsgiDavDebugFilter ]
337
+        config['middleware_stack'] = [
338
+            WsgiDavDirBrowser,
339
+            TracimHTTPAuthenticator,
340
+            ErrorPrinter,
341
+            TracimWsgiDavDebugFilter,
342
+        ]
292 343
 
293 344
         config['provider_mapping'] = {
294 345
             config['root_path']: Provider(
295
-                show_archived=config['show_archived'],
296
-                show_deleted=config['show_deleted'],
297
-                show_history=config['show_history'],
346
+                # TODO: Test to Re enabme archived and deleted
347
+                show_archived=False,  # config['show_archived'],
348
+                show_deleted=False,  # config['show_deleted'],
349
+                show_history=False,  # config['show_history'],
298 350
                 manage_locks=config['manager_locks']
299 351
             )
300 352
         }
@@ -335,39 +387,29 @@ class WsgiDavDaemon(Daemon):
335 387
         app = WsgiDAVApp(self.config)
336 388
 
337 389
         # Try running WsgiDAV inside the following external servers:
338
-        self._runCherryPy(app, self.config, "cherrypy-bundled")
390
+        self._runCherryPy(app, self.config)
339 391
 
340
-    def _runCherryPy(self, app, config, mode):
341
-        """Run WsgiDAV using cherrypy.wsgiserver, if CherryPy is installed."""
342
-        assert mode in ("cherrypy", "cherrypy-bundled")
343
-
344
-        try:
345
-            from wsgidav.server.cherrypy import wsgiserver
346
-
347
-            version = "WsgiDAV/%s %s Python/%s" % (
348
-                __version__,
349
-                wsgiserver.CherryPyWSGIServer.version,
350
-                PYTHON_VERSION)
392
+    def _runCherryPy(self, app, config):
393
+        version = "WsgiDAV/%s %s Python/%s" % (
394
+            __version__,
395
+            wsgiserver.CherryPyWSGIServer.version,
396
+            PYTHON_VERSION
397
+        )
351 398
 
352
-            wsgiserver.CherryPyWSGIServer.version = version
399
+        wsgiserver.CherryPyWSGIServer.version = version
353 400
 
354
-            protocol = "http"
401
+        protocol = "http"
355 402
 
356
-            if config["verbose"] >= 1:
357
-                print("Running %s" % version)
358
-                print("Listening on %s://%s:%s ..." % (protocol, config["host"], config["port"]))
359
-            self._server = wsgiserver.CherryPyWSGIServer(
360
-                (config["host"], config["port"]),
361
-                app,
362
-                server_name=version,
363
-            )
403
+        if config["verbose"] >= 1:
404
+            print("Running %s" % version)
405
+            print("Listening on %s://%s:%s ..." % (protocol, config["host"], config["port"]))
406
+        self._server = CherryPyWSGIServer(
407
+            (config["host"], config["port"]),
408
+            app,
409
+            server_name=version,
410
+        )
364 411
 
365
-            self._server.start()
366
-        except ImportError as e:
367
-            if config["verbose"] >= 1:
368
-                print("Could not import wsgiserver.CherryPyWSGIServer.")
369
-            return False
370
-        return True
412
+        self._server.start()
371 413
 
372 414
     def stop(self):
373 415
         self._server.stop()

+ 41 - 9
tracim/tracim/lib/email.py 查看文件

@@ -1,17 +1,50 @@
1 1
 # -*- coding: utf-8 -*-
2
-
3
-from email.mime.multipart import MIMEMultipart
4 2
 import smtplib
3
+from email.message import Message
4
+from email.mime.multipart import MIMEMultipart
5 5
 from email.mime.text import MIMEText
6 6
 
7
+import typing
7 8
 from mako.template import Template
8
-from tgext.asyncjob import asyncjob_perform
9
+from redis import Redis
10
+from rq import Queue
9 11
 from tg.i18n import ugettext as _
10 12
 
11 13
 from tracim.lib.base import logger
12 14
 from tracim.model import User
13 15
 
14 16
 
17
+def send_email_through(
18
+        send_callable: typing.Callable[[Message], None],
19
+        message: Message,
20
+) -> None:
21
+    """
22
+    Send mail encapsulation to send it in async or sync mode.
23
+    TODO BS 20170126: A global mail/sender management should be a good
24
+                      thing. Actually, this method is an fast solution.
25
+    :param send_callable: A callable who get message on first parameter
26
+    :param message: The message who have to be sent
27
+    """
28
+    from tracim.config.app_cfg import CFG
29
+    cfg = CFG.get_instance()
30
+
31
+    if cfg.EMAIL_PROCESSING_MODE == CFG.CST.SYNC:
32
+        send_callable(message)
33
+    elif cfg.EMAIL_PROCESSING_MODE == CFG.CST.ASYNC:
34
+        queue = Queue('mail_sender', connection=Redis(
35
+            host=cfg.EMAIL_SENDER_REDIS_HOST,
36
+            port=cfg.EMAIL_SENDER_REDIS_PORT,
37
+            db=cfg.EMAIL_SENDER_REDIS_DB,
38
+        ))
39
+        queue.enqueue(send_callable, message)
40
+    else:
41
+        raise NotImplementedError(
42
+            'Mail sender processing mode {} is not implemented'.format(
43
+                cfg.EMAIL_PROCESSING_MODE,
44
+            )
45
+        )
46
+
47
+
15 48
 class SmtpConfiguration(object):
16 49
     """
17 50
     Container class for SMTP configuration used in Tracim
@@ -103,7 +136,10 @@ class EmailManager(object):
103 136
             )
104 137
         message = MIMEMultipart('alternative')
105 138
         message['Subject'] = subject
106
-        message['From'] = self._global_config.EMAIL_NOTIFICATION_FROM
139
+        message['From'] = '{0} <{1}>'.format(
140
+            self._global_config.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL,
141
+            self._global_config.EMAIL_NOTIFICATION_FROM_EMAIL,
142
+        )
107 143
         message['To'] = user.email
108 144
 
109 145
         text_template_file_path = self._global_config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT  # nopep8
@@ -136,11 +172,7 @@ class EmailManager(object):
136 172
         message.attach(part1)
137 173
         message.attach(part2)
138 174
 
139
-        asyncjob_perform(async_email_sender.send_mail, message)
140
-
141
-        # Note: The following action allow to close the SMTP connection.
142
-        # This will work only if the async jobs are done in the right order
143
-        asyncjob_perform(async_email_sender.disconnect)
175
+        send_email_through(async_email_sender.send_mail, message)
144 176
 
145 177
     def _render(self, mako_template_filepath: str, context: dict):
146 178
         """

+ 42 - 14
tracim/tracim/lib/helpers.py 查看文件

@@ -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):
@@ -143,7 +167,11 @@ def convert_id_into_instances(id: str) -> (Workspace, Content):
143 167
     try:
144 168
         content_data = content_str.split(CST.TREEVIEW_MENU.ID_SEPARATOR)
145 169
         content_id = int(content_data[1])
146
-        content = ContentApi(tg.tmpl_context.current_user).get_one(content_id, ContentType.Any)
170
+        content = ContentApi(
171
+            tg.tmpl_context.current_user,
172
+            show_archived=True,
173
+            show_deleted=True,
174
+        ).get_one(content_id, ContentType.Any)
147 175
     except (IndexError, ValueError) as e:
148 176
         content = None
149 177
 

+ 103 - 0
tracim/tracim/lib/integrity.py 查看文件

@@ -0,0 +1,103 @@
1
+# -*- coding: utf-8 -*-
2
+import os
3
+
4
+from sqlalchemy import func
5
+from tg import tmpl_context
6
+from tg.render import render
7
+from tracim.lib.content import ContentApi
8
+from tracim.lib.workspace import UnsafeWorkspaceApi
9
+from tracim.model.data import Workspace
10
+from tracim.model.data import Content
11
+from tracim.model.serializers import DictLikeClass, Context, CTX
12
+
13
+
14
+class PathValidationManager(object):
15
+    def __init__(self, is_case_sensitive: bool=False):
16
+        """
17
+        :param is_case_sensitive: If True, consider name with different
18
+        case as different.
19
+        """
20
+        self._is_case_sensitive = is_case_sensitive
21
+        self._workspace_api = UnsafeWorkspaceApi(None)
22
+        self._content_api = ContentApi(None)
23
+
24
+    def workspace_label_is_free(self, workspace_name: str) -> bool:
25
+        """
26
+        :param workspace_name: Workspace name
27
+        :return: True if workspace is available
28
+        """
29
+        query = self._workspace_api.get_base_query()
30
+
31
+        label_filter = Workspace.label == workspace_name
32
+        if not self._is_case_sensitive:
33
+            label_filter = func.lower(Workspace.label) == \
34
+                           func.lower(workspace_name)
35
+
36
+        return not bool(query.filter(label_filter).count())
37
+
38
+    def content_label_is_free(
39
+            self,
40
+            content_label_as_file,
41
+            workspace: Workspace,
42
+            parent: Content=None,
43
+            exclude_content_id: int=None,
44
+    ) -> bool:
45
+        """
46
+        :param content_label_as_file:
47
+        :param workspace:
48
+        :param parent:
49
+        :return: True if content label is available
50
+        """
51
+        query = self._content_api.get_base_query(workspace)
52
+
53
+        if parent:
54
+            query = query.filter(Content.parent_id == parent.content_id)
55
+
56
+        if exclude_content_id:
57
+            query = query.filter(Content.content_id != exclude_content_id)
58
+
59
+        query = query.filter(Content.workspace_id == workspace.workspace_id)
60
+
61
+        return not \
62
+            bool(
63
+                self._content_api.filter_query_for_content_label_as_path(
64
+                    query=query,
65
+                    content_label_as_file=content_label_as_file,
66
+                    is_case_sensitive=self._is_case_sensitive,
67
+                ).count()
68
+            )
69
+
70
+    def validate_new_content(self, content: Content) -> bool:
71
+        """
72
+        :param content: Content with label to test
73
+        :return: True if content label is not in conflict with existing
74
+        resource
75
+        """
76
+        return self.content_label_is_free(
77
+            content_label_as_file=content.get_label_as_file(),
78
+            workspace=content.workspace,
79
+            parent=content.parent,
80
+            exclude_content_id=content.content_id,
81
+        )
82
+
83
+
84
+def render_invalid_integrity_chosen_path(invalid_label: str) -> str:
85
+    """
86
+    Return html page code of error about invalid label choice.
87
+    :param invalid_label: the invalid label
88
+    :return: html page code
89
+    """
90
+    user = tmpl_context.current_user
91
+    fake_api_content = DictLikeClass(
92
+        current_user=user,
93
+    )
94
+    fake_api = Context(CTX.USER).toDict(fake_api_content)
95
+
96
+    return render(
97
+        template_vars=dict(
98
+            invalid_label=invalid_label,
99
+            fake_api=fake_api,
100
+        ),
101
+        template_engine='mako',
102
+        template_name='tracim.templates.errors.label_invalid_path',
103
+    )

+ 55 - 39
tracim/tracim/lib/notifications.py 查看文件

@@ -8,15 +8,13 @@ from lxml.html.diff import htmldiff
8 8
 
9 9
 from mako.template import Template
10 10
 
11
-from tg.i18n import lazy_ugettext as l_
12
-from tg.i18n import ugettext as _
13
-
14 11
 from tracim.lib.base import logger
15 12
 from tracim.lib.email import SmtpConfiguration
13
+from tracim.lib.email import send_email_through
16 14
 from tracim.lib.email import EmailSender
17 15
 from tracim.lib.user import UserApi
18 16
 from tracim.lib.workspace import WorkspaceApi
19
-
17
+from tracim.lib.utils import lazy_ugettext as l_
20 18
 from tracim.model.serializers import Context
21 19
 from tracim.model.serializers import CTX
22 20
 from tracim.model.serializers import DictLikeClass
@@ -26,8 +24,6 @@ from tracim.model.data import Content, UserRoleInWorkspace, ContentType, \
26 24
 from tracim.model.auth import User
27 25
 
28 26
 
29
-from tgext.asyncjob import asyncjob_perform
30
-
31 27
 class INotifier(object):
32 28
     """
33 29
     Interface for Notifier instances
@@ -54,10 +50,13 @@ class NotifierFactory(object):
54 50
 
55 51
 
56 52
 class DummyNotifier(INotifier):
53
+    send_count = 0
54
+
57 55
     def __init__(self, current_user: User=None):
58 56
         logger.info(self, 'Instantiating Dummy Notifier')
59 57
 
60 58
     def notify_content_update(self, content: Content):
59
+        type(self).send_count += 1
61 60
         logger.info(self, 'Fake notifier, do not send email-notification for update of content {}'.format(content.content_id))
62 61
 
63 62
 
@@ -142,7 +141,6 @@ class RealNotifier(object):
142 141
                 # TODO - D.A - 2014-11-06
143 142
                 # This feature must be implemented in order to be able to scale to large communities
144 143
                 raise NotImplementedError('Sending emails through ASYNC mode is not working yet')
145
-                asyncjob_perform(EmailNotifier(self._smtp_config, global_config).notify_content_update, self._user.user_id, content.content_id)
146 144
             else:
147 145
                 logger.info(self, 'Sending email in SYNC mode')
148 146
                 EmailNotifier(self._smtp_config, global_config).notify_content_update(self._user.user_id, content.content_id)
@@ -182,6 +180,34 @@ class EmailNotifier(object):
182 180
         self._smtp_config = smtp_config
183 181
         self._global_config = global_config
184 182
 
183
+    def _get_sender(self, user: User=None) -> str:
184
+        """
185
+        Return sender string like "Bob Dylan
186
+            (via Tracim) <notification@mail.com>"
187
+        :param user: user to extract display name
188
+        :return: sender string
189
+        """
190
+        if user is None:
191
+            return '{0} <{1}>'.format(
192
+                self._global_config.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL,
193
+                self._global_config.EMAIL_NOTIFICATION_FROM_EMAIL,
194
+            )
195
+
196
+        # We add a suffix to email to prevent client like Thunderbird to
197
+        # display personal adressbook label.
198
+        email = self._global_config.EMAIL_NOTIFICATION_FROM_EMAIL
199
+        email_name, domain = email.split('@')
200
+        arranged_email = '{0}+{1}@{2}'.format(
201
+            email_name,
202
+            str(user.user_id),
203
+            domain,
204
+        )
205
+
206
+        return '{0} {1} <{2}>'.format(
207
+            user.display_name,
208
+            'via Tracim',
209
+            arranged_email,
210
+        )
185 211
 
186 212
     def notify_content_update(self, event_actor_id: int, event_content_id: int):
187 213
         """
@@ -228,12 +254,12 @@ class EmailNotifier(object):
228 254
             subject = self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT
229 255
             subject = subject.replace(EST.WEBSITE_TITLE, self._global_config.WEBSITE_TITLE.__str__())
230 256
             subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
231
-            subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__() if main_content.label else main_content.file_name.__str__())
257
+            subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__())
232 258
             subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
233 259
 
234 260
             message = MIMEMultipart('alternative')
235 261
             message['Subject'] = subject
236
-            message['From'] = self._global_config.EMAIL_NOTIFICATION_FROM
262
+            message['From'] = self._get_sender(user)
237 263
             message['To'] = to_addr
238 264
 
239 265
             body_text = self._build_email_body(self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT, role, content, user)
@@ -250,14 +276,7 @@ class EmailNotifier(object):
250 276
             message.attach(part1)
251 277
             message.attach(part2)
252 278
 
253
-            message_str = message.as_string()
254
-            asyncjob_perform(async_email_sender.send_mail, message)
255
-            # s.send_message(message)
256
-
257
-        # Note: The following action allow to close the SMTP connection.
258
-        # This will work only if the async jobs are done in the right order
259
-        asyncjob_perform(async_email_sender.disconnect)
260
-
279
+            send_email_through(async_email_sender.send_mail, message)
261 280
 
262 281
     def _build_email_body(self, mako_template_filepath: str, role: UserRoleInWorkspace, content: Content, actor: User) -> str:
263 282
         """
@@ -286,75 +305,72 @@ class EmailNotifier(object):
286 305
 
287 306
         action = content.get_last_action().id
288 307
         if ActionDescription.COMMENT == action:
289
-            content_intro = _('<span id="content-intro-username">{}</span> added a comment:').format(actor.display_name)
308
+            content_intro = l_('<span id="content-intro-username">{}</span> added a comment:').format(actor.display_name)
290 309
             content_text = content.description
291
-            call_to_action_text = _('Answer')
310
+            call_to_action_text = l_('Answer')
292 311
 
293 312
         elif ActionDescription.CREATION == action:
294 313
 
295 314
             # Default values (if not overriden)
296 315
             content_text = content.description
297
-            call_to_action_text = _('View online')
316
+            call_to_action_text = l_('View online')
298 317
 
299 318
             if ContentType.Thread == content.type:
300
-                call_to_action_text = _('Answer')
301
-                content_intro = _('<span id="content-intro-username">{}</span> started a thread entitled:').format(actor.display_name)
319
+                call_to_action_text = l_('Answer')
320
+                content_intro = l_('<span id="content-intro-username">{}</span> started a thread entitled:').format(actor.display_name)
302 321
                 content_text = '<p id="content-body-intro">{}</p>'.format(content.label) + \
303 322
                                content.get_last_comment_from(actor).description
304 323
 
305 324
             elif ContentType.File == content.type:
306
-                content_intro = _('<span id="content-intro-username">{}</span> added a file entitled:').format(actor.display_name)
325
+                content_intro = l_('<span id="content-intro-username">{}</span> added a file entitled:').format(actor.display_name)
307 326
                 if content.description:
308 327
                     content_text = content.description
309
-                elif content.label:
310
-                    content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
311 328
                 else:
312
-                    content_text = '<span id="content-body-only-title">{}</span>'.format(content.file_name)
313
-
329
+                    content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
314 330
 
315 331
             elif ContentType.Page == content.type:
316
-                content_intro = _('<span id="content-intro-username">{}</span> added a page entitled:').format(actor.display_name)
332
+                content_intro = l_('<span id="content-intro-username">{}</span> added a page entitled:').format(actor.display_name)
317 333
                 content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
318 334
 
319 335
         elif ActionDescription.REVISION == action:
320 336
             content_text = content.description
321
-            call_to_action_text = _('View online')
337
+            call_to_action_text = l_('View online')
322 338
 
323 339
             if ContentType.File == content.type:
324
-                content_intro = _('<span id="content-intro-username">{}</span> uploaded a new revision.').format(actor.display_name)
340
+                content_intro = l_('<span id="content-intro-username">{}</span> uploaded a new revision.').format(actor.display_name)
325 341
                 content_text = ''
326 342
 
327 343
             elif ContentType.Page == content.type:
328
-                content_intro = _('<span id="content-intro-username">{}</span> updated this page.').format(actor.display_name)
344
+                content_intro = l_('<span id="content-intro-username">{}</span> updated this page.').format(actor.display_name)
329 345
                 previous_revision = content.get_previous_revision()
330 346
                 title_diff = ''
331 347
                 if previous_revision.label != content.label:
332 348
                     title_diff = htmldiff(previous_revision.label, content.label)
333
-                content_text = _('<p id="content-body-intro">Here is an overview of the changes:</p>')+ \
349
+                content_text = str(l_('<p id="content-body-intro">Here is an overview of the changes:</p>'))+ \
334 350
                     title_diff + \
335 351
                     htmldiff(previous_revision.description, content.description)
336 352
 
337 353
             elif ContentType.Thread == content.type:
338
-                content_intro = _('<span id="content-intro-username">{}</span> updated the thread description.').format(actor.display_name)
354
+                content_intro = l_('<span id="content-intro-username">{}</span> updated the thread description.').format(actor.display_name)
339 355
                 previous_revision = content.get_previous_revision()
340 356
                 title_diff = ''
341 357
                 if previous_revision.label != content.label:
342 358
                     title_diff = htmldiff(previous_revision.label, content.label)
343
-                content_text = _('<p id="content-body-intro">Here is an overview of the changes:</p>')+ \
359
+                content_text = str(l_('<p id="content-body-intro">Here is an overview of the changes:</p>'))+ \
344 360
                     title_diff + \
345 361
                     htmldiff(previous_revision.description, content.description)
346 362
 
347 363
             # elif ContentType.Thread == content.type:
348
-            #     content_intro = _('<span id="content-intro-username">{}</span> updated this page.').format(actor.display_name)
364
+            #     content_intro = l_('<span id="content-intro-username">{}</span> updated this page.').format(actor.display_name)
349 365
             #     previous_revision = content.get_previous_revision()
350
-            #     content_text = _('<p id="content-body-intro">Here is an overview of the changes:</p>')+ \
366
+            #     content_text = l_('<p id="content-body-intro">Here is an overview of the changes:</p>')+ \
351 367
             #         htmldiff(previous_revision.description, content.description)
352 368
 
353 369
         elif ActionDescription.EDITION == action:
354
-            call_to_action_text = _('View online')
370
+            call_to_action_text = l_('View online')
355 371
 
356 372
             if ContentType.File == content.type:
357
-                content_intro = _('<span id="content-intro-username">{}</span> updated the file description.').format(actor.display_name)
373
+                content_intro = l_('<span id="content-intro-username">{}</span> updated the file description.').format(actor.display_name)
358 374
                 content_text = '<p id="content-body-intro">{}</p>'.format(content.get_label()) + \
359 375
                     content.description
360 376
 
@@ -374,7 +390,7 @@ class EmailNotifier(object):
374 390
         from tracim.config.app_cfg import CFG
375 391
         body_content = template.render(
376 392
             base_url=self._global_config.WEBSITE_BASE_URL,
377
-            _=_,
393
+            _=l_,
378 394
             h=helpers,
379 395
             user_display_name=role.user.display_name,
380 396
             user_role_label=role.role_as_label(),

+ 1 - 1
tracim/tracim/lib/predicates.py 查看文件

@@ -3,7 +3,7 @@
3 3
 from tg import abort
4 4
 from tg import request
5 5
 from tg import tmpl_context
6
-from tg.i18n import lazy_ugettext as l_
6
+from tracim.lib.utils import lazy_ugettext as l_
7 7
 from tg.i18n import ugettext as _
8 8
 from tg.predicates import Predicate
9 9
 

+ 2 - 0
tracim/tracim/lib/radicale/auth.py 查看文件

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

+ 1 - 1
tracim/tracim/lib/radicale/storage.py 查看文件

@@ -18,7 +18,7 @@ class Collection(BaseCollection):
18 18
     def __init__(self, path: str, principal: bool=False):
19 19
         super().__init__(path, principal)
20 20
         self._replacing = False  # See ``replacing`` context manager
21
-        self._manager = CalendarManager(None)
21
+        self._manager = CalendarManager(Auth.current_user)
22 22
 
23 23
     @contextmanager
24 24
     def replacing(self):

+ 90 - 20
tracim/tracim/lib/user.py 查看文件

@@ -1,14 +1,17 @@
1 1
 # -*- coding: utf-8 -*-
2
+import threading
2 3
 
3
-__author__ = 'damien'
4
-
4
+import cherrypy
5
+import transaction
5 6
 import tg
7
+import typing as typing
6 8
 
7 9
 from tracim.model.auth import User
8
-
9
-from tracim.model import auth as pbma
10 10
 from tracim.model import DBSession
11
-import tracim.model.data as pmd
11
+
12
+CURRENT_USER_WEB = 'WEB'
13
+CURRENT_USER_WSGIDAV = 'WSGIDAV'
14
+
12 15
 
13 16
 class UserApi(object):
14 17
 
@@ -30,19 +33,29 @@ class UserApi(object):
30 33
     def get_one_by_id(self, id: int) -> User:
31 34
         return self._base_query().filter(User.user_id==id).one()
32 35
 
33
-    def update(self, user: User, name: str=None, email: str=None, do_save=True):
36
+    def update(
37
+            self,
38
+            user: User,
39
+            name: str=None,
40
+            email: str=None,
41
+            do_save=True,
42
+            timezone: str='',
43
+    ):
34 44
         if name is not None:
35 45
             user.display_name = name
36 46
 
37 47
         if email is not None:
38 48
             user.email = email
39 49
 
50
+        user.timezone = timezone
51
+
40 52
         if do_save:
41 53
             self.save(user)
42 54
 
43
-        if self._user and user.user_id==self._user.user_id:
55
+        if email and self._user and user.user_id==self._user.user_id:
44 56
             # this is required for the session to keep on being up-to-date
45 57
             tg.request.identity['repoze.who.userid'] = email
58
+            tg.auth_force_login(email)
46 59
 
47 60
     def user_with_email_exists(self, email: str):
48 61
         try:
@@ -70,26 +83,83 @@ class UserApi(object):
70 83
     def save(self, user: User):
71 84
         DBSession.flush()
72 85
 
86
+    def execute_created_user_actions(self, created_user: User) -> None:
87
+        """
88
+        Execute actions when user just been created
89
+        :return:
90
+        """
91
+        # NOTE: Cyclic import
92
+        from tracim.lib.calendar import CalendarManager
93
+        from tracim.model.organisational import UserCalendar
73 94
 
74
-class UserStaticApi(object):
95
+        created_user.ensure_auth_token()
75 96
 
76
-    @classmethod
77
-    def get_current_user(cls) -> User:
97
+        # Ensure database is up-to-date
98
+        DBSession.flush()
99
+        transaction.commit()
100
+
101
+        calendar_manager = CalendarManager(created_user)
102
+        calendar_manager.create_then_remove_fake_event(
103
+            calendar_class=UserCalendar,
104
+            related_object_id=created_user.user_id,
105
+        )
106
+
107
+
108
+class CurrentUserGetterInterface(object):
109
+    def get_current_user(self) -> typing.Union[None, User]:
110
+        raise NotImplementedError()
111
+
112
+
113
+class BaseCurrentUserGetter(CurrentUserGetterInterface):
114
+    def __init__(self) -> None:
115
+        self.api = UserApi(None)
116
+
117
+
118
+class WebCurrentUserGetter(BaseCurrentUserGetter):
119
+    def get_current_user(self) -> typing.Union[None, User]:
78 120
         # HACK - D.A. - 2015-09-02
79 121
         # In tests, the tg.request.identity may not be set
80 122
         # (this is a buggy case, but for now this is how the software is;)
81
-        if tg.request != None:
123
+        if tg.request is not None:
82 124
             if hasattr(tg.request, 'identity'):
83
-                if tg.request.identity != None:
84
-                    return cls._get_user(tg.request.identity['repoze.who.userid'])
125
+                if tg.request.identity is not None:
126
+                    return self.api.get_one_by_email(
127
+                        tg.request.identity['repoze.who.userid'],
128
+                    )
129
+
130
+        return None
131
+
132
+
133
+class WsgidavCurrentUserGetter(BaseCurrentUserGetter):
134
+    def get_current_user(self) -> typing.Union[None, User]:
135
+        if hasattr(cherrypy.request, 'current_user_email'):
136
+            return self.api.get_one_by_email(
137
+                cherrypy.request.current_user_email,
138
+            )
85 139
 
86 140
         return None
87 141
 
142
+
143
+class CurrentUserGetterApi(object):
144
+    thread_local = threading.local()
145
+    matches = {
146
+        CURRENT_USER_WEB: WebCurrentUserGetter,
147
+        CURRENT_USER_WSGIDAV: WsgidavCurrentUserGetter,
148
+    }
149
+    default = CURRENT_USER_WEB
150
+
88 151
     @classmethod
89
-    def _get_user(cls, email) -> User:
90
-        """
91
-        Do not use directly in your code.
92
-        :param email:
93
-        :return:
94
-        """
95
-        return pbma.User.by_email_address(email)
152
+    def get_current_user(cls) -> User:
153
+        try:
154
+            return cls.thread_local.getter.get_current_user()
155
+        except AttributeError:
156
+            return cls.factory(cls.default).get_current_user()
157
+
158
+    @classmethod
159
+    def set_thread_local_getter(cls, name) -> None:
160
+        if not hasattr(cls.thread_local, 'getter'):
161
+            cls.thread_local.getter = cls.factory(name)
162
+
163
+    @classmethod
164
+    def factory(cls, name: str) -> CurrentUserGetterInterface:
165
+        return cls.matches[name]()

+ 114 - 12
tracim/tracim/lib/utils.py 查看文件

@@ -1,9 +1,23 @@
1 1
 # -*- coding: utf-8 -*-
2
-
2
+import os
3 3
 import time
4 4
 import signal
5 5
 
6
+from tg import config
7
+from tg import require
8
+from tg import response
9
+from tg.controllers.util import abort
10
+from tg.appwrappers.errorpage import ErrorPageApplicationWrapper \
11
+    as BaseErrorPageApplicationWrapper
12
+from tg.i18n import ugettext
13
+from tg.support.registry import StackedObjectProxy
14
+from tg.util import LazyString as BaseLazyString
15
+from tg.util import lazify
16
+
6 17
 from tracim.lib.base import logger
18
+from webob import Response
19
+from webob.exc import WSGIHTTPException
20
+
7 21
 
8 22
 def exec_time_monitor():
9 23
     def decorator_func(func):
@@ -49,23 +63,111 @@ def NotImplemented():
49 63
     raise NotImplementedError()
50 64
 
51 65
 
52
-def add_signal_handler(signal_id, handler, execute_before=True) -> None:
66
+def add_signal_handler(signal_id, handler) -> None:
53 67
     """
54 68
     Add a callback attached to python signal.
55 69
     :param signal_id: signal identifier (eg. signal.SIGTERM)
56 70
     :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 71
     """
60
-    previous_handler = signal.getsignal(signal_id)
72
+    def _handler(*args, **kwargs):
73
+        handler()
74
+        signal.signal(signal_id, signal.SIG_DFL)
75
+        os.kill(os.getpid(), signal_id)  # Rethrow signal
76
+
77
+    signal.signal(signal_id, _handler)
78
+
79
+
80
+class APIWSGIHTTPException(WSGIHTTPException):
81
+    def json_formatter(self, body, status, title, environ):
82
+        if self.comment:
83
+            msg = '{0}: {1}'.format(title, self.comment)
84
+        else:
85
+            msg = title
86
+        return {
87
+            'code': self.code,
88
+            'msg': msg,
89
+            'detail': self.detail,
90
+        }
91
+
92
+
93
+class api_require(require):
94
+    def default_denial_handler(self, reason):
95
+        # Add code here if we have to hide 401 errors (security reasons)
96
+
97
+        abort(response.status_int, reason, passthrough='json')
98
+
99
+
100
+class ErrorPageApplicationWrapper(BaseErrorPageApplicationWrapper):
101
+    # Define here response code to manage in APIWSGIHTTPException
102
+    api_managed_error_codes = [
103
+        400, 401, 403, 404,
104
+    ]
105
+
106
+    def __call__(self, controller, environ, context) -> Response:
107
+        # We only do ou work when it's /api request
108
+        # TODO BS 20161025: Look at PATH_INFO is not smart, find better way
109
+        if not environ['PATH_INFO'].startswith('/api'):
110
+            return super().__call__(controller, environ, context)
111
+
112
+        try:
113
+            resp = self.next_handler(controller, environ, context)
114
+        except:  # We catch all exception to display an 500 error json response
115
+            if config.get('debug', False):  # But in debug, we want to see it
116
+                raise
117
+            return APIWSGIHTTPException()
118
+
119
+        # We manage only specified errors codes
120
+        if resp.status_int not in self.api_managed_error_codes:
121
+            return resp
122
+
123
+        # Rewrite error in api format
124
+        return APIWSGIHTTPException(
125
+            code=resp.status_int,
126
+            detail=resp.detail,
127
+            title=resp.title,
128
+            comment=resp.comment,
129
+        )
130
+
131
+
132
+def get_valid_header_file_name(file_name: str) -> str:
133
+    """
134
+    :param file_name: file name to test
135
+    :return: Return given string if compatible to header encoding, or
136
+    download.ext if not.
137
+    """
138
+    try:
139
+        file_name.encode('iso-8859-1')
140
+        return file_name
141
+    except UnicodeEncodeError:
142
+        split_file_name = file_name.split('.')
143
+        if len(split_file_name) > 1:  # If > 1 so file have extension
144
+            return 'download.{0}'.format(split_file_name[-1])
145
+        return 'download'
61 146
 
62
-    def call_callback(*args, **kwargs):
63
-        if not execute_before and callable(previous_handler):
64
-            previous_handler(*args, **kwargs)
65 147
 
66
-        handler(*args, **kwargs)
148
+def str_as_bool(string: str) -> bool:
149
+    if string == '0':
150
+        return False
151
+    return bool(string)
67 152
 
68
-        if execute_before and callable(previous_handler):
69
-            previous_handler(*args, **kwargs)
70 153
 
71
-    signal.signal(signal_id, call_callback)
154
+class LazyString(BaseLazyString):
155
+    pass
156
+
157
+
158
+def _lazy_ugettext(text: str):
159
+    """
160
+    This function test if application context is available
161
+    :param text: String to traduce
162
+    :return: lazyfied string or string
163
+    """
164
+    try:
165
+        # Test if context is available,
166
+        # cf. https://github.com/tracim/tracim/issues/173
167
+        context = StackedObjectProxy(name="context")
168
+        context.translator
169
+        return ugettext(text)
170
+    except TypeError:
171
+        return text
172
+
173
+lazy_ugettext = lazify(_lazy_ugettext)

+ 1 - 1
tracim/tracim/lib/webdav/__init__.py 查看文件

@@ -139,4 +139,4 @@ class FakeFileStream(object):
139 139
                 self._file_stream.read()
140 140
             )
141 141
 
142
-            self._api.save(self._content, ActionDescription.EDITION)
142
+            self._api.save(self._content, ActionDescription.REVISION)

+ 144 - 53
tracim/tracim/lib/webdav/design.py 查看文件

@@ -5,6 +5,125 @@ from tracim.model.data import VirtualEvent
5 5
 from tracim.model.data import ContentType
6 6
 from tracim.model import data
7 7
 
8
+# FIXME: fix temporaire ...
9
+style = """
10
+.title {
11
+	background:#F5F5F5;
12
+	padding-right:15px;
13
+	padding-left:15px;
14
+	padding-top:10px;
15
+	border-bottom:1px solid #CCCCCC;
16
+	overflow:auto;
17
+} .title h1 { margin-top:0; }
18
+
19
+.content {
20
+	padding: 15px;
21
+}
22
+
23
+#left{ padding:0; }
24
+
25
+#right {
26
+	background:#F5F5F5;
27
+	border-left:1px solid #CCCCCC;
28
+	border-bottom: 1px solid #CCCCCC;
29
+	padding-top:15px;
30
+}
31
+@media (max-width: 1200px) {
32
+	#right {
33
+		border-top:1px solid #CCCCCC;
34
+		border-left: none;
35
+		border-bottom: none;
36
+	}
37
+}
38
+
39
+body { overflow:auto; }
40
+
41
+.btn {
42
+	text-align: left;
43
+}
44
+
45
+.table tbody tr .my-align {
46
+	vertical-align:middle;
47
+}
48
+
49
+.title-icon {
50
+	font-size:2.5em;
51
+	float:left;
52
+	margin-right:10px;
53
+}
54
+.title.page, .title-icon.page { color:#00CC00; }
55
+.title.thread, .title-icon.thread { color:#428BCA; }
56
+
57
+/* ****************************** */
58
+.description-icon {
59
+	color:#999;
60
+	font-size:3em;
61
+}
62
+
63
+.description {
64
+	border-left: 5px solid #999;
65
+	padding-left: 10px;
66
+	margin-left: 10px;
67
+	margin-bottom:10px;
68
+}
69
+
70
+.description-text {
71
+	display:block;
72
+	overflow:hidden;
73
+	color:#999;
74
+}
75
+
76
+.comment-row:nth-child(2n) {
77
+	background-color:#F5F5F5;
78
+}
79
+
80
+.comment-row:nth-child(2n+1) {
81
+	background-color:#FFF;
82
+}
83
+
84
+.comment-icon {
85
+	color:#CCC;
86
+	font-size:3em;
87
+	display:inline-block;
88
+	margin-right: 10px;
89
+	float:left;
90
+}
91
+
92
+.comment-content {
93
+	display:block;
94
+	overflow:hidden;
95
+}
96
+
97
+.comment, .comment-revision {
98
+	padding:10px;
99
+	border-top: 1px solid #999;
100
+}
101
+
102
+.comment-revision-icon {
103
+	color:#777;
104
+	margin-right: 10px;
105
+}
106
+
107
+.title-text {
108
+	display: inline-block;
109
+}
110
+"""
111
+
112
+_LABELS = {
113
+    'archiving': 'Item archived',
114
+    'content-comment': 'Item commented',
115
+    'creation': 'Item created',
116
+    'deletion': 'Item deleted',
117
+    'edition': 'item modified',
118
+    'revision': 'New revision',
119
+    'status-update': 'New status',
120
+    'unarchiving': 'Item unarchived',
121
+    'undeletion': 'Item undeleted',
122
+    'move': 'Item moved',
123
+    'comment': 'Comment'
124
+}
125
+
126
+
8 127
 def create_readable_date(created, delta_from_datetime: datetime = None):
9 128
     if not delta_from_datetime:
10 129
         delta_from_datetime = datetime.now()
@@ -29,28 +148,11 @@ def create_readable_date(created, delta_from_datetime: datetime = None):
29 148
     return aff
30 149
 
31 150
 def designPage(content: data.Content, content_revision: data.ContentRevisionRO) -> str:
32
-    f = open('tracim/lib/webdav/style.css', 'r')
33
-    style = f.read()
34
-    f.close()
35
-
36 151
     hist = content.get_history()
37 152
     histHTML = '<table class="table table-striped table-hover">'
38 153
     for event in hist:
39 154
         if isinstance(event, VirtualEvent):
40 155
             date = event.create_readable_date()
41
-            _LABELS = {
42
-                'archiving': 'Item archived',
43
-                'content-comment': 'Item commented',
44
-                'creation': 'Item created',
45
-                'deletion': 'Item deleted',
46
-                'edition': 'item modified',
47
-                'revision': 'New revision',
48
-                'status-update': 'New status',
49
-                'unarchiving': 'Item unarchived',
50
-                'undeletion': 'Item undeleted',
51
-                'move': 'Item moved'
52
-            }
53
-
54 156
             label = _LABELS[event.type.id]
55 157
 
56 158
             histHTML += '''
@@ -65,9 +167,10 @@ def designPage(content: data.Content, content_revision: data.ContentRevisionRO)
65 167
                        label,
66 168
                        date,
67 169
                        event.owner.display_name,
68
-                       '<i class="fa fa-caret-left"></i> shown' if event.id == content_revision.revision_id else '''<span><a class="revision-link" href="/.history/%s/(%s - %s) %s.html">(View revision)</a></span>''' % (
69
-                       content.label, event.id, event.type.id, event.ref_object.label) if event.type.id in ['revision', 'creation', 'edition'] else '')
70
-
170
+                       # NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
171
+                       '<i class="fa fa-caret-left"></i> shown'  if event.id == content_revision.revision_id else '' # '''<span><a class="revision-link" href="/.history/%s/(%s - %s) %s.html">(View revision)</a></span>''' % (
172
+                       # content.label, event.id, event.type.id, event.ref_object.label) if event.type.id in ['revision', 'creation', 'edition'] else '')
173
+                   )
71 174
     histHTML += '</table>'
72 175
 
73 176
     file = '''
@@ -93,9 +196,10 @@ def designPage(content: data.Content, content_revision: data.ContentRevisionRO)
93 196
             </div>
94 197
             <div class="pull-right">
95 198
                 <div class="btn-group btn-group-vertical">
96
-                    <a class="btn btn-default">
97
-                        <i class="fa fa-external-link"></i> View in tracim</a>
98
-                    </a>
199
+                    <!-- NOTE: Not omplemented yet, don't display not working link
200
+                     <a class="btn btn-default">
201
+                         <i class="fa fa-external-link"></i> View in tracim</a>
202
+                     </a>-->
99 203
                 </div>
100 204
             </div>
101 205
         </div>
@@ -113,9 +217,10 @@ def designPage(content: data.Content, content_revision: data.ContentRevisionRO)
113 217
             file_location = file_location.replace(/\/[^/]*$/, '')
114 218
             file_location = file_location.replace(/\/.history\/[^/]*$/, '')
115 219
 
116
-            $('.revision-link').each(function() {
117
-                $(this).attr('href', file_location + $(this).attr('href'))
118
-            });
220
+            // NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
221
+            // $('.revision-link').each(function() {
222
+            //    $(this).attr('href', file_location + $(this).attr('href'))
223
+            // });
119 224
         }
120 225
     </script>
121 226
 </body>
@@ -131,10 +236,6 @@ def designPage(content: data.Content, content_revision: data.ContentRevisionRO)
131 236
     return file
132 237
 
133 238
 def designThread(content: data.Content, content_revision: data.ContentRevisionRO, comments) -> str:
134
-        f = open('tracim/lib/webdav/style.css', 'r')
135
-        style = f.read()
136
-        f.close()
137
-
138 239
         hist = content.get_history()
139 240
 
140 241
         allT = []
@@ -165,20 +266,6 @@ def designThread(content: data.Content, content_revision: data.ContentRevisionRO
165 266
                     participants[t.owner.display_name][0] += 1
166 267
             else:
167 268
                 if isinstance(t, VirtualEvent) and t.type.id != 'comment':
168
-                    _LABELS = {
169
-                        'archiving': 'Item archived',
170
-                        'content-comment': 'Item commented',
171
-                        'creation': 'Item created',
172
-                        'deletion': 'Item deleted',
173
-                        'edition': 'item modified',
174
-                        'revision': 'New revision',
175
-                        'status-update': 'New status',
176
-                        'unarchiving': 'Item unarchived',
177
-                        'undeletion': 'Item undeleted',
178
-                        'move': 'Item moved',
179
-                        'comment' : 'hmmm'
180
-                    }
181
-
182 269
                     label = _LABELS[t.type.id]
183 270
 
184 271
                     disc += '''
@@ -197,10 +284,12 @@ def designThread(content: data.Content, content_revision: data.ContentRevisionRO
197 284
                            t.owner.display_name,
198 285
                            t.create_readable_date(),
199 286
                            label,
200
-                            '<i class="fa fa-caret-left"></i> shown' if t.id == content_revision.revision_id else '''<span><a class="revision-link" href="/.history/%s/%s-%s">(View revision)</a></span>''' % (
201
-                               content.label,
202
-                               t.id,
203
-                               t.ref_object.label) if t.type.id in ['revision', 'creation', 'edition'] else '')
287
+                            # NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
288
+                            '<i class="fa fa-caret-left"></i> shown' if t.id == content_revision.revision_id else '' # else '''<span><a class="revision-link" href="/.history/%s/%s-%s">(View revision)</a></span>''' % (
289
+                               # content.label,
290
+                               # t.id,
291
+                               # t.ref_object.label) if t.type.id in ['revision', 'creation', 'edition'] else '')
292
+                           )
204 293
 
205 294
         page = '''
206 295
 <html>
@@ -222,9 +311,10 @@ def designThread(content: data.Content, content_revision: data.ContentRevisionRO
222 311
             </div>
223 312
             <div class="pull-right">
224 313
                 <div class="btn-group btn-group-vertical">
314
+                    <!-- NOTE: Not omplemented yet, don't display not working link
225 315
                     <a class="btn btn-default" onclick="hide_elements()">
226
-                        <i id="hideshow" class="fa fa-eye-slash"></i> <span id="hideshowtxt" >Hide history</span></a>
227
-                    </a>
316
+                       <i id="hideshow" class="fa fa-eye-slash"></i> <span id="hideshowtxt" >Hide history</span></a>
317
+                    </a>-->
228 318
                     <a class="btn btn-default">
229 319
                         <i class="fa fa-external-link"></i> View in tracim</a>
230 320
                     </a>
@@ -244,9 +334,10 @@ def designThread(content: data.Content, content_revision: data.ContentRevisionRO
244 334
             file_location = file_location.replace(/\/[^/]*$/, '')
245 335
             file_location = file_location.replace(/\/.history\/[^/]*$/, '')
246 336
 
247
-            $('.revision-link').each(function() {
248
-                $(this).attr('href', file_location + $(this).attr('href'))
249
-            });
337
+            // NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
338
+            // $('.revision-link').each(function() {
339
+            //     $(this).attr('href', file_location + $(this).attr('href'))
340
+            // });
250 341
         }
251 342
 
252 343
         function hide_elements() {

+ 24 - 61
tracim/tracim/lib/webdav/sql_dav_provider.py 查看文件

@@ -1,7 +1,9 @@
1 1
 # coding: utf8
2 2
 
3 3
 import re
4
-from os.path import basename, dirname, normpath
4
+from os.path import basename, dirname
5
+from sqlalchemy.orm.exc import NoResultFound
6
+from tracim.lib.webdav.utils import transform_to_bdd
5 7
 
6 8
 from wsgidav.dav_provider import DAVProvider
7 9
 from wsgidav.lock_manager import LockManager
@@ -16,6 +18,7 @@ from tracim.lib.user import UserApi
16 18
 from tracim.lib.workspace import WorkspaceApi
17 19
 from tracim.model.data import Content, Workspace
18 20
 from tracim.model.data import ContentType
21
+from tracim.lib.webdav.utils import normpath
19 22
 
20 23
 
21 24
 class Provider(DAVProvider):
@@ -74,8 +77,8 @@ class Provider(DAVProvider):
74 77
 
75 78
         content_api = ContentApi(
76 79
             user,
77
-            show_archived=self._show_archive,
78
-            show_deleted=self._show_delete
80
+            show_archived=False,  # self._show_archive,
81
+            show_deleted=False,  # self._show_delete
79 82
         )
80 83
 
81 84
         content = self.get_content_from_path(
@@ -159,7 +162,9 @@ class Provider(DAVProvider):
159 162
         if parent_path == root_path or workspace is None:
160 163
             return workspace is not None
161 164
 
162
-        content_api = ContentApi(user, show_archived=True, show_deleted=True)
165
+        # TODO bastien: Arnaud avait mis a True, verif le comportement
166
+        # lorsque l'on explore les dossiers archive et deleted
167
+        content_api = ContentApi(user, show_archived=False, show_deleted=False)
163 168
 
164 169
         revision_id = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) ([^/].+)$', path)
165 170
 
@@ -237,26 +242,28 @@ class Provider(DAVProvider):
237 242
         path = self.reduce_path(path)
238 243
         parent_path = dirname(path)
239 244
 
240
-        blbl = parent_path.replace('/'+workspace.label, '')
245
+        relative_parents_path = parent_path[len(workspace.label)+1:]
246
+        parents = relative_parents_path.split('/')
241 247
 
242
-        parents = blbl.split('/')
243
-
244
-        parents.remove('')
245
-        parents = [self.transform_to_bdd(x) for x in parents]
248
+        try:
249
+            parents.remove('')
250
+        except ValueError:
251
+            pass
252
+        parents = [transform_to_bdd(x) for x in parents]
246 253
 
247 254
         try:
248
-            return content_api.get_one_by_label_and_parent_label(
249
-                self.transform_to_bdd(basename(path)),
250
-                parents,
251
-                workspace
255
+            return content_api.get_one_by_label_and_parent_labels(
256
+                content_label=transform_to_bdd(basename(path)),
257
+                content_parent_labels=parents,
258
+                workspace=workspace,
252 259
             )
253
-        except:
260
+        except NoResultFound:
254 261
             return None
255 262
 
256 263
     def get_content_from_revision(self, revision: ContentRevisionRO, api: ContentApi) -> Content:
257 264
         try:
258 265
             return api.get_one(revision.content_id, ContentType.Any)
259
-        except:
266
+        except NoResultFound:
260 267
             return None
261 268
 
262 269
     def get_parent_from_path(self, path, api: ContentApi, workspace) -> Content:
@@ -264,50 +271,6 @@ class Provider(DAVProvider):
264 271
 
265 272
     def get_workspace_from_path(self, path: str, api: WorkspaceApi) -> Workspace:
266 273
         try:
267
-            return api.get_one_by_label(self.transform_to_bdd(path.split('/')[1]))
268
-        except:
274
+            return api.get_one_by_label(transform_to_bdd(path.split('/')[1]))
275
+        except NoResultFound:
269 276
             return None
270
-
271
-    def transform_to_display(self, string):
272
-        """
273
-        As characters that Windows does not support may have been inserted through Tracim in names, before displaying
274
-        information we update path so that all these forbidden characters are replaced with similar shape character
275
-        that are allowed so that the user isn't trouble and isn't limited in his naming choice
276
-        """
277
-        _TO_DISPLAY = {
278
-            '/':'⧸',
279
-            '\\': '⧹',
280
-            ':': '∶',
281
-            '*': '∗',
282
-            '?': 'ʔ',
283
-            '"': 'ʺ',
284
-            '<': '❮',
285
-            '>': '❯',
286
-            '|': '∣'
287
-        }
288
-
289
-        for key, value in _TO_DISPLAY.items():
290
-            string = string.replace(key, value)
291
-
292
-        return string
293
-
294
-    def transform_to_bdd(self, string):
295
-        """
296
-        Called before sending request to the database to recover the right names
297
-        """
298
-        _TO_BDD = {
299
-            '⧸': '/',
300
-            '⧹': '\\',
301
-            '∶': ':',
302
-            '∗': '*',
303
-            'ʔ': '?',
304
-            'ʺ': '"',
305
-            '❮': '<',
306
-            '❯': '>',
307
-            '∣': '|'
308
-        }
309
-
310
-        for key, value in _TO_BDD.items():
311
-            string = string.replace(key, value)
312
-
313
-        return string

+ 96 - 64
tracim/tracim/lib/webdav/sql_resources.py 查看文件

@@ -1,15 +1,20 @@
1 1
 # coding: utf8
2
+import logging
3
+
4
+import os
5
+
2 6
 import transaction
3 7
 import re
4 8
 from datetime import datetime
5 9
 from time import mktime
6
-from os.path import normpath, dirname, basename
7
-import mimetypes
10
+from os.path import dirname, basename
8 11
 
9 12
 from tracim.lib.content import ContentApi
10 13
 from tracim.lib.user import UserApi
11 14
 from tracim.lib.webdav import HistoryType
12 15
 from tracim.lib.webdav import FakeFileStream
16
+from tracim.lib.webdav.utils import transform_to_display
17
+from tracim.lib.webdav.utils import transform_to_bdd
13 18
 from tracim.lib.workspace import WorkspaceApi
14 19
 from tracim.model import data, new_revision
15 20
 from tracim.model.data import Content, ActionDescription
@@ -20,9 +25,13 @@ from wsgidav import compat
20 25
 from wsgidav.dav_error import DAVError, HTTP_FORBIDDEN
21 26
 from wsgidav.dav_provider import DAVCollection, DAVNonCollection
22 27
 from wsgidav.dav_provider import _DAVResource
28
+from tracim.lib.webdav.utils import normpath
23 29
 
24 30
 from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound
25 31
 
32
+logger = logging.getLogger()
33
+
34
+
26 35
 class ManageActions(object):
27 36
     """
28 37
     This object is used to encapsulate all Deletion/Archiving related method as to not duplicate too much code
@@ -50,7 +59,7 @@ class ManageActions(object):
50 59
         try:
51 60
             # When undeleting/unarchiving we except a content with the new name to not exist, thus if we
52 61
             # don't get an error and the database request send back a result, we stop the action
53
-            self.content_api.get_one_by_label_and_parent(self._new_name, self.content.parent, self.content.workspace)
62
+            self.content_api.get_one_by_label_and_parent(self._new_name, self.content.parent)
54 63
             raise DAVError(HTTP_FORBIDDEN)
55 64
         except NoResultFound:
56 65
             with new_revision(self.content):
@@ -65,14 +74,16 @@ class ManageActions(object):
65 74
         Will create the new name, either by adding '- deleted the [date]' after the name when archiving/deleting or
66 75
         removing this string when undeleting/unarchiving
67 76
         """
68
-        new_name = self.content.get_label()
77
+        new_name = self.content.get_label_as_file()
69 78
         extension = ''
70 79
 
71 80
         # if the content has no label, the last .ext is important
72 81
         # thus we want to rename a file from 'file.txt' to 'file - deleted... .txt' and not 'file.txt - deleted...'
73 82
         is_file_name = self.content.label == ''
74 83
         if is_file_name:
75
-            extension = re.search(r'(\.[^.]+)$', new_name).group(0)
84
+            search = re.search(r'(\.[^.]+)$', new_name)
85
+            if search:
86
+                extension = search.group(0)
76 87
             new_name = re.sub(r'(\.[^.]+)$', '', new_name)
77 88
 
78 89
         if self._type in [ActionDescription.ARCHIVING, ActionDescription.DELETION]:
@@ -119,7 +130,7 @@ class Root(DAVCollection):
119 130
         """
120 131
         try:
121 132
             workspace = self.workspace_api.get_one_by_label(label)
122
-            workspace_path = '%s%s%s' % (self.path, '' if self.path == '/' else '/', self.provider.transform_to_display(workspace.label))
133
+            workspace_path = '%s%s%s' % (self.path, '' if self.path == '/' else '/', transform_to_display(workspace.label))
123 134
 
124 135
             return Workspace(workspace_path, self.environ, workspace)
125 136
         except AttributeError:
@@ -150,7 +161,7 @@ class Root(DAVCollection):
150 161
         self.workspace_api.save(new_workspace)
151 162
 
152 163
         workspace_path = '%s%s%s' % (
153
-        self.path, '' if self.path == '/' else '/', self.provider.transform_to_display(new_workspace.label))
164
+        self.path, '' if self.path == '/' else '/', transform_to_display(new_workspace.label))
154 165
 
155 166
         transaction.commit()
156 167
         return Workspace(workspace_path, self.environ, new_workspace)
@@ -213,14 +224,14 @@ class Workspace(DAVCollection):
213 224
             # the purpose is to display .history only if there's at least one content's type that has a history
214 225
             if content.type != ContentType.Folder:
215 226
                 self._file_count += 1
216
-            retlist.append(content.get_label())
227
+            retlist.append(content.get_label_as_file())
217 228
 
218 229
         return retlist
219 230
 
220 231
     def getMember(self, content_label: str) -> _DAVResource:
221 232
 
222 233
         return self.provider.getResourceInst(
223
-            '%s/%s' % (self.path, self.provider.transform_to_display(content_label)),
234
+            '%s/%s' % (self.path, transform_to_display(content_label)),
224 235
             self.environ
225 236
         )
226 237
 
@@ -233,11 +244,19 @@ class Workspace(DAVCollection):
233 244
         if '/.deleted/' in self.path or '/.archived/' in self.path:
234 245
             raise DAVError(HTTP_FORBIDDEN)
235 246
 
247
+        content = None
248
+
249
+        # Note: To prevent bugs, check here again if resource already exist
250
+        path = os.path.join(self.path, file_name)
251
+        resource = self.provider.getResourceInst(path, self.environ)
252
+        if resource:
253
+            content = resource.content
254
+
236 255
         return FakeFileStream(
237 256
             file_name=file_name,
238 257
             content_api=self.content_api,
239 258
             workspace=self.workspace,
240
-            content=None,
259
+            content=content,
241 260
             parent=self.content,
242 261
             path=self.path + '/' + file_name
243 262
         )
@@ -272,7 +291,7 @@ class Workspace(DAVCollection):
272 291
 
273 292
         transaction.commit()
274 293
 
275
-        return Folder('%s/%s' % (self.path, self.provider.transform_to_display(label)),
294
+        return Folder('%s/%s' % (self.path, transform_to_display(label)),
276 295
                       self.environ, folder,
277 296
                       self.workspace)
278 297
 
@@ -296,7 +315,7 @@ class Workspace(DAVCollection):
296 315
         children = self.content_api.get_all(False, ContentType.Any, self.workspace)
297 316
 
298 317
         for content in children:
299
-            content_path = '%s/%s' % (self.path, self.provider.transform_to_display(content.get_label()))
318
+            content_path = '%s/%s' % (self.path, transform_to_display(content.get_label_as_file()))
300 319
 
301 320
             if content.type == ContentType.Folder:
302 321
                 members.append(Folder(content_path, self.environ, self.workspace, content))
@@ -360,7 +379,7 @@ class Folder(Workspace):
360 379
         return mktime(self.content.created.timetuple())
361 380
 
362 381
     def getDisplayName(self) -> str:
363
-        return self.provider.transform_to_display(self.content.get_label())
382
+        return transform_to_display(self.content.get_label_as_file())
364 383
 
365 384
     def getLastModified(self) -> float:
366 385
         return mktime(self.content.updated.timetuple())
@@ -438,7 +457,7 @@ class Folder(Workspace):
438 457
 
439 458
         with new_revision(self.content):
440 459
             if basename(destpath) != self.getDisplayName():
441
-                self.content_api.update_content(self.content, self.provider.transform_to_bdd(basename(destpath)))
460
+                self.content_api.update_content(self.content, transform_to_bdd(basename(destpath)))
442 461
                 self.content_api.save(self.content)
443 462
             else:
444 463
                 if workspace.workspace_id == self.content.workspace.workspace_id:
@@ -458,16 +477,24 @@ class Folder(Workspace):
458 477
         )
459 478
 
460 479
         for content in visible_children:
461
-            content_path = '%s/%s' % (self.path, self.provider.transform_to_display(content.get_label()))
480
+            content_path = '%s/%s' % (self.path, transform_to_display(content.get_label_as_file()))
462 481
 
463
-            if content.type == ContentType.Folder:
464
-                members.append(Folder(content_path, self.environ, self.workspace, content))
465
-            elif content.type == ContentType.File:
466
-                self._file_count += 1
467
-                members.append(File(content_path, self.environ, content))
468
-            else:
469
-                self._file_count += 1
470
-                members.append(OtherFile(content_path, self.environ, content))
482
+            try:
483
+                if content.type == ContentType.Folder:
484
+                    members.append(Folder(content_path, self.environ, self.workspace, content))
485
+                elif content.type == ContentType.File:
486
+                    self._file_count += 1
487
+                    members.append(File(content_path, self.environ, content))
488
+                else:
489
+                    self._file_count += 1
490
+                    members.append(OtherFile(content_path, self.environ, content))
491
+            except Exception as exc:
492
+                logger.exception(
493
+                    'Unable to construct member {}'.format(
494
+                        content_path,
495
+                    ),
496
+                    exc_info=True,
497
+                )
471 498
 
472 499
         if self._file_count > 0 and self.provider.show_history():
473 500
             members.append(
@@ -541,7 +568,7 @@ class HistoryFolder(Folder):
541 568
         )
542 569
 
543 570
         return HistoryFileFolder(
544
-            path='%s/%s' % (self.path, content.get_label()),
571
+            path='%s/%s' % (self.path, content.get_label_as_file()),
545 572
             environ=self.environ,
546 573
             content=content)
547 574
 
@@ -554,7 +581,7 @@ class HistoryFolder(Folder):
554 581
                 self._is_deleted and content.is_deleted or
555 582
                 not (content.is_archived or self._is_archived or content.is_deleted or self._is_deleted))\
556 583
                     and content.type != ContentType.Folder:
557
-                ret.append(content.get_label())
584
+                ret.append(content.get_label_as_file())
558 585
 
559 586
         return ret
560 587
 
@@ -587,7 +614,7 @@ class HistoryFolder(Folder):
587 614
         for content in children:
588 615
             if content.is_archived == self._is_archived and content.is_deleted == self._is_deleted:
589 616
                 members.append(HistoryFileFolder(
590
-                    path='%s/%s' % (self.path, content.get_label()),
617
+                    path='%s/%s' % (self.path, content.get_label_as_file()),
591 618
                     environ=self.environ,
592 619
                     content=content))
593 620
 
@@ -624,7 +651,7 @@ class DeletedFolder(HistoryFolder):
624 651
         )
625 652
 
626 653
         return self.provider.getResourceInst(
627
-            path='%s/%s' % (self.path, self.provider.transform_to_display(content.get_label())),
654
+            path='%s/%s' % (self.path, transform_to_display(content.get_label_as_file())),
628 655
             environ=self.environ
629 656
             )
630 657
 
@@ -638,7 +665,7 @@ class DeletedFolder(HistoryFolder):
638 665
 
639 666
         for content in children:
640 667
             if content.is_deleted:
641
-                retlist.append(content.get_label())
668
+                retlist.append(content.get_label_as_file())
642 669
 
643 670
                 if content.type != ContentType.Folder:
644 671
                     self._file_count += 1
@@ -655,7 +682,7 @@ class DeletedFolder(HistoryFolder):
655 682
 
656 683
         for content in children:
657 684
             if content.is_deleted:
658
-                content_path = '%s/%s' % (self.path, self.provider.transform_to_display(content.get_label()))
685
+                content_path = '%s/%s' % (self.path, transform_to_display(content.get_label_as_file()))
659 686
 
660 687
                 if content.type == ContentType.Folder:
661 688
                     members.append(Folder(content_path, self.environ, self.workspace, content))
@@ -709,7 +736,7 @@ class ArchivedFolder(HistoryFolder):
709 736
         )
710 737
 
711 738
         return self.provider.getResourceInst(
712
-            path=self.path + '/' + self.provider.transform_to_display(content.get_label()),
739
+            path=self.path + '/' + transform_to_display(content.get_label_as_file()),
713 740
             environ=self.environ
714 741
         )
715 742
 
@@ -718,7 +745,7 @@ class ArchivedFolder(HistoryFolder):
718 745
 
719 746
         for content in self.content_api.get_all_with_filter(
720 747
                 self.content if self.content is None else self.content.id, ContentType.Any):
721
-            retlist.append(content.get_label())
748
+            retlist.append(content.get_label_as_file())
722 749
 
723 750
             if content.type != ContentType.Folder:
724 751
                 self._file_count += 1
@@ -735,7 +762,7 @@ class ArchivedFolder(HistoryFolder):
735 762
 
736 763
         for content in children:
737 764
             if content.is_archived:
738
-                content_path = '%s/%s' % (self.path, self.provider.transform_to_display(content.get_label()))
765
+                content_path = '%s/%s' % (self.path, transform_to_display(content.get_label_as_file()))
739 766
 
740 767
                 if content.type == ContentType.Folder:
741 768
                     members.append(Folder(content_path, self.environ, self.workspace, content))
@@ -772,7 +799,7 @@ class HistoryFileFolder(HistoryFolder):
772 799
         return "<DAVCollection: HistoryFileFolder (%s)" % self.content.file_name
773 800
 
774 801
     def getDisplayName(self) -> str:
775
-        return self.content.get_label()
802
+        return self.content.get_label_as_file()
776 803
 
777 804
     def createCollection(self, name):
778 805
         raise DAVError(HTTP_FORBIDDEN)
@@ -797,13 +824,13 @@ class HistoryFileFolder(HistoryFolder):
797 824
 
798 825
         if self.content.type == ContentType.File:
799 826
             return HistoryFile(
800
-                path='%s%s' % (left_side, self.provider.transform_to_display(revision.file_name)),
827
+                path='%s%s' % (left_side, transform_to_display(revision.file_name)),
801 828
                 environ=self.environ,
802 829
                 content=self.content,
803 830
                 content_revision=revision)
804 831
         else:
805 832
             return HistoryOtherFile(
806
-                path='%s%s' % (left_side, self.provider.transform_to_display(revision.get_label())),
833
+                path='%s%s' % (left_side, transform_to_display(revision.get_label_as_file())),
807 834
                 environ=self.environ,
808 835
                 content=self.content,
809 836
                 content_revision=revision)
@@ -817,14 +844,14 @@ class HistoryFileFolder(HistoryFolder):
817 844
 
818 845
             if self.content.type == ContentType.File:
819 846
                 members.append(HistoryFile(
820
-                    path='%s%s' % (left_side, self.provider.transform_to_display(content.file_name)),
847
+                    path='%s%s' % (left_side, transform_to_display(content.file_name)),
821 848
                     environ=self.environ,
822 849
                     content=self.content,
823 850
                     content_revision=content)
824 851
                 )
825 852
             else:
826 853
                 members.append(HistoryOtherFile(
827
-                    path='%s%s' % (left_side, self.provider.transform_to_display(content.file_name)),
854
+                    path='%s%s' % (left_side, transform_to_display(content.file_name)),
828 855
                     environ=self.environ,
829 856
                     content=self.content,
830 857
                     content_revision=content)
@@ -848,14 +875,6 @@ class File(DAVNonCollection):
848 875
         # but i wasn't able to set this property so you'll have to look into it >.>
849 876
         # self.setPropertyValue('Win32FileAttributes', '00000021')
850 877
 
851
-    def getPreferredPath(self):
852
-        fix_txt = '.txt' if self.getContentType() == 'text/plain' else mimetypes.guess_extension(self.getContentType())
853
-
854
-        if self.content.label == '' or self.path.endswith(fix_txt):
855
-            return self.path
856
-        else:
857
-            return self.path + fix_txt
858
-
859 878
     def __repr__(self) -> str:
860 879
         return "<DAVNonCollection: File (%d)>" % self.content.revision_id
861 880
 
@@ -869,7 +888,7 @@ class File(DAVNonCollection):
869 888
         return mktime(self.content.created.timetuple())
870 889
 
871 890
     def getDisplayName(self) -> str:
872
-        return self.content.get_label()
891
+        return self.content.file_name
873 892
 
874 893
     def getLastModified(self) -> float:
875 894
         return mktime(self.content.updated.timetuple())
@@ -885,7 +904,7 @@ class File(DAVNonCollection):
885 904
         return FakeFileStream(
886 905
             content=self.content,
887 906
             content_api=self.content_api,
888
-            file_name=self.content.get_label(),
907
+            file_name=self.content.get_label_as_file(),
889 908
             workspace=self.content.workspace,
890 909
             path=self.path
891 910
         )
@@ -942,28 +961,41 @@ class File(DAVNonCollection):
942 961
 
943 962
     def move_file(self, destpath):
944 963
 
945
-        workspace = self.provider.get_workspace_from_path(
946
-            normpath(destpath),
947
-            WorkspaceApi(self.user)
948
-        )
949
-
950
-        parent = self.provider.get_parent_from_path(
951
-            normpath(destpath),
952
-            self.content_api,
953
-            workspace
954
-        )
955
-
964
+        workspace = self.content.workspace
965
+        parent = self.content.parent
956 966
 
957 967
         with new_revision(self.content):
958 968
             if basename(destpath) != self.getDisplayName():
959
-                self.content_api.update_content(self.content, re.sub('\.[^\.]+$', '', self.provider.transform_to_bdd(basename(destpath))))
969
+                new_given_file_name = transform_to_bdd(basename(destpath))
970
+                new_file_name, new_file_extension = \
971
+                    os.path.splitext(new_given_file_name)
972
+
973
+                self.content_api.update_content(
974
+                    self.content,
975
+                    new_file_name,
976
+                )
977
+                self.content.file_extension = new_file_extension
960 978
                 self.content_api.save(self.content)
961 979
             else:
980
+                workspace_api = WorkspaceApi(self.user)
981
+                content_api = ContentApi(self.user)
982
+
983
+                destination_workspace = self.provider.get_workspace_from_path(
984
+                    destpath,
985
+                    workspace_api,
986
+                )
987
+
988
+                destination_parent = self.provider.get_parent_from_path(
989
+                    destpath,
990
+                    content_api,
991
+                    workspace,
992
+                )
993
+
962 994
                 self.content_api.move(
963 995
                     item=self.content,
964
-                    new_parent=parent,
996
+                    new_parent=destination_parent,
965 997
                     must_stay_in_same_workspace=False,
966
-                    new_workspace=workspace
998
+                    new_workspace=destination_workspace
967 999
                 )
968 1000
 
969 1001
         transaction.commit()
@@ -988,7 +1020,7 @@ class HistoryFile(File):
988 1020
 
989 1021
     def getDisplayName(self) -> str:
990 1022
         left_side = '(%d - %s) ' % (self.content_revision.revision_id, self.content_revision.revision_type)
991
-        return '%s%s' % (left_side, self.provider.transform_to_display(self.content_revision.file_name))
1023
+        return '%s%s' % (left_side, transform_to_display(self.content_revision.file_name))
992 1024
 
993 1025
     def getContent(self):
994 1026
         filestream = compat.BytesIO()
@@ -1031,7 +1063,7 @@ class OtherFile(File):
1031 1063
             self.path += '.html'
1032 1064
 
1033 1065
     def getDisplayName(self) -> str:
1034
-        return self.content.get_label()
1066
+        return self.content.get_label_as_file()
1035 1067
 
1036 1068
     def getPreferredPath(self):
1037 1069
         return self.path
@@ -1077,7 +1109,7 @@ class HistoryOtherFile(OtherFile):
1077 1109
 
1078 1110
     def getDisplayName(self) -> str:
1079 1111
         left_side = '(%d - %s) ' % (self.content_revision.revision_id, self.content_revision.revision_type)
1080
-        return '%s%s' % (left_side, self.provider.transform_to_display(self.content_revision.get_label()))
1112
+        return '%s%s' % (left_side, transform_to_display(self.content_revision.get_label_as_file()))
1081 1113
 
1082 1114
     def getContent(self):
1083 1115
         filestream = compat.BytesIO()

+ 12 - 1
tracim/tracim/lib/webdav/tracim_http_authenticator.py 查看文件

@@ -1,6 +1,12 @@
1
+import os
2
+import re
3
+
1 4
 from wsgidav.http_authenticator import HTTPAuthenticator
2 5
 from wsgidav import util
3
-import re
6
+import cherrypy
7
+
8
+from tracim.lib.user import CurrentUserGetterApi
9
+from tracim.lib.user import CURRENT_USER_WSGIDAV
4 10
 
5 11
 _logger = util.getModuleLogger(__name__, True)
6 12
 HOTFIX_WINXP_AcceptRootShareLogin = True
@@ -131,6 +137,11 @@ class TracimHTTPAuthenticator(HTTPAuthenticator):
131 137
 
132 138
         environ["http_authenticator.realm"] = realmname
133 139
         environ["http_authenticator.username"] = req_username
140
+
141
+        # Set request current user email to be able to recognise him later
142
+        cherrypy.request.current_user_email = req_username
143
+        CurrentUserGetterApi.set_thread_local_getter(CURRENT_USER_WSGIDAV)
144
+
134 145
         return self._application(environ, start_response)
135 146
 
136 147
     def tracim_compute_digest_response(self, left_digest_response_hash, method, uri, nonce, cnonce, qop, nc):

+ 285 - 0
tracim/tracim/lib/webdav/utils.py 查看文件

@@ -0,0 +1,285 @@
1
+# -*- coding: utf-8 -*-
2
+from xml.etree import ElementTree
3
+
4
+import os
5
+import sys
6
+import threading
7
+import time
8
+from datetime import datetime
9
+
10
+import yaml
11
+from os.path import normpath as base_normpath
12
+from wsgidav import util
13
+from wsgidav import compat
14
+from wsgidav.middleware import BaseMiddleware
15
+
16
+
17
+def transform_to_display(string: str) -> str:
18
+    """
19
+    As characters that Windows does not support may have been inserted
20
+    through Tracim in names, before displaying information we update path
21
+    so that all these forbidden characters are replaced with similar
22
+    shape character that are allowed so that the user isn't trouble and
23
+    isn't limited in his naming choice
24
+    """
25
+    _TO_DISPLAY = {
26
+        '/': '⧸',
27
+        '\\': '⧹',
28
+        ':': '∶',
29
+        '*': '∗',
30
+        '?': 'ʔ',
31
+        '"': 'ʺ',
32
+        '<': '❮',
33
+        '>': '❯',
34
+        '|': '∣'
35
+    }
36
+
37
+    for key, value in _TO_DISPLAY.items():
38
+        string = string.replace(key, value)
39
+
40
+    return string
41
+
42
+
43
+def transform_to_bdd(string: str) -> str:
44
+    """
45
+    Called before sending request to the database to recover the right names
46
+    """
47
+    _TO_BDD = {
48
+        '⧸': '/',
49
+        '⧹': '\\',
50
+        '∶': ':',
51
+        '∗': '*',
52
+        'ʔ': '?',
53
+        'ʺ': '"',
54
+        '❮': '<',
55
+        '❯': '>',
56
+        '∣': '|'
57
+    }
58
+
59
+    for key, value in _TO_BDD.items():
60
+        string = string.replace(key, value)
61
+
62
+    return string
63
+
64
+
65
+class TracimWsgiDavDebugFilter(BaseMiddleware):
66
+    """
67
+    COPY PASTE OF wsgidav.debug_filter.WsgiDavDebugFilter
68
+    WITH ADD OF DUMP RESPONSE & REQUEST
69
+    """
70
+    def __init__(self, application, config):
71
+        self._application = application
72
+        self._config = config
73
+        #        self.out = sys.stderr
74
+        self.out = sys.stdout
75
+        self.passedLitmus = {}
76
+        # These methods boost verbose=2 to verbose=3
77
+        self.debug_methods = config.get("debug_methods", [])
78
+        # Litmus tests containing these string boost verbose=2 to verbose=3
79
+        self.debug_litmus = config.get("debug_litmus", [])
80
+        # Exit server, as soon as this litmus test has finished
81
+        self.break_after_litmus = [
82
+            #                                   "locks: 15",
83
+        ]
84
+
85
+        self.last_request_time = '__NOT_SET__'
86
+
87
+        # We disable request content dump for moment
88
+        # if self._config.get('dump_requests'):
89
+        #     # Monkey patching
90
+        #     old_parseXmlBody = util.parseXmlBody
91
+        #     def new_parseXmlBody(environ, allowEmpty=False):
92
+        #         xml = old_parseXmlBody(environ, allowEmpty)
93
+        #         self._dump_request(environ, xml)
94
+        #         return xml
95
+        #     util.parseXmlBody = new_parseXmlBody
96
+
97
+    def __call__(self, environ, start_response):
98
+        """"""
99
+        #        srvcfg = environ["wsgidav.config"]
100
+        verbose = self._config.get("verbose", 2)
101
+        self.last_request_time = '{0}_{1}'.format(
102
+            datetime.utcnow().strftime('%Y-%m-%d_%H-%M-%S'),
103
+            int(round(time.time() * 1000)),
104
+        )
105
+
106
+        method = environ["REQUEST_METHOD"]
107
+
108
+        debugBreak = False
109
+        dumpRequest = False
110
+        dumpResponse = False
111
+
112
+        if verbose >= 3 or self._config.get("dump_requests"):
113
+            dumpRequest = dumpResponse = True
114
+
115
+        # Process URL commands
116
+        if "dump_storage" in environ.get("QUERY_STRING"):
117
+            dav = environ.get("wsgidav.provider")
118
+            if dav.lockManager:
119
+                dav.lockManager._dump()
120
+            if dav.propManager:
121
+                dav.propManager._dump()
122
+
123
+        # Turn on max. debugging for selected litmus tests
124
+        litmusTag = environ.get("HTTP_X_LITMUS",
125
+                                environ.get("HTTP_X_LITMUS_SECOND"))
126
+        if litmusTag and verbose >= 2:
127
+            print("----\nRunning litmus test '%s'..." % litmusTag,
128
+                  file=self.out)
129
+            for litmusSubstring in self.debug_litmus:
130
+                if litmusSubstring in litmusTag:
131
+                    verbose = 3
132
+                    debugBreak = True
133
+                    dumpRequest = True
134
+                    dumpResponse = True
135
+                    break
136
+            for litmusSubstring in self.break_after_litmus:
137
+                if litmusSubstring in self.passedLitmus and litmusSubstring not in litmusTag:
138
+                    print(" *** break after litmus %s" % litmusTag,
139
+                          file=self.out)
140
+                    sys.exit(-1)
141
+                if litmusSubstring in litmusTag:
142
+                    self.passedLitmus[litmusSubstring] = True
143
+
144
+        # Turn on max. debugging for selected request methods
145
+        if verbose >= 2 and method in self.debug_methods:
146
+            verbose = 3
147
+            debugBreak = True
148
+            dumpRequest = True
149
+            dumpResponse = True
150
+
151
+        # Set debug options to environment
152
+        environ["wsgidav.verbose"] = verbose
153
+        #        environ["wsgidav.debug_methods"] = self.debug_methods
154
+        environ["wsgidav.debug_break"] = debugBreak
155
+        environ["wsgidav.dump_request_body"] = dumpRequest
156
+        environ["wsgidav.dump_response_body"] = dumpResponse
157
+
158
+        # Dump request headers
159
+        if dumpRequest:
160
+            print("<%s> --- %s Request ---" % (
161
+            threading.currentThread().ident, method), file=self.out)
162
+            for k, v in environ.items():
163
+                if k == k.upper():
164
+                    print("%20s: '%s'" % (k, v), file=self.out)
165
+            print("\n", file=self.out)
166
+            self._dump_request(environ, xml=None)
167
+
168
+        # Intercept start_response
169
+        #
170
+        sub_app_start_response = util.SubAppStartResponse()
171
+
172
+        nbytes = 0
173
+        first_yield = True
174
+        app_iter = self._application(environ, sub_app_start_response)
175
+
176
+        for v in app_iter:
177
+            # Start response (the first time)
178
+            if first_yield:
179
+                # Success!
180
+                start_response(sub_app_start_response.status,
181
+                               sub_app_start_response.response_headers,
182
+                               sub_app_start_response.exc_info)
183
+
184
+            # Dump response headers
185
+            if first_yield and dumpResponse:
186
+                print("<%s> --- %s Response(%s): ---" % (
187
+                threading.currentThread().ident,
188
+                method,
189
+                sub_app_start_response.status),
190
+                      file=self.out)
191
+                headersdict = dict(sub_app_start_response.response_headers)
192
+                for envitem in headersdict.keys():
193
+                    print("%s: %s" % (envitem, repr(headersdict[envitem])),
194
+                          file=self.out)
195
+                print("", file=self.out)
196
+
197
+            # Check, if response is a binary string, otherwise we probably have
198
+            # calculated a wrong content-length
199
+            assert compat.is_bytes(v), v
200
+
201
+            # Dump response body
202
+            drb = environ.get("wsgidav.dump_response_body")
203
+            if compat.is_basestring(drb):
204
+                # Middleware provided a formatted body representation
205
+                print(drb, file=self.out)
206
+            elif drb is True:
207
+                # Else dump what we get, (except for long GET responses)
208
+                if method == "GET":
209
+                    if first_yield:
210
+                        print(v[:50], "...", file=self.out)
211
+                elif len(v) > 0:
212
+                    print(v, file=self.out)
213
+
214
+            if dumpResponse:
215
+                self._dump_response(sub_app_start_response, drb)
216
+
217
+            drb = environ["wsgidav.dump_response_body"] = None
218
+
219
+            nbytes += len(v)
220
+            first_yield = False
221
+            yield v
222
+        if hasattr(app_iter, "close"):
223
+            app_iter.close()
224
+
225
+        # Start response (if it hasn't been done yet)
226
+        if first_yield:
227
+            # Success!
228
+            start_response(sub_app_start_response.status,
229
+                           sub_app_start_response.response_headers,
230
+                           sub_app_start_response.exc_info)
231
+
232
+        if dumpResponse:
233
+            print("\n<%s> --- End of %s Response (%i bytes) ---" % (
234
+            threading.currentThread().ident, method, nbytes), file=self.out)
235
+        return
236
+
237
+    def _dump_response(self, sub_app_start_response, drb):
238
+        dump_to_path = self._config.get(
239
+            'dump_requests_path',
240
+            '/tmp/wsgidav_dumps',
241
+        )
242
+        os.makedirs(dump_to_path, exist_ok=True)
243
+        dump_file = '{0}/{1}_RESPONSE_{2}.yml'.format(
244
+            dump_to_path,
245
+            self.last_request_time,
246
+            sub_app_start_response.status[0:3],
247
+        )
248
+        with open(dump_file, 'w+') as f:
249
+            dump_content = dict()
250
+            headers = {}
251
+            for header_tuple in sub_app_start_response.response_headers:
252
+                headers[header_tuple[0]] = header_tuple[1]
253
+            dump_content['headers'] = headers
254
+            if isinstance(drb, str):
255
+                dump_content['content'] = drb.replace('PROPFIND XML response body:\n', '')
256
+
257
+            f.write(yaml.dump(dump_content, default_flow_style=False))
258
+
259
+    def _dump_request(self, environ, xml):
260
+        dump_to_path = self._config.get(
261
+            'dump_requests_path',
262
+            '/tmp/wsgidav_dumps',
263
+        )
264
+        os.makedirs(dump_to_path, exist_ok=True)
265
+        dump_file = '{0}/{1}_REQUEST_{2}.yml'.format(
266
+            dump_to_path,
267
+            self.last_request_time,
268
+            environ['REQUEST_METHOD'],
269
+        )
270
+        with open(dump_file, 'w+') as f:
271
+            dump_content = dict()
272
+            dump_content['path'] = environ.get('PATH_INFO', '')
273
+            dump_content['Authorization'] = environ.get('HTTP_AUTHORIZATION', '')
274
+            if xml:
275
+                dump_content['content'] = ElementTree.tostring(xml, 'utf-8')
276
+
277
+            f.write(yaml.dump(dump_content, default_flow_style=False))
278
+
279
+
280
+def normpath(path):
281
+    if path == b'':
282
+        path = b'/'
283
+    elif path == '':
284
+        path = '/'
285
+    return base_normpath(path)

+ 60 - 18
tracim/tracim/lib/workspace.py 查看文件

@@ -1,48 +1,50 @@
1 1
 # -*- coding: utf-8 -*-
2
+import transaction
2 3
 
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
4
+from sqlalchemy.orm import Query
5
+from tg.i18n import ugettext as _
17 6
 
18 7
 from tracim.lib.userworkspace import RoleApi
19 8
 from tracim.model.auth import Group
20 9
 from tracim.model.auth import User
21 10
 from tracim.model.data import Workspace
22 11
 from tracim.model.data import UserRoleInWorkspace
23
-
24
-from tracim.model import auth as pbma
25 12
 from tracim.model import DBSession
26 13
 
14
+__author__ = 'damien'
15
+
27 16
 
28 17
 class WorkspaceApi(object):
29 18
 
30 19
     def __init__(self, current_user: User):
31 20
         self._user = current_user
32 21
 
22
+    def _base_query_without_roles(self):
23
+        return DBSession.query(Workspace).filter(Workspace.is_deleted==False)
24
+
33 25
     def _base_query(self):
34 26
         if self._user.profile.id>=Group.TIM_ADMIN:
35
-            return DBSession.query(Workspace).filter(Workspace.is_deleted==False)
27
+            return self._base_query_without_roles()
36 28
 
37 29
         return DBSession.query(Workspace).\
38 30
             join(Workspace.roles).\
39 31
             filter(UserRoleInWorkspace.user_id==self._user.user_id).\
40 32
             filter(Workspace.is_deleted==False)
41 33
 
42
-    def create_workspace(self, label: str, description: str='', save_now:bool=False) -> Workspace:
34
+    def create_workspace(
35
+            self,
36
+            label: str='',
37
+            description: str='',
38
+            calendar_enabled: bool=False,
39
+            save_now: bool=False,
40
+    ) -> Workspace:
41
+        if not label:
42
+            label = self.generate_label()
43
+
43 44
         workspace = Workspace()
44 45
         workspace.label = label
45 46
         workspace.description = description
47
+        workspace.calendar_enabled = calendar_enabled
46 48
 
47 49
         # By default, we force the current user to be the workspace manager
48 50
         # And to receive email notifications
@@ -56,6 +58,9 @@ class WorkspaceApi(object):
56 58
         if save_now:
57 59
             DBSession.flush()
58 60
 
61
+        if calendar_enabled:
62
+            self.execute_created_workspace_actions(workspace)
63
+
59 64
         return workspace
60 65
 
61 66
     def get_one(self, id):
@@ -125,6 +130,43 @@ class WorkspaceApi(object):
125 130
 
126 131
         return workspace
127 132
 
133
+    def execute_created_workspace_actions(self, workspace: Workspace) -> None:
134
+        self.ensure_calendar_exist(workspace)
135
+
136
+    def ensure_calendar_exist(self, workspace: Workspace) -> None:
137
+        # Note: Cyclic imports
138
+        from tracim.lib.calendar import CalendarManager
139
+        from tracim.model.organisational import WorkspaceCalendar
140
+
141
+        if workspace.calendar_enabled:
142
+            self._user.ensure_auth_token()
143
+
144
+            # Ensure database is up-to-date
145
+            DBSession.flush()
146
+            transaction.commit()
147
+
148
+            calendar_manager = CalendarManager(self._user)
149
+            calendar_manager.create_then_remove_fake_event(
150
+                calendar_class=WorkspaceCalendar,
151
+                related_object_id=workspace.workspace_id,
152
+            )
153
+
154
+    def get_base_query(self) -> Query:
155
+        return self._base_query()
156
+
157
+    def generate_label(self) -> str:
158
+        """
159
+        :return: Generated workspace label
160
+        """
161
+        query = self._base_query_without_roles() \
162
+            .filter(Workspace.label.ilike('{0}%'.format(
163
+                _('Workspace'),
164
+            )))
165
+
166
+        return _('Workspace {}').format(
167
+            query.count() + 1,
168
+        )
169
+
128 170
 
129 171
 class UnsafeWorkspaceApi(WorkspaceApi):
130 172
     def _base_query(self):

+ 10 - 4
tracim/tracim/model/__init__.py 查看文件

@@ -31,7 +31,8 @@ class RevisionsIntegrity(object):
31 31
 
32 32
     @classmethod
33 33
     def remove_from_updatable(cls, revision: 'ContentRevisionRO') -> None:
34
-        cls._updatable_revisions.remove(revision)
34
+        if revision in cls._updatable_revisions:
35
+            cls._updatable_revisions.remove(revision)
35 36
 
36 37
     @classmethod
37 38
     def is_updatable(cls, revision: 'ContentRevisionRO') -> bool:
@@ -39,8 +40,12 @@ class RevisionsIntegrity(object):
39 40
 
40 41
 # Global session manager: DBSession() returns the Thread-local
41 42
 # session object appropriate for the current web request.
42
-maker = sessionmaker(autoflush=True, autocommit=False,
43
-                     extension=ZopeTransactionExtension())
43
+maker = sessionmaker(
44
+    autoflush=True,
45
+    autocommit=False,
46
+    extension=ZopeTransactionExtension(),
47
+    expire_on_commit=False,
48
+)
44 49
 DBSession = scoped_session(maker)
45 50
 
46 51
 # Base class for all of our model classes: By default, the data model is
@@ -80,7 +85,8 @@ metadata = DeclarativeBase.metadata
80 85
 
81 86
 def init_model(engine):
82 87
     """Call me before using any of the tables or classes in the model."""
83
-    DBSession.configure(bind=engine)
88
+    if not DBSession.registry.has():  # Prevent a SQLAlchemy warning
89
+        DBSession.configure(bind=engine)
84 90
 
85 91
     # If you are using reflection to introspect your database and create
86 92
     # table objects for you, your tables must be defined and mapped inside

+ 13 - 5
tracim/tracim/model/auth.py 查看文件

@@ -14,9 +14,8 @@ import os
14 14
 from datetime import datetime
15 15
 import time
16 16
 from hashlib import sha256
17
-from slugify import slugify
18 17
 from sqlalchemy.ext.hybrid import hybrid_property
19
-from tg.i18n import lazy_ugettext as l_
18
+from tracim.lib.utils import lazy_ugettext as l_
20 19
 from hashlib import md5
21 20
 
22 21
 __all__ = ['User', 'Group', 'Permission']
@@ -69,7 +68,7 @@ class Group(DeclarativeBase):
69 68
     group_id = Column(Integer, Sequence('seq__groups__group_id'), autoincrement=True, primary_key=True)
70 69
     group_name = Column(Unicode(16), unique=True, nullable=False)
71 70
     display_name = Column(Unicode(255))
72
-    created = Column(DateTime, default=datetime.now)
71
+    created = Column(DateTime, default=datetime.utcnow)
73 72
 
74 73
     users = relationship('User', secondary=user_group_table, backref='groups')
75 74
 
@@ -122,9 +121,10 @@ class User(DeclarativeBase):
122 121
     email = Column(Unicode(255), unique=True, nullable=False)
123 122
     display_name = Column(Unicode(255))
124 123
     _password = Column('password', Unicode(128))
125
-    created = Column(DateTime, default=datetime.now)
124
+    created = Column(DateTime, default=datetime.utcnow)
126 125
     is_active = Column(Boolean, default=True, nullable=False)
127 126
     imported_from = Column(Unicode(32), nullable=True)
127
+    timezone = Column(Unicode(255), nullable=False, server_default='')
128 128
     _webdav_left_digest_response_hash = Column('webdav_left_digest_response_hash', Unicode(128))
129 129
     auth_token = Column(Unicode(255))
130 130
     auth_token_created = Column(DateTime)
@@ -218,6 +218,14 @@ class User(DeclarativeBase):
218 218
                                                descriptor=property(_get_hash_digest,
219 219
                                                                     _set_hash_digest))
220 220
 
221
+    def update_webdav_digest_auth(self, password) -> None:
222
+        self.webdav_left_digest_response_hash \
223
+            = '{username}:/:{password}'.format(
224
+                username=self.email,
225
+                password=password,
226
+            )
227
+
228
+
221 229
     def validate_password(self, password):
222 230
         """
223 231
         Check the password against existing credentials.
@@ -268,7 +276,7 @@ class User(DeclarativeBase):
268 276
         validity_seconds = CFG.get_instance().USER_AUTH_TOKEN_VALIDITY
269 277
 
270 278
         if not self.auth_token or not self.auth_token_created:
271
-            self.auth_token = uuid.uuid4()
279
+            self.auth_token = str(uuid.uuid4())
272 280
             self.auth_token_created = datetime.utcnow()
273 281
             DBSession.flush()
274 282
             return

+ 119 - 24
tracim/tracim/model/data.py 查看文件

@@ -2,12 +2,12 @@
2 2
 
3 3
 import datetime as datetime_root
4 4
 import json
5
+import os
5 6
 from datetime import datetime
6 7
 
7 8
 import tg
8 9
 from babel.dates import format_timedelta
9 10
 from bs4 import BeautifulSoup
10
-from slugify import slugify
11 11
 from sqlalchemy import Column, inspect, Index
12 12
 from sqlalchemy import ForeignKey
13 13
 from sqlalchemy import Sequence
@@ -24,8 +24,8 @@ from sqlalchemy.types import Integer
24 24
 from sqlalchemy.types import LargeBinary
25 25
 from sqlalchemy.types import Text
26 26
 from sqlalchemy.types import Unicode
27
-from tg.i18n import lazy_ugettext as l_, ugettext as _
28 27
 
28
+from tracim.lib.utils import lazy_ugettext as l_
29 29
 from tracim.lib.exception import ContentRevisionUpdateError
30 30
 from tracim.model import DeclarativeBase, RevisionsIntegrity
31 31
 from tracim.model.auth import User
@@ -63,9 +63,16 @@ class Workspace(DeclarativeBase):
63 63
     revisions = relationship("ContentRevisionRO")
64 64
 
65 65
     @hybrid_property
66
-    def contents(self):
66
+    def contents(self) -> ['Content']:
67 67
         # Return a list of unique revisions parent content
68
-        return list(set([revision.node for revision in self.revisions]))
68
+        contents = []
69
+        for revision in self.revisions:
70
+            # TODO BS 20161209: This ``revision.node.workspace`` make a lot
71
+            # of SQL queries !
72
+            if revision.node.workspace == self and revision.node not in contents:
73
+                contents.append(revision.node)
74
+
75
+        return contents
69 76
 
70 77
     @property
71 78
     def calendar_url(self) -> str:
@@ -89,12 +96,17 @@ class Workspace(DeclarativeBase):
89 96
         # @see Content.get_allowed_content_types()
90 97
         return [ContentType('folder')]
91 98
 
92
-    def get_valid_children(self, content_types: list=None):
99
+    def get_valid_children(
100
+            self,
101
+            content_types: list=None,
102
+            show_deleted: bool=False,
103
+            show_archived: bool=False,
104
+    ):
93 105
         for child in self.contents:
94 106
             # we search only direct children
95 107
             if not child.parent \
96
-                    and not child.is_deleted \
97
-                    and not child.is_archived:
108
+                    and (show_deleted or not child.is_deleted) \
109
+                    and (show_archived or not child.is_archived):
98 110
                 if not content_types or child.type in content_types:
99 111
                     yield child
100 112
 
@@ -522,7 +534,12 @@ class ContentRevisionRO(DeclarativeBase):
522 534
 
523 535
     label = Column(Unicode(1024), unique=False, nullable=False)
524 536
     description = Column(Text(), unique=False, nullable=False, default='')
525
-    file_name = Column(Unicode(255),  unique=False, nullable=False, default='')
537
+    file_extension = Column(
538
+        Unicode(255),
539
+        unique=False,
540
+        nullable=False,
541
+        server_default='',
542
+    )
526 543
     file_mimetype = Column(Unicode(255),  unique=False, nullable=False, default='')
527 544
     file_content = deferred(Column(LargeBinary(), unique=False, nullable=True))
528 545
     properties = Column('properties', Text(), unique=False, nullable=False, default='')
@@ -547,9 +564,28 @@ class ContentRevisionRO(DeclarativeBase):
547 564
 
548 565
     """ List of column copied when make a new revision from another """
549 566
     _cloned_columns = (
550
-        'content_id', 'created', 'description', 'file_content', 'file_mimetype', 'file_name', 'is_archived',
551
-        'is_deleted', 'label', 'node', 'owner', 'owner_id', 'parent', 'parent_id', 'properties', 'revision_type',
552
-        'status', 'type', 'updated', 'workspace', 'workspace_id', 'is_temporary',
567
+        'content_id',
568
+        'created',
569
+        'description',
570
+        'file_content',
571
+        'file_mimetype',
572
+        'file_extension',
573
+        'is_archived',
574
+        'is_deleted',
575
+        'label',
576
+        'node',
577
+        'owner',
578
+        'owner_id',
579
+        'parent',
580
+        'parent_id',
581
+        'properties',
582
+        'revision_type',
583
+        'status',
584
+        'type',
585
+        'updated',
586
+        'workspace',
587
+        'workspace_id',
588
+        'is_temporary',
553 589
     )
554 590
 
555 591
     # Read by must be used like this:
@@ -562,6 +598,13 @@ class ContentRevisionRO(DeclarativeBase):
562 598
             RevisionReadStatus(user=k, view_datetime=v)
563 599
     )
564 600
 
601
+    @property
602
+    def file_name(self):
603
+        return '{0}{1}'.format(
604
+            self.label,
605
+            self.file_extension,
606
+        )
607
+
565 608
     @classmethod
566 609
     def new_from(cls, revision: 'ContentRevisionRO') -> 'ContentRevisionRO':
567 610
         """
@@ -580,7 +623,7 @@ class ContentRevisionRO(DeclarativeBase):
580 623
             column_value = getattr(revision, column_name)
581 624
             setattr(new_rev, column_name, column_value)
582 625
 
583
-        new_rev.updated = datetime.now()
626
+        new_rev.updated = datetime.utcnow()
584 627
 
585 628
         return new_rev
586 629
 
@@ -607,7 +650,7 @@ class ContentRevisionRO(DeclarativeBase):
607 650
         return ContentStatus(self.status)
608 651
 
609 652
     def get_label(self) -> str:
610
-        return self.label if self.label else self.file_name if self.file_name else ''
653
+        return self.label or self.file_name or ''
611 654
 
612 655
     def get_last_action(self) -> ActionDescription:
613 656
         return ActionDescription(self.revision_type)
@@ -626,6 +669,20 @@ class ContentRevisionRO(DeclarativeBase):
626 669
 
627 670
         return False
628 671
 
672
+    def get_label_as_file(self):
673
+        file_extension = self.file_extension or ''
674
+
675
+        if self.type == ContentType.Thread:
676
+            file_extension = '.html'
677
+        elif self.type == ContentType.Page:
678
+            file_extension = '.html'
679
+
680
+        return '{0}{1}'.format(
681
+            self.label,
682
+            file_extension,
683
+        )
684
+
685
+
629 686
 Index('idx__content_revisions__owner_id', ContentRevisionRO.owner_id)
630 687
 Index('idx__content_revisions__parent_id', ContentRevisionRO.parent_id)
631 688
 
@@ -734,15 +791,33 @@ class Content(DeclarativeBase):
734 791
 
735 792
     @hybrid_property
736 793
     def file_name(self) -> str:
737
-        return self.revision.file_name
794
+        return '{0}{1}'.format(
795
+            self.revision.label,
796
+            self.revision.file_extension,
797
+        )
738 798
 
739 799
     @file_name.setter
740 800
     def file_name(self, value: str) -> None:
741
-        self.revision.file_name = value
801
+        file_name, file_extension = os.path.splitext(value)
802
+        if not self.revision.label:
803
+            self.revision.label = file_name
804
+        self.revision.file_extension = file_extension
742 805
 
743 806
     @file_name.expression
744 807
     def file_name(cls) -> InstrumentedAttribute:
745
-        return ContentRevisionRO.file_name
808
+        return ContentRevisionRO.file_name + ContentRevisionRO.file_extension
809
+
810
+    @hybrid_property
811
+    def file_extension(self) -> str:
812
+        return self.revision.file_extension
813
+
814
+    @file_extension.setter
815
+    def file_extension(self, value: str) -> None:
816
+        self.revision.file_extension = value
817
+
818
+    @file_extension.expression
819
+    def file_extension(cls) -> InstrumentedAttribute:
820
+        return ContentRevisionRO.file_extension
746 821
 
747 822
     @hybrid_property
748 823
     def file_mimetype(self) -> str:
@@ -961,6 +1036,10 @@ class Content(DeclarativeBase):
961 1036
     def revision(self) -> ContentRevisionRO:
962 1037
         return self.get_current_revision()
963 1038
 
1039
+    @property
1040
+    def is_editable(self) -> bool:
1041
+        return not self.is_archived and not self.is_deleted
1042
+
964 1043
     def get_current_revision(self) -> ContentRevisionRO:
965 1044
         if not self.revisions:
966 1045
             return self.new_revision()
@@ -1007,9 +1086,19 @@ class Content(DeclarativeBase):
1007 1086
         self._properties = json.dumps(properties_struct)
1008 1087
         ContentChecker.check_properties(self)
1009 1088
 
1089
+    @property
1090
+    def clean_revisions(self):
1091
+        """
1092
+        This property return revisions with really only one of each revisions:
1093
+        Actually, .revisions list give duplicated last revision,
1094
+        see https://github.com/tracim/tracim/issues/126
1095
+        :return: list of revisions
1096
+        """
1097
+        return list(set(self.revisions))
1098
+
1010 1099
     def created_as_delta(self, delta_from_datetime:datetime=None):
1011 1100
         if not delta_from_datetime:
1012
-            delta_from_datetime = datetime.now()
1101
+            delta_from_datetime = datetime.utcnow()
1013 1102
 
1014 1103
         return format_timedelta(delta_from_datetime - self.created,
1015 1104
                                 locale=tg.i18n.get_lang()[0])
@@ -1017,7 +1106,7 @@ class Content(DeclarativeBase):
1017 1106
     def datetime_as_delta(self, datetime_object,
1018 1107
                           delta_from_datetime:datetime=None):
1019 1108
         if not delta_from_datetime:
1020
-            delta_from_datetime = datetime.now()
1109
+            delta_from_datetime = datetime.utcnow()
1021 1110
         return format_timedelta(delta_from_datetime - datetime_object,
1022 1111
                                 locale=tg.i18n.get_lang()[0])
1023 1112
 
@@ -1032,7 +1121,13 @@ class Content(DeclarativeBase):
1032 1121
         return child_nb
1033 1122
 
1034 1123
     def get_label(self):
1035
-        return self.label if self.label else self.file_name if self.file_name else ''
1124
+        return self.label or self.file_name or ''
1125
+
1126
+    def get_label_as_file(self) -> str:
1127
+        """
1128
+        :return: Return content label in file representation context
1129
+        """
1130
+        return self.revision.get_label_as_file()
1036 1131
 
1037 1132
     def get_status(self) -> ContentStatus:
1038 1133
         return ContentStatus(self.status, self.type.__str__())
@@ -1093,13 +1188,13 @@ class Content(DeclarativeBase):
1093 1188
         return last_comment
1094 1189
 
1095 1190
     def get_previous_revision(self) -> 'ContentRevisionRO':
1096
-        rev_ids = [revision.revision_id for revision in self.revisions]
1191
+        rev_ids = [revision.revision_id for revision in self.clean_revisions]
1097 1192
         rev_ids.sort()
1098 1193
 
1099 1194
         if len(rev_ids)>=2:
1100 1195
             revision_rev_id = rev_ids[-2]
1101 1196
 
1102
-            for revision in self.revisions:
1197
+            for revision in self.clean_revisions:
1103 1198
                 if revision.revision_id == revision_rev_id:
1104 1199
                     return revision
1105 1200
 
@@ -1128,7 +1223,7 @@ class Content(DeclarativeBase):
1128 1223
         events = []
1129 1224
         for comment in self.get_comments():
1130 1225
             events.append(VirtualEvent.create_from_content(comment))
1131
-        for revision in self.revisions:
1226
+        for revision in self.clean_revisions:
1132 1227
             events.append(VirtualEvent.create_from_content_revision(revision))
1133 1228
 
1134 1229
         sorted_events = sorted(events,
@@ -1232,7 +1327,7 @@ class VirtualEvent(object):
1232 1327
 
1233 1328
     def created_as_delta(self, delta_from_datetime:datetime=None):
1234 1329
         if not delta_from_datetime:
1235
-            delta_from_datetime = datetime.now()
1330
+            delta_from_datetime = datetime.utcnow()
1236 1331
         return format_timedelta(delta_from_datetime - self.created,
1237 1332
                                 locale=tg.i18n.get_lang()[0])
1238 1333
 
@@ -1240,7 +1335,7 @@ class VirtualEvent(object):
1240 1335
         aff = ''
1241 1336
 
1242 1337
         if not delta_from_datetime:
1243
-            delta_from_datetime = datetime.now()
1338
+            delta_from_datetime = datetime.utcnow()
1244 1339
 
1245 1340
         delta = delta_from_datetime - self.created
1246 1341
         

+ 77 - 15
tracim/tracim/model/serializers.py 查看文件

@@ -1,4 +1,7 @@
1 1
 # -*- coding: utf-8 -*-
2
+import cherrypy
3
+import os
4
+
2 5
 import types
3 6
 
4 7
 from bs4 import BeautifulSoup
@@ -10,8 +13,7 @@ import tg
10 13
 from tg.i18n import ugettext as _
11 14
 from tg.util import LazyString
12 15
 from tracim.lib.base import logger
13
-from tracim.lib.user import UserStaticApi
14
-from tracim.lib.utils import exec_time_monitor
16
+from tracim.lib.user import CurrentUserGetterApi
15 17
 from tracim.model.auth import Profile
16 18
 from tracim.model.auth import User
17 19
 from tracim.model.data import BreadcrumbItem, ActionDescription
@@ -86,6 +88,9 @@ class CTX(object):
86 88
     USER = 'USER'
87 89
     USERS = 'USERS'
88 90
     WORKSPACE = 'WORKSPACE'
91
+    API_WORKSPACE = 'API_WORKSPACE'
92
+    API_CALENDAR_WORKSPACE = 'API_CALENDAR_WORKSPACE'
93
+    API_CALENDAR_USER = 'API_CALENDAR_USER'
89 94
 
90 95
 
91 96
 class DictLikeClass(dict):
@@ -151,12 +156,17 @@ class Context(object):
151 156
         self.context_string = context_string
152 157
         self._current_user = current_user  # Allow to define the current user if any
153 158
         if not current_user:
154
-            self._current_user = UserStaticApi.get_current_user()
159
+            self._current_user = CurrentUserGetterApi.get_current_user()
155 160
 
156 161
         self._base_url = base_url # real root url like http://mydomain.com:8080
157 162
 
158 163
     def url(self, base_url='/', params=None, qualified=False) -> str:
159
-        url = tg.url(base_url, params)
164
+        # HACK (REF WSGIDAV.CONTEXT.TG.URL) This is a temporary hack who
165
+        # permit to know we are in WSGIDAV context.
166
+        if not hasattr(cherrypy.request, 'current_user_email'):
167
+            url = tg.url(base_url, params)
168
+        else:
169
+            url = base_url
160 170
 
161 171
         if self._base_url:
162 172
             url = '{}{}'.format(self._base_url, url)
@@ -259,10 +269,10 @@ def serialize_breadcrumb_item(item: BreadcrumbItem, context: Context):
259 269
 def serialize_version_for_page_or_file(version: ContentRevisionRO, context: Context):
260 270
     return DictLikeClass(
261 271
         id = version.revision_id,
262
-        label = version.label if version.label else version.file_name,
272
+        label = version.label,
263 273
         owner = context.toDict(version.owner),
264 274
         created = version.created,
265
-        action = context.toDict(version.get_last_action())
275
+        action = context.toDict(version.get_last_action()),
266 276
     )
267 277
 
268 278
 
@@ -282,7 +292,7 @@ def serialize_item(content: Content, context: Context):
282 292
 
283 293
     result = DictLikeClass(
284 294
         id = content.content_id,
285
-        label = content.label if content.label else content.file_name,
295
+        label = content.label,
286 296
         icon = ContentType.get_icon(content.type),
287 297
         status = context.toDict(content.get_status()),
288 298
         folder = context.toDict(DictLikeClass(id = content.parent.content_id if content.parent else None)),
@@ -338,7 +348,7 @@ def serialize_node_for_page_list(content: Content, context: Context):
338 348
     if content.type==ContentType.File:
339 349
         result = DictLikeClass(
340 350
             id = content.content_id,
341
-            label = content.label if content.label else content.file_name,
351
+            label = content.label,
342 352
             status = context.toDict(content.get_status()),
343 353
             folder = Context(CTX.DEFAULT).toDict(content.parent)
344 354
         )
@@ -386,6 +396,9 @@ def serialize_node_for_page(content: Content, context: Context):
386 396
             revisions=context.toDict(sorted(content.revisions, key=lambda v: v.created, reverse=True)),
387 397
             selected_revision='latest' if content.revision_to_serialize<=0 else content.revision_to_serialize,
388 398
             history=Context(CTX.CONTENT_HISTORY).toDict(content.get_history()),
399
+            is_editable=content.is_editable,
400
+            is_deleted=content.is_deleted,
401
+            is_archived=content.is_archived,
389 402
             urls = context.toDict({
390 403
                 'mark_read': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_read', content)),
391 404
                 'mark_unread': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_unread', content))
@@ -393,7 +406,7 @@ def serialize_node_for_page(content: Content, context: Context):
393 406
         )
394 407
 
395 408
         if content.type==ContentType.File:
396
-            result.label = content.label if content.label else content.file_name
409
+            result.label = content.label
397 410
             result['file'] = DictLikeClass(
398 411
                 name = data_container.file_name,
399 412
                 size = len(data_container.file_content),
@@ -455,6 +468,9 @@ def serialize_node_for_page(item: Content, context: Context):
455 468
             comments = reversed(context.toDict(item.get_comments())),
456 469
             is_new=item.has_new_information_for(context.get_user()),
457 470
             history = Context(CTX.CONTENT_HISTORY).toDict(item.get_history()),
471
+            is_editable=item.is_editable,
472
+            is_deleted=item.is_deleted,
473
+            is_archived=item.is_archived,
458 474
             urls = context.toDict({
459 475
                 'mark_read': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_read', item)),
460 476
                 'mark_unread': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_unread', item))
@@ -544,7 +560,8 @@ def serialize_content_for_workspace(content: Content, context: Context):
544 560
                 all = page_nb_all,
545 561
                 open = page_nb_open,
546 562
             ),
547
-            content_nb = DictLikeClass(all = content_nb_all)
563
+            content_nb = DictLikeClass(all = content_nb_all),
564
+            is_editable=content.is_editable,
548 565
         )
549 566
 
550 567
     return result
@@ -592,7 +609,10 @@ def serialize_content_for_workspace_and_folder(content: Content, context: Contex
592 609
                                     open=folder_nb_open),
593 610
             page_nb=DictLikeClass(all=page_nb_all,
594 611
                                   open=page_nb_open),
595
-            content_nb=DictLikeClass(all = content_nb_all)
612
+            content_nb=DictLikeClass(all = content_nb_all),
613
+            is_archived=content.is_archived,
614
+            is_deleted=content.is_deleted,
615
+            is_editable=content.is_editable,
596 616
         )
597 617
 
598 618
     elif content.type==ContentType.Page:
@@ -618,8 +638,10 @@ def serialize_content_for_general_list(content: Content, context: Context):
618 638
     last_activity_date = content.get_last_activity_date()
619 639
     last_activity_date_formatted = format_datetime(last_activity_date,
620 640
                                                    locale=tg.i18n.get_lang()[0])
621
-    last_activity_label = format_timedelta(datetime.now() - last_activity_date,
622
-                                           locale=tg.i18n.get_lang()[0])
641
+    last_activity_label = format_timedelta(
642
+        datetime.utcnow() - last_activity_date,
643
+        locale=tg.i18n.get_lang()[0],
644
+    )
623 645
     last_activity_label = last_activity_label.replace(' ', '\u00A0') # espace insécable
624 646
 
625 647
     return DictLikeClass(
@@ -630,6 +652,9 @@ def serialize_content_for_general_list(content: Content, context: Context):
630 652
         url=ContentType.fill_url(content),
631 653
         type=DictLikeClass(content_type.toDict()),
632 654
         status=context.toDict(content.get_status()),
655
+        is_deleted=content.is_deleted,
656
+        is_archived=content.is_archived,
657
+        is_editable=content.is_editable,
633 658
         last_activity = DictLikeClass({'date': last_activity_date,
634 659
                                        'label': last_activity_date_formatted,
635 660
                                        'delta': last_activity_label})
@@ -642,7 +667,7 @@ def serialize_content_for_folder_content_list(content: Content, context: Context
642 667
     last_activity_date = content.get_last_activity_date()
643 668
     last_activity_date_formatted = format_datetime(last_activity_date,
644 669
                                                    locale=tg.i18n.get_lang()[0])
645
-    last_activity_label = format_timedelta(datetime.now() - last_activity_date,
670
+    last_activity_label = format_timedelta(datetime.utcnow() - last_activity_date,
646 671
                                            locale=tg.i18n.get_lang()[0])
647 672
     last_activity_label = last_activity_label.replace(' ', '\u00A0') # espace insécable
648 673
 
@@ -701,6 +726,10 @@ def serialize_content_for_folder_content_list(content: Content, context: Context
701 726
         item = Context(CTX.CONTENT_LIST).toDict(content)
702 727
         item.notes = ''
703 728
 
729
+    item.is_deleted = content.is_deleted
730
+    item.is_archived = content.is_archived
731
+    item.is_editable = content.is_editable
732
+
704 733
     return item
705 734
 
706 735
 
@@ -758,7 +787,7 @@ def serialize_content_for_search_result(content: Content, context: Context):
758 787
         )
759 788
 
760 789
         if content.type==ContentType.File:
761
-            result.label = content.label.__str__() if content.label else content.file_name.__str__()
790
+            result.label = content.label.__str__()
762 791
 
763 792
         if not result.label or ''==result.label:
764 793
             result.label = 'No title'
@@ -855,6 +884,7 @@ def serialize_user_list_default(user: User, context: Context):
855 884
     result['enabled'] = user.is_active
856 885
     result['profile'] = user.profile
857 886
     result['has_password'] = user.password!=None
887
+    result['timezone'] = user.timezone
858 888
     return result
859 889
 
860 890
 
@@ -877,6 +907,7 @@ def serialize_user_for_user(user: User, context: Context):
877 907
     result['enabled'] = user.is_active
878 908
     result['profile'] = user.profile
879 909
     result['calendar_url'] = user.calendar_url
910
+    result['timezone'] = user.timezone
880 911
 
881 912
     return result
882 913
 
@@ -1023,3 +1054,34 @@ def serialize_node_tree_item_for_menu_api_tree(item: NodeTreeItem, context: Cont
1023 1054
             type='workspace',
1024 1055
             state={'opened': True if len(item.children)>0 else False, 'selected': item.is_selected}
1025 1056
         )
1057
+
1058
+
1059
+@pod_serializer(Workspace, CTX.API_WORKSPACE)
1060
+def serialize_api_workspace(item: Workspace, context: Context):
1061
+    return DictLikeClass(
1062
+        id=item.workspace_id,
1063
+        label=item.label,
1064
+        description=item.description,
1065
+        has_calendar=item.calendar_enabled,
1066
+    )
1067
+
1068
+
1069
+@pod_serializer(Workspace, CTX.API_CALENDAR_WORKSPACE)
1070
+def serialize_api_calendar_workspace(item: Workspace, context: Context):
1071
+    return DictLikeClass(
1072
+        id=item.workspace_id,
1073
+        label=item.label,
1074
+        description=item.description,
1075
+        type='workspace',
1076
+    )
1077
+
1078
+
1079
+@pod_serializer(User, CTX.API_CALENDAR_USER)
1080
+def serialize_api_calendar_workspace(item: User, context: Context):
1081
+    from tracim.lib.calendar import CalendarManager  # Cyclic import
1082
+    return DictLikeClass(
1083
+        id=item.user_id,
1084
+        label=item.display_name,
1085
+        description=CalendarManager.get_personal_calendar_description(),
1086
+        type='user',
1087
+    )

tracim/tracim/public/caldavzap/.gitignore → tracim/tracim/public/_caldavzap/.gitignore 查看文件


tracim/tracim/public/caldavzap/.htaccess → tracim/tracim/public/_caldavzap/.htaccess 查看文件


tracim/tracim/public/caldavzap/auth/.htaccess → tracim/tracim/public/_caldavzap/auth/.htaccess 查看文件


tracim/tracim/public/caldavzap/auth/common.inc → tracim/tracim/public/_caldavzap/auth/common.inc 查看文件


tracim/tracim/public/caldavzap/auth/config.inc → tracim/tracim/public/_caldavzap/auth/config.inc 查看文件


tracim/tracim/public/caldavzap/auth/cross_domain.inc → tracim/tracim/public/_caldavzap/auth/cross_domain.inc 查看文件


tracim/tracim/public/caldavzap/auth/doc/example_config_response.xml → tracim/tracim/public/_caldavzap/auth/doc/example_config_response.xml 查看文件


tracim/tracim/public/caldavzap/auth/doc/readme.txt → tracim/tracim/public/_caldavzap/auth/doc/readme.txt 查看文件


tracim/tracim/public/caldavzap/auth/index.php → tracim/tracim/public/_caldavzap/auth/index.php 查看文件


tracim/tracim/public/caldavzap/auth/plugins/generic.inc → tracim/tracim/public/_caldavzap/auth/plugins/generic.inc 查看文件


tracim/tracim/public/caldavzap/auth/plugins/generic_conf.inc → tracim/tracim/public/_caldavzap/auth/plugins/generic_conf.inc 查看文件


tracim/tracim/public/caldavzap/auth/plugins/ldap.inc → tracim/tracim/public/_caldavzap/auth/plugins/ldap.inc 查看文件


tracim/tracim/public/caldavzap/auth/plugins/ldap_conf.inc → tracim/tracim/public/_caldavzap/auth/plugins/ldap_conf.inc 查看文件


tracim/tracim/public/caldavzap/cache.manifest → tracim/tracim/public/_caldavzap/cache.manifest 查看文件


tracim/tracim/public/caldavzap/cache_handler.js → tracim/tracim/public/_caldavzap/cache_handler.js 查看文件


tracim/tracim/public/caldavzap/cache_update.sh → tracim/tracim/public/_caldavzap/cache_update.sh 查看文件


tracim/tracim/public/caldavzap/changelog.txt → tracim/tracim/public/_caldavzap/changelog.txt 查看文件


tracim/tracim/public/caldavzap/common.js → tracim/tracim/public/_caldavzap/common.js 查看文件


tracim/tracim/public/caldavzap/config.js → tracim/tracim/public/_caldavzap/config.js 查看文件


tracim/tracim/public/caldavzap/css/default.css → tracim/tracim/public/_caldavzap/css/default.css 查看文件


tracim/tracim/public/caldavzap/css/default_integration.css → tracim/tracim/public/_caldavzap/css/default_integration.css 查看文件


tracim/tracim/public/caldavzap/css/fullcalendar.css → tracim/tracim/public/_caldavzap/css/fullcalendar.css 查看文件


tracim/tracim/public/caldavzap/css/jquery-ui.custom.css → tracim/tracim/public/_caldavzap/css/jquery-ui.custom.css 查看文件


tracim/tracim/public/caldavzap/css/spectrum.custom.css → tracim/tracim/public/_caldavzap/css/spectrum.custom.css 查看文件


tracim/tracim/public/caldavzap/data_process.js → tracim/tracim/public/_caldavzap/data_process.js 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-Bold-webfont.eot → tracim/tracim/public/_caldavzap/fonts/Roboto-Bold-webfont.eot 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-Bold-webfont.svg → tracim/tracim/public/_caldavzap/fonts/Roboto-Bold-webfont.svg 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-Bold-webfont.ttf → tracim/tracim/public/_caldavzap/fonts/Roboto-Bold-webfont.ttf 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-Bold-webfont.woff → tracim/tracim/public/_caldavzap/fonts/Roboto-Bold-webfont.woff 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-BoldItalic-webfont.eot → tracim/tracim/public/_caldavzap/fonts/Roboto-BoldItalic-webfont.eot 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-BoldItalic-webfont.svg → tracim/tracim/public/_caldavzap/fonts/Roboto-BoldItalic-webfont.svg 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-BoldItalic-webfont.ttf → tracim/tracim/public/_caldavzap/fonts/Roboto-BoldItalic-webfont.ttf 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-BoldItalic-webfont.woff → tracim/tracim/public/_caldavzap/fonts/Roboto-BoldItalic-webfont.woff 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-Italic-webfont.eot → tracim/tracim/public/_caldavzap/fonts/Roboto-Italic-webfont.eot 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-Italic-webfont.svg → tracim/tracim/public/_caldavzap/fonts/Roboto-Italic-webfont.svg 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-Italic-webfont.ttf → tracim/tracim/public/_caldavzap/fonts/Roboto-Italic-webfont.ttf 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-Italic-webfont.woff → tracim/tracim/public/_caldavzap/fonts/Roboto-Italic-webfont.woff 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-Light-webfont.eot → tracim/tracim/public/_caldavzap/fonts/Roboto-Light-webfont.eot 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-Light-webfont.svg → tracim/tracim/public/_caldavzap/fonts/Roboto-Light-webfont.svg 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-Light-webfont.ttf → tracim/tracim/public/_caldavzap/fonts/Roboto-Light-webfont.ttf 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-Light-webfont.woff → tracim/tracim/public/_caldavzap/fonts/Roboto-Light-webfont.woff 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-LightItalic-webfont.eot → tracim/tracim/public/_caldavzap/fonts/Roboto-LightItalic-webfont.eot 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-LightItalic-webfont.svg → tracim/tracim/public/_caldavzap/fonts/Roboto-LightItalic-webfont.svg 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-LightItalic-webfont.ttf → tracim/tracim/public/_caldavzap/fonts/Roboto-LightItalic-webfont.ttf 查看文件


tracim/tracim/public/caldavzap/fonts/Roboto-LightItalic-webfont.woff → tracim/tracim/public/_caldavzap/fonts/Roboto-LightItalic-webfont.woff 查看文件


部分文件因为文件数量过多而无法显示