浏览代码

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

Damien ACCORSI 8 年前
父节点
当前提交
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
 wsgidav.conf
63
 wsgidav.conf
64
 # Temporary files
64
 # Temporary files
65
 *~
65
 *~
66
+*.sqlite

+ 151 - 0
API.md 查看文件

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
 The main parameters for notifications are the following ones:
318
 The main parameters for notifications are the following ones:
319
 
319
 
320
     email.notification.activated = true
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
     email.notification.smtp.server = smtp.mycompany.com
323
     email.notification.smtp.server = smtp.mycompany.com
323
     email.notification.smtp.port = 25
324
     email.notification.smtp.port = 25
324
     email.notification.smtp.user = username
325
     email.notification.smtp.user = username

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

47
 sprox==0.9.4
47
 sprox==0.9.4
48
 stevedore==1.1.0
48
 stevedore==1.1.0
49
 tg.devtools==2.3.7
49
 tg.devtools==2.3.7
50
-tgapp-resetpassword==0.1.8
50
+git+https://github.com/algoo/tgapp-resetpassword.git
51
 tgext.admin==0.6.4
51
 tgext.admin==0.6.4
52
-tgext.asyncjob==0.3.1
53
 tgext.crud==0.7.3
52
 tgext.crud==0.7.3
54
-tgext.pluggable==0.5.5
53
+tgext.pluggable==0.6.2
55
 transaction==1.4.4
54
 transaction==1.4.4
56
 tw2.core==2.2.2
55
 tw2.core==2.2.2
57
 tw2.forms==2.2.2.1
56
 tw2.forms==2.2.2.1
62
 -e git+https://github.com/algoo/wsgidav.git@py3#egg=wsgidav
61
 -e git+https://github.com/algoo/wsgidav.git@py3#egg=wsgidav
63
 zope.interface==4.1.3
62
 zope.interface==4.1.3
64
 zope.sqlalchemy==0.7.6
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
 website.server_name = 127.0.0.1
176
 website.server_name = 127.0.0.1
177
     
177
     
178
 email.notification.activated = False
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
 email.notification.content_update.template.html = ./tracim/templates/mail/content_update_body_html.mak
181
 email.notification.content_update.template.html = ./tracim/templates/mail/content_update_body_html.mak
181
 email.notification.content_update.template.text = ./tracim/templates/mail/content_update_body_text.mak
182
 email.notification.content_update.template.text = ./tracim/templates/mail/content_update_body_text.mak
182
 email.notification.created_account.template.html = ./tracim/templates/mail/created_account_body_html.mak
183
 email.notification.created_account.template.html = ./tracim/templates/mail/created_account_body_html.mak
191
 email.notification.smtp.user = your_smtp_user
192
 email.notification.smtp.user = your_smtp_user
192
 email.notification.smtp.password = your_smtp_password
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
 ## Radical (CalDav server) configuration
203
 ## Radical (CalDav server) configuration
195
 # radicale.server.host = 0.0.0.0
204
 # radicale.server.host = 0.0.0.0
196
 # radicale.server.port = 5232
205
 # radicale.server.port = 5232
200
 # radicale.server.realm_message = Tracim Calendar - Password Required
209
 # radicale.server.realm_message = Tracim Calendar - Password Required
201
 ## url can be extended like http://127.0.0.1:5232/calendar
210
 ## url can be extended like http://127.0.0.1:5232/calendar
202
 ## in this case, you have to create your own proxy behind this url.
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
 ## WSGIDAV
216
 ## WSGIDAV
206
 wsgidav.config_path = wsgidav.conf
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
             context.run_migrations()
65
             context.run_migrations()
66
     finally:
66
     finally:
67
         connection.close()
67
         connection.close()
68
+        engine.dispose()
68
 
69
 
69
 if context.is_offline_mode():
70
 if context.is_offline_mode():
70
     run_migrations_offline()
71
     run_migrations_offline()

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

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 查看文件

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 查看文件

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 查看文件

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 查看文件

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

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

12
 radicale.server.port = 15232
12
 radicale.server.port = 15232
13
 radicale.client.port = 15232
13
 radicale.client.port = 15232
14
 radicale.server.filesystem.folder = /tmp/tracim_tests_radicale_fs
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
 [server:main]
19
 [server:main]
17
 use = egg:gearbox#wsgiref
20
 use = egg:gearbox#wsgiref
39
 ldap_group_enabled = False
42
 ldap_group_enabled = False
40
 use = config:development.ini
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
 [app:radicale]
51
 [app:radicale]
43
 sqlalchemy.url = sqlite:///tracim_test.sqlite
52
 sqlalchemy.url = sqlite:///tracim_test.sqlite
44
 
53
 

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

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
 from sqlalchemy.exc import IntegrityError
3
 from sqlalchemy.exc import IntegrityError
4
 from tg import config
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
 from tracim.lib.auth.ldap import LDAPAuth
8
 from tracim.lib.auth.ldap import LDAPAuth
9
+from tracim.lib.daemons import DaemonsManager
10
+from tracim.lib.daemons import RadicaleDaemon
8
 from tracim.lib.email import get_email_manager
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
 from tracim.lib.group import GroupApi
14
 from tracim.lib.group import GroupApi
11
 from tracim.lib.user import UserApi
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
 class UserCommand(AppContextCommand):
20
 class UserCommand(AppContextCommand):
106
 
111
 
107
         try:
112
         try:
108
             user = User(email=login, password=password, **kwargs)
113
             user = User(email=login, password=password, **kwargs)
114
+            user.update_webdav_digest_auth(password)
109
             self._session.add(user)
115
             self._session.add(user)
110
             self._session.flush()
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
         except IntegrityError:
124
         except IntegrityError:
112
             self._session.rollback()
125
             self._session.rollback()
113
             raise AlreadyExistError()
126
             raise AlreadyExistError()
117
     def _update_password_for_login(self, login, password):
130
     def _update_password_for_login(self, login, password):
118
         user = self._user_api.get_one_by_email(login)
131
         user = self._user_api.get_one_by_email(login)
119
         user.password = password
132
         user.password = password
133
+        user.update_webdav_digest_auth(password)
120
         self._session.flush()
134
         self._session.flush()
121
         transaction.commit()
135
         transaction.commit()
122
 
136
 

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

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 from tg import AppConfig
2
 from tg import AppConfig
3
+from tg.appwrappers.errorpage import ErrorPageApplicationWrapper \
4
+    as BaseErrorPageApplicationWrapper
3
 
5
 
4
 from tracim.lib.auth.wrapper import AuthConfigWrapper
6
 from tracim.lib.auth.wrapper import AuthConfigWrapper
7
+from tracim.lib.utils import ErrorPageApplicationWrapper
5
 
8
 
6
 
9
 
7
 class TracimAppConfig(AppConfig):
10
 class TracimAppConfig(AppConfig):
8
     """
11
     """
9
     Tracim specific config processes.
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
     def after_init_config(self, conf):
26
     def after_init_config(self, conf):
13
         AuthConfigWrapper.wrap(conf)
27
         AuthConfigWrapper.wrap(conf)

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

12
     setting = asbool(global_conf.get('the_setting'))
12
     setting = asbool(global_conf.get('the_setting'))
13
  
13
  
14
 """
14
 """
15
+import imp
16
+import importlib
15
 from urllib.parse import urlparse
17
 from urllib.parse import urlparse
16
 
18
 
17
 import tg
19
 import tg
21
 from tgext.pluggable import plug
23
 from tgext.pluggable import plug
22
 from tgext.pluggable import replace_template
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
 import tracim
28
 import tracim
27
 from tracim import model
29
 from tracim import model
28
 from tracim.config import TracimAppConfig
30
 from tracim.config import TracimAppConfig
29
-from tracim.lib import app_globals, helpers
30
-from tracim.lib.auth.wrapper import AuthConfigWrapper
31
 from tracim.lib.base import logger
31
 from tracim.lib.base import logger
32
 from tracim.lib.daemons import DaemonsManager
32
 from tracim.lib.daemons import DaemonsManager
33
+from tracim.lib.daemons import MailSenderDaemon
33
 from tracim.lib.daemons import RadicaleDaemon
34
 from tracim.lib.daemons import RadicaleDaemon
34
 from tracim.lib.daemons import WsgiDavDaemon
35
 from tracim.lib.daemons import WsgiDavDaemon
35
 from tracim.model.data import ActionDescription
36
 from tracim.model.data import ActionDescription
102
 
103
 
103
     manager.run('radicale', RadicaleDaemon)
104
     manager.run('radicale', RadicaleDaemon)
104
     manager.run('webdav', WsgiDavDaemon)
105
     manager.run('webdav', WsgiDavDaemon)
106
+    manager.run('mail_sender', MailSenderDaemon)
105
 
107
 
106
 environment_loaded.register(lambda: start_daemons(daemons))
108
 environment_loaded.register(lambda: start_daemons(daemons))
107
 
109
 
200
         self.WEBSITE_SUBTITLE = tg.config.get('website.home.subtitle', '')
202
         self.WEBSITE_SUBTITLE = tg.config.get('website.home.subtitle', '')
201
         self.WEBSITE_HOME_BELOW_LOGIN_FORM = tg.config.get('website.home.below_login_form', '')
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
         self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML = tg.config.get('email.notification.content_update.template.html')
216
         self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML = tg.config.get('email.notification.content_update.template.html')
206
         self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT = tg.config.get('email.notification.content_update.template.text')
217
         self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT = tg.config.get('email.notification.content_update.template.text')
207
         self.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML = tg.config.get(
218
         self.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML = tg.config.get(
235
             ActionDescription.COMMENT,
246
             ActionDescription.COMMENT,
236
             ActionDescription.CREATION,
247
             ActionDescription.CREATION,
237
             ActionDescription.EDITION,
248
             ActionDescription.EDITION,
238
-            ActionDescription.REVISION
249
+            ActionDescription.REVISION,
250
+            ActionDescription.STATUS_UPDATE
239
         ]
251
         ]
240
 
252
 
241
         self.EMAIL_NOTIFICATION_NOTIFIED_CONTENTS = [
253
         self.EMAIL_NOTIFICATION_NOTIFIED_CONTENTS = [
274
             'Tracim Calendar - Password Required',
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
             logger.warning(
302
             logger.warning(
287
                 self,
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
         self.USER_AUTH_TOKEN_VALIDITY = int(tg.config.get(
315
         self.USER_AUTH_TOKEN_VALIDITY = int(tg.config.get(
295
             'user.auth_token.validity',
316
             'user.auth_token.validity',
296
             '604800',
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
     def get_tracker_js_content(self, js_tracker_file_path = None):
382
     def get_tracker_js_content(self, js_tracker_file_path = None):
302
         js_tracker_file_path = tg.config.get('js_tracker_path', None)
383
         js_tracker_file_path = tg.config.get('js_tracker_path', None)
338
 base_config.variable_provider = lambda: {
419
 base_config.variable_provider = lambda: {
339
     'CFG': CFG.get_instance()
420
     'CFG': CFG.get_instance()
340
 }
421
 }
341
-
342
-plug(base_config, 'tgext.asyncjob')

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

3
 
3
 
4
 from tracim.config.app_cfg import base_config
4
 from tracim.config.app_cfg import base_config
5
 from tracim.config.environment import load_environment
5
 from tracim.config.environment import load_environment
6
-from tracim.lib.daemons import DaemonsManager
7
-from tracim.lib.daemons import RadicaleDaemon
6
+from sqlalchemy.pool import NullPool
8
 
7
 
9
 __all__ = ['make_app']
8
 __all__ = ['make_app']
10
 
9
 
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
     app = make_base_app(global_conf, full_stack=True, **app_conf)
40
     app = make_base_app(global_conf, full_stack=True, **app_conf)
37
     
41
     
38
     # Wrap your base TurboGears 2 application with custom middleware here
42
     # Wrap your base TurboGears 2 application with custom middleware here

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

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 """Controllers for the tracim application."""
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
 from tracim.lib.workspace import WorkspaceApi
8
 from tracim.lib.workspace import WorkspaceApi
4
 
9
 
5
 import tg
10
 import tg
24
 from tracim.model.data import Workspace
29
 from tracim.model.data import Workspace
25
 
30
 
26
 from tracim.lib.content import ContentApi
31
 from tracim.lib.content import ContentApi
27
-from tracim.lib.user import UserStaticApi
28
 from tracim.lib.utils import SameValueError
32
 from tracim.lib.utils import SameValueError
29
 
33
 
30
 from tracim.model.serializers import Context
34
 from tracim.model.serializers import Context
34
 
38
 
35
     @classmethod
39
     @classmethod
36
     def current_user(cls) -> User:
40
     def current_user(cls) -> User:
37
-        user = UserStaticApi.get_current_user()
41
+        user = CurrentUserGetterApi.get_current_user()
38
         tmpl_context.current_user_id = user.user_id if user else None
42
         tmpl_context.current_user_id = user.user_id if user else None
39
         tmpl_context.current_user = user if user else None
43
         tmpl_context.current_user = user if user else None
40
         return user
44
         return user
69
 
73
 
70
     @classmethod
74
     @classmethod
71
     def current_folder(cls) -> Content:
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
         folder_id = int(tg.request.controller_state.routing_args.get('folder_id'))
81
         folder_id = int(tg.request.controller_state.routing_args.get('folder_id'))
74
         folder = content_api.get_one(folder_id, ContentType.Folder, tg.tmpl_context.workspace)
82
         folder = content_api.get_one(folder_id, ContentType.Folder, tg.tmpl_context.workspace)
75
 
83
 
120
     TEMPLATE_NEW = 'unknown "template new"'
128
     TEMPLATE_NEW = 'unknown "template new"'
121
     TEMPLATE_EDIT = 'unknown "template edit"'
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
     def _before(self, *args, **kw):
137
     def _before(self, *args, **kw):
124
         """
138
         """
125
         Instantiate the current workspace in tg.tmpl_context
139
         Instantiate the current workspace in tg.tmpl_context
139
         :param item_id: an item id (item may be normal content or folder
153
         :param item_id: an item id (item may be normal content or folder
140
         :return:
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
     def _struct_new_serialized(self, workspace_id, parent_id):
162
     def _struct_new_serialized(self, workspace_id, parent_id):
145
         print('values are: ', workspace_id, parent_id)
163
         print('values are: ', workspace_id, parent_id)
167
     /dashboard/workspaces/{}/folders/{}/someitems/{}
185
     /dashboard/workspaces/{}/folders/{}/someitems/{}
168
     """
186
     """
169
     def _before(self, *args, **kw):
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
     @property
195
     @property
176
     def _std_url(self):
196
     def _std_url(self):
270
             item = api.get_one(int(item_id), self._item_type, workspace)
290
             item = api.get_one(int(item_id), self._item_type, workspace)
271
             with new_revision(item):
291
             with new_revision(item):
272
                 api.update_content(item, label, content)
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
                 api.save(item, ActionDescription.REVISION)
299
                 api.save(item, ActionDescription.REVISION)
274
 
300
 
275
             msg = _('{} updated').format(self._item_type_label)
301
             msg = _('{} updated').format(self._item_type_label)

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

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 import uuid
2
 import uuid
3
 
3
 
4
+import pytz
4
 from tracim import model  as pm
5
 from tracim import model  as pm
5
 
6
 
6
 from sprox.tablebase import TableBase
7
 from sprox.tablebase import TableBase
10
 import tg
11
 import tg
11
 from tg import predicates
12
 from tg import predicates
12
 from tg import tmpl_context
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
 from sprox.widgets import PropertyMultipleSelectField
16
 from sprox.widgets import PropertyMultipleSelectField
16
 from sprox._compat import unicode_text
17
 from sprox._compat import unicode_text
27
 from tracim.lib.email import get_email_manager
28
 from tracim.lib.email import get_email_manager
28
 from tracim.lib.user import UserApi
29
 from tracim.lib.user import UserApi
29
 from tracim.lib.group import GroupApi
30
 from tracim.lib.group import GroupApi
30
-from tracim.lib.user import UserStaticApi
31
 from tracim.lib.userworkspace import RoleApi
31
 from tracim.lib.userworkspace import RoleApi
32
 from tracim.lib.workspace import WorkspaceApi
32
 from tracim.lib.workspace import WorkspaceApi
33
 
33
 
210
             tg.redirect(next_url)
210
             tg.redirect(next_url)
211
 
211
 
212
         user.password = new_password1
212
         user.password = new_password1
213
+        user.update_webdav_digest_auth(new_password1)
213
         pm.DBSession.flush()
214
         pm.DBSession.flush()
214
 
215
 
215
         tg.flash(_('The password has been changed'), CST.STATUS_OK)
216
         tg.flash(_('The password has been changed'), CST.STATUS_OK)
312
             is_tracim_manager = False
313
             is_tracim_manager = False
313
             is_tracim_admin = False
314
             is_tracim_admin = False
314
 
315
 
315
-
316
         api = UserApi(current_user)
316
         api = UserApi(current_user)
317
 
317
 
318
         if api.user_with_email_exists(email):
318
         if api.user_with_email_exists(email):
347
             email_manager = get_email_manager()
347
             email_manager = get_email_manager()
348
             email_manager.notify_created_account(user, password=password)
348
             email_manager.notify_created_account(user, password=password)
349
 
349
 
350
+        api.execute_created_user_actions(user)
350
         tg.flash(_('User {} created.').format(user.get_display_name()), CST.STATUS_OK)
351
         tg.flash(_('User {} created.').format(user.get_display_name()), CST.STATUS_OK)
351
         tg.redirect(self.url())
352
         tg.redirect(self.url())
352
 
353
 
353
-
354
     @tg.expose('tracim.templates.admin.user_getone')
354
     @tg.expose('tracim.templates.admin.user_getone')
355
     def get_one(self, user_id):
355
     def get_one(self, user_id):
356
         current_user = tmpl_context.current_user
356
         current_user = tmpl_context.current_user
380
         user = api.get_one(id)
380
         user = api.get_one(id)
381
 
381
 
382
         dictified_user = Context(CTX.USER).toDict(user, 'user')
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
     @tg.require(predicates.in_group(Group.TIM_MANAGER_GROUPNAME))
388
     @tg.require(predicates.in_group(Group.TIM_MANAGER_GROUPNAME))
386
     @tg.expose()
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
         api = UserApi(tmpl_context.current_user)
391
         api = UserApi(tmpl_context.current_user)
389
 
392
 
390
         user = api.get_one(int(user_id))
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
         tg.flash(_('User {} updated.').format(user.get_display_name()), CST.STATUS_OK)
396
         tg.flash(_('User {} updated.').format(user.get_display_name()), CST.STATUS_OK)
394
         if next_url:
397
         if next_url:

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

5
 from tg.i18n import ugettext as _
5
 from tg.i18n import ugettext as _
6
 
6
 
7
 from tracim.controllers import TIMRestController
7
 from tracim.controllers import TIMRestController
8
-from tracim.controllers import TIMRestPathContextSetup
9
 
8
 
10
 
9
 
11
 from tracim.lib import CST
10
 from tracim.lib import CST
12
 from tracim.lib.base import BaseController
11
 from tracim.lib.base import BaseController
13
 from tracim.lib.helpers import on_off_to_boolean
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
 from tracim.lib.user import UserApi
15
 from tracim.lib.user import UserApi
15
 from tracim.lib.userworkspace import RoleApi
16
 from tracim.lib.userworkspace import RoleApi
16
-from tracim.lib.content import ContentApi
17
 from tracim.lib.workspace import WorkspaceApi
17
 from tracim.lib.workspace import WorkspaceApi
18
-from tracim.model import DBSession
19
 
18
 
20
 from tracim.model.auth import Group
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
 from tracim.model.data import UserRoleInWorkspace
20
 from tracim.model.data import UserRoleInWorkspace
26
 
21
 
27
 from tracim.model.serializers import Context, CTX, DictLikeClass
22
 from tracim.model.serializers import Context, CTX, DictLikeClass
28
 
23
 
29
-from tracim.controllers.content import UserWorkspaceFolderRestController
30
-
31
-
32
-
33
 
24
 
34
 class RoleInWorkspaceRestController(TIMRestController, BaseController):
25
 class RoleInWorkspaceRestController(TIMRestController, BaseController):
35
 
26
 
150
      responsible / advanced contributor. / contributor / reader
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
     @property
150
     @property
154
     def _base_url(self):
151
     def _base_url(self):
155
         return '/admin/workspaces'
152
         return '/admin/workspaces'
198
         workspace_api_controller = WorkspaceApi(user)
195
         workspace_api_controller = WorkspaceApi(user)
199
         calendar_enabled = on_off_to_boolean(calendar_enabled)
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
         tg.flash(_('{} workspace created.').format(workspace.label), CST.STATUS_OK)
209
         tg.flash(_('{} workspace created.').format(workspace.label), CST.STATUS_OK)
206
         tg.redirect(self.url())
210
         tg.redirect(self.url())
221
         user = tmpl_context.current_user
225
         user = tmpl_context.current_user
222
         workspace_api_controller = WorkspaceApi(user)
226
         workspace_api_controller = WorkspaceApi(user)
223
         calendar_enabled = on_off_to_boolean(calendar_enabled)
227
         calendar_enabled = on_off_to_boolean(calendar_enabled)
224
-
225
         workspace = workspace_api_controller.get_one(id)
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
         workspace.label = name
235
         workspace.label = name
227
         workspace.description = description
236
         workspace.description = description
228
         workspace.calendar_enabled = calendar_enabled
237
         workspace.calendar_enabled = calendar_enabled
229
         workspace_api_controller.save(workspace)
238
         workspace_api_controller.save(workspace)
230
 
239
 
240
+        if calendar_enabled:
241
+            workspace_api_controller.ensure_calendar_exist(workspace)
242
+
231
         tg.flash(_('{} workspace updated.').format(workspace.label), CST.STATUS_OK)
243
         tg.flash(_('{} workspace updated.').format(workspace.label), CST.STATUS_OK)
232
         tg.redirect(self.url(workspace.workspace_id))
244
         tg.redirect(self.url(workspace.workspace_id))
233
         return
245
         return

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

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

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

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
-import sys
3
-
4
 __author__ = 'damien'
2
 __author__ = 'damien'
5
 
3
 
6
-from cgi import FieldStorage
4
+import sys
5
+import traceback
7
 
6
 
7
+from cgi import FieldStorage
8
 import tg
8
 import tg
9
 from tg import tmpl_context
9
 from tg import tmpl_context
10
 from tg.i18n import ugettext as _
10
 from tg.i18n import ugettext as _
11
 from tg.predicates import not_anonymous
11
 from tg.predicates import not_anonymous
12
 
12
 
13
-import traceback
14
-
15
 from tracim.controllers import TIMRestController
13
 from tracim.controllers import TIMRestController
16
 from tracim.controllers import TIMRestPathContextSetup
14
 from tracim.controllers import TIMRestPathContextSetup
17
 from tracim.controllers import TIMRestControllerWithBreadcrumb
15
 from tracim.controllers import TIMRestControllerWithBreadcrumb
18
 from tracim.controllers import TIMWorkspaceContentRestController
16
 from tracim.controllers import TIMWorkspaceContentRestController
19
-
20
 from tracim.lib import CST
17
 from tracim.lib import CST
21
 from tracim.lib.base import BaseController
18
 from tracim.lib.base import BaseController
22
 from tracim.lib.base import logger
19
 from tracim.lib.base import logger
23
 from tracim.lib.utils import SameValueError
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
 from tracim.lib.content import ContentApi
23
 from tracim.lib.content import ContentApi
25
 from tracim.lib.helpers import convert_id_into_instances
24
 from tracim.lib.helpers import convert_id_into_instances
26
 from tracim.lib.predicates import current_user_is_reader
25
 from tracim.lib.predicates import current_user_is_reader
27
 from tracim.lib.predicates import current_user_is_contributor
26
 from tracim.lib.predicates import current_user_is_contributor
28
 from tracim.lib.predicates import current_user_is_content_manager
27
 from tracim.lib.predicates import current_user_is_content_manager
29
 from tracim.lib.predicates import require_current_user_is_owner
28
 from tracim.lib.predicates import require_current_user_is_owner
30
-
31
 from tracim.model.serializers import Context, CTX, DictLikeClass
29
 from tracim.model.serializers import Context, CTX, DictLikeClass
32
 from tracim.model.data import ActionDescription
30
 from tracim.model.data import ActionDescription
33
 from tracim.model import new_revision
31
 from tracim.model import new_revision
32
+from tracim.model import DBSession
34
 from tracim.model.data import Content
33
 from tracim.model.data import Content
35
 from tracim.model.data import ContentType
34
 from tracim.model.data import ContentType
36
 from tracim.model.data import UserRoleInWorkspace
35
 from tracim.model.data import UserRoleInWorkspace
37
 from tracim.model.data import Workspace
36
 from tracim.model.data import Workspace
37
+from tracim.lib.integrity import render_invalid_integrity_chosen_path
38
+
38
 
39
 
39
 class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
40
 class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
40
 
41
 
154
         return tg.url('/workspaces/{}/folders/{}')
155
         return tg.url('/workspaces/{}/folders/{}')
155
 
156
 
156
     @property
157
     @property
158
+    def _err_url(self):
159
+        return tg.url('/dashboard/workspaces/{}/folders/{}/file/{}')
160
+
161
+    @property
157
     def _item_type(self):
162
     def _item_type(self):
158
         return ContentType.File
163
         return ContentType.File
159
 
164
 
175
         file_id = int(file_id)
180
         file_id = int(file_id)
176
         user = tmpl_context.current_user
181
         user = tmpl_context.current_user
177
         workspace = tmpl_context.workspace
182
         workspace = tmpl_context.workspace
178
-        workspace_id = tmpl_context.workspace_id
179
 
183
 
180
         current_user_content = Context(CTX.CURRENT_USER,
184
         current_user_content = Context(CTX.CURRENT_USER,
181
                                        current_user=user).toDict(user)
185
                                        current_user=user).toDict(user)
182
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
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
         if revision_id:
193
         if revision_id:
186
             file = content_api.get_one_from_revision(file_id,  self._item_type, workspace, revision_id)
194
             file = content_api.get_one_from_revision(file_id,  self._item_type, workspace, revision_id)
187
         else:
195
         else:
231
             tg.response.headers['Content-type'] = str(revision_to_send.file_mimetype)
239
             tg.response.headers['Content-type'] = str(revision_to_send.file_mimetype)
232
 
240
 
233
         tg.response.headers['Content-Type'] = content_type
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
         return revision_to_send.file_content
245
         return revision_to_send.file_content
236
 
246
 
237
 
247
 
257
     def post(self, label='', file_data=None):
267
     def post(self, label='', file_data=None):
258
         # TODO - SECURE THIS
268
         # TODO - SECURE THIS
259
         workspace = tmpl_context.workspace
269
         workspace = tmpl_context.workspace
270
+        folder = tmpl_context.folder
260
 
271
 
261
         api = ContentApi(tmpl_context.current_user)
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
         api.save(file, ActionDescription.CREATION)
282
         api.save(file, ActionDescription.CREATION)
266
 
283
 
267
         tg.flash(_('File created'), CST.STATUS_OK)
284
         tg.flash(_('File created'), CST.STATUS_OK)
270
 
287
 
271
     @tg.require(current_user_is_contributor())
288
     @tg.require(current_user_is_contributor())
272
     @tg.expose()
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
         # TODO - SECURE THIS
291
         # TODO - SECURE THIS
275
         workspace = tmpl_context.workspace
292
         workspace = tmpl_context.workspace
276
 
293
 
277
         try:
294
         try:
278
-            item_saved = False
279
             api = ContentApi(tmpl_context.current_user)
295
             api = ContentApi(tmpl_context.current_user)
280
             item = api.get_one(int(item_id), self._item_type, workspace)
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
             # TODO - D.A. - 2015-03-19
304
             # TODO - D.A. - 2015-03-19
283
             # refactor this method in order to make code easier to understand
305
             # refactor this method in order to make code easier to understand
284
 
306
 
285
             with new_revision(item):
307
             with new_revision(item):
286
 
308
 
287
-                if comment and label:
309
+                if (comment and label) or (not comment and label_changed):
288
                     updated_item = api.update_content(
310
                     updated_item = api.update_content(
289
                         item, label if label else item.label,
311
                         item, label if label else item.label,
290
                         comment if comment else ''
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
                     api.save(updated_item, ActionDescription.EDITION)
323
                     api.save(updated_item, ActionDescription.EDITION)
293
 
324
 
294
                     # This case is the default "file title and description update"
325
                     # This case is the default "file title and description update"
315
 
346
 
316
                     if isinstance(file_data, FieldStorage):
347
                     if isinstance(file_data, FieldStorage):
317
                         api.update_file_data(item, file_data.filename, file_data.type, file_data.file.read())
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
                         api.save(item, ActionDescription.REVISION)
359
                         api.save(item, ActionDescription.REVISION)
319
 
360
 
320
             msg = _('{} updated').format(self._item_type_label)
361
             msg = _('{} updated').format(self._item_type_label)
375
         current_user_content = Context(CTX.CURRENT_USER).toDict(user)
416
         current_user_content = Context(CTX.CURRENT_USER).toDict(user)
376
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
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
         if revision_id:
424
         if revision_id:
380
             page = content_api.get_one_from_revision(page_id, ContentType.Page, workspace, revision_id)
425
             page = content_api.get_one_from_revision(page_id, ContentType.Page, workspace, revision_id)
381
         else:
426
         else:
413
 
458
 
414
         api = ContentApi(tmpl_context.current_user)
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
         api.save(page, ActionDescription.CREATION, do_notify=True)
470
         api.save(page, ActionDescription.CREATION, do_notify=True)
419
 
471
 
420
         tg.flash(_('Page created'), CST.STATUS_OK)
472
         tg.flash(_('Page created'), CST.STATUS_OK)
433
             item = api.get_one(int(item_id), self._item_type, workspace)
485
             item = api.get_one(int(item_id), self._item_type, workspace)
434
             with new_revision(item):
486
             with new_revision(item):
435
                 api.update_content(item, label, content)
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
                 api.save(item, ActionDescription.REVISION)
494
                 api.save(item, ActionDescription.REVISION)
437
 
495
 
438
             msg = _('{} updated').format(self._item_type_label)
496
             msg = _('{} updated').format(self._item_type_label)
509
 
567
 
510
         api = ContentApi(tmpl_context.current_user)
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
         api.save(comment, ActionDescription.COMMENT, do_notify=False)
584
         api.save(comment, ActionDescription.COMMENT, do_notify=False)
520
         api.do_notify(thread)
585
         api.do_notify(thread)
521
 
586
 
525
 
590
 
526
     @tg.require(current_user_is_reader())
591
     @tg.require(current_user_is_reader())
527
     @tg.expose('tracim.templates.thread.getone')
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
         thread_id = int(thread_id)
601
         thread_id = int(thread_id)
530
         user = tmpl_context.current_user
602
         user = tmpl_context.current_user
531
         workspace = tmpl_context.workspace
603
         workspace = tmpl_context.workspace
533
         current_user_content = Context(CTX.CURRENT_USER).toDict(user)
605
         current_user_content = Context(CTX.CURRENT_USER).toDict(user)
534
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
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
         thread = content_api.get_one(thread_id, ContentType.Thread, workspace)
613
         thread = content_api.get_one(thread_id, ContentType.Thread, workspace)
538
 
614
 
539
         fake_api_breadcrumb = self.get_breadcrumb(thread_id)
615
         fake_api_breadcrumb = self.get_breadcrumb(thread_id)
541
         fake_api = Context(CTX.FOLDER).toDict(fake_api_content)
617
         fake_api = Context(CTX.FOLDER).toDict(fake_api_content)
542
 
618
 
543
         dictified_thread = Context(CTX.THREAD).toDict(thread, 'thread')
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
 
766
 
683
     @tg.require(current_user_is_reader())
767
     @tg.require(current_user_is_reader())
684
     @tg.expose('tracim.templates.folder.getone')
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
         folder_id = int(folder_id)
779
         folder_id = int(folder_id)
687
         user = tmpl_context.current_user
780
         user = tmpl_context.current_user
688
         workspace = tmpl_context.workspace
781
         workspace = tmpl_context.workspace
689
-        workspace_id = tmpl_context.workspace_id
690
 
782
 
691
         current_user_content = Context(CTX.CURRENT_USER,
783
         current_user_content = Context(CTX.CURRENT_USER,
692
                                        current_user=user).toDict(user)
784
                                        current_user=user).toDict(user)
693
         current_user_content.roles.sort(key=lambda role: role.workspace.name)
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
         fake_api_breadcrumb = self.get_breadcrumb(folder_id)
799
         fake_api_breadcrumb = self.get_breadcrumb(folder_id)
699
         fake_api_subfolders = self.get_all_fake(workspace, folder.content_id).result
800
         fake_api_subfolders = self.get_all_fake(workspace, folder.content_id).result
712
 
813
 
713
         fake_api = Context(CTX.FOLDER).toDict(fake_api_content)
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
         fake_api.content_types = Context(CTX.DEFAULT).toDict(
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
         dictified_folder = Context(CTX.FOLDER).toDict(folder, 'folder')
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
     def get_all_fake(self, context_workspace: Workspace, parent_id=None):
841
     def get_all_fake(self, context_workspace: Workspace, parent_id=None):
736
         """
849
         """
737
         workspace = context_workspace
850
         workspace = context_workspace
738
         content_api = ContentApi(tmpl_context.current_user)
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
         folders = content_api.get_child_folders(parent_folder, workspace)
854
         folders = content_api.get_child_folders(parent_folder, workspace)
741
 
855
 
742
         folders = Context(CTX.FOLDERS).toDict(folders)
856
         folders = Context(CTX.FOLDERS).toDict(folders)
759
             parent = None
873
             parent = None
760
             if parent_id:
874
             if parent_id:
761
                 parent = api.get_one(int(parent_id), ContentType.Folder, workspace)
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
             api.save(folder)
893
             api.save(folder)
772
 
894
 
773
             tg.flash(_('Folder created'), CST.STATUS_OK)
895
             tg.flash(_('Folder created'), CST.STATUS_OK)
813
                     # TODO - D.A. - 2015-05-25 - Allow to set folder description
935
                     # TODO - D.A. - 2015-05-25 - Allow to set folder description
814
                     api.update_content(folder, label, folder.description)
936
                     api.update_content(folder, label, folder.description)
815
                 api.set_allowed_content(folder, subcontent)
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
                 api.save(folder)
944
                 api.save(folder)
817
 
945
 
818
             tg.flash(_('Folder updated'), CST.STATUS_OK)
946
             tg.flash(_('Folder updated'), CST.STATUS_OK)
941
             back_url = self._parent_url.format(item.workspace_id, item.parent_id)
1069
             back_url = self._parent_url.format(item.workspace_id, item.parent_id)
942
             msg = _('{} not un-deleted: {}').format(self._item_type_label, str(e))
1070
             msg = _('{} not un-deleted: {}').format(self._item_type_label, str(e))
943
             tg.flash(msg, CST.STATUS_ERROR)
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
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
-
2
+import tg
3
 from tg import expose
3
 from tg import expose
4
 from tg import flash
4
 from tg import flash
5
 from tg import lurl
5
 from tg import lurl
11
 from tg import url
11
 from tg import url
12
 
12
 
13
 from tg.i18n import ugettext as _
13
 from tg.i18n import ugettext as _
14
+from tracim.controllers.api import APIController
14
 
15
 
15
 from tracim.lib import CST
16
 from tracim.lib import CST
16
 from tracim.lib.base import logger
17
 from tracim.lib.base import logger
17
-from tracim.lib.user import UserStaticApi
18
+from tracim.lib.user import CurrentUserGetterApi
18
 from tracim.lib.content import ContentApi
19
 from tracim.lib.content import ContentApi
19
 
20
 
20
 from tracim.controllers import StandardController
21
 from tracim.controllers import StandardController
60
     workspaces = UserWorkspaceRestController()
61
     workspaces = UserWorkspaceRestController()
61
     user = UserRestController()
62
     user = UserRestController()
62
 
63
 
64
+    # api
65
+    api = APIController()
66
+
63
     def _render_response(self, tgl, controller, response):
67
     def _render_response(self, tgl, controller, response):
64
         replace_reset_password_templates(controller.decoration.engines)
68
         replace_reset_password_templates(controller.decoration.engines)
65
         return super()._render_response(tgl, controller, response)
69
         return super()._render_response(tgl, controller, response)
111
             redirect(url('/login'),
115
             redirect(url('/login'),
112
                 params=dict(came_from=came_from, __logins=login_counter))
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
         flash(_('Welcome back, %s!') % user.get_display_name())
120
         flash(_('Welcome back, %s!') % user.get_display_name())
117
         redirect(came_from)
121
         redirect(came_from)

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

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
+import pytz
3
+from sqlalchemy.orm.exc import NoResultFound
4
+from tracim.lib import CST
2
 from webob.exc import HTTPForbidden
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
 import tg
6
 import tg
11
 from tg import tmpl_context
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
 from tracim.controllers import TIMRestController
10
 from tracim.controllers import TIMRestController
21
-from tracim.lib import helpers as h
22
 from tracim.lib.user import UserApi
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
 from tracim.lib.workspace import WorkspaceApi
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
 class UserWorkspaceRestController(TIMRestController):
19
 class UserWorkspaceRestController(TIMRestController):
123
             tg.redirect(redirect_url)
109
             tg.redirect(redirect_url)
124
 
110
 
125
         current_user.password = new_password1
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
         pm.DBSession.flush()
113
         pm.DBSession.flush()
128
 
114
 
129
         tg.flash(_('Your password has been changed'))
115
         tg.flash(_('Your password has been changed'))
174
 
160
 
175
         dictified_user = Context(CTX.USER).toDict(current_user, 'user')
161
         dictified_user = Context(CTX.USER).toDict(current_user, 'user')
176
         fake_api = DictLikeClass(next_url=next_url)
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
     @tg.expose('tracim.templates.workspace.edit')
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
         user_id = tmpl_context.current_user.user_id
171
         user_id = tmpl_context.current_user.user_id
182
         current_user = tmpl_context.current_user
172
         current_user = tmpl_context.current_user
173
+        user_api = UserApi(current_user)
183
         assert user_id==current_user.user_id
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
         # Only keep allowed field update
188
         # Only keep allowed field update
186
         updated_fields = self._clean_update_fields({
189
         updated_fields = self._clean_update_fields({
187
             'name': name,
190
             'name': name,
188
-            'email': email
191
+            'email': email,
192
+            'timezone': timezone,
189
         })
193
         })
190
 
194
 
191
         api = UserApi(tmpl_context.current_user)
195
         api = UserApi(tmpl_context.current_user)
192
         api.update(current_user, do_save=True, **updated_fields)
196
         api.update(current_user, do_save=True, **updated_fields)
193
         tg.flash(_('profile updated.'))
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
     def _clean_update_fields(self, fields: dict):
200
     def _clean_update_fields(self, fields: dict):
199
         """
201
         """

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

12
 
12
 
13
 from tracim.lib.helpers import convert_id_into_instances
13
 from tracim.lib.helpers import convert_id_into_instances
14
 from tracim.lib.content import ContentApi
14
 from tracim.lib.content import ContentApi
15
+from tracim.lib.utils import str_as_bool
15
 from tracim.lib.workspace import WorkspaceApi
16
 from tracim.lib.workspace import WorkspaceApi
16
 
17
 
17
 from tracim.model.data import NodeTreeItem
18
 from tracim.model.data import NodeTreeItem
42
         tg.redirect(tg.url('/home'))
43
         tg.redirect(tg.url('/home'))
43
 
44
 
44
     @tg.expose('tracim.templates.workspace.getone')
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
         user = tmpl_context.current_user
56
         user = tmpl_context.current_user
47
 
57
 
48
         current_user_content = Context(CTX.CURRENT_USER).toDict(user)
58
         current_user_content = Context(CTX.CURRENT_USER).toDict(user)
60
         )
70
         )
61
 
71
 
62
         fake_api.sub_items = Context(CTX.FOLDER_CONTENT_LIST).toDict(
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
         dictified_workspace = Context(CTX.WORKSPACE).toDict(workspace, 'workspace')
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
     @tg.expose('json')
92
     @tg.expose('json')
72
     def treeview_root(self, id='#',
93
     def treeview_root(self, id='#',

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

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
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
-from tg.i18n import lazy_ugettext as l_
2
+
3
 
3
 
4
 class NotFoundError(Exception):
4
 class NotFoundError(Exception):
5
     pass
5
     pass

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

3
 from markupsafe import escape_silent as escape
3
 from markupsafe import escape_silent as escape
4
 
4
 
5
 import tg
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
 from tg.flash import TGFlash
7
 from tg.flash import TGFlash
8
 
8
 
9
 """The application's Globals object"""
9
 """The application's Globals object"""
23
         pass
23
         pass
24
 
24
 
25
     VERSION_NUMBER = '1.0.3'
25
     VERSION_NUMBER = '1.0.3'
26
-    LONG_DATE_FORMAT = '%A, the %d of %B %Y at %H:%M'
27
     SHORT_DATE_FORMAT = l_('%B %d at %I:%M%p')
26
     SHORT_DATE_FORMAT = l_('%B %d at %I:%M%p')
28
 
27
 
29
 
28
 

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

1
+import caldav
1
 import os
2
 import os
2
 
3
 
3
 import re
4
 import re
4
 import transaction
5
 import transaction
6
+from caldav.lib.error import PutError
5
 
7
 
6
 from icalendar import Event as iCalendarEvent
8
 from icalendar import Event as iCalendarEvent
7
 from sqlalchemy.orm.exc import NoResultFound
9
 from sqlalchemy.orm.exc import NoResultFound
10
+from tg import tmpl_context
11
+from tg.i18n import ugettext as _
8
 
12
 
9
 from tracim.lib.content import ContentApi
13
 from tracim.lib.content import ContentApi
10
 from tracim.lib.exceptions import UnknownCalendarType
14
 from tracim.lib.exceptions import UnknownCalendarType
37
 
41
 
38
 class CalendarManager(object):
42
 class CalendarManager(object):
39
     @classmethod
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
         from tracim.config.app_cfg import CFG
53
         from tracim.config.app_cfg import CFG
42
         cfg = CFG.get_instance()
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
     @classmethod
61
     @classmethod
46
     def get_user_base_url(cls):
62
     def get_user_base_url(cls):
55
         return os.path.join(cfg.RADICALE_CLIENT_BASE_URL_TEMPLATE, 'workspace/')
71
         return os.path.join(cfg.RADICALE_CLIENT_BASE_URL_TEMPLATE, 'workspace/')
56
 
72
 
57
     @classmethod
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
         user_path = CALENDAR_USER_URL_TEMPLATE.format(id=str(user_id))
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
     @classmethod
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
         workspace_path = CALENDAR_WORKSPACE_URL_TEMPLATE.format(
91
         workspace_path = CALENDAR_WORKSPACE_URL_TEMPLATE.format(
65
             id=str(workspace_id)
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
     def __init__(self, user: User):
99
     def __init__(self, user: User):
70
         self._user = user
100
         self._user = user
284
     def get_workspace_readable_calendars_urls_for_user(cls, user: User)\
314
     def get_workspace_readable_calendars_urls_for_user(cls, user: User)\
285
             -> [str]:
315
             -> [str]:
286
         calendar_urls = []
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
         workspace_api = WorkspaceApi(user)
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
             if workspace.calendar_enabled:
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
     def is_discovery_path(self, path: str) -> bool:
336
     def is_discovery_path(self, path: str) -> bool:
297
         """
337
         """
301
         :return: True if given collection path is an discover path
341
         :return: True if given collection path is an discover path
302
         """
342
         """
303
         return path in ('user', 'workspace')
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
 # -*- coding: utf-8 -*-
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
 __author__ = 'damien'
10
 __author__ = 'damien'
3
 
11
 
4
 import datetime
12
 import datetime
94
         self._force_show_all_types = force_show_all_types
102
         self._force_show_all_types = force_show_all_types
95
         self._disable_user_workspaces_filter = disable_user_workspaces_filter
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
     @classmethod
133
     @classmethod
98
     def get_revision_join(cls):
134
     def get_revision_join(cls):
99
         """
135
         """
184
         if workspace:
220
         if workspace:
185
             result = result.filter(Content.workspace_id==workspace.workspace_id)
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
         if self._user and not self._disable_user_workspaces_filter:
225
         if self._user and not self._disable_user_workspaces_filter:
188
             user = DBSession.query(User).get(self._user_id)
226
             user = DBSession.query(User).get(self._user_id)
189
             # Filter according to user workspaces
227
             # Filter according to user workspaces
190
             workspace_ids = [r.workspace_id for r in user.roles \
228
             workspace_ids = [r.workspace_id for r in user.roles \
191
                              if r.role>=UserRoleInWorkspace.READER]
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
         return result
239
         return result
195
 
240
 
275
 
320
 
276
         return result
321
         return result
277
 
322
 
323
+    def get_base_query(self, workspace: Workspace) -> Query:
324
+        return self._base_query(workspace)
325
+
278
     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]:
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
         This method returns child items (folders or items) for left bar treeview.
328
         This method returns child items (folders or items) for left bar treeview.
321
 
369
 
322
     def create(self, content_type: str, workspace: Workspace, parent: Content=None, label:str ='', do_save=False, is_temporary: bool=False) -> Content:
370
     def create(self, content_type: str, workspace: Workspace, parent: Content=None, label:str ='', do_save=False, is_temporary: bool=False) -> Content:
323
         assert content_type in ContentType.allowed_types()
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
         content = Content()
376
         content = Content()
325
         content.owner = self._user
377
         content.owner = self._user
326
         content.parent = parent
378
         content.parent = parent
330
         content.is_temporary = is_temporary
382
         content.is_temporary = is_temporary
331
         content.revision_type = ActionDescription.CREATION
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
         if do_save:
391
         if do_save:
334
             DBSession.add(content)
392
             DBSession.add(content)
335
             self.save(content, ActionDescription.CREATION)
393
             self.save(content, ActionDescription.CREATION)
394
 
452
 
395
         return revision
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
         This method let us request the database to obtain a Content with its name and parent
461
         This method let us request the database to obtain a Content with its name and parent
401
         :param content_label: Either the content's label or the content's filename if the label is None
462
         :param content_label: Either the content's label or the content's filename if the label is None
403
         :param workspace: The workspace's content
464
         :param workspace: The workspace's content
404
         :return The corresponding Content
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
         parent_id = content_parent.content_id if content_parent else None
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
     def get_all(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> [Content]:
642
     def get_all(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> [Content]:
454
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
643
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
467
 
656
 
468
         return resultset.all()
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
     # TODO find an other name to filter on is_deleted / is_archived
677
     # TODO find an other name to filter on is_deleted / is_archived
471
     def get_all_with_filter(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> [Content]:
678
     def get_all_with_filter(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> [Content]:
472
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
679
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
541
             .filter(~ContentRevisionRO.revision_id.in_(read_revision_ids)) \
748
             .filter(~ContentRevisionRO.revision_id.in_(read_revision_ids)) \
542
             .subquery()
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
         not_read_contents = self._base_query(workspace) \
759
         not_read_contents = self._base_query(workspace) \
548
             .filter(Content.content_id.in_(not_read_content_ids)) \
760
             .filter(Content.content_id.in_(not_read_content_ids)) \
868
             )
1080
             )
869
         )
1081
         )
870
         return query.one()
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
 import signal
4
 import signal
5
 
5
 
6
 import collections
6
 import collections
7
-import transaction
8
 
7
 
9
 from radicale import Application as RadicaleApplication
8
 from radicale import Application as RadicaleApplication
10
 from radicale import HTTPServer as BaseRadicaleHTTPServer
9
 from radicale import HTTPServer as BaseRadicaleHTTPServer
11
 from radicale import HTTPSServer as BaseRadicaleHTTPSServer
10
 from radicale import HTTPSServer as BaseRadicaleHTTPSServer
12
 from radicale import RequestHandler as RadicaleRequestHandler
11
 from radicale import RequestHandler as RadicaleRequestHandler
13
 from radicale import config as radicale_config
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
 from tracim.lib.base import logger
17
 from tracim.lib.base import logger
16
 from tracim.lib.exceptions import AlreadyRunningDaemon
18
 from tracim.lib.exceptions import AlreadyRunningDaemon
21
     def __init__(self):
23
     def __init__(self):
22
         self._running_daemons = {}
24
         self._running_daemons = {}
23
         add_signal_handler(signal.SIGTERM, self.stop_all)
25
         add_signal_handler(signal.SIGTERM, self.stop_all)
24
-        add_signal_handler(signal.SIGINT, self.stop_all)
25
 
26
 
26
     def run(self, name: str, daemon_class: object, **kwargs) -> None:
27
     def run(self, name: str, daemon_class: object, **kwargs) -> None:
27
         """
28
         """
145
         raise NotImplementedError()
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
 class RadicaleHTTPSServer(TracimSocketServerMixin, BaseRadicaleHTTPSServer):
186
 class RadicaleHTTPSServer(TracimSocketServerMixin, BaseRadicaleHTTPSServer):
149
     pass
187
     pass
150
 
188
 
192
         radicale_config.set('storage', 'filesystem_folder', fs_path)
230
         radicale_config.set('storage', 'filesystem_folder', fs_path)
193
 
231
 
194
         radicale_config.set('server', 'realm', realm_message)
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
         try:
239
         try:
197
             radicale_config.add_section('headers')
240
             radicale_config.add_section('headers')
255
 from inspect import isfunction
298
 from inspect import isfunction
256
 import traceback
299
 import traceback
257
 
300
 
301
+from wsgidav.server.cherrypy import wsgiserver
302
+from wsgidav.server.cherrypy.wsgiserver.wsgiserver3 import CherryPyWSGIServer
303
+
258
 DEFAULT_CONFIG_FILE = "wsgidav.conf"
304
 DEFAULT_CONFIG_FILE = "wsgidav.conf"
259
 PYTHON_VERSION = "%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])
305
 PYTHON_VERSION = "%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])
260
 
306
 
284
             print(
330
             print(
285
                 "WARNING: Could not import lxml: using xml instead (slower). Consider installing lxml from http://codespeak.net/lxml/.")
331
                 "WARNING: Could not import lxml: using xml instead (slower). Consider installing lxml from http://codespeak.net/lxml/.")
286
         from wsgidav.dir_browser import WsgiDavDirBrowser
332
         from wsgidav.dir_browser import WsgiDavDirBrowser
287
-        from wsgidav.debug_filter import WsgiDavDebugFilter
288
         from tracim.lib.webdav.tracim_http_authenticator import TracimHTTPAuthenticator
333
         from tracim.lib.webdav.tracim_http_authenticator import TracimHTTPAuthenticator
289
         from wsgidav.error_printer import ErrorPrinter
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
         config['provider_mapping'] = {
344
         config['provider_mapping'] = {
294
             config['root_path']: Provider(
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
                 manage_locks=config['manager_locks']
350
                 manage_locks=config['manager_locks']
299
             )
351
             )
300
         }
352
         }
335
         app = WsgiDAVApp(self.config)
387
         app = WsgiDAVApp(self.config)
336
 
388
 
337
         # Try running WsgiDAV inside the following external servers:
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
     def stop(self):
414
     def stop(self):
373
         self._server.stop()
415
         self._server.stop()

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

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
-
3
-from email.mime.multipart import MIMEMultipart
4
 import smtplib
2
 import smtplib
3
+from email.message import Message
4
+from email.mime.multipart import MIMEMultipart
5
 from email.mime.text import MIMEText
5
 from email.mime.text import MIMEText
6
 
6
 
7
+import typing
7
 from mako.template import Template
8
 from mako.template import Template
8
-from tgext.asyncjob import asyncjob_perform
9
+from redis import Redis
10
+from rq import Queue
9
 from tg.i18n import ugettext as _
11
 from tg.i18n import ugettext as _
10
 
12
 
11
 from tracim.lib.base import logger
13
 from tracim.lib.base import logger
12
 from tracim.model import User
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
 class SmtpConfiguration(object):
48
 class SmtpConfiguration(object):
16
     """
49
     """
17
     Container class for SMTP configuration used in Tracim
50
     Container class for SMTP configuration used in Tracim
103
             )
136
             )
104
         message = MIMEMultipart('alternative')
137
         message = MIMEMultipart('alternative')
105
         message['Subject'] = subject
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
         message['To'] = user.email
143
         message['To'] = user.email
108
 
144
 
109
         text_template_file_path = self._global_config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT  # nopep8
145
         text_template_file_path = self._global_config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT  # nopep8
136
         message.attach(part1)
172
         message.attach(part1)
137
         message.attach(part2)
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
     def _render(self, mako_template_filepath: str, context: dict):
177
     def _render(self, mako_template_filepath: str, context: dict):
146
         """
178
         """

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

6
 
6
 
7
 import datetime
7
 import datetime
8
 
8
 
9
+import pytz
9
 import slugify
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
 from markupsafe import Markup
13
 from markupsafe import Markup
12
 
14
 
13
 import tg
15
 import tg
16
+from tg import tmpl_context
14
 from tg.i18n import ugettext as _
17
 from tg.i18n import ugettext as _
15
 
18
 
16
 from tracim.lib import app_globals as plag
19
 from tracim.lib import app_globals as plag
20
 from tracim.lib.content import ContentApi
23
 from tracim.lib.content import ContentApi
21
 from tracim.lib.userworkspace import RoleApi
24
 from tracim.lib.userworkspace import RoleApi
22
 from tracim.lib.workspace import WorkspaceApi
25
 from tracim.lib.workspace import WorkspaceApi
23
-from tracim.model import User
24
 
26
 
25
 from tracim.model.data import ContentStatus
27
 from tracim.model.data import ContentStatus
26
 from tracim.model.data import Content
28
 from tracim.model.data import Content
28
 from tracim.model.data import UserRoleInWorkspace
30
 from tracim.model.data import UserRoleInWorkspace
29
 from tracim.model.data import Workspace
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
 def date_time_in_long_format(datetime_object, format=''):
66
 def date_time_in_long_format(datetime_object, format=''):
32
 
67
 
33
     current_locale = tg.i18n.get_lang()[0]
68
     current_locale = tg.i18n.get_lang()[0]
63
   now = datetime.datetime.now()
98
   now = datetime.datetime.now()
64
   return now.strftime('%Y')
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
 def icon(icon_name, white=False):
102
 def icon(icon_name, white=False):
79
     if (white):
103
     if (white):
143
     try:
167
     try:
144
         content_data = content_str.split(CST.TREEVIEW_MENU.ID_SEPARATOR)
168
         content_data = content_str.split(CST.TREEVIEW_MENU.ID_SEPARATOR)
145
         content_id = int(content_data[1])
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
     except (IndexError, ValueError) as e:
175
     except (IndexError, ValueError) as e:
148
         content = None
176
         content = None
149
 
177
 

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

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
 
8
 
9
 from mako.template import Template
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
 from tracim.lib.base import logger
11
 from tracim.lib.base import logger
15
 from tracim.lib.email import SmtpConfiguration
12
 from tracim.lib.email import SmtpConfiguration
13
+from tracim.lib.email import send_email_through
16
 from tracim.lib.email import EmailSender
14
 from tracim.lib.email import EmailSender
17
 from tracim.lib.user import UserApi
15
 from tracim.lib.user import UserApi
18
 from tracim.lib.workspace import WorkspaceApi
16
 from tracim.lib.workspace import WorkspaceApi
19
-
17
+from tracim.lib.utils import lazy_ugettext as l_
20
 from tracim.model.serializers import Context
18
 from tracim.model.serializers import Context
21
 from tracim.model.serializers import CTX
19
 from tracim.model.serializers import CTX
22
 from tracim.model.serializers import DictLikeClass
20
 from tracim.model.serializers import DictLikeClass
26
 from tracim.model.auth import User
24
 from tracim.model.auth import User
27
 
25
 
28
 
26
 
29
-from tgext.asyncjob import asyncjob_perform
30
-
31
 class INotifier(object):
27
 class INotifier(object):
32
     """
28
     """
33
     Interface for Notifier instances
29
     Interface for Notifier instances
54
 
50
 
55
 
51
 
56
 class DummyNotifier(INotifier):
52
 class DummyNotifier(INotifier):
53
+    send_count = 0
54
+
57
     def __init__(self, current_user: User=None):
55
     def __init__(self, current_user: User=None):
58
         logger.info(self, 'Instantiating Dummy Notifier')
56
         logger.info(self, 'Instantiating Dummy Notifier')
59
 
57
 
60
     def notify_content_update(self, content: Content):
58
     def notify_content_update(self, content: Content):
59
+        type(self).send_count += 1
61
         logger.info(self, 'Fake notifier, do not send email-notification for update of content {}'.format(content.content_id))
60
         logger.info(self, 'Fake notifier, do not send email-notification for update of content {}'.format(content.content_id))
62
 
61
 
63
 
62
 
142
                 # TODO - D.A - 2014-11-06
141
                 # TODO - D.A - 2014-11-06
143
                 # This feature must be implemented in order to be able to scale to large communities
142
                 # This feature must be implemented in order to be able to scale to large communities
144
                 raise NotImplementedError('Sending emails through ASYNC mode is not working yet')
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
             else:
144
             else:
147
                 logger.info(self, 'Sending email in SYNC mode')
145
                 logger.info(self, 'Sending email in SYNC mode')
148
                 EmailNotifier(self._smtp_config, global_config).notify_content_update(self._user.user_id, content.content_id)
146
                 EmailNotifier(self._smtp_config, global_config).notify_content_update(self._user.user_id, content.content_id)
182
         self._smtp_config = smtp_config
180
         self._smtp_config = smtp_config
183
         self._global_config = global_config
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
     def notify_content_update(self, event_actor_id: int, event_content_id: int):
212
     def notify_content_update(self, event_actor_id: int, event_content_id: int):
187
         """
213
         """
228
             subject = self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT
254
             subject = self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT
229
             subject = subject.replace(EST.WEBSITE_TITLE, self._global_config.WEBSITE_TITLE.__str__())
255
             subject = subject.replace(EST.WEBSITE_TITLE, self._global_config.WEBSITE_TITLE.__str__())
230
             subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
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
             subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
258
             subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
233
 
259
 
234
             message = MIMEMultipart('alternative')
260
             message = MIMEMultipart('alternative')
235
             message['Subject'] = subject
261
             message['Subject'] = subject
236
-            message['From'] = self._global_config.EMAIL_NOTIFICATION_FROM
262
+            message['From'] = self._get_sender(user)
237
             message['To'] = to_addr
263
             message['To'] = to_addr
238
 
264
 
239
             body_text = self._build_email_body(self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT, role, content, user)
265
             body_text = self._build_email_body(self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT, role, content, user)
250
             message.attach(part1)
276
             message.attach(part1)
251
             message.attach(part2)
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
     def _build_email_body(self, mako_template_filepath: str, role: UserRoleInWorkspace, content: Content, actor: User) -> str:
281
     def _build_email_body(self, mako_template_filepath: str, role: UserRoleInWorkspace, content: Content, actor: User) -> str:
263
         """
282
         """
286
 
305
 
287
         action = content.get_last_action().id
306
         action = content.get_last_action().id
288
         if ActionDescription.COMMENT == action:
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
             content_text = content.description
309
             content_text = content.description
291
-            call_to_action_text = _('Answer')
310
+            call_to_action_text = l_('Answer')
292
 
311
 
293
         elif ActionDescription.CREATION == action:
312
         elif ActionDescription.CREATION == action:
294
 
313
 
295
             # Default values (if not overriden)
314
             # Default values (if not overriden)
296
             content_text = content.description
315
             content_text = content.description
297
-            call_to_action_text = _('View online')
316
+            call_to_action_text = l_('View online')
298
 
317
 
299
             if ContentType.Thread == content.type:
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
                 content_text = '<p id="content-body-intro">{}</p>'.format(content.label) + \
321
                 content_text = '<p id="content-body-intro">{}</p>'.format(content.label) + \
303
                                content.get_last_comment_from(actor).description
322
                                content.get_last_comment_from(actor).description
304
 
323
 
305
             elif ContentType.File == content.type:
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
                 if content.description:
326
                 if content.description:
308
                     content_text = content.description
327
                     content_text = content.description
309
-                elif content.label:
310
-                    content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
311
                 else:
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
             elif ContentType.Page == content.type:
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
                 content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
333
                 content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
318
 
334
 
319
         elif ActionDescription.REVISION == action:
335
         elif ActionDescription.REVISION == action:
320
             content_text = content.description
336
             content_text = content.description
321
-            call_to_action_text = _('View online')
337
+            call_to_action_text = l_('View online')
322
 
338
 
323
             if ContentType.File == content.type:
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
                 content_text = ''
341
                 content_text = ''
326
 
342
 
327
             elif ContentType.Page == content.type:
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
                 previous_revision = content.get_previous_revision()
345
                 previous_revision = content.get_previous_revision()
330
                 title_diff = ''
346
                 title_diff = ''
331
                 if previous_revision.label != content.label:
347
                 if previous_revision.label != content.label:
332
                     title_diff = htmldiff(previous_revision.label, content.label)
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
                     title_diff + \
350
                     title_diff + \
335
                     htmldiff(previous_revision.description, content.description)
351
                     htmldiff(previous_revision.description, content.description)
336
 
352
 
337
             elif ContentType.Thread == content.type:
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
                 previous_revision = content.get_previous_revision()
355
                 previous_revision = content.get_previous_revision()
340
                 title_diff = ''
356
                 title_diff = ''
341
                 if previous_revision.label != content.label:
357
                 if previous_revision.label != content.label:
342
                     title_diff = htmldiff(previous_revision.label, content.label)
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
                     title_diff + \
360
                     title_diff + \
345
                     htmldiff(previous_revision.description, content.description)
361
                     htmldiff(previous_revision.description, content.description)
346
 
362
 
347
             # elif ContentType.Thread == content.type:
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
             #     previous_revision = content.get_previous_revision()
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
             #         htmldiff(previous_revision.description, content.description)
367
             #         htmldiff(previous_revision.description, content.description)
352
 
368
 
353
         elif ActionDescription.EDITION == action:
369
         elif ActionDescription.EDITION == action:
354
-            call_to_action_text = _('View online')
370
+            call_to_action_text = l_('View online')
355
 
371
 
356
             if ContentType.File == content.type:
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
                 content_text = '<p id="content-body-intro">{}</p>'.format(content.get_label()) + \
374
                 content_text = '<p id="content-body-intro">{}</p>'.format(content.get_label()) + \
359
                     content.description
375
                     content.description
360
 
376
 
374
         from tracim.config.app_cfg import CFG
390
         from tracim.config.app_cfg import CFG
375
         body_content = template.render(
391
         body_content = template.render(
376
             base_url=self._global_config.WEBSITE_BASE_URL,
392
             base_url=self._global_config.WEBSITE_BASE_URL,
377
-            _=_,
393
+            _=l_,
378
             h=helpers,
394
             h=helpers,
379
             user_display_name=role.user.display_name,
395
             user_display_name=role.user.display_name,
380
             user_role_label=role.role_as_label(),
396
             user_role_label=role.role_as_label(),

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

3
 from tg import abort
3
 from tg import abort
4
 from tg import request
4
 from tg import request
5
 from tg import tmpl_context
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
 from tg.i18n import ugettext as _
7
 from tg.i18n import ugettext as _
8
 from tg.predicates import Predicate
8
 from tg.predicates import Predicate
9
 
9
 

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

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

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

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

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

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
+import threading
2
 
3
 
3
-__author__ = 'damien'
4
-
4
+import cherrypy
5
+import transaction
5
 import tg
6
 import tg
7
+import typing as typing
6
 
8
 
7
 from tracim.model.auth import User
9
 from tracim.model.auth import User
8
-
9
-from tracim.model import auth as pbma
10
 from tracim.model import DBSession
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
 class UserApi(object):
16
 class UserApi(object):
14
 
17
 
30
     def get_one_by_id(self, id: int) -> User:
33
     def get_one_by_id(self, id: int) -> User:
31
         return self._base_query().filter(User.user_id==id).one()
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
         if name is not None:
44
         if name is not None:
35
             user.display_name = name
45
             user.display_name = name
36
 
46
 
37
         if email is not None:
47
         if email is not None:
38
             user.email = email
48
             user.email = email
39
 
49
 
50
+        user.timezone = timezone
51
+
40
         if do_save:
52
         if do_save:
41
             self.save(user)
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
             # this is required for the session to keep on being up-to-date
56
             # this is required for the session to keep on being up-to-date
45
             tg.request.identity['repoze.who.userid'] = email
57
             tg.request.identity['repoze.who.userid'] = email
58
+            tg.auth_force_login(email)
46
 
59
 
47
     def user_with_email_exists(self, email: str):
60
     def user_with_email_exists(self, email: str):
48
         try:
61
         try:
70
     def save(self, user: User):
83
     def save(self, user: User):
71
         DBSession.flush()
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
         # HACK - D.A. - 2015-09-02
120
         # HACK - D.A. - 2015-09-02
79
         # In tests, the tg.request.identity may not be set
121
         # In tests, the tg.request.identity may not be set
80
         # (this is a buggy case, but for now this is how the software is;)
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
             if hasattr(tg.request, 'identity'):
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
         return None
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
     @classmethod
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
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
-
2
+import os
3
 import time
3
 import time
4
 import signal
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
 from tracim.lib.base import logger
17
 from tracim.lib.base import logger
18
+from webob import Response
19
+from webob.exc import WSGIHTTPException
20
+
7
 
21
 
8
 def exec_time_monitor():
22
 def exec_time_monitor():
9
     def decorator_func(func):
23
     def decorator_func(func):
49
     raise NotImplementedError()
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
     Add a callback attached to python signal.
68
     Add a callback attached to python signal.
55
     :param signal_id: signal identifier (eg. signal.SIGTERM)
69
     :param signal_id: signal identifier (eg. signal.SIGTERM)
56
     :param handler: callback to execute when signal trig
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
                 self._file_stream.read()
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
 from tracim.model.data import ContentType
5
 from tracim.model.data import ContentType
6
 from tracim.model import data
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
 def create_readable_date(created, delta_from_datetime: datetime = None):
127
 def create_readable_date(created, delta_from_datetime: datetime = None):
9
     if not delta_from_datetime:
128
     if not delta_from_datetime:
10
         delta_from_datetime = datetime.now()
129
         delta_from_datetime = datetime.now()
29
     return aff
148
     return aff
30
 
149
 
31
 def designPage(content: data.Content, content_revision: data.ContentRevisionRO) -> str:
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
     hist = content.get_history()
151
     hist = content.get_history()
37
     histHTML = '<table class="table table-striped table-hover">'
152
     histHTML = '<table class="table table-striped table-hover">'
38
     for event in hist:
153
     for event in hist:
39
         if isinstance(event, VirtualEvent):
154
         if isinstance(event, VirtualEvent):
40
             date = event.create_readable_date()
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
             label = _LABELS[event.type.id]
156
             label = _LABELS[event.type.id]
55
 
157
 
56
             histHTML += '''
158
             histHTML += '''
65
                        label,
167
                        label,
66
                        date,
168
                        date,
67
                        event.owner.display_name,
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
     histHTML += '</table>'
174
     histHTML += '</table>'
72
 
175
 
73
     file = '''
176
     file = '''
93
             </div>
196
             </div>
94
             <div class="pull-right">
197
             <div class="pull-right">
95
                 <div class="btn-group btn-group-vertical">
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
                 </div>
203
                 </div>
100
             </div>
204
             </div>
101
         </div>
205
         </div>
113
             file_location = file_location.replace(/\/[^/]*$/, '')
217
             file_location = file_location.replace(/\/[^/]*$/, '')
114
             file_location = file_location.replace(/\/.history\/[^/]*$/, '')
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
     </script>
225
     </script>
121
 </body>
226
 </body>
131
     return file
236
     return file
132
 
237
 
133
 def designThread(content: data.Content, content_revision: data.ContentRevisionRO, comments) -> str:
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
         hist = content.get_history()
239
         hist = content.get_history()
139
 
240
 
140
         allT = []
241
         allT = []
165
                     participants[t.owner.display_name][0] += 1
266
                     participants[t.owner.display_name][0] += 1
166
             else:
267
             else:
167
                 if isinstance(t, VirtualEvent) and t.type.id != 'comment':
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
                     label = _LABELS[t.type.id]
269
                     label = _LABELS[t.type.id]
183
 
270
 
184
                     disc += '''
271
                     disc += '''
197
                            t.owner.display_name,
284
                            t.owner.display_name,
198
                            t.create_readable_date(),
285
                            t.create_readable_date(),
199
                            label,
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
         page = '''
294
         page = '''
206
 <html>
295
 <html>
222
             </div>
311
             </div>
223
             <div class="pull-right">
312
             <div class="pull-right">
224
                 <div class="btn-group btn-group-vertical">
313
                 <div class="btn-group btn-group-vertical">
314
+                    <!-- NOTE: Not omplemented yet, don't display not working link
225
                     <a class="btn btn-default" onclick="hide_elements()">
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
                     <a class="btn btn-default">
318
                     <a class="btn btn-default">
229
                         <i class="fa fa-external-link"></i> View in tracim</a>
319
                         <i class="fa fa-external-link"></i> View in tracim</a>
230
                     </a>
320
                     </a>
244
             file_location = file_location.replace(/\/[^/]*$/, '')
334
             file_location = file_location.replace(/\/[^/]*$/, '')
245
             file_location = file_location.replace(/\/.history\/[^/]*$/, '')
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
         function hide_elements() {
343
         function hide_elements() {

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

1
 # coding: utf8
1
 # coding: utf8
2
 
2
 
3
 import re
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
 from wsgidav.dav_provider import DAVProvider
8
 from wsgidav.dav_provider import DAVProvider
7
 from wsgidav.lock_manager import LockManager
9
 from wsgidav.lock_manager import LockManager
16
 from tracim.lib.workspace import WorkspaceApi
18
 from tracim.lib.workspace import WorkspaceApi
17
 from tracim.model.data import Content, Workspace
19
 from tracim.model.data import Content, Workspace
18
 from tracim.model.data import ContentType
20
 from tracim.model.data import ContentType
21
+from tracim.lib.webdav.utils import normpath
19
 
22
 
20
 
23
 
21
 class Provider(DAVProvider):
24
 class Provider(DAVProvider):
74
 
77
 
75
         content_api = ContentApi(
78
         content_api = ContentApi(
76
             user,
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
         content = self.get_content_from_path(
84
         content = self.get_content_from_path(
159
         if parent_path == root_path or workspace is None:
162
         if parent_path == root_path or workspace is None:
160
             return workspace is not None
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
         revision_id = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) ([^/].+)$', path)
169
         revision_id = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) ([^/].+)$', path)
165
 
170
 
237
         path = self.reduce_path(path)
242
         path = self.reduce_path(path)
238
         parent_path = dirname(path)
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
         try:
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
             return None
261
             return None
255
 
262
 
256
     def get_content_from_revision(self, revision: ContentRevisionRO, api: ContentApi) -> Content:
263
     def get_content_from_revision(self, revision: ContentRevisionRO, api: ContentApi) -> Content:
257
         try:
264
         try:
258
             return api.get_one(revision.content_id, ContentType.Any)
265
             return api.get_one(revision.content_id, ContentType.Any)
259
-        except:
266
+        except NoResultFound:
260
             return None
267
             return None
261
 
268
 
262
     def get_parent_from_path(self, path, api: ContentApi, workspace) -> Content:
269
     def get_parent_from_path(self, path, api: ContentApi, workspace) -> Content:
264
 
271
 
265
     def get_workspace_from_path(self, path: str, api: WorkspaceApi) -> Workspace:
272
     def get_workspace_from_path(self, path: str, api: WorkspaceApi) -> Workspace:
266
         try:
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
             return None
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
 # coding: utf8
1
 # coding: utf8
2
+import logging
3
+
4
+import os
5
+
2
 import transaction
6
 import transaction
3
 import re
7
 import re
4
 from datetime import datetime
8
 from datetime import datetime
5
 from time import mktime
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
 from tracim.lib.content import ContentApi
12
 from tracim.lib.content import ContentApi
10
 from tracim.lib.user import UserApi
13
 from tracim.lib.user import UserApi
11
 from tracim.lib.webdav import HistoryType
14
 from tracim.lib.webdav import HistoryType
12
 from tracim.lib.webdav import FakeFileStream
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
 from tracim.lib.workspace import WorkspaceApi
18
 from tracim.lib.workspace import WorkspaceApi
14
 from tracim.model import data, new_revision
19
 from tracim.model import data, new_revision
15
 from tracim.model.data import Content, ActionDescription
20
 from tracim.model.data import Content, ActionDescription
20
 from wsgidav.dav_error import DAVError, HTTP_FORBIDDEN
25
 from wsgidav.dav_error import DAVError, HTTP_FORBIDDEN
21
 from wsgidav.dav_provider import DAVCollection, DAVNonCollection
26
 from wsgidav.dav_provider import DAVCollection, DAVNonCollection
22
 from wsgidav.dav_provider import _DAVResource
27
 from wsgidav.dav_provider import _DAVResource
28
+from tracim.lib.webdav.utils import normpath
23
 
29
 
24
 from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound
30
 from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound
25
 
31
 
32
+logger = logging.getLogger()
33
+
34
+
26
 class ManageActions(object):
35
 class ManageActions(object):
27
     """
36
     """
28
     This object is used to encapsulate all Deletion/Archiving related method as to not duplicate too much code
37
     This object is used to encapsulate all Deletion/Archiving related method as to not duplicate too much code
50
         try:
59
         try:
51
             # When undeleting/unarchiving we except a content with the new name to not exist, thus if we
60
             # When undeleting/unarchiving we except a content with the new name to not exist, thus if we
52
             # don't get an error and the database request send back a result, we stop the action
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
             raise DAVError(HTTP_FORBIDDEN)
63
             raise DAVError(HTTP_FORBIDDEN)
55
         except NoResultFound:
64
         except NoResultFound:
56
             with new_revision(self.content):
65
             with new_revision(self.content):
65
         Will create the new name, either by adding '- deleted the [date]' after the name when archiving/deleting or
74
         Will create the new name, either by adding '- deleted the [date]' after the name when archiving/deleting or
66
         removing this string when undeleting/unarchiving
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
         extension = ''
78
         extension = ''
70
 
79
 
71
         # if the content has no label, the last .ext is important
80
         # if the content has no label, the last .ext is important
72
         # thus we want to rename a file from 'file.txt' to 'file - deleted... .txt' and not 'file.txt - deleted...'
81
         # thus we want to rename a file from 'file.txt' to 'file - deleted... .txt' and not 'file.txt - deleted...'
73
         is_file_name = self.content.label == ''
82
         is_file_name = self.content.label == ''
74
         if is_file_name:
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
             new_name = re.sub(r'(\.[^.]+)$', '', new_name)
87
             new_name = re.sub(r'(\.[^.]+)$', '', new_name)
77
 
88
 
78
         if self._type in [ActionDescription.ARCHIVING, ActionDescription.DELETION]:
89
         if self._type in [ActionDescription.ARCHIVING, ActionDescription.DELETION]:
119
         """
130
         """
120
         try:
131
         try:
121
             workspace = self.workspace_api.get_one_by_label(label)
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
             return Workspace(workspace_path, self.environ, workspace)
135
             return Workspace(workspace_path, self.environ, workspace)
125
         except AttributeError:
136
         except AttributeError:
150
         self.workspace_api.save(new_workspace)
161
         self.workspace_api.save(new_workspace)
151
 
162
 
152
         workspace_path = '%s%s%s' % (
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
         transaction.commit()
166
         transaction.commit()
156
         return Workspace(workspace_path, self.environ, new_workspace)
167
         return Workspace(workspace_path, self.environ, new_workspace)
213
             # the purpose is to display .history only if there's at least one content's type that has a history
224
             # the purpose is to display .history only if there's at least one content's type that has a history
214
             if content.type != ContentType.Folder:
225
             if content.type != ContentType.Folder:
215
                 self._file_count += 1
226
                 self._file_count += 1
216
-            retlist.append(content.get_label())
227
+            retlist.append(content.get_label_as_file())
217
 
228
 
218
         return retlist
229
         return retlist
219
 
230
 
220
     def getMember(self, content_label: str) -> _DAVResource:
231
     def getMember(self, content_label: str) -> _DAVResource:
221
 
232
 
222
         return self.provider.getResourceInst(
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
             self.environ
235
             self.environ
225
         )
236
         )
226
 
237
 
233
         if '/.deleted/' in self.path or '/.archived/' in self.path:
244
         if '/.deleted/' in self.path or '/.archived/' in self.path:
234
             raise DAVError(HTTP_FORBIDDEN)
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
         return FakeFileStream(
255
         return FakeFileStream(
237
             file_name=file_name,
256
             file_name=file_name,
238
             content_api=self.content_api,
257
             content_api=self.content_api,
239
             workspace=self.workspace,
258
             workspace=self.workspace,
240
-            content=None,
259
+            content=content,
241
             parent=self.content,
260
             parent=self.content,
242
             path=self.path + '/' + file_name
261
             path=self.path + '/' + file_name
243
         )
262
         )
272
 
291
 
273
         transaction.commit()
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
                       self.environ, folder,
295
                       self.environ, folder,
277
                       self.workspace)
296
                       self.workspace)
278
 
297
 
296
         children = self.content_api.get_all(False, ContentType.Any, self.workspace)
315
         children = self.content_api.get_all(False, ContentType.Any, self.workspace)
297
 
316
 
298
         for content in children:
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
             if content.type == ContentType.Folder:
320
             if content.type == ContentType.Folder:
302
                 members.append(Folder(content_path, self.environ, self.workspace, content))
321
                 members.append(Folder(content_path, self.environ, self.workspace, content))
360
         return mktime(self.content.created.timetuple())
379
         return mktime(self.content.created.timetuple())
361
 
380
 
362
     def getDisplayName(self) -> str:
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
     def getLastModified(self) -> float:
384
     def getLastModified(self) -> float:
366
         return mktime(self.content.updated.timetuple())
385
         return mktime(self.content.updated.timetuple())
438
 
457
 
439
         with new_revision(self.content):
458
         with new_revision(self.content):
440
             if basename(destpath) != self.getDisplayName():
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
                 self.content_api.save(self.content)
461
                 self.content_api.save(self.content)
443
             else:
462
             else:
444
                 if workspace.workspace_id == self.content.workspace.workspace_id:
463
                 if workspace.workspace_id == self.content.workspace.workspace_id:
458
         )
477
         )
459
 
478
 
460
         for content in visible_children:
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
         if self._file_count > 0 and self.provider.show_history():
499
         if self._file_count > 0 and self.provider.show_history():
473
             members.append(
500
             members.append(
541
         )
568
         )
542
 
569
 
543
         return HistoryFileFolder(
570
         return HistoryFileFolder(
544
-            path='%s/%s' % (self.path, content.get_label()),
571
+            path='%s/%s' % (self.path, content.get_label_as_file()),
545
             environ=self.environ,
572
             environ=self.environ,
546
             content=content)
573
             content=content)
547
 
574
 
554
                 self._is_deleted and content.is_deleted or
581
                 self._is_deleted and content.is_deleted or
555
                 not (content.is_archived or self._is_archived or content.is_deleted or self._is_deleted))\
582
                 not (content.is_archived or self._is_archived or content.is_deleted or self._is_deleted))\
556
                     and content.type != ContentType.Folder:
583
                     and content.type != ContentType.Folder:
557
-                ret.append(content.get_label())
584
+                ret.append(content.get_label_as_file())
558
 
585
 
559
         return ret
586
         return ret
560
 
587
 
587
         for content in children:
614
         for content in children:
588
             if content.is_archived == self._is_archived and content.is_deleted == self._is_deleted:
615
             if content.is_archived == self._is_archived and content.is_deleted == self._is_deleted:
589
                 members.append(HistoryFileFolder(
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
                     environ=self.environ,
618
                     environ=self.environ,
592
                     content=content))
619
                     content=content))
593
 
620
 
624
         )
651
         )
625
 
652
 
626
         return self.provider.getResourceInst(
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
             environ=self.environ
655
             environ=self.environ
629
             )
656
             )
630
 
657
 
638
 
665
 
639
         for content in children:
666
         for content in children:
640
             if content.is_deleted:
667
             if content.is_deleted:
641
-                retlist.append(content.get_label())
668
+                retlist.append(content.get_label_as_file())
642
 
669
 
643
                 if content.type != ContentType.Folder:
670
                 if content.type != ContentType.Folder:
644
                     self._file_count += 1
671
                     self._file_count += 1
655
 
682
 
656
         for content in children:
683
         for content in children:
657
             if content.is_deleted:
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
                 if content.type == ContentType.Folder:
687
                 if content.type == ContentType.Folder:
661
                     members.append(Folder(content_path, self.environ, self.workspace, content))
688
                     members.append(Folder(content_path, self.environ, self.workspace, content))
709
         )
736
         )
710
 
737
 
711
         return self.provider.getResourceInst(
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
             environ=self.environ
740
             environ=self.environ
714
         )
741
         )
715
 
742
 
718
 
745
 
719
         for content in self.content_api.get_all_with_filter(
746
         for content in self.content_api.get_all_with_filter(
720
                 self.content if self.content is None else self.content.id, ContentType.Any):
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
             if content.type != ContentType.Folder:
750
             if content.type != ContentType.Folder:
724
                 self._file_count += 1
751
                 self._file_count += 1
735
 
762
 
736
         for content in children:
763
         for content in children:
737
             if content.is_archived:
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
                 if content.type == ContentType.Folder:
767
                 if content.type == ContentType.Folder:
741
                     members.append(Folder(content_path, self.environ, self.workspace, content))
768
                     members.append(Folder(content_path, self.environ, self.workspace, content))
772
         return "<DAVCollection: HistoryFileFolder (%s)" % self.content.file_name
799
         return "<DAVCollection: HistoryFileFolder (%s)" % self.content.file_name
773
 
800
 
774
     def getDisplayName(self) -> str:
801
     def getDisplayName(self) -> str:
775
-        return self.content.get_label()
802
+        return self.content.get_label_as_file()
776
 
803
 
777
     def createCollection(self, name):
804
     def createCollection(self, name):
778
         raise DAVError(HTTP_FORBIDDEN)
805
         raise DAVError(HTTP_FORBIDDEN)
797
 
824
 
798
         if self.content.type == ContentType.File:
825
         if self.content.type == ContentType.File:
799
             return HistoryFile(
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
                 environ=self.environ,
828
                 environ=self.environ,
802
                 content=self.content,
829
                 content=self.content,
803
                 content_revision=revision)
830
                 content_revision=revision)
804
         else:
831
         else:
805
             return HistoryOtherFile(
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
                 environ=self.environ,
834
                 environ=self.environ,
808
                 content=self.content,
835
                 content=self.content,
809
                 content_revision=revision)
836
                 content_revision=revision)
817
 
844
 
818
             if self.content.type == ContentType.File:
845
             if self.content.type == ContentType.File:
819
                 members.append(HistoryFile(
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
                     environ=self.environ,
848
                     environ=self.environ,
822
                     content=self.content,
849
                     content=self.content,
823
                     content_revision=content)
850
                     content_revision=content)
824
                 )
851
                 )
825
             else:
852
             else:
826
                 members.append(HistoryOtherFile(
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
                     environ=self.environ,
855
                     environ=self.environ,
829
                     content=self.content,
856
                     content=self.content,
830
                     content_revision=content)
857
                     content_revision=content)
848
         # but i wasn't able to set this property so you'll have to look into it >.>
875
         # but i wasn't able to set this property so you'll have to look into it >.>
849
         # self.setPropertyValue('Win32FileAttributes', '00000021')
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
     def __repr__(self) -> str:
878
     def __repr__(self) -> str:
860
         return "<DAVNonCollection: File (%d)>" % self.content.revision_id
879
         return "<DAVNonCollection: File (%d)>" % self.content.revision_id
861
 
880
 
869
         return mktime(self.content.created.timetuple())
888
         return mktime(self.content.created.timetuple())
870
 
889
 
871
     def getDisplayName(self) -> str:
890
     def getDisplayName(self) -> str:
872
-        return self.content.get_label()
891
+        return self.content.file_name
873
 
892
 
874
     def getLastModified(self) -> float:
893
     def getLastModified(self) -> float:
875
         return mktime(self.content.updated.timetuple())
894
         return mktime(self.content.updated.timetuple())
885
         return FakeFileStream(
904
         return FakeFileStream(
886
             content=self.content,
905
             content=self.content,
887
             content_api=self.content_api,
906
             content_api=self.content_api,
888
-            file_name=self.content.get_label(),
907
+            file_name=self.content.get_label_as_file(),
889
             workspace=self.content.workspace,
908
             workspace=self.content.workspace,
890
             path=self.path
909
             path=self.path
891
         )
910
         )
942
 
961
 
943
     def move_file(self, destpath):
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
         with new_revision(self.content):
967
         with new_revision(self.content):
958
             if basename(destpath) != self.getDisplayName():
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
                 self.content_api.save(self.content)
978
                 self.content_api.save(self.content)
961
             else:
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
                 self.content_api.move(
994
                 self.content_api.move(
963
                     item=self.content,
995
                     item=self.content,
964
-                    new_parent=parent,
996
+                    new_parent=destination_parent,
965
                     must_stay_in_same_workspace=False,
997
                     must_stay_in_same_workspace=False,
966
-                    new_workspace=workspace
998
+                    new_workspace=destination_workspace
967
                 )
999
                 )
968
 
1000
 
969
         transaction.commit()
1001
         transaction.commit()
988
 
1020
 
989
     def getDisplayName(self) -> str:
1021
     def getDisplayName(self) -> str:
990
         left_side = '(%d - %s) ' % (self.content_revision.revision_id, self.content_revision.revision_type)
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
     def getContent(self):
1025
     def getContent(self):
994
         filestream = compat.BytesIO()
1026
         filestream = compat.BytesIO()
1031
             self.path += '.html'
1063
             self.path += '.html'
1032
 
1064
 
1033
     def getDisplayName(self) -> str:
1065
     def getDisplayName(self) -> str:
1034
-        return self.content.get_label()
1066
+        return self.content.get_label_as_file()
1035
 
1067
 
1036
     def getPreferredPath(self):
1068
     def getPreferredPath(self):
1037
         return self.path
1069
         return self.path
1077
 
1109
 
1078
     def getDisplayName(self) -> str:
1110
     def getDisplayName(self) -> str:
1079
         left_side = '(%d - %s) ' % (self.content_revision.revision_id, self.content_revision.revision_type)
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
     def getContent(self):
1114
     def getContent(self):
1083
         filestream = compat.BytesIO()
1115
         filestream = compat.BytesIO()

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

1
+import os
2
+import re
3
+
1
 from wsgidav.http_authenticator import HTTPAuthenticator
4
 from wsgidav.http_authenticator import HTTPAuthenticator
2
 from wsgidav import util
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
 _logger = util.getModuleLogger(__name__, True)
11
 _logger = util.getModuleLogger(__name__, True)
6
 HOTFIX_WINXP_AcceptRootShareLogin = True
12
 HOTFIX_WINXP_AcceptRootShareLogin = True
131
 
137
 
132
         environ["http_authenticator.realm"] = realmname
138
         environ["http_authenticator.realm"] = realmname
133
         environ["http_authenticator.username"] = req_username
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
         return self._application(environ, start_response)
145
         return self._application(environ, start_response)
135
 
146
 
136
     def tracim_compute_digest_response(self, left_digest_response_hash, method, uri, nonce, cnonce, qop, nc):
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 查看文件

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
 # -*- coding: utf-8 -*-
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
 from tracim.lib.userworkspace import RoleApi
7
 from tracim.lib.userworkspace import RoleApi
19
 from tracim.model.auth import Group
8
 from tracim.model.auth import Group
20
 from tracim.model.auth import User
9
 from tracim.model.auth import User
21
 from tracim.model.data import Workspace
10
 from tracim.model.data import Workspace
22
 from tracim.model.data import UserRoleInWorkspace
11
 from tracim.model.data import UserRoleInWorkspace
23
-
24
-from tracim.model import auth as pbma
25
 from tracim.model import DBSession
12
 from tracim.model import DBSession
26
 
13
 
14
+__author__ = 'damien'
15
+
27
 
16
 
28
 class WorkspaceApi(object):
17
 class WorkspaceApi(object):
29
 
18
 
30
     def __init__(self, current_user: User):
19
     def __init__(self, current_user: User):
31
         self._user = current_user
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
     def _base_query(self):
25
     def _base_query(self):
34
         if self._user.profile.id>=Group.TIM_ADMIN:
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
         return DBSession.query(Workspace).\
29
         return DBSession.query(Workspace).\
38
             join(Workspace.roles).\
30
             join(Workspace.roles).\
39
             filter(UserRoleInWorkspace.user_id==self._user.user_id).\
31
             filter(UserRoleInWorkspace.user_id==self._user.user_id).\
40
             filter(Workspace.is_deleted==False)
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
         workspace = Workspace()
44
         workspace = Workspace()
44
         workspace.label = label
45
         workspace.label = label
45
         workspace.description = description
46
         workspace.description = description
47
+        workspace.calendar_enabled = calendar_enabled
46
 
48
 
47
         # By default, we force the current user to be the workspace manager
49
         # By default, we force the current user to be the workspace manager
48
         # And to receive email notifications
50
         # And to receive email notifications
56
         if save_now:
58
         if save_now:
57
             DBSession.flush()
59
             DBSession.flush()
58
 
60
 
61
+        if calendar_enabled:
62
+            self.execute_created_workspace_actions(workspace)
63
+
59
         return workspace
64
         return workspace
60
 
65
 
61
     def get_one(self, id):
66
     def get_one(self, id):
125
 
130
 
126
         return workspace
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
 class UnsafeWorkspaceApi(WorkspaceApi):
171
 class UnsafeWorkspaceApi(WorkspaceApi):
130
     def _base_query(self):
172
     def _base_query(self):

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

31
 
31
 
32
     @classmethod
32
     @classmethod
33
     def remove_from_updatable(cls, revision: 'ContentRevisionRO') -> None:
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
     @classmethod
37
     @classmethod
37
     def is_updatable(cls, revision: 'ContentRevisionRO') -> bool:
38
     def is_updatable(cls, revision: 'ContentRevisionRO') -> bool:
39
 
40
 
40
 # Global session manager: DBSession() returns the Thread-local
41
 # Global session manager: DBSession() returns the Thread-local
41
 # session object appropriate for the current web request.
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
 DBSession = scoped_session(maker)
49
 DBSession = scoped_session(maker)
45
 
50
 
46
 # Base class for all of our model classes: By default, the data model is
51
 # Base class for all of our model classes: By default, the data model is
80
 
85
 
81
 def init_model(engine):
86
 def init_model(engine):
82
     """Call me before using any of the tables or classes in the model."""
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
     # If you are using reflection to introspect your database and create
91
     # If you are using reflection to introspect your database and create
86
     # table objects for you, your tables must be defined and mapped inside
92
     # table objects for you, your tables must be defined and mapped inside

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

14
 from datetime import datetime
14
 from datetime import datetime
15
 import time
15
 import time
16
 from hashlib import sha256
16
 from hashlib import sha256
17
-from slugify import slugify
18
 from sqlalchemy.ext.hybrid import hybrid_property
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
 from hashlib import md5
19
 from hashlib import md5
21
 
20
 
22
 __all__ = ['User', 'Group', 'Permission']
21
 __all__ = ['User', 'Group', 'Permission']
69
     group_id = Column(Integer, Sequence('seq__groups__group_id'), autoincrement=True, primary_key=True)
68
     group_id = Column(Integer, Sequence('seq__groups__group_id'), autoincrement=True, primary_key=True)
70
     group_name = Column(Unicode(16), unique=True, nullable=False)
69
     group_name = Column(Unicode(16), unique=True, nullable=False)
71
     display_name = Column(Unicode(255))
70
     display_name = Column(Unicode(255))
72
-    created = Column(DateTime, default=datetime.now)
71
+    created = Column(DateTime, default=datetime.utcnow)
73
 
72
 
74
     users = relationship('User', secondary=user_group_table, backref='groups')
73
     users = relationship('User', secondary=user_group_table, backref='groups')
75
 
74
 
122
     email = Column(Unicode(255), unique=True, nullable=False)
121
     email = Column(Unicode(255), unique=True, nullable=False)
123
     display_name = Column(Unicode(255))
122
     display_name = Column(Unicode(255))
124
     _password = Column('password', Unicode(128))
123
     _password = Column('password', Unicode(128))
125
-    created = Column(DateTime, default=datetime.now)
124
+    created = Column(DateTime, default=datetime.utcnow)
126
     is_active = Column(Boolean, default=True, nullable=False)
125
     is_active = Column(Boolean, default=True, nullable=False)
127
     imported_from = Column(Unicode(32), nullable=True)
126
     imported_from = Column(Unicode(32), nullable=True)
127
+    timezone = Column(Unicode(255), nullable=False, server_default='')
128
     _webdav_left_digest_response_hash = Column('webdav_left_digest_response_hash', Unicode(128))
128
     _webdav_left_digest_response_hash = Column('webdav_left_digest_response_hash', Unicode(128))
129
     auth_token = Column(Unicode(255))
129
     auth_token = Column(Unicode(255))
130
     auth_token_created = Column(DateTime)
130
     auth_token_created = Column(DateTime)
218
                                                descriptor=property(_get_hash_digest,
218
                                                descriptor=property(_get_hash_digest,
219
                                                                     _set_hash_digest))
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
     def validate_password(self, password):
229
     def validate_password(self, password):
222
         """
230
         """
223
         Check the password against existing credentials.
231
         Check the password against existing credentials.
268
         validity_seconds = CFG.get_instance().USER_AUTH_TOKEN_VALIDITY
276
         validity_seconds = CFG.get_instance().USER_AUTH_TOKEN_VALIDITY
269
 
277
 
270
         if not self.auth_token or not self.auth_token_created:
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
             self.auth_token_created = datetime.utcnow()
280
             self.auth_token_created = datetime.utcnow()
273
             DBSession.flush()
281
             DBSession.flush()
274
             return
282
             return

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

2
 
2
 
3
 import datetime as datetime_root
3
 import datetime as datetime_root
4
 import json
4
 import json
5
+import os
5
 from datetime import datetime
6
 from datetime import datetime
6
 
7
 
7
 import tg
8
 import tg
8
 from babel.dates import format_timedelta
9
 from babel.dates import format_timedelta
9
 from bs4 import BeautifulSoup
10
 from bs4 import BeautifulSoup
10
-from slugify import slugify
11
 from sqlalchemy import Column, inspect, Index
11
 from sqlalchemy import Column, inspect, Index
12
 from sqlalchemy import ForeignKey
12
 from sqlalchemy import ForeignKey
13
 from sqlalchemy import Sequence
13
 from sqlalchemy import Sequence
24
 from sqlalchemy.types import LargeBinary
24
 from sqlalchemy.types import LargeBinary
25
 from sqlalchemy.types import Text
25
 from sqlalchemy.types import Text
26
 from sqlalchemy.types import Unicode
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
 from tracim.lib.exception import ContentRevisionUpdateError
29
 from tracim.lib.exception import ContentRevisionUpdateError
30
 from tracim.model import DeclarativeBase, RevisionsIntegrity
30
 from tracim.model import DeclarativeBase, RevisionsIntegrity
31
 from tracim.model.auth import User
31
 from tracim.model.auth import User
63
     revisions = relationship("ContentRevisionRO")
63
     revisions = relationship("ContentRevisionRO")
64
 
64
 
65
     @hybrid_property
65
     @hybrid_property
66
-    def contents(self):
66
+    def contents(self) -> ['Content']:
67
         # Return a list of unique revisions parent content
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
     @property
77
     @property
71
     def calendar_url(self) -> str:
78
     def calendar_url(self) -> str:
89
         # @see Content.get_allowed_content_types()
96
         # @see Content.get_allowed_content_types()
90
         return [ContentType('folder')]
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
         for child in self.contents:
105
         for child in self.contents:
94
             # we search only direct children
106
             # we search only direct children
95
             if not child.parent \
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
                 if not content_types or child.type in content_types:
110
                 if not content_types or child.type in content_types:
99
                     yield child
111
                     yield child
100
 
112
 
522
 
534
 
523
     label = Column(Unicode(1024), unique=False, nullable=False)
535
     label = Column(Unicode(1024), unique=False, nullable=False)
524
     description = Column(Text(), unique=False, nullable=False, default='')
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
     file_mimetype = Column(Unicode(255),  unique=False, nullable=False, default='')
543
     file_mimetype = Column(Unicode(255),  unique=False, nullable=False, default='')
527
     file_content = deferred(Column(LargeBinary(), unique=False, nullable=True))
544
     file_content = deferred(Column(LargeBinary(), unique=False, nullable=True))
528
     properties = Column('properties', Text(), unique=False, nullable=False, default='')
545
     properties = Column('properties', Text(), unique=False, nullable=False, default='')
547
 
564
 
548
     """ List of column copied when make a new revision from another """
565
     """ List of column copied when make a new revision from another """
549
     _cloned_columns = (
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
     # Read by must be used like this:
591
     # Read by must be used like this:
562
             RevisionReadStatus(user=k, view_datetime=v)
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
     @classmethod
608
     @classmethod
566
     def new_from(cls, revision: 'ContentRevisionRO') -> 'ContentRevisionRO':
609
     def new_from(cls, revision: 'ContentRevisionRO') -> 'ContentRevisionRO':
567
         """
610
         """
580
             column_value = getattr(revision, column_name)
623
             column_value = getattr(revision, column_name)
581
             setattr(new_rev, column_name, column_value)
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
         return new_rev
628
         return new_rev
586
 
629
 
607
         return ContentStatus(self.status)
650
         return ContentStatus(self.status)
608
 
651
 
609
     def get_label(self) -> str:
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
     def get_last_action(self) -> ActionDescription:
655
     def get_last_action(self) -> ActionDescription:
613
         return ActionDescription(self.revision_type)
656
         return ActionDescription(self.revision_type)
626
 
669
 
627
         return False
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
 Index('idx__content_revisions__owner_id', ContentRevisionRO.owner_id)
686
 Index('idx__content_revisions__owner_id', ContentRevisionRO.owner_id)
630
 Index('idx__content_revisions__parent_id', ContentRevisionRO.parent_id)
687
 Index('idx__content_revisions__parent_id', ContentRevisionRO.parent_id)
631
 
688
 
734
 
791
 
735
     @hybrid_property
792
     @hybrid_property
736
     def file_name(self) -> str:
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
     @file_name.setter
799
     @file_name.setter
740
     def file_name(self, value: str) -> None:
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
     @file_name.expression
806
     @file_name.expression
744
     def file_name(cls) -> InstrumentedAttribute:
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
     @hybrid_property
822
     @hybrid_property
748
     def file_mimetype(self) -> str:
823
     def file_mimetype(self) -> str:
961
     def revision(self) -> ContentRevisionRO:
1036
     def revision(self) -> ContentRevisionRO:
962
         return self.get_current_revision()
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
     def get_current_revision(self) -> ContentRevisionRO:
1043
     def get_current_revision(self) -> ContentRevisionRO:
965
         if not self.revisions:
1044
         if not self.revisions:
966
             return self.new_revision()
1045
             return self.new_revision()
1007
         self._properties = json.dumps(properties_struct)
1086
         self._properties = json.dumps(properties_struct)
1008
         ContentChecker.check_properties(self)
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
     def created_as_delta(self, delta_from_datetime:datetime=None):
1099
     def created_as_delta(self, delta_from_datetime:datetime=None):
1011
         if not delta_from_datetime:
1100
         if not delta_from_datetime:
1012
-            delta_from_datetime = datetime.now()
1101
+            delta_from_datetime = datetime.utcnow()
1013
 
1102
 
1014
         return format_timedelta(delta_from_datetime - self.created,
1103
         return format_timedelta(delta_from_datetime - self.created,
1015
                                 locale=tg.i18n.get_lang()[0])
1104
                                 locale=tg.i18n.get_lang()[0])
1017
     def datetime_as_delta(self, datetime_object,
1106
     def datetime_as_delta(self, datetime_object,
1018
                           delta_from_datetime:datetime=None):
1107
                           delta_from_datetime:datetime=None):
1019
         if not delta_from_datetime:
1108
         if not delta_from_datetime:
1020
-            delta_from_datetime = datetime.now()
1109
+            delta_from_datetime = datetime.utcnow()
1021
         return format_timedelta(delta_from_datetime - datetime_object,
1110
         return format_timedelta(delta_from_datetime - datetime_object,
1022
                                 locale=tg.i18n.get_lang()[0])
1111
                                 locale=tg.i18n.get_lang()[0])
1023
 
1112
 
1032
         return child_nb
1121
         return child_nb
1033
 
1122
 
1034
     def get_label(self):
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
     def get_status(self) -> ContentStatus:
1132
     def get_status(self) -> ContentStatus:
1038
         return ContentStatus(self.status, self.type.__str__())
1133
         return ContentStatus(self.status, self.type.__str__())
1093
         return last_comment
1188
         return last_comment
1094
 
1189
 
1095
     def get_previous_revision(self) -> 'ContentRevisionRO':
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
         rev_ids.sort()
1192
         rev_ids.sort()
1098
 
1193
 
1099
         if len(rev_ids)>=2:
1194
         if len(rev_ids)>=2:
1100
             revision_rev_id = rev_ids[-2]
1195
             revision_rev_id = rev_ids[-2]
1101
 
1196
 
1102
-            for revision in self.revisions:
1197
+            for revision in self.clean_revisions:
1103
                 if revision.revision_id == revision_rev_id:
1198
                 if revision.revision_id == revision_rev_id:
1104
                     return revision
1199
                     return revision
1105
 
1200
 
1128
         events = []
1223
         events = []
1129
         for comment in self.get_comments():
1224
         for comment in self.get_comments():
1130
             events.append(VirtualEvent.create_from_content(comment))
1225
             events.append(VirtualEvent.create_from_content(comment))
1131
-        for revision in self.revisions:
1226
+        for revision in self.clean_revisions:
1132
             events.append(VirtualEvent.create_from_content_revision(revision))
1227
             events.append(VirtualEvent.create_from_content_revision(revision))
1133
 
1228
 
1134
         sorted_events = sorted(events,
1229
         sorted_events = sorted(events,
1232
 
1327
 
1233
     def created_as_delta(self, delta_from_datetime:datetime=None):
1328
     def created_as_delta(self, delta_from_datetime:datetime=None):
1234
         if not delta_from_datetime:
1329
         if not delta_from_datetime:
1235
-            delta_from_datetime = datetime.now()
1330
+            delta_from_datetime = datetime.utcnow()
1236
         return format_timedelta(delta_from_datetime - self.created,
1331
         return format_timedelta(delta_from_datetime - self.created,
1237
                                 locale=tg.i18n.get_lang()[0])
1332
                                 locale=tg.i18n.get_lang()[0])
1238
 
1333
 
1240
         aff = ''
1335
         aff = ''
1241
 
1336
 
1242
         if not delta_from_datetime:
1337
         if not delta_from_datetime:
1243
-            delta_from_datetime = datetime.now()
1338
+            delta_from_datetime = datetime.utcnow()
1244
 
1339
 
1245
         delta = delta_from_datetime - self.created
1340
         delta = delta_from_datetime - self.created
1246
         
1341
         

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

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
+import cherrypy
3
+import os
4
+
2
 import types
5
 import types
3
 
6
 
4
 from bs4 import BeautifulSoup
7
 from bs4 import BeautifulSoup
10
 from tg.i18n import ugettext as _
13
 from tg.i18n import ugettext as _
11
 from tg.util import LazyString
14
 from tg.util import LazyString
12
 from tracim.lib.base import logger
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
 from tracim.model.auth import Profile
17
 from tracim.model.auth import Profile
16
 from tracim.model.auth import User
18
 from tracim.model.auth import User
17
 from tracim.model.data import BreadcrumbItem, ActionDescription
19
 from tracim.model.data import BreadcrumbItem, ActionDescription
86
     USER = 'USER'
88
     USER = 'USER'
87
     USERS = 'USERS'
89
     USERS = 'USERS'
88
     WORKSPACE = 'WORKSPACE'
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
 class DictLikeClass(dict):
96
 class DictLikeClass(dict):
151
         self.context_string = context_string
156
         self.context_string = context_string
152
         self._current_user = current_user  # Allow to define the current user if any
157
         self._current_user = current_user  # Allow to define the current user if any
153
         if not current_user:
158
         if not current_user:
154
-            self._current_user = UserStaticApi.get_current_user()
159
+            self._current_user = CurrentUserGetterApi.get_current_user()
155
 
160
 
156
         self._base_url = base_url # real root url like http://mydomain.com:8080
161
         self._base_url = base_url # real root url like http://mydomain.com:8080
157
 
162
 
158
     def url(self, base_url='/', params=None, qualified=False) -> str:
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
         if self._base_url:
171
         if self._base_url:
162
             url = '{}{}'.format(self._base_url, url)
172
             url = '{}{}'.format(self._base_url, url)
259
 def serialize_version_for_page_or_file(version: ContentRevisionRO, context: Context):
269
 def serialize_version_for_page_or_file(version: ContentRevisionRO, context: Context):
260
     return DictLikeClass(
270
     return DictLikeClass(
261
         id = version.revision_id,
271
         id = version.revision_id,
262
-        label = version.label if version.label else version.file_name,
272
+        label = version.label,
263
         owner = context.toDict(version.owner),
273
         owner = context.toDict(version.owner),
264
         created = version.created,
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
 
292
 
283
     result = DictLikeClass(
293
     result = DictLikeClass(
284
         id = content.content_id,
294
         id = content.content_id,
285
-        label = content.label if content.label else content.file_name,
295
+        label = content.label,
286
         icon = ContentType.get_icon(content.type),
296
         icon = ContentType.get_icon(content.type),
287
         status = context.toDict(content.get_status()),
297
         status = context.toDict(content.get_status()),
288
         folder = context.toDict(DictLikeClass(id = content.parent.content_id if content.parent else None)),
298
         folder = context.toDict(DictLikeClass(id = content.parent.content_id if content.parent else None)),
338
     if content.type==ContentType.File:
348
     if content.type==ContentType.File:
339
         result = DictLikeClass(
349
         result = DictLikeClass(
340
             id = content.content_id,
350
             id = content.content_id,
341
-            label = content.label if content.label else content.file_name,
351
+            label = content.label,
342
             status = context.toDict(content.get_status()),
352
             status = context.toDict(content.get_status()),
343
             folder = Context(CTX.DEFAULT).toDict(content.parent)
353
             folder = Context(CTX.DEFAULT).toDict(content.parent)
344
         )
354
         )
386
             revisions=context.toDict(sorted(content.revisions, key=lambda v: v.created, reverse=True)),
396
             revisions=context.toDict(sorted(content.revisions, key=lambda v: v.created, reverse=True)),
387
             selected_revision='latest' if content.revision_to_serialize<=0 else content.revision_to_serialize,
397
             selected_revision='latest' if content.revision_to_serialize<=0 else content.revision_to_serialize,
388
             history=Context(CTX.CONTENT_HISTORY).toDict(content.get_history()),
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
             urls = context.toDict({
402
             urls = context.toDict({
390
                 'mark_read': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_read', content)),
403
                 'mark_read': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_read', content)),
391
                 'mark_unread': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_unread', content))
404
                 'mark_unread': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_unread', content))
393
         )
406
         )
394
 
407
 
395
         if content.type==ContentType.File:
408
         if content.type==ContentType.File:
396
-            result.label = content.label if content.label else content.file_name
409
+            result.label = content.label
397
             result['file'] = DictLikeClass(
410
             result['file'] = DictLikeClass(
398
                 name = data_container.file_name,
411
                 name = data_container.file_name,
399
                 size = len(data_container.file_content),
412
                 size = len(data_container.file_content),
455
             comments = reversed(context.toDict(item.get_comments())),
468
             comments = reversed(context.toDict(item.get_comments())),
456
             is_new=item.has_new_information_for(context.get_user()),
469
             is_new=item.has_new_information_for(context.get_user()),
457
             history = Context(CTX.CONTENT_HISTORY).toDict(item.get_history()),
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
             urls = context.toDict({
474
             urls = context.toDict({
459
                 'mark_read': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_read', item)),
475
                 'mark_read': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_read', item)),
460
                 'mark_unread': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_unread', item))
476
                 'mark_unread': context.url(Content.format_path('/workspaces/{wid}/folders/{fid}/{ctype}s/{cid}/put_unread', item))
544
                 all = page_nb_all,
560
                 all = page_nb_all,
545
                 open = page_nb_open,
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
     return result
567
     return result
592
                                     open=folder_nb_open),
609
                                     open=folder_nb_open),
593
             page_nb=DictLikeClass(all=page_nb_all,
610
             page_nb=DictLikeClass(all=page_nb_all,
594
                                   open=page_nb_open),
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
     elif content.type==ContentType.Page:
618
     elif content.type==ContentType.Page:
618
     last_activity_date = content.get_last_activity_date()
638
     last_activity_date = content.get_last_activity_date()
619
     last_activity_date_formatted = format_datetime(last_activity_date,
639
     last_activity_date_formatted = format_datetime(last_activity_date,
620
                                                    locale=tg.i18n.get_lang()[0])
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
     last_activity_label = last_activity_label.replace(' ', '\u00A0') # espace insécable
645
     last_activity_label = last_activity_label.replace(' ', '\u00A0') # espace insécable
624
 
646
 
625
     return DictLikeClass(
647
     return DictLikeClass(
630
         url=ContentType.fill_url(content),
652
         url=ContentType.fill_url(content),
631
         type=DictLikeClass(content_type.toDict()),
653
         type=DictLikeClass(content_type.toDict()),
632
         status=context.toDict(content.get_status()),
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
         last_activity = DictLikeClass({'date': last_activity_date,
658
         last_activity = DictLikeClass({'date': last_activity_date,
634
                                        'label': last_activity_date_formatted,
659
                                        'label': last_activity_date_formatted,
635
                                        'delta': last_activity_label})
660
                                        'delta': last_activity_label})
642
     last_activity_date = content.get_last_activity_date()
667
     last_activity_date = content.get_last_activity_date()
643
     last_activity_date_formatted = format_datetime(last_activity_date,
668
     last_activity_date_formatted = format_datetime(last_activity_date,
644
                                                    locale=tg.i18n.get_lang()[0])
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
                                            locale=tg.i18n.get_lang()[0])
671
                                            locale=tg.i18n.get_lang()[0])
647
     last_activity_label = last_activity_label.replace(' ', '\u00A0') # espace insécable
672
     last_activity_label = last_activity_label.replace(' ', '\u00A0') # espace insécable
648
 
673
 
701
         item = Context(CTX.CONTENT_LIST).toDict(content)
726
         item = Context(CTX.CONTENT_LIST).toDict(content)
702
         item.notes = ''
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
     return item
733
     return item
705
 
734
 
706
 
735
 
758
         )
787
         )
759
 
788
 
760
         if content.type==ContentType.File:
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
         if not result.label or ''==result.label:
792
         if not result.label or ''==result.label:
764
             result.label = 'No title'
793
             result.label = 'No title'
855
     result['enabled'] = user.is_active
884
     result['enabled'] = user.is_active
856
     result['profile'] = user.profile
885
     result['profile'] = user.profile
857
     result['has_password'] = user.password!=None
886
     result['has_password'] = user.password!=None
887
+    result['timezone'] = user.timezone
858
     return result
888
     return result
859
 
889
 
860
 
890
 
877
     result['enabled'] = user.is_active
907
     result['enabled'] = user.is_active
878
     result['profile'] = user.profile
908
     result['profile'] = user.profile
879
     result['calendar_url'] = user.calendar_url
909
     result['calendar_url'] = user.calendar_url
910
+    result['timezone'] = user.timezone
880
 
911
 
881
     return result
912
     return result
882
 
913
 
1023
             type='workspace',
1054
             type='workspace',
1024
             state={'opened': True if len(item.children)>0 else False, 'selected': item.is_selected}
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 查看文件


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