Browse Source

import backend repository

Guénaël Muller 5 years ago
parent
commit
a8f2b5e6e2
100 changed files with 25873 additions and 0 deletions
  1. 6 0
      backend/.coveragerc
  2. 67 0
      backend/.gitignore
  3. 32 0
      backend/.travis.yml
  4. 4 0
      backend/CHANGES.txt
  5. 2 0
      backend/MANIFEST.in
  6. 174 0
      backend/README.md
  7. 278 0
      backend/development.ini.sample
  8. 56 0
      backend/doc/cli.md
  9. 28 0
      backend/doc/devtools.md
  10. 36 0
      backend/doc/migration.md
  11. 50 0
      backend/doc/roles.md
  12. 46 0
      backend/doc/setting.md
  13. 65 0
      backend/production.ini
  14. 4 0
      backend/pytest.ini
  15. 73 0
      backend/requirements.txt
  16. 116 0
      backend/setup.py
  17. 65 0
      backend/tests_configs.ini
  18. 141 0
      backend/tracim/__init__.py
  19. 97 0
      backend/tracim/command/__init__.py
  20. 137 0
      backend/tracim/command/database.py
  21. 254 0
      backend/tracim/command/user.py
  22. 27 0
      backend/tracim/command/webdav.py
  23. 462 0
      backend/tracim/config.py
  24. 205 0
      backend/tracim/exceptions.py
  25. 3 0
      backend/tracim/extensions.py
  26. 48 0
      backend/tracim/fixtures/__init__.py
  27. 319 0
      backend/tracim/fixtures/content.py
  28. 58 0
      backend/tracim/fixtures/ldap.py
  29. 72 0
      backend/tracim/fixtures/users_and_groups.py
  30. 0 0
      backend/tracim/lib/__init__.py
  31. 1 0
      backend/tracim/lib/core/__init__.py
  32. 1543 0
      backend/tracim/lib/core/content.py
  33. 47 0
      backend/tracim/lib/core/group.py
  34. 57 0
      backend/tracim/lib/core/notifications.py
  35. 377 0
      backend/tracim/lib/core/user.py
  36. 198 0
      backend/tracim/lib/core/userworkspace.py
  37. 283 0
      backend/tracim/lib/core/workspace.py
  38. 61 0
      backend/tracim/lib/mail_notifier/daemon.py
  39. 571 0
      backend/tracim/lib/mail_notifier/notifier.py
  40. 114 0
      backend/tracim/lib/mail_notifier/sender.py
  41. 33 0
      backend/tracim/lib/mail_notifier/utils.py
  42. 1 0
      backend/tracim/lib/utils/__init__.py
  43. 57 0
      backend/tracim/lib/utils/authentification.py
  44. 185 0
      backend/tracim/lib/utils/authorization.py
  45. 77 0
      backend/tracim/lib/utils/cors.py
  46. 47 0
      backend/tracim/lib/utils/logger.py
  47. 399 0
      backend/tracim/lib/utils/request.py
  48. 12 0
      backend/tracim/lib/utils/translation.py
  49. 74 0
      backend/tracim/lib/utils/utils.py
  50. 152 0
      backend/tracim/lib/webdav/__init__.py
  51. 49 0
      backend/tracim/lib/webdav/authentification.py
  52. 370 0
      backend/tracim/lib/webdav/dav_provider.py
  53. 385 0
      backend/tracim/lib/webdav/design.py
  54. 275 0
      backend/tracim/lib/webdav/lock_storage.py
  55. 295 0
      backend/tracim/lib/webdav/middlewares.py
  56. 28 0
      backend/tracim/lib/webdav/model.py
  57. 1478 0
      backend/tracim/lib/webdav/resources.py
  58. 212 0
      backend/tracim/lib/webdav/utils.py
  59. 78 0
      backend/tracim/migration/env.py
  60. 22 0
      backend/tracim/migration/script.py.mako
  61. 26 0
      backend/tracim/migration/versions/2b4043fa2502_remove_webdav_right_digest_response_.py
  62. 26 0
      backend/tracim/migration/versions/ad79f58ec2bf_tracim_v2.py
  63. 95 0
      backend/tracim/models/__init__.py
  64. 99 0
      backend/tracim/models/applications.py
  65. 330 0
      backend/tracim/models/auth.py
  66. 302 0
      backend/tracim/models/contents.py
  67. 810 0
      backend/tracim/models/context_models.py
  68. 1522 0
      backend/tracim/models/data.py
  69. 19 0
      backend/tracim/models/meta.py
  70. 54 0
      backend/tracim/models/organisational.py
  71. 97 0
      backend/tracim/models/revision_protection.py
  72. 61 0
      backend/tracim/models/roles.py
  73. 71 0
      backend/tracim/models/workspace_menu_entries.py
  74. 0 0
      backend/tracim/templates/mail/__init__.py
  75. 73 0
      backend/tracim/templates/mail/content_update_body_html.mak
  76. 31 0
      backend/tracim/templates/mail/content_update_body_text.mak
  77. 88 0
      backend/tracim/templates/mail/created_account_body_html.mak
  78. 25 0
      backend/tracim/templates/mail/created_account_body_text.mak
  79. 293 0
      backend/tracim/tests/__init__.py
  80. 1 0
      backend/tracim/tests/commands/__init__.py
  81. 200 0
      backend/tracim/tests/commands/test_commands.py
  82. 0 0
      backend/tracim/tests/functional/__init__.py
  83. 304 0
      backend/tracim/tests/functional/test_comments.py
  84. 2134 0
      backend/tracim/tests/functional/test_contents.py
  85. 44 0
      backend/tracim/tests/functional/test_doc.py
  86. 267 0
      backend/tracim/tests/functional/test_mail_notification.py
  87. 214 0
      backend/tracim/tests/functional/test_session.py
  88. 152 0
      backend/tracim/tests/functional/test_system.py
  89. 2290 0
      backend/tracim/tests/functional/test_user.py
  90. 1984 0
      backend/tracim/tests/functional/test_workspaces.py
  91. 2 0
      backend/tracim/tests/library/__init__.py
  92. 2668 0
      backend/tracim/tests/library/test_content_api.py
  93. 74 0
      backend/tracim/tests/library/test_group_api.py
  94. 41 0
      backend/tracim/tests/library/test_notification.py
  95. 77 0
      backend/tracim/tests/library/test_role_api.py
  96. 216 0
      backend/tracim/tests/library/test_user_api.py
  97. 660 0
      backend/tracim/tests/library/test_webdav.py
  98. 115 0
      backend/tracim/tests/library/test_workspace.py
  99. 2 0
      backend/tracim/tests/models/__init__.py
  100. 0 0
      backend/tracim/tests/models/test_content.py

+ 6 - 0
backend/.coveragerc View File

@@ -0,0 +1,6 @@
1
+[run]
2
+source = tracim
3
+omit = tracim/test*
4
+
5
+[report]
6
+omit = tests/*

+ 67 - 0
backend/.gitignore View File

@@ -0,0 +1,67 @@
1
+# Byte-compiled / optimized / DLL files
2
+__pycache__/
3
+*.py[cod]
4
+
5
+# C extensions
6
+*.so
7
+
8
+# Distribution / packaging
9
+.Python
10
+env/
11
+build/
12
+develop-eggs/
13
+eggs/
14
+#lib/
15
+lib64/
16
+parts/
17
+sdist/
18
+var/
19
+*.egg-info/
20
+.installed.cfg
21
+*.egg
22
+
23
+# Installer logs
24
+pip-log.txt
25
+pip-delete-this-directory.txt
26
+
27
+# Unit test / coverage reports
28
+htmlcov/
29
+.tox/
30
+.coverage
31
+.cache
32
+nosetests.xml
33
+coverage.xml
34
+.pytest_cache
35
+
36
+# Mr Developer
37
+.mr.developer.cfg
38
+.project
39
+.pydevproject
40
+
41
+# Rope
42
+.ropeproject
43
+
44
+# Django stuff:
45
+*.log
46
+*.pot
47
+
48
+# Sphinx documentation
49
+docs/_build/
50
+
51
+# Vim and dev tools
52
+*.swp
53
+.idea
54
+
55
+# Site-local config file
56
+development.ini
57
+track.js
58
+wsgidav.conf
59
+# Temporary files
60
+*~
61
+*.sqlite
62
+*.lock
63
+depot/
64
+
65
+# binary translation files
66
+*.mo
67
+.mypy_cache/

+ 32 - 0
backend/.travis.yml View File

@@ -0,0 +1,32 @@
1
+sudo: false
2
+language: python
3
+python:
4
+  - "3.4"
5
+  - "3.5"
6
+  - "3.6"
7
+
8
+addons:
9
+  apt:
10
+    packages:
11
+    - libreoffice
12
+    - imagemagick
13
+    - libmagickwand-dev
14
+    - ghostscript
15
+services:
16
+  - docker
17
+  - redis-server
18
+
19
+before_install:
20
+  - docker pull mailhog/mailhog
21
+  - docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
22
+install:
23
+  - pip install --upgrade pip setuptools
24
+  - pip install -e ".[testing]"
25
+  - pip install pytest-cov
26
+  - pip install python-coveralls
27
+
28
+script:
29
+ - py.test --cov tracim
30
+
31
+after_success:
32
+  - coveralls

+ 4 - 0
backend/CHANGES.txt View File

@@ -0,0 +1,4 @@
1
+1.9.1
2
+---
3
+
4
+- Begin Pyramid Project for Tracim v2 : Prototype step

+ 2 - 0
backend/MANIFEST.in View File

@@ -0,0 +1,2 @@
1
+include *.txt *.ini *.cfg *.rst
2
+recursive-include tracim *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml *.jinja2

+ 174 - 0
backend/README.md View File

@@ -0,0 +1,174 @@
1
+[![Build Status](https://travis-ci.org/tracim/tracim_backend.svg?branch=master)](https://travis-ci.org/tracim/tracim_backend)
2
+[![Coverage Status](https://coveralls.io/repos/github/tracim/tracim_backend/badge.svg?branch=master)](https://coveralls.io/github/tracim/tracim_backend?branch=master)
3
+[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/tracim/tracim_backend/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/tracim/tracim_backend/?branch=master)
4
+
5
+tracim_backend
6
+==============
7
+
8
+This code is "work in progress". Not usable at all for production.
9
+
10
+Backend source code of tracim v2, using Pyramid Framework.
11
+
12
+Installation
13
+---------------
14
+
15
+### Distribution dependencies ###
16
+
17
+on Debian Stretch (9) with sudo:
18
+
19
+    sudo apt update
20
+    sudo apt install git
21
+    sudo apt install python3 python3-venv python3-dev python3-pip
22
+    sudo apt install redis-server
23
+    sudo apt install zlib1g-dev libjpeg-dev
24
+    sudo apt install imagemagick libmagickwand-dev ghostscript
25
+
26
+for better preview support:
27
+
28
+    sudo apt install libreoffice # most office documents file and text format
29
+    sudo apt install inkscape # for .svg files.
30
+
31
+### Get the source ###
32
+
33
+get source from github:
34
+
35
+    git clone https://github.com/tracim/tracim_backend.git
36
+
37
+go to *tracim_backend* directory:
38
+
39
+    cd tracim_backend
40
+
41
+### Setup Python Virtualenv ###
42
+
43
+Create a Python virtual environment:
44
+
45
+    python3 -m venv env
46
+
47
+Activate it in your terminal session (**all tracim command execution must be executed under this virtual environment**):
48
+
49
+    source env/bin/activate
50
+
51
+Upgrade packaging tools:
52
+
53
+    pip install --upgrade pip setuptools
54
+
55
+Install the project in editable mode with its testing requirements :
56
+
57
+    pip install -e ".[testing]"
58
+
59
+If you want to use postgresql, mysql or other databases
60
+than the default one: sqlite, you need to install python driver for those databases
61
+that are supported by sqlalchemy.
62
+
63
+For postgreSQL and mySQL, those are shortcuts to install Tracim with test and
64
+specific driver.
65
+
66
+For PostgreSQL:
67
+
68
+    pip install -e ".[testing,postgresql]"
69
+
70
+For mySQL:
71
+
72
+    pip install -e ".[testing,mysql]"
73
+
74
+### Configure Tracim_backend ###
75
+
76
+Create [configuration file](doc/setting.md) for a development environment:
77
+
78
+    cp development.ini.sample development.ini
79
+
80
+Initialize the database using [tracimcli](doc/cli.md) tool
81
+
82
+    tracimcli db init
83
+
84
+create wsgidav configuration file for webdav:
85
+
86
+    cp wsgidav.conf.sample wsgidav.conf
87
+
88
+## Run Tracim_backend ##
89
+
90
+### With Uwsgi ###
91
+
92
+Run all services with uwsgi
93
+
94
+    # install uwsgi with pip ( unneeded if you already have uwsgi with python3 plugin enabled)
95
+    sudo pip3 install uwsgi
96
+    # set tracim_conf_file path
97
+    export TRACIM_CONF_PATH="$(pwd)/development.ini"
98
+    export TRACIM_WEBDAV_CONF_PATH="$(pwd)/wsgidav.conf"
99
+    # pyramid webserver
100
+    uwsgi -d /tmp/tracim_web.log --http-socket :6543 --wsgi-file wsgi/web.py -H env --pidfile /tmp/tracim_web.pid
101
+    # webdav wsgidav server
102
+    uwsgi -d /tmp/tracim_webdav.log --http-socket :3030 --wsgi-file wsgi/webdav.py -H env --pidfile /tmp/tracim_webdav.pid
103
+
104
+to stop them:
105
+
106
+    # pyramid webserver
107
+    uwsgi --stop /tmp/tracim_web.pid
108
+    # webdav wsgidav server
109
+    uwsgi --stop /tmp/tracim_webdav.pid
110
+
111
+### With Waitress (legacy way, usefull for debug) ###
112
+
113
+run tracim_backend web api:
114
+
115
+    pserve development.ini
116
+
117
+run wsgidav server:
118
+
119
+    tracimcli webdav start
120
+
121
+
122
+## Run Tests and others checks ##
123
+
124
+### Run Tests ###
125
+
126
+Before running some functional test related to email, you need a local working *MailHog*
127
+see here : https://github.com/mailhog/MailHog
128
+
129
+You can run it this way with docker :
130
+
131
+    docker pull mailhog/mailhog
132
+    docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
133
+
134
+Run your project's tests:
135
+
136
+    pytest
137
+
138
+### Lints and others checks ###
139
+
140
+Run mypy checks:
141
+
142
+    mypy --ignore-missing-imports --disallow-untyped-defs tracim
143
+
144
+Run pep8 checks:
145
+
146
+    pep8 tracim
147
+
148
+Tracim API
149
+----------
150
+
151
+Tracim_backend give access to a REST API in */api/v2*.
152
+This API is auto-documented with [Hapic](https://github.com/algoo/hapic).
153
+The specification is accessible when you run Tracim, go to */api/v2/doc* .
154
+
155
+For example, with default config:
156
+
157
+    # run tracim
158
+    pserve development.ini
159
+    # launch your favorite web-browser
160
+    firefox http://localhost:6543/api/v2/doc/
161
+
162
+## Roles, profile and access rights
163
+
164
+In Tracim, only some user can access to some informations, this is also true in
165
+Tracim REST API. you can check the [roles documentation](doc/roles.md) to check
166
+what a specific user can do.
167
+
168
+
169
+CI
170
+---
171
+
172
+* Code quality: https://scrutinizer-ci.com/g/tracim/tracim_backend/
173
+* Test validation: https://travis-ci.org/tracim/tracim_backend
174
+* Code coverage: https://coveralls.io/github/tracim/tracim_backend

+ 278 - 0
backend/development.ini.sample View File

@@ -0,0 +1,278 @@
1
+###
2
+# app configuration
3
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html
4
+###
5
+[pipeline:main]
6
+pipeline = tracim_web
7
+[app:tracim_web]
8
+use = egg:tracim_backend
9
+
10
+pyramid.reload_templates = true
11
+pyramid.debug_authorization = false
12
+pyramid.debug_notfound = false
13
+pyramid.debug_routematch = false
14
+pyramid.default_locale_name = en
15
+pyramid.includes =
16
+    pyramid_debugtoolbar
17
+
18
+[pipeline:webdav]
19
+pipeline = tracim_webdav
20
+[app:tracim_webdav]
21
+use = egg:tracim_backend#webdav
22
+
23
+[DEFAULT]
24
+sqlalchemy.url = sqlite:///%(here)s/tracim.sqlite
25
+
26
+retry.attempts = 3
27
+
28
+# By default, the toolbar only appears for clients from IP addresses
29
+# '127.0.0.1' and '::1'.
30
+# debugtoolbar.hosts = 127.0.0.1 ::1
31
+
32
+###
33
+# TRACIM SPECIFIC CONF
34
+###
35
+
36
+### Global
37
+
38
+cache_dir = %(here)s/data
39
+# preview generator cache directory
40
+preview_cache_dir = /tmp/tracim/preview/
41
+# file depot storage
42
+depot_storage_name = tracim
43
+depot_storage_dir = %(here)s/depot/
44
+
45
+# The following parameters allow to personalize the home page
46
+# They are html ready (you can put html tags they will be interpreted)
47
+website.title = TRACIM
48
+website.title.color = #555
49
+website.home.subtitle = Default login: email: admin@admin.admin (password: admin@admin.admin)
50
+website.home.tag_line = <div class="text-center" style="font-weight: bold;">Collaboration, versionning and traceability</div>
51
+website.home.below_login_form = in case of problem, please contact the administrator.
52
+# Values may be 'all' or 'folders'
53
+website.treeview.content = all
54
+# The following base_url is used for links and icons
55
+# integrated in the email notifcations
56
+website.base_url = http://127.0.0.1:8080
57
+# If config not provided, it will be extracted from website.base_url
58
+website.server_name = 127.0.0.1
59
+
60
+# Specifies if the update of comments and attached files is allowed (by the owner only).
61
+# Examples:
62
+#    600 means 10 minutes (ie 600 seconds)
63
+#   3600 means 1 hour (60x60 seconds)
64
+#
65
+# Allowed values:
66
+#  -1 means that content update is allowed for ever
67
+#   0 means that content update is not allowed
68
+#   x means that content update is allowed for x seconds (with x>0)
69
+content.update.allowed.duration = 3600
70
+
71
+# Auth type (internal or ldap)
72
+auth_type = internal
73
+# If auth_type is ldap, uncomment following ldap_* parameters
74
+# LDAP server address
75
+# ldap_url = ldap://localhost:389
76
+# Base dn to make queries
77
+# ldap_base_dn = dc=directory,dc=fsf,dc=org
78
+# Bind dn to identify the search
79
+# ldap_bind_dn = cn=admin,dc=directory,dc=fsf,dc=org
80
+# The bind password
81
+# ldap_bind_pass = toor
82
+# Attribute name of user record who contain user login (email)
83
+# ldap_ldap_naming_attribute = uid
84
+# Matching between ldap attribute and ldap user field (ldap_attr1=user_field1,ldap_attr2=user_field2,...)
85
+# ldap_user_attributes = mail=email
86
+# TLS usage to communicate with your LDAP server
87
+# ldap_tls = False
88
+# If True, LDAP own tracim group managment (not available for now!)
89
+# ldap_group_enabled = False
90
+# User auth token validity in seconds (used to interfaces like web calendars)
91
+user.auth_token.validity = 604800
92
+
93
+### Mail
94
+
95
+# Reset password through email related configuration.
96
+# These emails will be sent through SMTP
97
+#
98
+resetpassword.email_sender = email@sender.com
99
+resetpassword.smtp_host = smtp.sender
100
+resetpassword.smtp_port = 25
101
+resetpassword.smtp_login = smtp.login
102
+resetpassword.smtp_passwd = smtp.password
103
+
104
+email.notification.activated = False
105
+# email.notification.log_file_path = /tmp/mail-notifications.log
106
+# email notifications can be sent with the user_id added as an identifier
107
+# this way email clients like Thunderbird will be able to distinguish
108
+# notifications generated by a user or another one
109
+email.notification.from.email = noreply+{user_id}@trac.im
110
+email.notification.from.default_label = Tracim Notifications
111
+email.notification.reply_to.email = reply+{content_id}@trac.im
112
+email.notification.references.email = thread+{content_id}@trac.im
113
+email.notification.content_update.template.html = %(here)s/tracim/templates/mail/content_update_body_html.mak
114
+email.notification.content_update.template.text = %(here)s/tracim/templates/mail/content_update_body_text.mak
115
+email.notification.created_account.template.html = %(here)s/tracim/templates/mail/created_account_body_html.mak
116
+email.notification.created_account.template.text = %(here)s/tracim/templates/mail/created_account_body_text.mak
117
+# Note: items between { and } are variable names. Do not remove / rename them
118
+email.notification.content_update.subject = [{website_title}] [{workspace_label}] {content_label} ({content_status_label})
119
+email.notification.created_account.subject = [{website_title}] Created account
120
+# processing_mode may be sync or async
121
+email.notification.processing_mode = sync
122
+email.notification.smtp.server = your_smtp_server
123
+email.notification.smtp.port = 25
124
+email.notification.smtp.user = your_smtp_user
125
+email.notification.smtp.password = your_smtp_password
126
+
127
+## Email sending configuration
128
+# processing_mode may be sync or async,
129
+# with async, please configure redis below
130
+email.processing_mode = sync
131
+# email.async.redis.host = localhost
132
+# email.async.redis.port = 6379
133
+# email.async.redis.db = 0
134
+
135
+# Email reply configuration
136
+email.reply.activated = False
137
+email.reply.imap.server = your_imap_server
138
+email.reply.imap.port = 993
139
+email.reply.imap.user = your_imap_user
140
+email.reply.imap.password = your_imap_password
141
+email.reply.imap.folder = INBOX
142
+email.reply.imap.use_ssl = true
143
+email.reply.imap.use_idle = true
144
+# Re-new connection each 10 minutes
145
+email.reply.connection.max_lifetime = 600
146
+# Token for communication between mail fetcher and tracim controller
147
+email.reply.token = mysecuretoken
148
+# Delay in seconds between each check
149
+email.reply.check.heartbeat = 60
150
+email.reply.use_html_parsing = true
151
+email.reply.use_txt_parsing = true
152
+# Lockfile path is required for email_reply feature,
153
+# it's just an empty file use to prevent concurrent access to imap unseen mail
154
+email.reply.lockfile_path = %(here)s/email_fetcher.lock
155
+
156
+### Radical (CalDav server) configuration
157
+
158
+# radicale.server.host = 0.0.0.0
159
+# radicale.server.port = 5232
160
+# radicale.server.ssl = false
161
+radicale.server.filesystem.folder = %(here)s/radicale/collections/
162
+# radicale.server.allow_origin = *
163
+# radicale.server.realm_message = Tracim Calendar - Password Required
164
+## url can be extended like http://127.0.0.1:5232/calendar
165
+## in this case, you have to create your own proxy behind this url.
166
+## and update following parameters
167
+# radicale.client.base_url.host = http://127.0.0.1:5232
168
+# radicale.client.base_url.prefix = /
169
+
170
+### WSGIDAV
171
+
172
+wsgidav.config_path = %(here)s/wsgidav.conf
173
+## url can be extended like 127.0.0.1/webdav
174
+## in this case, you have to create your own proxy behind this url.
175
+## Do not set http:// prefix.
176
+# wsgidav.client.base_url = 127.0.0.1:<WSGIDAV_PORT>
177
+
178
+### Preview
179
+## You can parametrized allowed jpg preview dimension list, if not set, default
180
+## is 256x256. First {width}x{length} items is default preview dimensions.
181
+## all items should be separated by ',' and you should be really careful to do
182
+## set anything else than '{int}x{int}' item and ', ' separator
183
+# preview.jpg.allowed_dims = 256x256,1000x1000
184
+## Preview dimensions can be set as restricted, if set as restricted, access
185
+## endpoint to  to get any other preview dimensions than allowed_dims will
186
+## return error
187
+# preview.jpg.restricted_dims = True
188
+
189
+###
190
+# wsgi server configuration
191
+###
192
+
193
+[server:main]
194
+use = egg:waitress#main
195
+listen = localhost:6543
196
+
197
+[alembic]
198
+# path to migration scripts
199
+script_location = tracim/migration
200
+
201
+# template used to generate migration files
202
+# file_template = %%(rev)s_%%(slug)s
203
+
204
+# timezone to use when rendering the date
205
+# within the migration file as well as the filename.
206
+# string value is passed to dateutil.tz.gettz()
207
+# leave blank for localtime
208
+# timezone =
209
+
210
+# max length of characters to apply to the
211
+# "slug" field
212
+#truncate_slug_length = 40
213
+
214
+# set to 'true' to run the environment during
215
+# the 'revision' command, regardless of autogenerate
216
+# revision_environment = false
217
+
218
+# set to 'true' to allow .pyc and .pyo files without
219
+# a source .py file to be detected as revisions in the
220
+# versions/ directory
221
+# sourceless = false
222
+
223
+# version location specification; this defaults
224
+# to migrate/versions.  When using multiple version
225
+# directories, initial revisions must be specified with --version-path
226
+# version_locations = %(here)s/bar %(here)s/bat migrate/versions
227
+
228
+# the output encoding used when revision files
229
+# are written from script.py.mako
230
+# output_encoding = utf-8
231
+
232
+sqlalchemy.url = sqlite:///%(here)s/tracim.sqlite
233
+
234
+###
235
+# logging configuration
236
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html
237
+###
238
+
239
+[loggers]
240
+keys = root, tracim, sqlalchemy, alembic
241
+
242
+[handlers]
243
+keys = console
244
+
245
+[formatters]
246
+keys = generic
247
+
248
+[logger_root]
249
+level = INFO
250
+handlers = console
251
+
252
+[logger_tracim]
253
+level = DEBUG
254
+handlers =
255
+qualname = tracim
256
+
257
+[logger_sqlalchemy]
258
+level = INFO
259
+handlers =
260
+qualname = sqlalchemy.engine
261
+# "level = INFO" logs SQL queries.
262
+# "level = DEBUG" logs SQL queries and results.
263
+# "level = WARN" logs neither.  (Recommended for production systems.)
264
+
265
+[logger_alembic]
266
+level = INFO
267
+handlers =
268
+qualname = alembic
269
+
270
+[handler_console]
271
+class = StreamHandler
272
+args = (sys.stderr,)
273
+level = NOTSET
274
+formatter = generic
275
+
276
+[formatter_generic]
277
+format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s
278
+datefmt = %H:%M:%S

+ 56 - 0
backend/doc/cli.md View File

@@ -0,0 +1,56 @@
1
+# TracimCli #
2
+
3
+Tracim has a build-in command line tool.
4
+
5
+## Introduction ##
6
+
7
+This document is intended to developers or sysadmin.
8
+
9
+In order to use the `tracimcli` commands, go to the root of the project and
10
+and active the Tracim virtualenv:
11
+
12
+    user@host:~/tracim_backend$ source env/bin/activate
13
+    (env) user@host:~/tracim_backend$
14
+
15
+## Database ##
16
+
17
+### Create database
18
+
19
+    tracimcli db init
20
+
21
+### Create database with some default test data (many users, workspaces, etc…)
22
+
23
+    tracimcli db init --test-data
24
+
25
+### Delete database /!\
26
+
27
+This will drop all your database, be carefull !
28
+
29
+    tracimcli db delete --force
30
+
31
+## User ##
32
+   
33
+### add a user
34
+
35
+    tracimcli user create -l "john@john@john.john" -p "superpassword"
36
+
37
+### update user password
38
+
39
+    tracimcli user update -l "john@john@john.john" -p "mynewsuperpassword"
40
+
41
+### Help
42
+
43
+    tracim user create -h
44
+    tracim user update -h
45
+ 
46
+## Help ##
47
+
48
+    tracimcli -h
49
+    
50
+## Run services ##
51
+
52
+### Webdav wsgidav server ###
53
+
54
+    tracimcli webdav start
55
+
56
+

+ 28 - 0
backend/doc/devtools.md View File

@@ -0,0 +1,28 @@
1
+# Devtools
2
+
3
+# Check third party licences
4
+
5
+Install `yolk3k` pip package:
6
+
7
+    pip install yolk3k
8
+
9
+Then execute command:
10
+
11
+    yolk -l -f license
12
+
13
+Output will look like:
14
+
15
+```
16
+PyYAML (3.12) !
17
+    License: MIT
18
+
19
+Pygments (2.2.0) !
20
+    License: BSD License
21
+
22
+SQLAlchemy (1.2.5) !
23
+    License: MIT License
24
+
25
+Unidecode (1.0.22) !
26
+    License: GPL
27
+...
28
+```

+ 36 - 0
backend/doc/migration.md View File

@@ -0,0 +1,36 @@
1
+# Performing migrations #
2
+
3
+## Introduction ##
4
+
5
+This document is intended to developers.
6
+
7
+Migrations on `Tracim` lays on [`alembic`](http://alembic.zzzcomputing.com/en/latest/index.html) which is the migration tool dedicated to `SQLAlchemy`.
8
+
9
+In order to use the `tracimcli` commands, go to the root of the project and
10
+and active the Tracim virtualenv:
11
+
12
+    user@host:~/tracim_backend$ source env/bin/activate
13
+    (env) user@host:~/tracim_backend$
14
+
15
+## Migration howto - Overview ##
16
+   
17
+### Upgrading schema to last revision ###
18
+
19
+    alembic -c development.ini upgrade head
20
+
21
+### Downgrading schema ###
22
+
23
+    alembic -c development.ini downgrade -1
24
+
25
+## Migration howto - Advanced (for developers) ##
26
+
27
+### Retrieving schema current version ###
28
+
29
+    alembic -c development.ini current
30
+
31
+### Creating new schema migration ###
32
+
33
+This creates a new auto-generated python migration file 
34
+in `tracim/migration/versions/` ending by `migration_label.py`:
35
+
36
+    alembic -c development.ini revision --autogenerate -m "migration label"

+ 50 - 0
backend/doc/roles.md View File

@@ -0,0 +1,50 @@
1
+# Introduction
2
+
3
+In Tracim, you have 2 system of "roles".
4
+
5
+One is global to whole tracim instance and is called "global profile" (Groups).
6
+The other is workspace related and is called "workspace role".
7
+
8
+## Global profile
9
+
10
+
11
+|                               | Normal User | Managers    | Admin   |
12
+|-------------------------------|-------------|-------------|---------|
13
+| participate to workspaces     |  yes        | yes         | yes     |
14
+| access to tracim apps         |  yes        | yes         | yes     |
15
+|-------------------------------|-------------|-------------|---------|
16
+| create workspace              |  no         | yes         | yes     |
17
+| invite user to tracim         |  no         | yes, if manager of a given workspace         | yes     |
18
+|-------------------------------|-------------|-------------|---------|
19
+| set user global profile rights|  no         | no          | yes     |
20
+| deactivate user               |  no         | no          | yes     |
21
+|-------------------------------|-------------|-------------|---------|
22
+| access to all user data (/users/{user_id} endpoints) |personal-only|personal-only| yes     |
23
+
24
+
25
+## Workspace Roles
26
+
27
+
28
+|                              | Reader | Contributor | Content Manager | Workspace Manager |
29
+|------------------------------|--------|-------------|-----------------|-------------------|
30
+| read content                 |  yes   | yes         | yes             | yes               |
31
+|------------------------------|--------|-------------|-----------------|-------------------|
32
+| create content               |  no    | yes         | yes             | yes               |
33
+| edit content                 |  no    | yes         | yes             | yes               |
34
+| copy content                 |  no    | yes         | yes             | yes               |
35
+| comments content             |  no    | yes         | yes             | yes               |
36
+| update content status        |  no    | yes         | yes             | yes               |
37
+-------------------------------|--------|-------------|-----------------|-------------------|
38
+| move content                 |  no    | no          | yes             | yes               |
39
+| archive content              |  no    | no          | yes             | yes               |
40
+| delete content               |  no    | no          | yes             | yes               |
41
+|------------------------------|--------|-------------|-----------------|-------------------|
42
+| edit workspace               |  no    | no          | no              | yes               |
43
+| invite users (to workspace)  |  no    | no          | no              | yes               |
44
+| set user workspace role      |  no    | no          | no              | yes               |
45
+| revoke users (from workspace)|  no    | no          | no              | yes               |
46
+|------------------------------|--------|-------------|-----------------|-------------------|
47
+| modify comments              |  no    | owner       | owner             | yes             |
48
+| delete comments              |  no    | owner       | owner             | yes             |
49
+ 
50
+ 

+ 46 - 0
backend/doc/setting.md View File

@@ -0,0 +1,46 @@
1
+# Setting #
2
+
3
+Here is a short description of settings available in the file `development.ini`.
4
+
5
+## Listening port ##
6
+
7
+Default configuration is to listen on port 6534.
8
+If you want to adapt this to your environment, edit the `.ini` file and setup the port you want:
9
+
10
+    [server:main]
11
+    ...
12
+    listen = localhost:6543
13
+
14
+To allow other computer to access to this website, listen to "*" instead of localhost:
15
+
16
+    [server:main]
17
+    ...
18
+    listen = *:6534
19
+
20
+## Prod/Debug configuration ##
21
+
22
+
23
+To enable simple debug conf:
24
+
25
+    [app:main]
26
+    ...
27
+    pyramid.reload_templates = true
28
+    pyramid.debug_all = true
29
+    pyramid.includes =
30
+        pyramid_debugtoolbar
31
+
32
+production conf (no reload, no debugtoolbar):
33
+
34
+    [app:main]
35
+    ...
36
+    pyramid.reload_templates = false
37
+    pyramid.debug_authorization = false
38
+    pyramid.debug_notfound = false
39
+    pyramid.debug_routematch = false
40
+
41
+You can, of course, also set level of one of the different logger to have more/less log
42
+about something.
43
+
44
+    [logger_sqlalchemy]
45
+    ...
46
+    level = INFO

+ 65 - 0
backend/production.ini View File

@@ -0,0 +1,65 @@
1
+###
2
+# app configuration
3
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html
4
+###
5
+
6
+[app:main]
7
+use = egg:tracim
8
+
9
+pyramid.reload_templates = false
10
+pyramid.debug_authorization = false
11
+pyramid.debug_notfound = false
12
+pyramid.debug_routematch = false
13
+pyramid.default_locale_name = en
14
+
15
+sqlalchemy.url = sqlite:///%(here)s/tracim.sqlite
16
+
17
+retry.attempts = 3
18
+
19
+###
20
+# wsgi server configuration
21
+###
22
+
23
+[server:main]
24
+use = egg:waitress#main
25
+listen = *:6543
26
+
27
+###
28
+# logging configuration
29
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html
30
+###
31
+
32
+[loggers]
33
+keys = root, tracim, sqlalchemy
34
+
35
+[handlers]
36
+keys = console
37
+
38
+[formatters]
39
+keys = generic
40
+
41
+[logger_root]
42
+level = WARN
43
+handlers = console
44
+
45
+[logger_tracim]
46
+level = WARN
47
+handlers =
48
+qualname = tracim
49
+
50
+[logger_sqlalchemy]
51
+level = WARN
52
+handlers =
53
+qualname = sqlalchemy.engine
54
+# "level = INFO" logs SQL queries.
55
+# "level = DEBUG" logs SQL queries and results.
56
+# "level = WARN" logs neither.  (Recommended for production systems.)
57
+
58
+[handler_console]
59
+class = StreamHandler
60
+args = (sys.stderr,)
61
+level = NOTSET
62
+formatter = generic
63
+
64
+[formatter_generic]
65
+format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s

+ 4 - 0
backend/pytest.ini View File

@@ -0,0 +1,4 @@
1
+[pytest]
2
+minversion = 2.8
3
+testpaths = tracim
4
+addopts = -s -v

+ 73 - 0
backend/requirements.txt View File

@@ -0,0 +1,73 @@
1
+alembic==0.9.9
2
+atomicwrites==1.1.5
3
+attrs==18.1.0
4
+Babel==2.6.0
5
+beautifulsoup4==4.6.0
6
+certifi==2018.4.16
7
+chardet==3.0.4
8
+click==6.7
9
+cliff==2.12.0
10
+cmd2==0.9.1
11
+colorama==0.3.9
12
+coverage==4.5.1
13
+defusedxml==0.5.0
14
+filedepot==0.5.2
15
+hapic==0.42
16
+hapic-apispec==0.37.0
17
+hupper==1.3
18
+idna==2.7
19
+Jinja2==2.10
20
+jsmin==2.2.2
21
+lxml==4.2.1
22
+Mako==1.0.7
23
+MarkupSafe==1.0
24
+marshmallow==2.15.3
25
+more-itertools==4.2.0
26
+multidict==4.3.1
27
+mypy==0.610
28
+PasteDeploy==1.5.2
29
+pbr==4.0.4
30
+pep8==1.7.1
31
+pkg-resources==0.0.0
32
+plaster==1.0
33
+plaster-pastedeploy==0.5
34
+pluggy==0.6.0
35
+prettytable==0.7.2
36
+psycopg2==2.7.4
37
+py==1.5.3
38
+Pygments==2.2.0
39
+pyparsing==2.2.0
40
+pyperclip==1.6.2
41
+pyramid==1.9.2
42
+pyramid-debugtoolbar==4.4
43
+pyramid-jinja2==2.7
44
+pyramid-mako==1.0.2
45
+pyramid-retry==0.5
46
+pyramid-tm==2.2
47
+pytest==3.6.1
48
+pytest-cov==2.5.1
49
+python-dateutil==2.7.3
50
+python-editor==1.0.3
51
+pytz==2018.4
52
+PyYAML==3.12
53
+redis==2.10.6
54
+repoze.lru==0.7
55
+requests==2.19.1
56
+rq==0.11.0
57
+six==1.11.0
58
+SQLAlchemy==1.2.8
59
+stevedore==1.28.0
60
+transaction==2.2.1
61
+translationstring==1.3
62
+typed-ast==1.1.0
63
+Unidecode==1.0.22
64
+urllib3==1.23
65
+venusian==1.1.0
66
+waitress==1.1.0
67
+wcwidth==0.1.7
68
+WebOb==1.8.2
69
+WebTest==2.0.29
70
+WsgiDAV==2.4.0
71
+zope.deprecation==4.3.0
72
+zope.interface==4.5.0
73
+zope.sqlalchemy==1.0

+ 116 - 0
backend/setup.py View File

@@ -0,0 +1,116 @@
1
+import os
2
+
3
+import sys
4
+from setuptools import setup, find_packages
5
+
6
+here = os.path.abspath(os.path.dirname(__file__))
7
+with open(os.path.join(here, 'README.md')) as f:
8
+    README = f.read()
9
+with open(os.path.join(here, 'CHANGES.txt')) as f:
10
+    CHANGES = f.read()
11
+
12
+requires = [
13
+    # pyramid
14
+    'plaster_pastedeploy',
15
+    'pyramid >= 1.9a',
16
+    'pyramid_debugtoolbar',
17
+    'pyramid_jinja2',
18
+    'pyramid_retry',
19
+    'waitress',
20
+    # Database
21
+    'pyramid_tm',
22
+    'SQLAlchemy',
23
+    'transaction',
24
+    'zope.sqlalchemy',
25
+    'alembic',
26
+    # API
27
+    'hapic>=0.41',
28
+    'marshmallow <3.0.0a1,>2.0.0',
29
+    # CLI
30
+    'cliff',
31
+    # Webdav
32
+    'wsgidav',
33
+    'PyYAML',
34
+    # others
35
+    'filedepot',
36
+    'babel',
37
+    'python-slugify',
38
+    'preview-generator',
39
+    # mail-notifier
40
+    'mako',
41
+    'lxml',
42
+    'redis',
43
+    'rq',
44
+]
45
+
46
+tests_require = [
47
+    'WebTest >= 1.3.1',  # py3 compat
48
+    'pytest',
49
+    'pytest-cov',
50
+    'pep8',
51
+    'mypy',
52
+    'requests',
53
+    'Pillow'
54
+]
55
+
56
+mysql_require = [
57
+    'PyMySQL'
58
+]
59
+
60
+postgresql_require = [
61
+    'psycopg2',
62
+]
63
+# Python version adaptations
64
+if sys.version_info < (3, 5):
65
+    requires.append('typing')
66
+
67
+
68
+setup(
69
+    name='tracim_backend',
70
+    version='1.9.1',
71
+    description='Rest API (Back-end) of Tracim v2',
72
+    long_description=README + '\n\n' + CHANGES,
73
+    classifiers=[
74
+        'Development Status :: 2 - Pre-Alpha',
75
+        'Programming Language :: Python',
76
+        "Programming Language :: Python :: 3.4",
77
+        "Programming Language :: Python :: 3.5",
78
+        "Programming Language :: Python :: 3.6",
79
+        'Framework :: Pyramid',
80
+        'Topic :: Internet :: WWW/HTTP',
81
+        'Topic :: Internet :: WWW/HTTP :: WSGI :: Application',
82
+        'Topic :: Communications :: File Sharing',
83
+        'Topic :: Communications',
84
+        'License :: OSI Approved :: MIT License',
85
+    ],
86
+    author='',
87
+    author_email='',
88
+    url='https://github.com/tracim/tracim_backend',
89
+    keywords='web pyramid tracim ',
90
+    packages=find_packages(),
91
+    include_package_data=True,
92
+    zip_safe=False,
93
+    extras_require={
94
+        'testing': tests_require,
95
+        'mysql': mysql_require,
96
+        'postgresql': postgresql_require,
97
+    },
98
+    install_requires=requires,
99
+    entry_points={
100
+        'paste.app_factory': [
101
+            'main = tracim:web',
102
+            'webdav = tracim:webdav'
103
+        ],
104
+        'console_scripts': [
105
+            'tracimcli = tracim.command:main',
106
+        ],
107
+        'tracimcli': [
108
+            'test = tracim.command:TestTracimCommand',
109
+            'user_create = tracim.command.user:CreateUserCommand',
110
+            'user_update = tracim.command.user:UpdateUserCommand',
111
+            'db_init = tracim.command.database:InitializeDBCommand',
112
+            'db_delete = tracim.command.database:DeleteDBCommand',
113
+            'webdav start = tracim.command.webdav:WebdavRunnerCommand',
114
+        ]
115
+    },
116
+)

+ 65 - 0
backend/tests_configs.ini View File

@@ -0,0 +1,65 @@
1
+[base_test]
2
+sqlalchemy.url = sqlite:///:memory:
3
+depot_storage_name = test
4
+depot_storage_dir = /tmp/test/depot
5
+user.auth_token.validity = 604800
6
+preview_cache_dir = /tmp/test/preview_cache
7
+
8
+[app:command_test]
9
+use = egg:tracim_backend
10
+sqlalchemy.url = sqlite:///tracim_test.sqlite
11
+depot_storage_name = test
12
+depot_storage_dir = /tmp/test/depot
13
+user.auth_token.validity = 604800
14
+preview_cache_dir = /tmp/test/preview_cache
15
+
16
+[mail_test]
17
+sqlalchemy.url = sqlite:///:memory:
18
+depot_storage_name = test
19
+depot_storage_dir = /tmp/test/depot
20
+user.auth_token.validity = 604800
21
+preview_cache_dir = /tmp/test/preview_cache
22
+email.notification.activated = true
23
+email.notification.from.email = test_user_from+{user_id}@localhost
24
+email.notification.from.default_label = Tracim Notifications
25
+email.notification.reply_to.email = test_user_reply+{content_id}@localhost
26
+email.notification.references.email = test_user_refs+{content_id}@localhost
27
+email.notification.content_update.template.html = %(here)s/tracim/templates/mail/content_update_body_html.mak
28
+email.notification.content_update.template.text = %(here)s/tracim/templates/mail/content_update_body_text.mak
29
+email.notification.created_account.template.html = %(here)s/tracim/templates/mail/created_account_body_html.mak
30
+email.notification.created_account.template.text = %(here)s/tracim/templates/mail/created_account_body_text.mak
31
+# Note: items between { and } are variable names. Do not remove / rename them
32
+email.notification.content_update.subject = [{website_title}] [{workspace_label}] {content_label} ({content_status_label})
33
+email.notification.created_account.subject = [{website_title}] Created account
34
+# processing_mode may be sync or async
35
+email.notification.processing_mode = sync
36
+email.notification.smtp.server = 127.0.0.1
37
+email.notification.smtp.port = 1025
38
+email.notification.smtp.user = test_user
39
+email.notification.smtp.password = just_a_password
40
+
41
+[mail_test_async]
42
+sqlalchemy.url = sqlite:///:memory:
43
+depot_storage_name = test
44
+depot_storage_dir = /tmp/test/depot
45
+user.auth_token.validity = 604800
46
+preview_cache_dir = /tmp/test/preview_cache
47
+email.notification.activated = true
48
+email.notification.from.email = test_user_from+{user_id}@localhost
49
+email.notification.from.default_label = Tracim Notifications
50
+email.notification.reply_to.email = test_user_reply+{content_id}@localhost
51
+email.notification.references.email = test_user_refs+{content_id}@localhost
52
+email.notification.content_update.template.html = %(here)s/tracim/templates/mail/content_update_body_html.mak
53
+email.notification.content_update.template.text = %(here)s/tracim/templates/mail/content_update_body_text.mak
54
+email.notification.created_account.template.html = %(here)s/tracim/templates/mail/created_account_body_html.mak
55
+email.notification.created_account.template.text = %(here)s/tracim/templates/mail/created_account_body_text.mak
56
+# Note: items between { and } are variable names. Do not remove / rename them
57
+email.notification.content_update.subject = [{website_title}] [{workspace_label}] {content_label} ({content_status_label})
58
+email.notification.created_account.subject = [{website_title}] Created account
59
+# processing_mode may be sync or async
60
+email.notification.processing_mode = sync
61
+email.processing_mode = async
62
+email.notification.smtp.server = 127.0.0.1
63
+email.notification.smtp.port = 1025
64
+email.notification.smtp.user = test_user
65
+email.notification.smtp.password = just_a_password

+ 141 - 0
backend/tracim/__init__.py View File

@@ -0,0 +1,141 @@
1
+# -*- coding: utf-8 -*-
2
+
3
+
4
+try:  # Python 3.5+
5
+    from http import HTTPStatus
6
+except ImportError:
7
+    from http import client as HTTPStatus
8
+
9
+from pyramid.config import Configurator
10
+from pyramid.authentication import BasicAuthAuthenticationPolicy
11
+from hapic.ext.pyramid import PyramidContext
12
+from pyramid.exceptions import NotFound
13
+from sqlalchemy.exc import OperationalError
14
+
15
+from tracim.extensions import hapic
16
+from tracim.config import CFG
17
+from tracim.lib.utils.request import TracimRequest
18
+from tracim.lib.utils.authentification import basic_auth_check_credentials
19
+from tracim.lib.utils.authentification import BASIC_AUTH_WEBUI_REALM
20
+from tracim.lib.utils.authorization import AcceptAllAuthorizationPolicy
21
+from tracim.lib.utils.authorization import TRACIM_DEFAULT_PERM
22
+from tracim.lib.utils.cors import add_cors_support
23
+from tracim.lib.webdav import WebdavAppFactory
24
+from tracim.views import BASE_API_V2
25
+from tracim.views.contents_api.html_document_controller import HTMLDocumentController  # nopep8
26
+from tracim.views.contents_api.threads_controller import ThreadController
27
+from tracim.views.core_api.session_controller import SessionController
28
+from tracim.views.core_api.system_controller import SystemController
29
+from tracim.views.core_api.user_controller import UserController
30
+from tracim.views.core_api.workspace_controller import WorkspaceController
31
+from tracim.views.contents_api.comment_controller import CommentController
32
+from tracim.views.contents_api.file_controller import FileController
33
+from tracim.views.errors import ErrorSchema
34
+from tracim.exceptions import NotAuthenticated
35
+from tracim.exceptions import UserNotActive
36
+from tracim.exceptions import InvalidId
37
+from tracim.exceptions import InsufficientUserProfile
38
+from tracim.exceptions import InsufficientUserRoleInWorkspace
39
+from tracim.exceptions import WorkspaceNotFoundInTracimRequest
40
+from tracim.exceptions import UserNotFoundInTracimRequest
41
+from tracim.exceptions import ContentNotFoundInTracimRequest
42
+from tracim.exceptions import WorkspaceNotFound
43
+from tracim.exceptions import ContentNotFound
44
+from tracim.exceptions import UserDoesNotExist
45
+from tracim.exceptions import AuthenticationFailed
46
+from tracim.exceptions import ContentTypeNotAllowed
47
+
48
+
49
+def web(global_config, **local_settings):
50
+    """ This function returns a Pyramid WSGI application.
51
+    """
52
+    settings = global_config
53
+    settings.update(local_settings)
54
+    # set CFG object
55
+    app_config = CFG(settings)
56
+    app_config.configure_filedepot()
57
+    settings['CFG'] = app_config
58
+    configurator = Configurator(settings=settings, autocommit=True)
59
+    # Add BasicAuthPolicy
60
+    authn_policy = BasicAuthAuthenticationPolicy(
61
+        basic_auth_check_credentials,
62
+        realm=BASIC_AUTH_WEBUI_REALM,
63
+    )
64
+    configurator.include(add_cors_support)
65
+    # make sure to add this before other routes to intercept OPTIONS
66
+    configurator.add_cors_preflight_handler()
67
+    # Default authorization : Accept anything.
68
+    configurator.set_authorization_policy(AcceptAllAuthorizationPolicy())
69
+    configurator.set_authentication_policy(authn_policy)
70
+    # INFO - GM - 11-04-2018 - set default perm
71
+    # setting default perm is needed to force authentification
72
+    # mecanism in all views.
73
+    configurator.set_default_permission(TRACIM_DEFAULT_PERM)
74
+    # Override default request
75
+    configurator.set_request_factory(TracimRequest)
76
+    # Pyramids "plugin" include.
77
+    configurator.include('pyramid_jinja2')
78
+    # Add SqlAlchemy DB
79
+    configurator.include('.models')
80
+    # set Hapic
81
+    context = PyramidContext(
82
+        configurator=configurator,
83
+        default_error_builder=ErrorSchema(),
84
+        debug=app_config.DEBUG,
85
+    )
86
+    hapic.set_context(context)
87
+    # INFO - G.M - 2018-07-04 - global-context exceptions
88
+    # Not found
89
+    context.handle_exception(NotFound, HTTPStatus.NOT_FOUND)
90
+    # Bad request
91
+    context.handle_exception(WorkspaceNotFoundInTracimRequest, HTTPStatus.BAD_REQUEST)  # nopep8
92
+    context.handle_exception(UserNotFoundInTracimRequest, HTTPStatus.BAD_REQUEST)  # nopep8
93
+    context.handle_exception(ContentNotFoundInTracimRequest, HTTPStatus.BAD_REQUEST)  # nopep8
94
+    context.handle_exception(WorkspaceNotFound, HTTPStatus.BAD_REQUEST)
95
+    context.handle_exception(UserDoesNotExist, HTTPStatus.BAD_REQUEST)
96
+    context.handle_exception(ContentNotFound, HTTPStatus.BAD_REQUEST)
97
+    context.handle_exception(ContentTypeNotAllowed, HTTPStatus.BAD_REQUEST)
98
+    context.handle_exception(InvalidId, HTTPStatus.BAD_REQUEST)
99
+    # Auth exception
100
+    context.handle_exception(NotAuthenticated, HTTPStatus.UNAUTHORIZED)
101
+    context.handle_exception(UserNotActive, HTTPStatus.FORBIDDEN)
102
+    context.handle_exception(AuthenticationFailed, HTTPStatus.FORBIDDEN)
103
+    context.handle_exception(InsufficientUserRoleInWorkspace, HTTPStatus.FORBIDDEN)  # nopep8
104
+    context.handle_exception(InsufficientUserProfile, HTTPStatus.FORBIDDEN)
105
+    # Internal server error
106
+    context.handle_exception(OperationalError, HTTPStatus.INTERNAL_SERVER_ERROR)
107
+    context.handle_exception(Exception, HTTPStatus.INTERNAL_SERVER_ERROR)
108
+
109
+    # Add controllers
110
+    session_controller = SessionController()
111
+    system_controller = SystemController()
112
+    user_controller = UserController()
113
+    workspace_controller = WorkspaceController()
114
+    comment_controller = CommentController()
115
+    html_document_controller = HTMLDocumentController()
116
+    thread_controller = ThreadController()
117
+    file_controller = FileController()
118
+    configurator.include(session_controller.bind, route_prefix=BASE_API_V2)
119
+    configurator.include(system_controller.bind, route_prefix=BASE_API_V2)
120
+    configurator.include(user_controller.bind, route_prefix=BASE_API_V2)
121
+    configurator.include(workspace_controller.bind, route_prefix=BASE_API_V2)
122
+    configurator.include(comment_controller.bind, route_prefix=BASE_API_V2)
123
+    configurator.include(html_document_controller.bind, route_prefix=BASE_API_V2)  # nopep8
124
+    configurator.include(thread_controller.bind, route_prefix=BASE_API_V2)
125
+    configurator.include(file_controller.bind, route_prefix=BASE_API_V2)
126
+
127
+    hapic.add_documentation_view(
128
+        '/api/v2/doc',
129
+        'Tracim v2 API',
130
+        'API of Tracim v2',
131
+    )
132
+    return configurator.make_wsgi_app()
133
+
134
+
135
+def webdav(global_config, **local_settings):
136
+    settings = global_config
137
+    settings.update(local_settings)
138
+    app_factory = WebdavAppFactory(
139
+        tracim_config_file_path=settings['__file__'],
140
+    )
141
+    return app_factory.get_wsgi_app()

+ 97 - 0
backend/tracim/command/__init__.py View File

@@ -0,0 +1,97 @@
1
+# -*- coding: utf-8 -*-
2
+import sys
3
+import argparse
4
+import transaction
5
+
6
+from cliff.app import App
7
+from cliff.command import Command
8
+from cliff.commandmanager import CommandManager
9
+
10
+from pyramid.paster import bootstrap
11
+from pyramid.scripting import AppEnvironment
12
+from tracim.exceptions import BadCommandError
13
+from tracim.lib.utils.utils import DEFAULT_TRACIM_CONFIG_FILE
14
+
15
+
16
+class TracimCLI(App):
17
+    def __init__(self) -> None:
18
+        super(TracimCLI, self).__init__(
19
+            description='TracimCli',
20
+            version='0.1',
21
+            command_manager=CommandManager('tracimcli'),
22
+            deferred_help=True,
23
+            )
24
+
25
+    def initialize_app(self, argv) -> None:
26
+        self.LOG.debug('initialize_app')
27
+
28
+    def prepare_to_run_command(self, cmd) -> None:
29
+        self.LOG.debug('prepare_to_run_command %s', cmd.__class__.__name__)
30
+
31
+    def clean_up(self, cmd, result, err) -> None:
32
+        self.LOG.debug('clean_up %s', cmd.__class__.__name__)
33
+        if err:
34
+            self.LOG.debug('got an error: %s', err)
35
+
36
+
37
+def main(argv=sys.argv[1:]):
38
+    myapp = TracimCLI()
39
+    return myapp.run(argv)
40
+
41
+
42
+if __name__ == "__main__":
43
+    main()
44
+
45
+
46
+class AppContextCommand(Command):
47
+    """
48
+    Command who initialize app context at beginning of take_action method.
49
+    """
50
+    auto_setup_context = True
51
+
52
+    def take_action(self, parsed_args: argparse.Namespace) -> None:
53
+        super(AppContextCommand, self).take_action(parsed_args)
54
+        if self.auto_setup_context:
55
+            with bootstrap(parsed_args.config_file) as app_context:
56
+                with app_context['request'].tm:
57
+                    self.take_app_action(parsed_args, app_context)
58
+
59
+
60
+    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
61
+        parser = super(AppContextCommand, self).get_parser(prog_name)
62
+
63
+        parser.add_argument(
64
+            "-c",
65
+            "--config",
66
+            help='application config file to read (default: {})'.format(
67
+                DEFAULT_TRACIM_CONFIG_FILE
68
+            ),
69
+            dest='config_file',
70
+            default=DEFAULT_TRACIM_CONFIG_FILE,
71
+        )
72
+        return parser
73
+
74
+    # def run(self, parsed_args):
75
+    #     super().run(parsed_args)
76
+    #     transaction.commit()
77
+
78
+
79
+class Extender(argparse.Action):
80
+    """
81
+    Copied class from http://stackoverflow.com/a/12461237/801924
82
+    """
83
+    def __call__(self, parser, namespace, values, option_strings=None):
84
+        # Need None here incase `argparse.SUPPRESS` was supplied for `dest`
85
+        dest = getattr(namespace, self.dest, None)
86
+        # print dest,self.default,values,option_strings
87
+        if not hasattr(dest, 'extend') or dest == self.default:
88
+            dest = []
89
+            setattr(namespace, self.dest, dest)
90
+            # if default isn't set to None, this method might be called
91
+            # with the default as `values` for other arguements which
92
+            # share this destination.
93
+            parser.set_defaults(**{self.dest: None})
94
+        try:
95
+            dest.extend(values)
96
+        except ValueError:
97
+            dest.append(values)

+ 137 - 0
backend/tracim/command/database.py View File

@@ -0,0 +1,137 @@
1
+# -*- coding: utf-8 -*-
2
+import argparse
3
+
4
+import plaster_pastedeploy
5
+import transaction
6
+from depot.manager import DepotManager
7
+from pyramid.paster import (
8
+    get_appsettings,
9
+    setup_logging,
10
+    )
11
+
12
+from tracim import CFG
13
+from tracim.fixtures import FixturesLoader
14
+from tracim.fixtures.users_and_groups import Base as BaseFixture
15
+from tracim.fixtures.content import Content as ContentFixture
16
+from sqlalchemy.exc import IntegrityError
17
+from tracim.command import AppContextCommand
18
+from tracim.models.meta import DeclarativeBase
19
+from tracim.models import (
20
+    get_engine,
21
+    get_session_factory,
22
+    get_tm_session,
23
+    )
24
+
25
+
26
+class InitializeDBCommand(AppContextCommand):
27
+    auto_setup_context = False
28
+
29
+    def get_description(self) -> str:
30
+        return "Initialize database"
31
+
32
+    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
33
+        parser = super().get_parser(prog_name)
34
+        parser.add_argument(
35
+            "--test-data",
36
+            help='Add some default data to database to make test',
37
+            dest='test_data',
38
+            required=False,
39
+            action='store_true',
40
+            default=False,
41
+        )
42
+        return parser
43
+
44
+    def take_action(self, parsed_args: argparse.Namespace) -> None:
45
+        super(InitializeDBCommand, self).take_action(parsed_args)
46
+        config_uri = parsed_args.config_file
47
+        setup_logging(config_uri)
48
+        settings = get_appsettings(config_uri)
49
+        # INFO - G.M - 2018-06-178 - We need to add info from [DEFAULT]
50
+        # section of config file in order to have both global and
51
+        # web app specific param.
52
+        settings.update(settings.global_conf)
53
+        self._create_schema(settings)
54
+        self._populate_database(settings, add_test_data=parsed_args.test_data)
55
+
56
+    @classmethod
57
+    def _create_schema(
58
+            cls,
59
+            settings: plaster_pastedeploy.ConfigDict
60
+    ) -> None:
61
+        print("- Create Schemas of databases -")
62
+        engine = get_engine(settings)
63
+        DeclarativeBase.metadata.create_all(engine)
64
+
65
+    @classmethod
66
+    def _populate_database(
67
+            cls,
68
+            settings: plaster_pastedeploy.ConfigDict,
69
+            add_test_data: bool
70
+    ) -> None:
71
+        engine = get_engine(settings)
72
+        session_factory = get_session_factory(engine)
73
+        app_config = CFG(settings)
74
+        print("- Populate database with default data -")
75
+        with transaction.manager:
76
+            dbsession = get_tm_session(session_factory, transaction.manager)
77
+            try:
78
+                fixtures = [BaseFixture]
79
+                fixtures_loader = FixturesLoader(dbsession, app_config)
80
+                fixtures_loader.loads(fixtures)
81
+                transaction.commit()
82
+                if add_test_data:
83
+                    app_config.configure_filedepot()
84
+                    fixtures = [ContentFixture]
85
+                    fixtures_loader.loads(fixtures)
86
+                transaction.commit()
87
+                print("Database initialized.")
88
+            except IntegrityError:
89
+                print('Warning, there was a problem when adding default data'
90
+                      ', it may have already been added:')
91
+                import traceback
92
+                print(traceback.format_exc())
93
+                transaction.abort()
94
+                print('Database initialization failed')
95
+
96
+
97
+class DeleteDBCommand(AppContextCommand):
98
+    auto_setup_context = False
99
+
100
+    def get_description(self) -> str:
101
+        return "Delete database"
102
+
103
+    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
104
+        parser = super().get_parser(prog_name)
105
+        parser.add_argument(
106
+            "--force",
107
+            help='force delete of database',
108
+            dest='force',
109
+            required=False,
110
+            action='store_true',
111
+            default=False,
112
+        )
113
+        return parser
114
+
115
+    def take_action(self, parsed_args: argparse.Namespace) -> None:
116
+        super(DeleteDBCommand, self).take_action(parsed_args)
117
+        config_uri = parsed_args.config_file
118
+        setup_logging(config_uri)
119
+        settings = get_appsettings(config_uri)
120
+        settings.update(settings.global_conf)
121
+        engine = get_engine(settings)
122
+        app_config = CFG(settings)
123
+        app_config.configure_filedepot()
124
+
125
+        if parsed_args.force:
126
+            print('Database deletion begin.')
127
+            DeclarativeBase.metadata.drop_all(engine)
128
+            print('Database deletion done.')
129
+            print('Cleaning depot begin.')
130
+            depot = DepotManager.get()
131
+            depot_files = depot.list()
132
+            for file_ in depot_files:
133
+                depot.delete(file_)
134
+            print('Cleaning depot done.')
135
+        else:
136
+            print('Warning, You should use --force if you really want to'
137
+                  ' delete database.')

+ 254 - 0
backend/tracim/command/user.py View File

@@ -0,0 +1,254 @@
1
+# -*- coding: utf-8 -*-
2
+import argparse
3
+from pyramid.scripting import AppEnvironment
4
+import transaction
5
+from sqlalchemy.exc import IntegrityError
6
+from sqlalchemy.orm.exc import NoResultFound
7
+
8
+from tracim import CFG
9
+from tracim.command import AppContextCommand
10
+from tracim.command import Extender
11
+from tracim.exceptions import UserAlreadyExistError
12
+from tracim.exceptions import GroupDoesNotExist
13
+from tracim.exceptions import NotificationNotSend
14
+from tracim.exceptions import BadCommandError
15
+from tracim.lib.core.group import GroupApi
16
+from tracim.lib.core.user import UserApi
17
+from tracim.models import User
18
+from tracim.models import Group
19
+
20
+
21
+class UserCommand(AppContextCommand):
22
+
23
+    ACTION_CREATE = 'create'
24
+    ACTION_UPDATE = 'update'
25
+
26
+    action = NotImplemented
27
+
28
+    def get_description(self) -> str:
29
+        return '''Create or update user.'''
30
+
31
+    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
32
+        parser = super().get_parser(prog_name)
33
+
34
+        parser.add_argument(
35
+            "-l",
36
+            "--login",
37
+            help='User login (email)',
38
+            dest='login',
39
+            required=True
40
+        )
41
+
42
+        parser.add_argument(
43
+            "-p",
44
+            "--password",
45
+            help='User password',
46
+            dest='password',
47
+            required=False,
48
+            default=None
49
+        )
50
+
51
+        parser.add_argument(
52
+            "-g",
53
+            "--add-to-group",
54
+            help='Add user to group',
55
+            dest='add_to_group',
56
+            nargs='*',
57
+            action=Extender,
58
+            default=[],
59
+        )
60
+
61
+        parser.add_argument(
62
+            "-rmg",
63
+            "--remove-from-group",
64
+            help='Remove user from group',
65
+            dest='remove_from_group',
66
+            nargs='*',
67
+            action=Extender,
68
+            default=[],
69
+        )
70
+
71
+        parser.add_argument(
72
+            "--send-email",
73
+            help='Send mail to user',
74
+            dest='send_email',
75
+            required=False,
76
+            action='store_true',
77
+            default=False,
78
+        )
79
+
80
+        return parser
81
+
82
+    def _user_exist(self, login: str) -> User:
83
+        return self._user_api.user_with_email_exists(login)
84
+
85
+    def _get_group(self, name: str) -> Group:
86
+        groups_availables = [group.group_name
87
+                             for group in self._group_api.get_all()]
88
+        if name not in groups_availables:
89
+            msg = "Group '{}' does not exist, choose a group name in : ".format(name)  # nopep8
90
+            for group in groups_availables:
91
+                msg+= "'{}',".format(group)
92
+            self._session.rollback()
93
+            raise GroupDoesNotExist(msg)
94
+        return self._group_api.get_one_with_name(name)
95
+
96
+    def _add_user_to_named_group(
97
+            self,
98
+            user: str,
99
+            group_name: str
100
+    ) -> None:
101
+
102
+        group = self._get_group(group_name)
103
+        if user not in group.users:
104
+            group.users.append(user)
105
+        self._session.flush()
106
+
107
+    def _remove_user_from_named_group(
108
+            self,
109
+            user: User,
110
+            group_name: str
111
+    ) -> None:
112
+        group = self._get_group(group_name)
113
+        if user in group.users:
114
+            group.users.remove(user)
115
+        self._session.flush()
116
+
117
+    def _create_user(
118
+            self,
119
+            login: str,
120
+            password: str,
121
+            do_notify: bool,
122
+            **kwargs
123
+    ) -> User:
124
+        if not password:
125
+            if self._password_required():
126
+                raise BadCommandError(
127
+                    "You must provide -p/--password parameter"
128
+                )
129
+            password = ''
130
+
131
+        try:
132
+            user = self._user_api.create_user(
133
+                email=login,
134
+                password=password,
135
+                do_save=True,
136
+                do_notify=do_notify,
137
+            )
138
+            # TODO - G.M - 04-04-2018 - [Caldav] Check this code
139
+            # # We need to enable radicale if it not already done
140
+            # daemons = DaemonsManager()
141
+            # daemons.run('radicale', RadicaleDaemon)
142
+            self._user_api.execute_created_user_actions(user)
143
+        except IntegrityError:
144
+            self._session.rollback()
145
+            raise UserAlreadyExistError()
146
+        except NotificationNotSend as exception:
147
+            self._session.rollback()
148
+            raise exception
149
+
150
+        return user
151
+
152
+    def _update_password_for_login(self, login: str, password: str) -> None:
153
+        user = self._user_api.get_one_by_email(login)
154
+        user.password = password
155
+        self._session.flush()
156
+        transaction.commit()
157
+
158
+    def take_app_action(
159
+            self,
160
+            parsed_args: argparse.Namespace,
161
+            app_context: AppEnvironment
162
+    ) -> None:
163
+        # TODO - G.M - 05-04-2018 -Refactor this in order
164
+        # to not setup object var outside of __init__ .
165
+        self._session = app_context['request'].dbsession
166
+        self._app_config = app_context['registry'].settings['CFG']
167
+        self._user_api = UserApi(
168
+            current_user=None,
169
+            session=self._session,
170
+            config=self._app_config,
171
+        )
172
+        self._group_api = GroupApi(
173
+            current_user=None,
174
+            session=self._session,
175
+            config=self._app_config,
176
+        )
177
+        user = self._proceed_user(parsed_args)
178
+        self._proceed_groups(user, parsed_args)
179
+
180
+        print("User created/updated")
181
+
182
+    def _proceed_user(self, parsed_args: argparse.Namespace) -> User:
183
+        self._check_context(parsed_args)
184
+
185
+        if self.action == self.ACTION_CREATE:
186
+            try:
187
+                user = self._create_user(
188
+                    login=parsed_args.login,
189
+                    password=parsed_args.password,
190
+                    do_notify=parsed_args.send_email,
191
+                )
192
+            except UserAlreadyExistError:
193
+                raise UserAlreadyExistError("Error: User already exist (use `user update` command instead)")
194
+            except NotificationNotSend:
195
+                raise NotificationNotSend("Error: Cannot send email notification, user not created.")
196
+            # TODO - G.M - 04-04-2018 - [Email] Check this code
197
+            # if parsed_args.send_email:
198
+            #     email_manager = get_email_manager()
199
+            #     email_manager.notify_created_account(
200
+            #         user=user,
201
+            #         password=parsed_args.password,
202
+            #     )
203
+
204
+        else:
205
+            if parsed_args.password:
206
+                self._update_password_for_login(
207
+                    login=parsed_args.login,
208
+                    password=parsed_args.password
209
+                )
210
+            user = self._user_api.get_one_by_email(parsed_args.login)
211
+
212
+        return user
213
+
214
+    def _proceed_groups(
215
+            self,
216
+            user: User,
217
+            parsed_args: argparse.Namespace
218
+    ) -> None:
219
+        # User always in "users" group
220
+        self._add_user_to_named_group(user, 'users')
221
+
222
+        for group_name in parsed_args.add_to_group:
223
+            self._add_user_to_named_group(user, group_name)
224
+
225
+        for group_name in parsed_args.remove_from_group:
226
+            self._remove_user_from_named_group(user, group_name)
227
+
228
+    def _password_required(self) -> bool:
229
+        # TODO - G.M - 04-04-2018 - [LDAP] Check this code
230
+        # if config.get('auth_type') == LDAPAuth.name:
231
+        #     return False
232
+        return True
233
+
234
+    def _check_context(self, parsed_args: argparse.Namespace) -> None:
235
+        # TODO - G.M - 04-04-2018 - [LDAP] Check this code
236
+        # if config.get('auth_type') == LDAPAuth.name:
237
+        #     auth_instance = config.get('auth_instance')
238
+        #     if not auth_instance.ldap_auth.user_exist(parsed_args.login):
239
+        #         raise LDAPUserUnknown(
240
+        #             "LDAP is enabled and user with login/email \"%s\" not found in LDAP" % parsed_args.login
241
+        #         )
242
+        pass
243
+
244
+
245
+class CreateUserCommand(UserCommand):
246
+    action = UserCommand.ACTION_CREATE
247
+
248
+
249
+class UpdateUserCommand(UserCommand):
250
+    action = UserCommand.ACTION_UPDATE
251
+
252
+
253
+class LDAPUserUnknown(BadCommandError):
254
+    pass

+ 27 - 0
backend/tracim/command/webdav.py View File

@@ -0,0 +1,27 @@
1
+# -*- coding: utf-8 -*-
2
+import argparse
3
+
4
+import plaster_pastedeploy
5
+from waitress import serve
6
+
7
+from tracim.command import AppContextCommand
8
+from tracim.lib.webdav import WebdavAppFactory
9
+from wsgi import webdav_app
10
+
11
+
12
+class WebdavRunnerCommand(AppContextCommand):
13
+    auto_setup_context = False
14
+
15
+    def get_description(self) -> str:
16
+        return "run webdav server"
17
+
18
+    def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
19
+        parser = super().get_parser(prog_name)
20
+        return parser
21
+
22
+    def take_action(self, parsed_args: argparse.Namespace) -> None:
23
+        super(WebdavRunnerCommand, self).take_action(parsed_args)
24
+        tracim_config = parsed_args.config_file
25
+        # TODO - G.M - 16-04-2018 - Allow specific webdav config file
26
+        app = webdav_app(tracim_config)
27
+        serve(app, port=app.config['port'], host=app.config['host'])

+ 462 - 0
backend/tracim/config.py View File

@@ -0,0 +1,462 @@
1
+# -*- coding: utf-8 -*-
2
+from urllib.parse import urlparse
3
+from paste.deploy.converters import asbool
4
+from tracim.lib.utils.logger import logger
5
+from depot.manager import DepotManager
6
+
7
+from tracim.models.data import ActionDescription, ContentType
8
+
9
+
10
+class CFG(object):
11
+    """Object used for easy access to config file parameters."""
12
+
13
+    def __setattr__(self, key, value):
14
+        """
15
+        Log-ready setter.
16
+
17
+        Logs all configuration parameters except password.
18
+        :param key:
19
+        :param value:
20
+        :return:
21
+        """
22
+        if 'PASSWORD' not in key and \
23
+                ('URL' not in key or type(value) == str) and \
24
+                'CONTENT' not in key:
25
+            # We do not show PASSWORD for security reason
26
+            # we do not show URL because At the time of configuration setup,
27
+            # it can't be evaluated
28
+            # We do not show CONTENT in order not to pollute log files
29
+            logger.info(self, 'CONFIG: [ {} | {} ]'.format(key, value))
30
+        else:
31
+            logger.info(self, 'CONFIG: [ {} | <value not shown> ]'.format(key))
32
+
33
+        self.__dict__[key] = value
34
+
35
+    def __init__(self, settings):
36
+        """Parse configuration file."""
37
+
38
+        ###
39
+        # General
40
+        ###
41
+
42
+        mandatory_msg = \
43
+            'ERROR: {} configuration is mandatory. Set it before continuing.'
44
+        self.DEPOT_STORAGE_DIR = settings.get(
45
+            'depot_storage_dir',
46
+        )
47
+        if not self.DEPOT_STORAGE_DIR:
48
+            raise Exception(
49
+                mandatory_msg.format('depot_storage_dir')
50
+            )
51
+        self.DEPOT_STORAGE_NAME = settings.get(
52
+            'depot_storage_name',
53
+        )
54
+        if not self.DEPOT_STORAGE_NAME:
55
+            raise Exception(
56
+                mandatory_msg.format('depot_storage_name')
57
+            )
58
+        self.PREVIEW_CACHE_DIR = settings.get(
59
+            'preview_cache_dir',
60
+        )
61
+        if not self.PREVIEW_CACHE_DIR:
62
+            raise Exception(
63
+                'ERROR: preview_cache_dir configuration is mandatory. '
64
+                'Set it before continuing.'
65
+            )
66
+
67
+        self.DATA_UPDATE_ALLOWED_DURATION = int(settings.get(
68
+            'content.update.allowed.duration',
69
+            0,
70
+        ))
71
+
72
+        self.WEBSITE_TITLE = settings.get(
73
+            'website.title',
74
+            'TRACIM',
75
+        )
76
+
77
+        self.WEBSITE_BASE_URL = settings.get(
78
+            'website.base_url',
79
+            '',
80
+        )
81
+
82
+        # TODO - G.M - 26-03-2018 - [Cleanup] These params seems deprecated for tracimv2,  # nopep8
83
+        # Verify this
84
+        #
85
+        # self.WEBSITE_HOME_TITLE_COLOR = settings.get(
86
+        #     'website.title.color',
87
+        #     '#555',
88
+        # )
89
+        # self.WEBSITE_HOME_IMAGE_PATH = settings.get(
90
+        #     '/assets/img/home_illustration.jpg',
91
+        # )
92
+        # self.WEBSITE_HOME_BACKGROUND_IMAGE_PATH = settings.get(
93
+        #     '/assets/img/bg.jpg',
94
+        # )
95
+        #
96
+
97
+        self.WEBSITE_SERVER_NAME = settings.get(
98
+            'website.server_name',
99
+            None,
100
+        )
101
+
102
+        if not self.WEBSITE_SERVER_NAME:
103
+            self.WEBSITE_SERVER_NAME = urlparse(self.WEBSITE_BASE_URL).hostname
104
+            logger.warning(
105
+                self,
106
+                'NOTE: Generated website.server_name parameter from '
107
+                'website.base_url parameter -> {0}'
108
+                .format(self.WEBSITE_SERVER_NAME)
109
+            )
110
+
111
+        self.WEBSITE_HOME_TAG_LINE = settings.get(
112
+            'website.home.tag_line',
113
+            '',
114
+        )
115
+        self.WEBSITE_SUBTITLE = settings.get(
116
+            'website.home.subtitle',
117
+            '',
118
+        )
119
+        self.WEBSITE_HOME_BELOW_LOGIN_FORM = settings.get(
120
+            'website.home.below_login_form',
121
+            '',
122
+        )
123
+
124
+        self.WEBSITE_TREEVIEW_CONTENT = settings.get(
125
+            'website.treeview.content',
126
+        )
127
+
128
+        self.USER_AUTH_TOKEN_VALIDITY = int(settings.get(
129
+            'user.auth_token.validity',
130
+            '604800',
131
+        ))
132
+
133
+        self.DEBUG = asbool(settings.get('debug', False))
134
+        # TODO - G.M - 27-03-2018 - [Email] Restore email config
135
+        ###
136
+        # EMAIL related stuff (notification, reply)
137
+        ##
138
+
139
+        self.EMAIL_NOTIFICATION_NOTIFIED_EVENTS = [
140
+            ActionDescription.COMMENT,
141
+            ActionDescription.CREATION,
142
+            ActionDescription.EDITION,
143
+            ActionDescription.REVISION,
144
+            ActionDescription.STATUS_UPDATE
145
+        ]
146
+
147
+        self.EMAIL_NOTIFICATION_NOTIFIED_CONTENTS = [
148
+            ContentType.Page,
149
+            ContentType.Thread,
150
+            ContentType.File,
151
+            ContentType.Comment,
152
+            # ContentType.Folder -- Folder is skipped
153
+        ]
154
+        if settings.get('email.notification.from'):
155
+            raise Exception(
156
+                'email.notification.from configuration is deprecated. '
157
+                'Use instead email.notification.from.email and '
158
+                'email.notification.from.default_label.'
159
+            )
160
+
161
+        self.EMAIL_NOTIFICATION_FROM_EMAIL = settings.get(
162
+            'email.notification.from.email',
163
+        )
164
+        self.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL = settings.get(
165
+            'email.notification.from.default_label'
166
+        )
167
+        self.EMAIL_NOTIFICATION_REPLY_TO_EMAIL = settings.get(
168
+            'email.notification.reply_to.email',
169
+        )
170
+        self.EMAIL_NOTIFICATION_REFERENCES_EMAIL = settings.get(
171
+            'email.notification.references.email'
172
+        )
173
+        self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML = settings.get(
174
+            'email.notification.content_update.template.html',
175
+        )
176
+        self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT = settings.get(
177
+            'email.notification.content_update.template.text',
178
+        )
179
+        self.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML = settings.get(
180
+            'email.notification.created_account.template.html',
181
+            './tracim/templates/mail/created_account_body_html.mak',
182
+        )
183
+        self.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT = settings.get(
184
+            'email.notification.created_account.template.text',
185
+            './tracim/templates/mail/created_account_body_text.mak',
186
+        )
187
+        self.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT = settings.get(
188
+            'email.notification.content_update.subject',
189
+        )
190
+        self.EMAIL_NOTIFICATION_CREATED_ACCOUNT_SUBJECT = settings.get(
191
+            'email.notification.created_account.subject',
192
+            '[{website_title}] Created account',
193
+        )
194
+        self.EMAIL_NOTIFICATION_PROCESSING_MODE = settings.get(
195
+            'email.notification.processing_mode',
196
+        )
197
+
198
+        self.EMAIL_NOTIFICATION_ACTIVATED = asbool(settings.get(
199
+            'email.notification.activated',
200
+        ))
201
+        self.EMAIL_NOTIFICATION_SMTP_SERVER = settings.get(
202
+            'email.notification.smtp.server',
203
+        )
204
+        self.EMAIL_NOTIFICATION_SMTP_PORT = settings.get(
205
+            'email.notification.smtp.port',
206
+        )
207
+        self.EMAIL_NOTIFICATION_SMTP_USER = settings.get(
208
+            'email.notification.smtp.user',
209
+        )
210
+        self.EMAIL_NOTIFICATION_SMTP_PASSWORD = settings.get(
211
+            'email.notification.smtp.password',
212
+        )
213
+        self.EMAIL_NOTIFICATION_LOG_FILE_PATH = settings.get(
214
+            'email.notification.log_file_path',
215
+            None,
216
+        )
217
+
218
+        # self.EMAIL_REPLY_ACTIVATED = asbool(settings.get(
219
+        #     'email.reply.activated',
220
+        #     False,
221
+        # ))
222
+        #
223
+        # self.EMAIL_REPLY_IMAP_SERVER = settings.get(
224
+        #     'email.reply.imap.server',
225
+        # )
226
+        # self.EMAIL_REPLY_IMAP_PORT = settings.get(
227
+        #     'email.reply.imap.port',
228
+        # )
229
+        # self.EMAIL_REPLY_IMAP_USER = settings.get(
230
+        #     'email.reply.imap.user',
231
+        # )
232
+        # self.EMAIL_REPLY_IMAP_PASSWORD = settings.get(
233
+        #     'email.reply.imap.password',
234
+        # )
235
+        # self.EMAIL_REPLY_IMAP_FOLDER = settings.get(
236
+        #     'email.reply.imap.folder',
237
+        # )
238
+        # self.EMAIL_REPLY_CHECK_HEARTBEAT = int(settings.get(
239
+        #     'email.reply.check.heartbeat',
240
+        #     60,
241
+        # ))
242
+        # self.EMAIL_REPLY_TOKEN = settings.get(
243
+        #     'email.reply.token',
244
+        # )
245
+        # self.EMAIL_REPLY_IMAP_USE_SSL = asbool(settings.get(
246
+        #     'email.reply.imap.use_ssl',
247
+        # ))
248
+        # self.EMAIL_REPLY_IMAP_USE_IDLE = asbool(settings.get(
249
+        #     'email.reply.imap.use_idle',
250
+        #     True,
251
+        # ))
252
+        # self.EMAIL_REPLY_CONNECTION_MAX_LIFETIME = int(settings.get(
253
+        #     'email.reply.connection.max_lifetime',
254
+        #     600,  # 10 minutes
255
+        # ))
256
+        # self.EMAIL_REPLY_USE_HTML_PARSING = asbool(settings.get(
257
+        #     'email.reply.use_html_parsing',
258
+        #     True,
259
+        # ))
260
+        # self.EMAIL_REPLY_USE_TXT_PARSING = asbool(settings.get(
261
+        #     'email.reply.use_txt_parsing',
262
+        #     True,
263
+        # ))
264
+        # self.EMAIL_REPLY_LOCKFILE_PATH = settings.get(
265
+        #     'email.reply.lockfile_path',
266
+        #     ''
267
+        # )
268
+        # if not self.EMAIL_REPLY_LOCKFILE_PATH and self.EMAIL_REPLY_ACTIVATED:
269
+        #     raise Exception(
270
+        #         mandatory_msg.format('email.reply.lockfile_path')
271
+        #     )
272
+        #
273
+        self.EMAIL_PROCESSING_MODE = settings.get(
274
+            'email.processing_mode',
275
+            'sync',
276
+        ).upper()
277
+
278
+        if self.EMAIL_PROCESSING_MODE not in (
279
+                self.CST.ASYNC,
280
+                self.CST.SYNC,
281
+        ):
282
+            raise Exception(
283
+                'email.processing_mode '
284
+                'can ''be "{}" or "{}", not "{}"'.format(
285
+                    self.CST.ASYNC,
286
+                    self.CST.SYNC,
287
+                    self.EMAIL_PROCESSING_MODE,
288
+                )
289
+            )
290
+
291
+        self.EMAIL_SENDER_REDIS_HOST = settings.get(
292
+            'email.async.redis.host',
293
+            'localhost',
294
+        )
295
+        self.EMAIL_SENDER_REDIS_PORT = int(settings.get(
296
+            'email.async.redis.port',
297
+            6379,
298
+        ))
299
+        self.EMAIL_SENDER_REDIS_DB = int(settings.get(
300
+            'email.async.redis.db',
301
+            0,
302
+        ))
303
+
304
+        ###
305
+        # WSGIDAV (Webdav server)
306
+        ###
307
+
308
+        # TODO - G.M - 27-03-2018 - [WebDav] Restore wsgidav config
309
+        #self.WSGIDAV_CONFIG_PATH = settings.get(
310
+        #    'wsgidav.config_path',
311
+        #    'wsgidav.conf',
312
+        #)
313
+        # TODO: Convert to importlib
314
+        # http://stackoverflow.com/questions/41063938/use-importlib-instead-imp-for-non-py-file
315
+        #self.wsgidav_config = imp.load_source(
316
+        #    'wsgidav_config',
317
+        #    self.WSGIDAV_CONFIG_PATH,
318
+        #)
319
+        # self.WSGIDAV_PORT = self.wsgidav_config.port
320
+        # self.WSGIDAV_CLIENT_BASE_URL = settings.get(
321
+        #     'wsgidav.client.base_url',
322
+        #     None,
323
+        # )
324
+        #
325
+        # if not self.WSGIDAV_CLIENT_BASE_URL:
326
+        #     self.WSGIDAV_CLIENT_BASE_URL = \
327
+        #         '{0}:{1}'.format(
328
+        #             self.WEBSITE_SERVER_NAME,
329
+        #             self.WSGIDAV_PORT,
330
+        #         )
331
+        #     logger.warning(self,
332
+        #         'NOTE: Generated wsgidav.client.base_url parameter with '
333
+        #         'followings parameters: website.server_name and '
334
+        #         'wsgidav.conf port'.format(
335
+        #             self.WSGIDAV_CLIENT_BASE_URL,
336
+        #         )
337
+        #     )
338
+        #
339
+        # if not self.WSGIDAV_CLIENT_BASE_URL.endswith('/'):
340
+        #     self.WSGIDAV_CLIENT_BASE_URL += '/'
341
+
342
+        # TODO - G.M - 27-03-2018 - [Caldav] Restore radicale config
343
+        ###
344
+        # RADICALE (Caldav server)
345
+        ###
346
+        # self.RADICALE_SERVER_HOST = settings.get(
347
+        #     'radicale.server.host',
348
+        #     '127.0.0.1',
349
+        # )
350
+        # self.RADICALE_SERVER_PORT = int(settings.get(
351
+        #     'radicale.server.port',
352
+        #     5232,
353
+        # ))
354
+        # # Note: Other parameters needed to work in SSL (cert file, etc)
355
+        # self.RADICALE_SERVER_SSL = asbool(settings.get(
356
+        #     'radicale.server.ssl',
357
+        #     False,
358
+        # ))
359
+        # self.RADICALE_SERVER_FILE_SYSTEM_FOLDER = settings.get(
360
+        #     'radicale.server.filesystem.folder',
361
+        # )
362
+        # if not self.RADICALE_SERVER_FILE_SYSTEM_FOLDER:
363
+        #     raise Exception(
364
+        #         mandatory_msg.format('radicale.server.filesystem.folder')
365
+        #     )
366
+        # self.RADICALE_SERVER_ALLOW_ORIGIN = settings.get(
367
+        #     'radicale.server.allow_origin',
368
+        #     None,
369
+        # )
370
+        # if not self.RADICALE_SERVER_ALLOW_ORIGIN:
371
+        #     self.RADICALE_SERVER_ALLOW_ORIGIN = self.WEBSITE_BASE_URL
372
+        #     logger.warning(self,
373
+        #         'NOTE: Generated radicale.server.allow_origin parameter with '
374
+        #         'followings parameters: website.base_url ({0})'
375
+        #         .format(self.WEBSITE_BASE_URL)
376
+        #     )
377
+        #
378
+        # self.RADICALE_SERVER_REALM_MESSAGE = settings.get(
379
+        #     'radicale.server.realm_message',
380
+        #     'Tracim Calendar - Password Required',
381
+        # )
382
+        #
383
+        # self.RADICALE_CLIENT_BASE_URL_HOST = settings.get(
384
+        #     'radicale.client.base_url.host',
385
+        #     'http://{}:{}'.format(
386
+        #         self.RADICALE_SERVER_HOST,
387
+        #         self.RADICALE_SERVER_PORT,
388
+        #     ),
389
+        # )
390
+        #
391
+        # self.RADICALE_CLIENT_BASE_URL_PREFIX = settings.get(
392
+        #     'radicale.client.base_url.prefix',
393
+        #     '/',
394
+        # )
395
+        # # Ensure finished by '/'
396
+        # if '/' != self.RADICALE_CLIENT_BASE_URL_PREFIX[-1]:
397
+        #     self.RADICALE_CLIENT_BASE_URL_PREFIX += '/'
398
+        # if '/' != self.RADICALE_CLIENT_BASE_URL_PREFIX[0]:
399
+        #     self.RADICALE_CLIENT_BASE_URL_PREFIX \
400
+        #         = '/' + self.RADICALE_CLIENT_BASE_URL_PREFIX
401
+        #
402
+        # if not self.RADICALE_CLIENT_BASE_URL_HOST:
403
+        #     logger.warning(self,
404
+        #         'Generated radicale.client.base_url.host parameter with '
405
+        #         'followings parameters: website.server_name -> {}'
406
+        #         .format(self.WEBSITE_SERVER_NAME)
407
+        #     )
408
+        #     self.RADICALE_CLIENT_BASE_URL_HOST = self.WEBSITE_SERVER_NAME
409
+        #
410
+        # self.RADICALE_CLIENT_BASE_URL_TEMPLATE = '{}{}'.format(
411
+        #     self.RADICALE_CLIENT_BASE_URL_HOST,
412
+        #     self.RADICALE_CLIENT_BASE_URL_PREFIX,
413
+        # )
414
+        self.PREVIEW_JPG_RESTRICTED_DIMS = asbool(settings.get(
415
+            'preview.jpg.restricted_dims', False
416
+        ))
417
+        preview_jpg_allowed_dims_str = settings.get('preview.jpg.allowed_dims', '')  # nopep8
418
+        allowed_dims = []
419
+        if preview_jpg_allowed_dims_str:
420
+            for sizes in preview_jpg_allowed_dims_str.split(','):
421
+                parts = sizes.split('x')
422
+                assert len(parts) == 2
423
+                width, height = parts
424
+                assert width.isdecimal()
425
+                assert height.isdecimal()
426
+                size = PreviewDim(int(width), int(height))
427
+                allowed_dims.append(size)
428
+
429
+        if not allowed_dims:
430
+            size = PreviewDim(256, 256)
431
+            allowed_dims.append(size)
432
+
433
+        self.PREVIEW_JPG_ALLOWED_DIMS = allowed_dims
434
+
435
+    def configure_filedepot(self):
436
+        depot_storage_name = self.DEPOT_STORAGE_NAME
437
+        depot_storage_path = self.DEPOT_STORAGE_DIR
438
+        depot_storage_settings = {'depot.storage_path': depot_storage_path}
439
+        DepotManager.configure(
440
+            depot_storage_name,
441
+            depot_storage_settings,
442
+        )
443
+
444
+    class CST(object):
445
+        ASYNC = 'ASYNC'
446
+        SYNC = 'SYNC'
447
+
448
+        TREEVIEW_FOLDERS = 'folders'
449
+        TREEVIEW_ALL = 'all'
450
+
451
+
452
+class PreviewDim(object):
453
+
454
+    def __init__(self, width: int, height: int) -> None:
455
+        self.width = width
456
+        self.height = height
457
+
458
+    def __repr__(self):
459
+        return "<PreviewDim width:{width} height:{height}>".format(
460
+            width=self.width,
461
+            height=self.height,
462
+        )

+ 205 - 0
backend/tracim/exceptions.py View File

@@ -0,0 +1,205 @@
1
+# -*- coding: utf-8 -*-
2
+
3
+
4
+class TracimError(Exception):
5
+    pass
6
+
7
+
8
+class TracimException(Exception):
9
+    pass
10
+
11
+
12
+class RunTimeError(TracimError):
13
+    pass
14
+
15
+
16
+class ContentRevisionUpdateError(RuntimeError):
17
+    pass
18
+
19
+
20
+class ContentRevisionDeleteError(ContentRevisionUpdateError):
21
+    pass
22
+
23
+
24
+class ConfigurationError(TracimError):
25
+    pass
26
+
27
+
28
+class UserAlreadyExistError(TracimError):
29
+    pass
30
+
31
+
32
+class BadCommandError(TracimError):
33
+    pass
34
+
35
+
36
+class DaemonException(TracimException):
37
+    pass
38
+
39
+
40
+class AlreadyRunningDaemon(DaemonException):
41
+    pass
42
+
43
+
44
+class CalendarException(TracimException):
45
+    pass
46
+
47
+
48
+class UnknownCalendarType(CalendarException):
49
+    pass
50
+
51
+
52
+class NotFound(TracimException):
53
+    pass
54
+
55
+
56
+class SameValueError(ValueError):
57
+    pass
58
+
59
+
60
+class NotAuthenticated(TracimException):
61
+    pass
62
+
63
+
64
+class WorkspaceNotFound(NotFound):
65
+    pass
66
+
67
+
68
+class WorkspaceNotFoundInTracimRequest(NotFound):
69
+    pass
70
+
71
+
72
+class InsufficientUserRoleInWorkspace(TracimException):
73
+    pass
74
+
75
+
76
+class InsufficientUserProfile(TracimException):
77
+    pass
78
+
79
+
80
+class ImmutableAttribute(TracimException):
81
+    pass
82
+
83
+
84
+class DigestAuthNotImplemented(Exception):
85
+    pass
86
+
87
+
88
+class AuthenticationFailed(TracimException):
89
+    pass
90
+
91
+
92
+class WrongUserPassword(TracimException):
93
+    pass
94
+
95
+
96
+class NotificationNotSend(TracimException):
97
+    pass
98
+
99
+
100
+class GroupDoesNotExist(TracimError):
101
+    pass
102
+
103
+
104
+class ContentStatusNotExist(TracimError):
105
+    pass
106
+
107
+
108
+class ContentTypeNotExist(TracimError):
109
+    pass
110
+
111
+
112
+class UserDoesNotExist(TracimException):
113
+    pass
114
+
115
+
116
+class UserNotFoundInTracimRequest(TracimException):
117
+    pass
118
+
119
+
120
+class ContentNotFoundInTracimRequest(TracimException):
121
+    pass
122
+
123
+
124
+class InvalidId(TracimException):
125
+    pass
126
+
127
+
128
+class InvalidContentId(InvalidId):
129
+    pass
130
+
131
+
132
+class InvalidCommentId(InvalidId):
133
+    pass
134
+
135
+
136
+class InvalidWorkspaceId(InvalidId):
137
+    pass
138
+
139
+
140
+class InvalidUserId(InvalidId):
141
+    pass
142
+
143
+
144
+class ContentNotFound(TracimException):
145
+    pass
146
+
147
+
148
+class ContentTypeNotAllowed(TracimException):
149
+    pass
150
+
151
+
152
+class WorkspacesDoNotMatch(TracimException):
153
+    pass
154
+
155
+
156
+class PasswordDoNotMatch(TracimException):
157
+    pass
158
+
159
+
160
+class EmptyValueNotAllowed(TracimException):
161
+    pass
162
+
163
+
164
+class EmptyLabelNotAllowed(EmptyValueNotAllowed):
165
+    pass
166
+
167
+
168
+class EmptyCommentContentNotAllowed(EmptyValueNotAllowed):
169
+    pass
170
+
171
+
172
+class UserNotActive(TracimException):
173
+    pass
174
+
175
+
176
+class NoUserSetted(TracimException):
177
+    pass
178
+
179
+
180
+class RoleDoesNotExist(TracimException):
181
+    pass
182
+
183
+
184
+class EmailValidationFailed(TracimException):
185
+    pass
186
+
187
+
188
+class UserCreationFailed(TracimException):
189
+    pass
190
+
191
+
192
+class ParentNotFound(NotFound):
193
+    pass
194
+
195
+
196
+class RevisionDoesNotMatchThisContent(TracimException):
197
+    pass
198
+
199
+
200
+class PageOfPreviewNotFound(NotFound):
201
+    pass
202
+
203
+
204
+class PreviewDimNotAllowed(TracimException):
205
+    pass

+ 3 - 0
backend/tracim/extensions.py View File

@@ -0,0 +1,3 @@
1
+from hapic import Hapic
2
+
3
+hapic = Hapic()

+ 48 - 0
backend/tracim/fixtures/__init__.py View File

@@ -0,0 +1,48 @@
1
+import copy
2
+import transaction
3
+
4
+
5
+class Fixture(object):
6
+
7
+    """ Fixture classes (list) required for this fixtures"""
8
+    require = NotImplemented
9
+
10
+    def __init__(self, session, config):
11
+        self._session = session
12
+        self._config = config
13
+
14
+    def insert(self):
15
+        raise NotImplementedError()
16
+
17
+
18
+class FixturesLoader(object):
19
+    """
20
+    Fixtures loader. Load each fixture once.
21
+    """
22
+
23
+    def __init__(self, session, config, loaded=None):
24
+        loaded = [] if loaded is None else loaded
25
+        self._loaded = loaded
26
+        self._session = session
27
+        # FIXME - G.M - 2018-06-169 - Fixture failed with email_notification
28
+        # activated, disable it there now. Find better way to fix this
29
+        # later
30
+        self._config = copy.copy(config)
31
+        self._config.EMAIL_NOTIFICATION_ACTIVATED = False
32
+
33
+    def loads(self, fixtures_classes):
34
+        for fixture_class in fixtures_classes:
35
+            for required_fixture_class in fixture_class.require:
36
+                self._load(required_fixture_class)
37
+            self._load(fixture_class)
38
+
39
+    def _load(self, fixture_class: Fixture):
40
+        if fixture_class not in self._loaded:
41
+            fixture = fixture_class(
42
+                session=self._session,
43
+                config=self._config,
44
+            )
45
+            fixture.insert()
46
+            self._loaded.append(fixture_class)
47
+            self._session.flush()
48
+            transaction.commit()

+ 319 - 0
backend/tracim/fixtures/content.py View File

@@ -0,0 +1,319 @@
1
+# -*- coding: utf-8 -*-
2
+from depot.io.utils import FileIntent
3
+import transaction
4
+
5
+from tracim import models
6
+from tracim.fixtures import Fixture
7
+from tracim.fixtures.users_and_groups import Test
8
+from tracim.lib.core.content import ContentApi
9
+from tracim.lib.core.userworkspace import RoleApi
10
+from tracim.lib.core.workspace import WorkspaceApi
11
+from tracim.models.data import ContentType
12
+from tracim.models.data import UserRoleInWorkspace
13
+from tracim.models.revision_protection import new_revision
14
+
15
+
16
+class Content(Fixture):
17
+    require = [Test]
18
+
19
+    def insert(self):
20
+        admin = self._session.query(models.User) \
21
+            .filter(models.User.email == 'admin@admin.admin') \
22
+            .one()
23
+        bob = self._session.query(models.User) \
24
+            .filter(models.User.email == 'bob@fsf.local') \
25
+            .one()
26
+        john_the_reader = self._session.query(models.User) \
27
+            .filter(models.User.email == 'john-the-reader@reader.local') \
28
+            .one()
29
+
30
+        admin_workspace_api = WorkspaceApi(
31
+            current_user=admin,
32
+            session=self._session,
33
+            config=self._config,
34
+        )
35
+        bob_workspace_api = WorkspaceApi(
36
+            current_user=bob,
37
+            session=self._session,
38
+            config=self._config
39
+        )
40
+        content_api = ContentApi(
41
+            current_user=admin,
42
+            session=self._session,
43
+            config=self._config
44
+        )
45
+        bob_content_api = ContentApi(
46
+            current_user=bob,
47
+            session=self._session,
48
+            config=self._config
49
+        )
50
+        reader_content_api = ContentApi(
51
+            current_user=john_the_reader,
52
+            session=self._session,
53
+            config=self._config
54
+        )
55
+        role_api = RoleApi(
56
+            current_user=admin,
57
+            session=self._session,
58
+            config=self._config,
59
+        )
60
+
61
+        # Workspaces
62
+        business_workspace = admin_workspace_api.create_workspace(
63
+            'Business',
64
+            description='All importants documents',
65
+            save_now=True,
66
+        )
67
+        recipe_workspace = admin_workspace_api.create_workspace(
68
+            'Recipes',
69
+            description='Our best recipes',
70
+            save_now=True,
71
+        )
72
+        other_workspace = bob_workspace_api.create_workspace(
73
+            'Others',
74
+            description='Other Workspace',
75
+            save_now=True,
76
+        )
77
+
78
+        # Workspaces roles
79
+        role_api.create_one(
80
+            user=bob,
81
+            workspace=recipe_workspace,
82
+            role_level=UserRoleInWorkspace.CONTENT_MANAGER,
83
+            with_notif=False,
84
+        )
85
+        role_api.create_one(
86
+            user=john_the_reader,
87
+            workspace=recipe_workspace,
88
+            role_level=UserRoleInWorkspace.READER,
89
+            with_notif=False,
90
+        )
91
+        # Folders
92
+
93
+        tool_workspace = content_api.create(
94
+            content_type=ContentType.Folder,
95
+            workspace=business_workspace,
96
+            label='Tools',
97
+            do_save=True,
98
+            do_notify=False,
99
+        )
100
+        menu_workspace = content_api.create(
101
+            content_type=ContentType.Folder,
102
+            workspace=business_workspace,
103
+            label='Menus',
104
+            do_save=True,
105
+            do_notify=False,
106
+        )
107
+
108
+        dessert_folder = content_api.create(
109
+            content_type=ContentType.Folder,
110
+            workspace=recipe_workspace,
111
+            label='Desserts',
112
+            do_save=True,
113
+            do_notify=False,
114
+        )
115
+        salads_folder = content_api.create(
116
+            content_type=ContentType.Folder,
117
+            workspace=recipe_workspace,
118
+            label='Salads',
119
+            do_save=True,
120
+            do_notify=False,
121
+        )
122
+        other_folder = content_api.create(
123
+            content_type=ContentType.Folder,
124
+            workspace=other_workspace,
125
+            label='Infos',
126
+            do_save=True,
127
+            do_notify=False,
128
+        )
129
+
130
+        # Pages, threads, ..
131
+        tiramisu_page = content_api.create(
132
+            content_type=ContentType.Page,
133
+            workspace=recipe_workspace,
134
+            parent=dessert_folder,
135
+            label='Tiramisu Recipes!!!',
136
+            do_save=True,
137
+            do_notify=False,
138
+        )
139
+        with new_revision(
140
+                session=self._session,
141
+                tm=transaction.manager,
142
+                content=tiramisu_page,
143
+        ):
144
+            content_api.update_content(
145
+                item=tiramisu_page,
146
+                new_content='<p>To cook a greet Tiramisu, you need many ingredients.</p>',  # nopep8
147
+                new_label='Tiramisu Recipes!!!',
148
+            )
149
+            content_api.save(tiramisu_page)
150
+
151
+        best_cake_thread = content_api.create(
152
+            content_type=ContentType.Thread,
153
+            workspace=recipe_workspace,
154
+            parent=dessert_folder,
155
+            label='Best Cake',
156
+            do_save=False,
157
+            do_notify=False,
158
+        )
159
+        best_cake_thread.description = 'Which is the best cake?'
160
+        self._session.add(best_cake_thread)
161
+        apple_pie_recipe = content_api.create(
162
+            content_type=ContentType.File,
163
+            workspace=recipe_workspace,
164
+            parent=dessert_folder,
165
+            label='Apple_Pie',
166
+            do_save=False,
167
+            do_notify=False,
168
+        )
169
+        apple_pie_recipe.file_extension = '.txt'
170
+        apple_pie_recipe.depot_file = FileIntent(
171
+            b'Apple pie Recipe',
172
+            'apple_Pie.txt',
173
+            'text/plain',
174
+        )
175
+        self._session.add(apple_pie_recipe)
176
+        Brownie_recipe = content_api.create(
177
+            content_type=ContentType.File,
178
+            workspace=recipe_workspace,
179
+            parent=dessert_folder,
180
+            label='Brownie Recipe',
181
+            do_save=False,
182
+            do_notify=False,
183
+        )
184
+        Brownie_recipe.file_extension = '.html'
185
+        Brownie_recipe.depot_file = FileIntent(
186
+            b'<p>Brownie Recipe</p>',
187
+            'brownie_recipe.html',
188
+            'text/html',
189
+        )
190
+        self._session.add(Brownie_recipe)
191
+        fruits_desserts_folder = content_api.create(
192
+            content_type=ContentType.Folder,
193
+            workspace=recipe_workspace,
194
+            label='Fruits Desserts',
195
+            parent=dessert_folder,
196
+            do_save=True,
197
+        )
198
+
199
+        menu_page = content_api.create(
200
+            content_type=ContentType.Page,
201
+            workspace=business_workspace,
202
+            parent=menu_workspace,
203
+            label='Current Menu',
204
+            do_save=True,
205
+        )
206
+
207
+        new_fruit_salad = content_api.create(
208
+            content_type=ContentType.Page,
209
+            workspace=recipe_workspace,
210
+            parent=fruits_desserts_folder,
211
+            label='New Fruit Salad',
212
+            do_save=True,
213
+        )
214
+        old_fruit_salad = content_api.create(
215
+            content_type=ContentType.Page,
216
+            workspace=recipe_workspace,
217
+            parent=fruits_desserts_folder,
218
+            label='Fruit Salad',
219
+            do_save=True,
220
+            do_notify=False,
221
+        )
222
+        with new_revision(
223
+                session=self._session,
224
+                tm=transaction.manager,
225
+                content=old_fruit_salad,
226
+        ):
227
+            content_api.archive(old_fruit_salad)
228
+        content_api.save(old_fruit_salad)
229
+
230
+        bad_fruit_salad = content_api.create(
231
+            content_type=ContentType.Page,
232
+            workspace=recipe_workspace,
233
+            parent=fruits_desserts_folder,
234
+            label='Bad Fruit Salad',
235
+            do_save=True,
236
+            do_notify=False,
237
+        )
238
+        with new_revision(
239
+                session=self._session,
240
+                tm=transaction.manager,
241
+                content=bad_fruit_salad,
242
+        ):
243
+            content_api.delete(bad_fruit_salad)
244
+        content_api.save(bad_fruit_salad)
245
+
246
+        # File at the root for test
247
+        new_fruit_salad = content_api.create(
248
+            content_type=ContentType.Page,
249
+            workspace=other_workspace,
250
+            label='New Fruit Salad',
251
+            do_save=True,
252
+        )
253
+        old_fruit_salad = content_api.create(
254
+            content_type=ContentType.Page,
255
+            workspace=other_workspace,
256
+            label='Fruit Salad',
257
+            do_save=True,
258
+        )
259
+        with new_revision(
260
+                session=self._session,
261
+                tm=transaction.manager,
262
+                content=old_fruit_salad,
263
+        ):
264
+            content_api.archive(old_fruit_salad)
265
+        content_api.save(old_fruit_salad)
266
+
267
+        bad_fruit_salad = content_api.create(
268
+            content_type=ContentType.Page,
269
+            workspace=other_workspace,
270
+            label='Bad Fruit Salad',
271
+            do_save=True,
272
+        )
273
+        with new_revision(
274
+                session=self._session,
275
+                tm=transaction.manager,
276
+                content=bad_fruit_salad,
277
+        ):
278
+            content_api.delete(bad_fruit_salad)
279
+        content_api.save(bad_fruit_salad)
280
+
281
+        content_api.create_comment(
282
+            parent=best_cake_thread,
283
+            content='<p>What is for you the best cake ever? </br> I personnally vote for Chocolate cupcake!</p>',  # nopep8
284
+            do_save=True,
285
+        )
286
+        bob_content_api.create_comment(
287
+            parent=best_cake_thread,
288
+            content='<p>What about Apple Pie? There are Awesome!</p>',
289
+            do_save=True,
290
+        )
291
+        reader_content_api.create_comment(
292
+            parent=best_cake_thread,
293
+            content='<p>You are right, but Kouign-amann are clearly better.</p>',
294
+            do_save=True,
295
+        )
296
+        with new_revision(
297
+                session=self._session,
298
+                tm=transaction.manager,
299
+                content=best_cake_thread,
300
+        ):
301
+            bob_content_api.update_content(
302
+                item=best_cake_thread,
303
+                new_content='What is the best cake?',
304
+                new_label='Best Cakes?',
305
+            )
306
+            bob_content_api.save(best_cake_thread)
307
+
308
+        with new_revision(
309
+                session=self._session,
310
+                tm=transaction.manager,
311
+                content=tiramisu_page,
312
+        ):
313
+            bob_content_api.update_content(
314
+                item=tiramisu_page,
315
+                new_content='<p>To cook a great Tiramisu, you need many ingredients.</p>',  # nopep8
316
+                new_label='Tiramisu Recipe',
317
+            )
318
+            bob_content_api.save(tiramisu_page)
319
+        self._session.flush()

+ 58 - 0
backend/tracim/fixtures/ldap.py View File

@@ -0,0 +1,58 @@
1
+ldap_test_server_fixtures = {
2
+    'port': 3333,
3
+    'password': 'toor',
4
+
5
+    'bind_dn': 'cn=admin,dc=directory,dc=fsf,dc=org',
6
+    'base': {
7
+        'objectclass': ['dcObject', 'organization'],
8
+        'dn': 'dc=directory,dc=fsf,dc=org',
9
+        'attributes': {
10
+            'o': 'Free Software Foundation',
11
+            'dc': 'directory'
12
+        }
13
+    },
14
+
15
+    'entries': [
16
+        {
17
+            'objectclass': ['organizationalRole'],
18
+            'dn': 'cn=admin,dc=directory,dc=fsf,dc=org',
19
+            'attributes': {
20
+                'cn': 'admin'
21
+            }
22
+        },
23
+        {
24
+            'objectclass': ['organizationalUnit'],
25
+            'dn': 'ou=people,dc=directory,dc=fsf,dc=org',
26
+            'attributes': {
27
+                'ou': 'people',
28
+            }
29
+        },
30
+        {
31
+            'objectclass': ['organizationalUnit'],
32
+            'dn': 'ou=groups,dc=directory,dc=fsf,dc=org',
33
+            'attributes': {
34
+                'ou': 'groups',
35
+            }
36
+        },
37
+        {
38
+            'objectclass': ['account', 'top'],
39
+            'dn': 'cn=richard-not-real-email@fsf.org,ou=people,dc=directory,dc=fsf,dc=org',
40
+            'attributes': {
41
+                'uid': 'richard-not-real-email@fsf.org',
42
+                'userPassword': 'rms',
43
+                'mail': 'richard-not-real-email@fsf.org',
44
+                'pubname': 'Richard Stallman',
45
+            }
46
+        },
47
+        {
48
+            'objectclass': ['account', 'top'],
49
+            'dn': 'cn=lawrence-not-real-email@fsf.local,ou=people,dc=directory,dc=fsf,dc=org',
50
+            'attributes': {
51
+                'uid': 'lawrence-not-real-email@fsf.local',
52
+                'userPassword': 'foobarbaz',
53
+                'mail': 'lawrence-not-real-email@fsf.local',
54
+                'pubname': 'Lawrence Lessig',
55
+            }
56
+        },
57
+    ]
58
+}

+ 72 - 0
backend/tracim/fixtures/users_and_groups.py View File

@@ -0,0 +1,72 @@
1
+# -*- coding: utf-8 -*-
2
+from tracim import models
3
+from tracim.fixtures import Fixture
4
+from tracim.lib.core.user import UserApi
5
+
6
+
7
+class Base(Fixture):
8
+    require = []
9
+
10
+    def insert(self):
11
+        u = models.User()
12
+        u.display_name = 'Global manager'
13
+        u.email = 'admin@admin.admin'
14
+        u.password = 'admin@admin.admin'
15
+        self._session.add(u)
16
+        uapi = UserApi(
17
+            session=self._session,
18
+            config=self._config,
19
+            current_user=u)
20
+        uapi.execute_created_user_actions(u)
21
+
22
+        g1 = models.Group()
23
+        g1.group_id = 1
24
+        g1.group_name = 'users'
25
+        g1.display_name = 'Users'
26
+        g1.users.append(u)
27
+        self._session.add(g1)
28
+
29
+        g2 = models.Group()
30
+        g2.group_id = 2
31
+        g2.group_name = 'managers'
32
+        g2.display_name = 'Global Managers'
33
+        g2.users.append(u)
34
+        self._session.add(g2)
35
+
36
+        g3 = models.Group()
37
+        g3.group_id = 3
38
+        g3.group_name = 'administrators'
39
+        g3.display_name = 'Administrators'
40
+        g3.users.append(u)
41
+        self._session.add(g3)
42
+
43
+
44
+class Test(Fixture):
45
+    require = [Base, ]
46
+
47
+    def insert(self):
48
+        g2 = self._session.query(models.Group).\
49
+            filter(models.Group.group_name == 'managers').one()
50
+
51
+        lawrence = models.User()
52
+        lawrence.display_name = 'Lawrence L.'
53
+        lawrence.email = 'lawrence-not-real-email@fsf.local'
54
+        lawrence.password = 'foobarbaz'
55
+        self._session.add(lawrence)
56
+        g2.users.append(lawrence)
57
+
58
+        bob = models.User()
59
+        bob.display_name = 'Bob i.'
60
+        bob.email = 'bob@fsf.local'
61
+        bob.password = 'foobarbaz'
62
+        self._session.add(bob)
63
+        g2.users.append(bob)
64
+
65
+        g1 = self._session.query(models.Group).\
66
+            filter(models.Group.group_name == 'users').one()
67
+        reader = models.User()
68
+        reader.display_name = 'John Reader'
69
+        reader.email = 'john-the-reader@reader.local'
70
+        reader.password = 'read'
71
+        self._session.add(reader)
72
+        g1.users.append(reader)

+ 0 - 0
backend/tracim/lib/__init__.py View File


+ 1 - 0
backend/tracim/lib/core/__init__.py View File

@@ -0,0 +1 @@
1
+# coding=utf-8

File diff suppressed because it is too large
+ 1543 - 0
backend/tracim/lib/core/content.py


+ 47 - 0
backend/tracim/lib/core/group.py View File

@@ -0,0 +1,47 @@
1
+# -*- coding: utf-8 -*-
2
+import typing
3
+
4
+from sqlalchemy.orm.exc import NoResultFound
5
+
6
+from tracim.exceptions import GroupDoesNotExist
7
+from tracim import CFG
8
+
9
+
10
+__author__ = 'damien'
11
+
12
+from tracim.models.auth import Group, User
13
+from sqlalchemy.orm import Query
14
+from sqlalchemy.orm import Session
15
+
16
+
17
+class GroupApi(object):
18
+
19
+    def __init__(
20
+            self,
21
+            session: Session,
22
+            current_user: typing.Optional[User],
23
+            config: CFG
24
+    ):
25
+        self._user = current_user
26
+        self._session = session
27
+        self._config = config
28
+
29
+    def _base_query(self) -> Query:
30
+        return self._session.query(Group)
31
+
32
+    def get_one(self, group_id) -> Group:
33
+        try:
34
+            group = self._base_query().filter(Group.group_id == group_id).one()
35
+            return group
36
+        except NoResultFound:
37
+            raise GroupDoesNotExist()
38
+
39
+    def get_one_with_name(self, group_name) -> Group:
40
+        try:
41
+            group = self._base_query().filter(Group.group_name == group_name).one()
42
+            return group
43
+        except NoResultFound:
44
+            raise GroupDoesNotExist()
45
+
46
+    def get_all(self):
47
+        return self._base_query().order_by(Group.group_id).all()

+ 57 - 0
backend/tracim/lib/core/notifications.py View File

@@ -0,0 +1,57 @@
1
+# -*- coding: utf-8 -*-
2
+from sqlalchemy.orm import Session
3
+
4
+from tracim import CFG
5
+from tracim.lib.utils.logger import logger
6
+from tracim.models.auth import User
7
+from tracim.models.data import Content
8
+
9
+
10
+class INotifier(object):
11
+    """
12
+    Interface for Notifier instances
13
+    """
14
+    def __init__(self,
15
+                 config: CFG,
16
+                 session: Session,
17
+                 current_user: User=None,
18
+    ) -> None:
19
+        pass
20
+
21
+    def notify_content_update(self, content: Content):
22
+        raise NotImplementedError
23
+
24
+
25
+class NotifierFactory(object):
26
+
27
+    @classmethod
28
+    def create(cls, config, session, current_user: User=None) -> INotifier:
29
+        if not config.EMAIL_NOTIFICATION_ACTIVATED:
30
+            return DummyNotifier(config, session, current_user)
31
+        from tracim.lib.mail_notifier.notifier import EmailNotifier
32
+        return EmailNotifier(config, session, current_user)
33
+
34
+
35
+class DummyNotifier(INotifier):
36
+    send_count = 0
37
+
38
+    def __init__(
39
+            self,
40
+            config: CFG,
41
+            session: Session,
42
+            current_user: User=None
43
+    ) -> None:
44
+        INotifier.__init__(
45
+            self,
46
+            config,
47
+            session,
48
+            current_user,
49
+        )
50
+        logger.info(self, 'Instantiating Dummy Notifier')
51
+
52
+    def notify_content_update(self, content: Content):
53
+        type(self).send_count += 1
54
+        logger.info(
55
+            self,
56
+            'Fake notifier, do not send notification for update of content {}'.format(content.content_id)  # nopep8
57
+        )

+ 377 - 0
backend/tracim/lib/core/user.py View File

@@ -0,0 +1,377 @@
1
+# -*- coding: utf-8 -*-
2
+from smtplib import SMTPException
3
+
4
+import transaction
5
+import typing as typing
6
+from sqlalchemy.orm import Session
7
+from sqlalchemy.orm.exc import NoResultFound
8
+
9
+from tracim import CFG
10
+from tracim.models.auth import User
11
+from tracim.models.auth import Group
12
+from tracim.exceptions import NoUserSetted
13
+from tracim.exceptions import PasswordDoNotMatch
14
+from tracim.exceptions import EmailValidationFailed
15
+from tracim.exceptions import UserDoesNotExist
16
+from tracim.exceptions import WrongUserPassword
17
+from tracim.exceptions import AuthenticationFailed
18
+from tracim.exceptions import NotificationNotSend
19
+from tracim.exceptions import UserNotActive
20
+from tracim.models.context_models import UserInContext
21
+from tracim.lib.mail_notifier.notifier import get_email_manager
22
+from tracim.models.context_models import TypeUser
23
+
24
+
25
+class UserApi(object):
26
+
27
+    def __init__(
28
+            self,
29
+            current_user: typing.Optional[User],
30
+            session: Session,
31
+            config: CFG,
32
+    ) -> None:
33
+        self._session = session
34
+        self._user = current_user
35
+        self._config = config
36
+
37
+    def _base_query(self):
38
+        return self._session.query(User)
39
+
40
+    def get_user_with_context(self, user: User) -> UserInContext:
41
+        """
42
+        Return UserInContext object from User
43
+        """
44
+        user = UserInContext(
45
+            user=user,
46
+            dbsession=self._session,
47
+            config=self._config,
48
+        )
49
+        return user
50
+
51
+    # Getters
52
+
53
+    def get_one(self, user_id: int) -> User:
54
+        """
55
+        Get one user by user id
56
+        """
57
+        try:
58
+            user = self._base_query().filter(User.user_id == user_id).one()
59
+        except NoResultFound as exc:
60
+            raise UserDoesNotExist('User "{}" not found in database'.format(user_id)) from exc  # nopep8
61
+        return user
62
+
63
+    def get_one_by_email(self, email: str) -> User:
64
+        """
65
+        Get one user by email
66
+        :param email: Email of the user
67
+        :return: one user
68
+        """
69
+        try:
70
+            user = self._base_query().filter(User.email == email).one()
71
+        except NoResultFound as exc:
72
+            raise UserDoesNotExist('User "{}" not found in database'.format(email)) from exc  # nopep8
73
+        return user
74
+
75
+    def get_one_by_public_name(self, public_name: str) -> User:
76
+        """
77
+        Get one user by public_name
78
+        """
79
+        try:
80
+            user = self._base_query().filter(User.display_name == public_name).one()
81
+        except NoResultFound as exc:
82
+            raise UserDoesNotExist('User "{}" not found in database'.format(public_name)) from exc  # nopep8
83
+        return user
84
+    # FIXME - G.M - 24-04-2018 - Duplicate method with get_one.
85
+
86
+    def get_one_by_id(self, id: int) -> User:
87
+        return self.get_one(user_id=id)
88
+
89
+    def get_current_user(self) -> User:
90
+        """
91
+        Get current_user
92
+        """
93
+        if not self._user:
94
+            raise UserDoesNotExist('There is no current user')
95
+        return self._user
96
+
97
+    def get_all(self) -> typing.Iterable[User]:
98
+        return self._session.query(User).order_by(User.display_name).all()
99
+
100
+    def find(
101
+            self,
102
+            user_id: int=None,
103
+            email: str=None,
104
+            public_name: str=None
105
+    ) -> typing.Tuple[TypeUser, User]:
106
+        """
107
+        Find existing user from all theses params.
108
+        Check is made in this order: user_id, email, public_name
109
+        If no user found raise UserDoesNotExist exception
110
+        """
111
+        user = None
112
+
113
+        if user_id:
114
+            try:
115
+                user = self.get_one(user_id)
116
+                return TypeUser.USER_ID, user
117
+            except UserDoesNotExist:
118
+                pass
119
+        if email:
120
+            try:
121
+                user = self.get_one_by_email(email)
122
+                return TypeUser.EMAIL, user
123
+            except UserDoesNotExist:
124
+                pass
125
+        if public_name:
126
+            try:
127
+                user = self.get_one_by_public_name(public_name)
128
+                return TypeUser.PUBLIC_NAME, user
129
+            except UserDoesNotExist:
130
+                pass
131
+
132
+        raise UserDoesNotExist('User not found with any of given params.')
133
+
134
+    # Check methods
135
+
136
+    def user_with_email_exists(self, email: str) -> bool:
137
+        try:
138
+            self.get_one_by_email(email)
139
+            return True
140
+        # TODO - G.M - 09-04-2018 - Better exception
141
+        except:
142
+            return False
143
+
144
+    def authenticate_user(self, email: str, password: str) -> User:
145
+        """
146
+        Authenticate user with email and password, raise AuthenticationFailed
147
+        if uncorrect.
148
+        :param email: email of the user
149
+        :param password: cleartext password of the user
150
+        :return: User who was authenticated.
151
+        """
152
+        try:
153
+            user = self.get_one_by_email(email)
154
+            if not user.is_active:
155
+                raise UserNotActive('User "{}" is not active'.format(email))
156
+            if user.validate_password(password):
157
+                return user
158
+            else:
159
+                raise WrongUserPassword('User "{}" password is incorrect'.format(email))  # nopep8
160
+        except (WrongUserPassword, UserDoesNotExist) as exc:
161
+            raise AuthenticationFailed('User "{}" authentication failed'.format(email)) from exc  # nopep8
162
+
163
+    # Actions
164
+    def set_password(
165
+            self,
166
+            user: User,
167
+            loggedin_user_password: str,
168
+            new_password: str,
169
+            new_password2: str,
170
+            do_save: bool=True
171
+    ):
172
+        """
173
+        Set User password if loggedin user password is correct
174
+        and both new_password are the same.
175
+        :param user: User who need password changed
176
+        :param loggedin_user_password: cleartext password of logged user (not
177
+        same as user)
178
+        :param new_password: new password for user
179
+        :param new_password2: should be same as new_password
180
+        :param do_save: should we save new user password ?
181
+        :return:
182
+        """
183
+        if not self._user:
184
+            raise NoUserSetted('Current User should be set in UserApi to use this method')  # nopep8
185
+        if not self._user.validate_password(loggedin_user_password):  # nopep8
186
+            raise WrongUserPassword(
187
+                'Wrong password for authenticated user {}'. format(self._user.user_id)  # nopep8
188
+            )
189
+        if new_password != new_password2:
190
+            raise PasswordDoNotMatch('Passwords given are different')
191
+
192
+        self.update(
193
+            user=user,
194
+            password=new_password,
195
+            do_save=do_save,
196
+        )
197
+        if do_save:
198
+            # TODO - G.M - 2018-07-24 - Check why commit is needed here
199
+            transaction.commit()
200
+        return user
201
+
202
+    def set_email(
203
+            self,
204
+            user: User,
205
+            loggedin_user_password: str,
206
+            email: str,
207
+            do_save: bool = True
208
+    ):
209
+        """
210
+        Set email address of user if loggedin user password is correct
211
+        :param user: User who need email changed
212
+        :param loggedin_user_password: cleartext password of logged user (not
213
+        same as user)
214
+        :param email:
215
+        :param do_save:
216
+        :return:
217
+        """
218
+        if not self._user:
219
+            raise NoUserSetted('Current User should be set in UserApi to use this method')  # nopep8
220
+        if not self._user.validate_password(loggedin_user_password):  # nopep8
221
+            raise WrongUserPassword(
222
+                'Wrong password for authenticated user {}'. format(self._user.user_id)  # nopep8
223
+            )
224
+        self.update(
225
+            user=user,
226
+            email=email,
227
+            do_save=do_save,
228
+        )
229
+        return user
230
+
231
+    def _check_email(self, email: str) -> bool:
232
+        # TODO - G.M - 2018-07-05 - find a better way to check email
233
+        if not email:
234
+            return False
235
+        email = email.split('@')
236
+        if len(email) != 2:
237
+            return False
238
+        return True
239
+
240
+    def update(
241
+            self,
242
+            user: User,
243
+            name: str=None,
244
+            email: str=None,
245
+            password: str=None,
246
+            timezone: str=None,
247
+            groups: typing.Optional[typing.List[Group]]=None,
248
+            do_save=True,
249
+    ) -> User:
250
+        if name is not None:
251
+            user.display_name = name
252
+
253
+        if email is not None:
254
+            email_exist = self._check_email(email)
255
+            if not email_exist:
256
+                raise EmailValidationFailed('Email given form {} is uncorrect'.format(email))  # nopep8
257
+            user.email = email
258
+
259
+        if password is not None:
260
+            user.password = password
261
+
262
+        if timezone is not None:
263
+            user.timezone = timezone
264
+
265
+        if groups is not None:
266
+            # INFO - G.M - 2018-07-18 - Delete old groups
267
+            for group in user.groups:
268
+                if group not in groups:
269
+                    user.groups.remove(group)
270
+            # INFO - G.M - 2018-07-18 - add new groups
271
+            for group in groups:
272
+                if group not in user.groups:
273
+                    user.groups.append(group)
274
+
275
+        if do_save:
276
+            self.save(user)
277
+
278
+        return user
279
+
280
+    def create_user(
281
+        self,
282
+        email,
283
+        password: str = None,
284
+        name: str = None,
285
+        timezone: str = '',
286
+        groups=[],
287
+        do_save: bool=True,
288
+        do_notify: bool=True,
289
+    ) -> User:
290
+        new_user = self.create_minimal_user(email, groups, save_now=False)
291
+        self.update(
292
+            user=new_user,
293
+            name=name,
294
+            email=email,
295
+            password=password,
296
+            timezone=timezone,
297
+            do_save=False,
298
+        )
299
+        if do_notify:
300
+            try:
301
+                email_manager = get_email_manager(self._config, self._session)
302
+                email_manager.notify_created_account(
303
+                    new_user,
304
+                    password=password
305
+                )
306
+            except SMTPException as e:
307
+                raise NotificationNotSend()
308
+        if do_save:
309
+            self.save(new_user)
310
+        return new_user
311
+
312
+    def create_minimal_user(
313
+            self,
314
+            email,
315
+            groups=[],
316
+            save_now=False
317
+    ) -> User:
318
+        """Previous create_user method"""
319
+        user = User()
320
+
321
+        email_exist = self._check_email(email)
322
+        if not email_exist:
323
+            raise EmailValidationFailed('Email given form {} is uncorrect'.format(email))  # nopep8
324
+        user.email = email
325
+        user.display_name = email.split('@')[0]
326
+
327
+        for group in groups:
328
+            user.groups.append(group)
329
+
330
+        self._session.add(user)
331
+
332
+        if save_now:
333
+            self._session.flush()
334
+
335
+        return user
336
+
337
+    def enable(self, user: User, do_save=False):
338
+        user.is_active = True
339
+        if do_save:
340
+            self.save(user)
341
+
342
+    def disable(self, user:User, do_save=False):
343
+        user.is_active = False
344
+        if do_save:
345
+            self.save(user)
346
+
347
+    def save(self, user: User):
348
+        self._session.flush()
349
+
350
+    def execute_created_user_actions(self, created_user: User) -> None:
351
+        """
352
+        Execute actions when user just been created
353
+        :return:
354
+        """
355
+        # NOTE: Cyclic import
356
+        # TODO - G.M - 28-03-2018 - [Calendar] Reenable Calendar stuff
357
+        #from tracim.lib.calendar import CalendarManager
358
+        #from tracim.model.organisational import UserCalendar
359
+
360
+        # TODO - G.M - 04-04-2018 - [auth]
361
+        # Check if this is already needed with
362
+        # new auth system
363
+        created_user.ensure_auth_token(
364
+            session=self._session,
365
+            validity_seconds=self._config.USER_AUTH_TOKEN_VALIDITY
366
+        )
367
+
368
+        # Ensure database is up-to-date
369
+        self._session.flush()
370
+        transaction.commit()
371
+
372
+        # TODO - G.M - 28-03-2018 - [Calendar] Reenable Calendar stuff
373
+        # calendar_manager = CalendarManager(created_user)
374
+        # calendar_manager.create_then_remove_fake_event(
375
+        #     calendar_class=UserCalendar,
376
+        #     related_object_id=created_user.user_id,
377
+        # )

+ 198 - 0
backend/tracim/lib/core/userworkspace.py View File

@@ -0,0 +1,198 @@
1
+# -*- coding: utf-8 -*-
2
+import typing
3
+
4
+from tracim import CFG
5
+from tracim.models.context_models import UserRoleWorkspaceInContext
6
+from tracim.models.roles import WorkspaceRoles
7
+
8
+__author__ = 'damien'
9
+
10
+from sqlalchemy.orm import Session
11
+from sqlalchemy.orm import Query
12
+from tracim.models.auth import User
13
+from tracim.models.data import Workspace
14
+from tracim.models.data import UserRoleInWorkspace
15
+
16
+
17
+class RoleApi(object):
18
+
19
+    # TODO - G.M - 29-06-2018 - [Cleanup] Drop this
20
+    # ALL_ROLE_VALUES = UserRoleInWorkspace.get_all_role_values()
21
+    # Dict containing readable members roles for given role
22
+    # members_read_rights = {
23
+    #     UserRoleInWorkspace.NOT_APPLICABLE: [],
24
+    #     UserRoleInWorkspace.READER: [
25
+    #         UserRoleInWorkspace.WORKSPACE_MANAGER,
26
+    #     ],
27
+    #     UserRoleInWorkspace.CONTRIBUTOR: [
28
+    #         UserRoleInWorkspace.WORKSPACE_MANAGER,
29
+    #         UserRoleInWorkspace.CONTENT_MANAGER,
30
+    #         UserRoleInWorkspace.CONTRIBUTOR,
31
+    #     ],
32
+    #     UserRoleInWorkspace.CONTENT_MANAGER: [
33
+    #         UserRoleInWorkspace.WORKSPACE_MANAGER,
34
+    #         UserRoleInWorkspace.CONTENT_MANAGER,
35
+    #         UserRoleInWorkspace.CONTRIBUTOR,
36
+    #         UserRoleInWorkspace.READER,
37
+    #     ],
38
+    #     UserRoleInWorkspace.WORKSPACE_MANAGER: [
39
+    #         UserRoleInWorkspace.WORKSPACE_MANAGER,
40
+    #         UserRoleInWorkspace.CONTENT_MANAGER,
41
+    #         UserRoleInWorkspace.CONTRIBUTOR,
42
+    #         UserRoleInWorkspace.READER,
43
+    #     ],
44
+    # }
45
+
46
+    # TODO - G.M - 29-06-2018 - [Cleanup] Drop this
47
+    # @classmethod
48
+    # def role_can_read_member_role(cls, reader_role: int, tested_role: int) \
49
+    #         -> bool:
50
+    #     """
51
+    #     :param reader_role: role as viewer
52
+    #     :param tested_role: role as viwed
53
+    #     :return: True if given role can view member role in workspace.
54
+    #     """
55
+    #     if reader_role in cls.members_read_rights:
56
+    #         return tested_role in cls.members_read_rights[reader_role]
57
+    #     return False
58
+
59
+    def get_user_role_workspace_with_context(
60
+            self,
61
+            user_role: UserRoleInWorkspace,
62
+            newly_created:bool = None,
63
+            email_sent: bool = None,
64
+    ) -> UserRoleWorkspaceInContext:
65
+        """
66
+        Return WorkspaceInContext object from Workspace
67
+        """
68
+        assert self._config
69
+        workspace = UserRoleWorkspaceInContext(
70
+            user_role=user_role,
71
+            dbsession=self._session,
72
+            config=self._config,
73
+            newly_created=newly_created,
74
+            email_sent=email_sent,
75
+        )
76
+        return workspace
77
+
78
+    def __init__(
79
+        self,
80
+        session: Session,
81
+        current_user: typing.Optional[User],
82
+        config: CFG,
83
+    )-> None:
84
+        self._session = session
85
+        self._user = current_user
86
+        self._config = config
87
+
88
+    def _get_one_rsc(self, user_id: int, workspace_id: int) -> Query:
89
+        """
90
+        :param user_id:
91
+        :param workspace_id:
92
+        :return: a Query object, filtered query but without fetching the object.
93
+        """
94
+        return self._session.query(UserRoleInWorkspace).\
95
+            filter(UserRoleInWorkspace.workspace_id == workspace_id).\
96
+            filter(UserRoleInWorkspace.user_id == user_id)
97
+
98
+    def get_one(self, user_id: int, workspace_id: int) -> UserRoleInWorkspace:
99
+        return self._get_one_rsc(user_id, workspace_id).one()
100
+
101
+    def update_role(
102
+        self,
103
+        role: UserRoleInWorkspace,
104
+        role_level: int,
105
+        with_notif: typing.Optional[bool] = None,
106
+        save_now: bool=False,
107
+    ):
108
+        """
109
+        Update role of user in this workspace
110
+        :param role: UserRoleInWorkspace object
111
+        :param role_level: level of new role wanted
112
+        :param with_notif: is user notification enabled in this workspace ?
113
+        :param save_now: database flush
114
+        :return: updated role
115
+        """
116
+        role.role = role_level
117
+        if with_notif is not None:
118
+            role.do_notify == with_notif
119
+        if save_now:
120
+            self.save(role)
121
+
122
+        return role
123
+
124
+    def create_one(
125
+        self,
126
+        user: User,
127
+        workspace: Workspace,
128
+        role_level: int,
129
+        with_notif: bool,
130
+        flush: bool=True
131
+    ) -> UserRoleInWorkspace:
132
+        role = UserRoleInWorkspace()
133
+        role.user_id = user.user_id
134
+        role.workspace = workspace
135
+        role.role = role_level
136
+        role.do_notify = with_notif
137
+        if flush:
138
+            self._session.flush()
139
+        return role
140
+
141
+    def delete_one(self, user_id: int, workspace_id: int, flush=True) -> None:
142
+        self._get_one_rsc(user_id, workspace_id).delete()
143
+        if flush:
144
+            self._session.flush()
145
+
146
+    def get_all_for_workspace(
147
+        self,
148
+        workspace:Workspace
149
+    ) -> typing.List[UserRoleInWorkspace]:
150
+        return self._session.query(UserRoleInWorkspace)\
151
+            .filter(UserRoleInWorkspace.workspace_id==workspace.workspace_id)\
152
+            .all()
153
+
154
+    def save(self, role: UserRoleInWorkspace) -> None:
155
+        self._session.flush()
156
+
157
+
158
+    # TODO - G.M - 29-06-2018 - [Cleanup] Drop this
159
+    # @classmethod
160
+    # def role_can_read_member_role(cls, reader_role: int, tested_role: int) \
161
+    #         -> bool:
162
+    #     """
163
+    #     :param reader_role: role as viewer
164
+    #     :param tested_role: role as viwed
165
+    #     :return: True if given role can view member role in workspace.
166
+    #     """
167
+    #     if reader_role in cls.members_read_rights:
168
+    #         return tested_role in cls.members_read_rights[reader_role]
169
+    #     return False
170
+    # def _get_all_for_user(self, user_id) -> typing.List[UserRoleInWorkspace]:
171
+    #     return self._session.query(UserRoleInWorkspace)\
172
+    #         .filter(UserRoleInWorkspace.user_id == user_id)
173
+    #
174
+    # def get_all_for_user(self, user: User) -> typing.List[UserRoleInWorkspace]:
175
+    #     return self._get_all_for_user(user.user_id).all()
176
+    #
177
+    # def get_all_for_user_order_by_workspace(
178
+    #     self,
179
+    #     user_id: int
180
+    # ) -> typing.List[UserRoleInWorkspace]:
181
+    #     return self._get_all_for_user(user_id)\
182
+    #         .join(UserRoleInWorkspace.workspace).order_by(Workspace.label).all()
183
+
184
+    # TODO - G.M - 07-06-2018 - [Cleanup] Check if this method is already needed
185
+    # @classmethod
186
+    # def get_roles_for_select_field(cls) -> typing.List[RoleType]:
187
+    #     """
188
+    #
189
+    #     :return: list of DictLikeClass instances representing available Roles
190
+    #     (to be used in select fields)
191
+    #     """
192
+    #     result = list()
193
+    #
194
+    #     for role_id in UserRoleInWorkspace.get_all_role_values():
195
+    #         role = RoleType(role_id)
196
+    #         result.append(role)
197
+    #
198
+    #     return result

+ 283 - 0
backend/tracim/lib/core/workspace.py View File

@@ -0,0 +1,283 @@
1
+# -*- coding: utf-8 -*-
2
+import typing
3
+
4
+from sqlalchemy.orm import Query
5
+from sqlalchemy.orm import Session
6
+
7
+from tracim import CFG
8
+from tracim.exceptions import EmptyLabelNotAllowed
9
+from tracim.lib.utils.translation import fake_translator as _
10
+
11
+from tracim.lib.core.userworkspace import RoleApi
12
+from tracim.models.auth import Group
13
+from tracim.models.auth import User
14
+from tracim.models.context_models import WorkspaceInContext
15
+from tracim.models.data import UserRoleInWorkspace
16
+from tracim.models.data import Workspace
17
+
18
+__author__ = 'damien'
19
+
20
+
21
+class WorkspaceApi(object):
22
+
23
+    def __init__(
24
+            self,
25
+            session: Session,
26
+            current_user: User,
27
+            config: CFG,
28
+            force_role: bool=False
29
+    ):
30
+        """
31
+        :param current_user: Current user of context
32
+        :param force_role: If True, app role in queries even if admin
33
+        """
34
+        self._session = session
35
+        self._user = current_user
36
+        self._config = config
37
+        self._force_role = force_role
38
+
39
+    def _base_query_without_roles(self):
40
+        return self._session.query(Workspace).filter(Workspace.is_deleted == False)
41
+
42
+    def _base_query(self):
43
+        if not self._force_role and self._user.profile.id>=Group.TIM_ADMIN:
44
+            return self._base_query_without_roles()
45
+
46
+        return self._session.query(Workspace).\
47
+            join(Workspace.roles).\
48
+            filter(UserRoleInWorkspace.user_id == self._user.user_id).\
49
+            filter(Workspace.is_deleted == False)
50
+
51
+    def get_workspace_with_context(
52
+            self,
53
+            workspace: Workspace
54
+    ) -> WorkspaceInContext:
55
+        """
56
+        Return WorkspaceInContext object from Workspace
57
+        """
58
+        workspace = WorkspaceInContext(
59
+            workspace=workspace,
60
+            dbsession=self._session,
61
+            config=self._config,
62
+        )
63
+        return workspace
64
+
65
+    def create_workspace(
66
+            self,
67
+            label: str='',
68
+            description: str='',
69
+            calendar_enabled: bool=False,
70
+            save_now: bool=False,
71
+    ) -> Workspace:
72
+        if not label:
73
+            raise EmptyLabelNotAllowed('Workspace label cannot be empty')
74
+
75
+        workspace = Workspace()
76
+        workspace.label = label
77
+        workspace.description = description
78
+        workspace.calendar_enabled = calendar_enabled
79
+
80
+        # By default, we force the current user to be the workspace manager
81
+        # And to receive email notifications
82
+        role_api = RoleApi(
83
+            session=self._session,
84
+            current_user=self._user,
85
+            config=self._config
86
+        )
87
+
88
+        role = role_api.create_one(
89
+            self._user,
90
+            workspace,
91
+            UserRoleInWorkspace.WORKSPACE_MANAGER,
92
+            with_notif=True,
93
+        )
94
+
95
+        self._session.add(workspace)
96
+        self._session.add(role)
97
+
98
+        if save_now:
99
+            self._session.flush()
100
+
101
+        # TODO - G.M - 28-03-2018 - [Calendar] Reenable calendar stuff
102
+        # if calendar_enabled:
103
+        #     self._ensure_calendar_exist(workspace)
104
+        # else:
105
+        #     self._disable_calendar(workspace)
106
+
107
+        return workspace
108
+
109
+    def update_workspace(
110
+            self,
111
+            workspace: Workspace,
112
+            label: str,
113
+            description: str,
114
+            save_now: bool=False,
115
+    ) -> Workspace:
116
+        """
117
+        Update workspace
118
+        :param workspace: workspace to update
119
+        :param label: new label of workspace
120
+        :param description: new description
121
+        :param save_now: database flush
122
+        :return: updated workspace
123
+        """
124
+        if not label:
125
+            raise EmptyLabelNotAllowed('Workspace label cannot be empty')
126
+        workspace.label = label
127
+        workspace.description = description
128
+
129
+        if save_now:
130
+            self.save(workspace)
131
+
132
+        return workspace
133
+
134
+    def get_one(self, id):
135
+        return self._base_query().filter(Workspace.workspace_id == id).one()
136
+
137
+    def get_one_by_label(self, label: str) -> Workspace:
138
+        return self._base_query().filter(Workspace.label == label).one()
139
+
140
+    """
141
+    def get_one_for_current_user(self, id):
142
+        return self._base_query().filter(Workspace.workspace_id==id).\
143
+            session.query(ZKContact).filter(ZKContact.groups.any(ZKGroup.id.in_([1,2,3])))
144
+            filter(sqla.).one()
145
+    """
146
+
147
+    def get_all(self):
148
+        return self._base_query().all()
149
+
150
+    def get_all_for_user(self, user: User, ignored_ids=None):
151
+        workspaces = []
152
+
153
+        for role in user.roles:
154
+            if not role.workspace.is_deleted:
155
+                if not ignored_ids:
156
+                    workspaces.append(role.workspace)
157
+                elif role.workspace.workspace_id not in ignored_ids:
158
+                        workspaces.append(role.workspace)
159
+                else:
160
+                    pass  # do not return workspace
161
+
162
+        workspaces.sort(key=lambda workspace: workspace.label.lower())
163
+        return workspaces
164
+
165
+    def get_all_manageable(self) -> typing.List[Workspace]:
166
+        """Get all workspaces the current user has manager rights on."""
167
+        workspaces = []  # type: typing.List[Workspace]
168
+        if self._user.profile.id == Group.TIM_ADMIN:
169
+            workspaces = self._base_query().order_by(Workspace.label).all()
170
+        elif self._user.profile.id == Group.TIM_MANAGER:
171
+            workspaces = self._base_query() \
172
+                .filter(
173
+                    UserRoleInWorkspace.role ==
174
+                    UserRoleInWorkspace.WORKSPACE_MANAGER
175
+                ) \
176
+                .order_by(Workspace.label) \
177
+                .all()
178
+        return workspaces
179
+
180
+    def disable_notifications(self, user: User, workspace: Workspace):
181
+        for role in user.roles:
182
+            if role.workspace==workspace:
183
+                role.do_notify = False
184
+
185
+    def enable_notifications(self, user: User, workspace: Workspace):
186
+        for role in user.roles:
187
+            if role.workspace==workspace:
188
+                role.do_notify = True
189
+
190
+    def get_notifiable_roles(self, workspace: Workspace) -> [UserRoleInWorkspace]:
191
+        roles = []
192
+        for role in workspace.roles:
193
+            if role.do_notify==True \
194
+                    and role.user!=self._user \
195
+                    and role.user.is_active:
196
+                roles.append(role)
197
+        return roles
198
+
199
+    def save(self, workspace: Workspace):
200
+        self._session.flush()
201
+
202
+    def delete_one(self, workspace_id, flush=True):
203
+        workspace = self.get_one(workspace_id)
204
+        workspace.is_deleted = True
205
+
206
+        if flush:
207
+            self._session.flush()
208
+
209
+    def restore_one(self, workspace_id, flush=True):
210
+        workspace = self._session.query(Workspace)\
211
+            .filter(Workspace.is_deleted==True)\
212
+            .filter(Workspace.workspace_id==workspace_id).one()
213
+        workspace.is_deleted = False
214
+
215
+        if flush:
216
+            self._session.flush()
217
+
218
+        return workspace
219
+
220
+    def execute_created_workspace_actions(self, workspace: Workspace) -> None:
221
+        pass
222
+        # TODO - G.M - 28-03-2018 - [Calendar] Re-enable this calendar stuff
223
+        # self.ensure_calendar_exist(workspace)
224
+
225
+    # TODO - G.M - 28-03-2018 - [Calendar] Re-enable this calendar stuff
226
+    # def ensure_calendar_exist(self, workspace: Workspace) -> None:
227
+    #     # Note: Cyclic imports
228
+    #     from tracim.lib.calendar import CalendarManager
229
+    #     from tracim.model.organisational import WorkspaceCalendar
230
+    #
231
+    #     calendar_manager = CalendarManager(self._user)
232
+    #
233
+    #     try:
234
+    #         calendar_manager.enable_calendar_file(
235
+    #             calendar_class=WorkspaceCalendar,
236
+    #             related_object_id=workspace.workspace_id,
237
+    #             raise_=True,
238
+    #         )
239
+    #     # If previous calendar file no exist, calendar must be created
240
+    #     except FileNotFoundError:
241
+    #         self._user.ensure_auth_token()
242
+    #
243
+    #         # Ensure database is up-to-date
244
+    #         self.session.flush()
245
+    #         transaction.commit()
246
+    #
247
+    #         calendar_manager.create_then_remove_fake_event(
248
+    #             calendar_class=WorkspaceCalendar,
249
+    #             related_object_id=workspace.workspace_id,
250
+    #         )
251
+    #
252
+    # def disable_calendar(self, workspace: Workspace) -> None:
253
+    #     # Note: Cyclic imports
254
+    #     from tracim.lib.calendar import CalendarManager
255
+    #     from tracim.model.organisational import WorkspaceCalendar
256
+    #
257
+    #     calendar_manager = CalendarManager(self._user)
258
+    #     calendar_manager.disable_calendar_file(
259
+    #         calendar_class=WorkspaceCalendar,
260
+    #         related_object_id=workspace.workspace_id,
261
+    #         raise_=False,
262
+    #     )
263
+
264
+    def get_base_query(self) -> Query:
265
+        return self._base_query()
266
+
267
+    def generate_label(self) -> str:
268
+        """
269
+        :return: Generated workspace label
270
+        """
271
+        query = self._base_query_without_roles() \
272
+            .filter(Workspace.label.ilike('{0}%'.format(
273
+                _('Workspace'),
274
+            )))
275
+
276
+        return _('Workspace {}').format(
277
+            query.count() + 1,
278
+        )
279
+
280
+
281
+class UnsafeWorkspaceApi(WorkspaceApi):
282
+    def _base_query(self):
283
+        return self.session.query(Workspace).filter(Workspace.is_deleted==False)

+ 61 - 0
backend/tracim/lib/mail_notifier/daemon.py View File

@@ -0,0 +1,61 @@
1
+from sqlalchemy.orm import collections
2
+
3
+from tracim.lib.utils.logger import logger
4
+from tracim.lib.utils.utils import get_rq_queue
5
+from tracim.lib.utils.utils import get_redis_connection
6
+from rq.dummy import do_nothing
7
+from rq.worker import StopRequested
8
+from rq import Connection as RQConnection
9
+from rq import Worker as BaseRQWorker
10
+
11
+
12
+class FakeDaemon(object):
13
+    """
14
+    Temporary class for transition between tracim 1 and tracim 2
15
+    """
16
+    def __init__(self, config, *args, **kwargs):
17
+        pass
18
+
19
+
20
+class MailSenderDaemon(FakeDaemon):
21
+    # NOTE: use *args and **kwargs because parent __init__ use strange
22
+    # * parameter
23
+    def __init__(self, config, *args, **kwargs):
24
+        super().__init__(*args, **kwargs)
25
+        self.config = config
26
+        self.worker = None  # type: RQWorker
27
+
28
+    def append_thread_callback(self, callback: collections.Callable) -> None:
29
+        logger.warning('MailSenderDaemon not implement append_thread_callback')
30
+        pass
31
+
32
+    def stop(self) -> None:
33
+        # When _stop_requested at False, tracim.lib.daemons.RQWorker
34
+        # will raise StopRequested exception in worker thread after receive a
35
+        # job.
36
+        self.worker._stop_requested = True
37
+        redis_connection = get_redis_connection(self.config)
38
+        queue = get_rq_queue(redis_connection, 'mail_sender')
39
+        queue.enqueue(do_nothing)
40
+
41
+    def run(self) -> None:
42
+
43
+        with RQConnection(get_redis_connection(self.config)):
44
+            self.worker = RQWorker(['mail_sender'])
45
+            self.worker.work()
46
+
47
+
48
+class RQWorker(BaseRQWorker):
49
+    def _install_signal_handlers(self):
50
+        # RQ Worker is designed to work in main thread
51
+        # So we have to disable these signals (we implement server stop in
52
+        # MailSenderDaemon.stop method).
53
+        pass
54
+
55
+    def dequeue_job_and_maintain_ttl(self, timeout):
56
+        # RQ Worker is designed to work in main thread, so we add behaviour
57
+        # here: if _stop_requested has been set to True, raise the standard way
58
+        # StopRequested exception to stop worker.
59
+        if self._stop_requested:
60
+            raise StopRequested()
61
+        return super().dequeue_job_and_maintain_ttl(timeout)

+ 571 - 0
backend/tracim/lib/mail_notifier/notifier.py View File

@@ -0,0 +1,571 @@
1
+# -*- coding: utf-8 -*-
2
+import datetime
3
+import typing
4
+
5
+from email.mime.multipart import MIMEMultipart
6
+from email.mime.text import MIMEText
7
+from email.utils import formataddr
8
+
9
+from lxml.html.diff import htmldiff
10
+from mako.template import Template
11
+from sqlalchemy.orm import Session
12
+
13
+from tracim import CFG
14
+from tracim.lib.core.notifications import INotifier
15
+from tracim.lib.mail_notifier.sender import EmailSender
16
+from tracim.lib.mail_notifier.utils import SmtpConfiguration, EST
17
+from tracim.lib.mail_notifier.sender import send_email_through
18
+from tracim.lib.core.workspace import WorkspaceApi
19
+from tracim.lib.utils.logger import logger
20
+from tracim.models import User
21
+from tracim.models.auth import User
22
+from tracim.models.data import ActionDescription
23
+from tracim.models.data import Content
24
+from tracim.models.data import ContentType
25
+from tracim.models.data import UserRoleInWorkspace
26
+from tracim.lib.utils.translation import fake_translator as l_, \
27
+    fake_translator as _
28
+
29
+
30
+class EmailNotifier(INotifier):
31
+    """
32
+    EmailNotifier, this class will decide how to notify by mail
33
+    in order to let a EmailManager create email
34
+    """
35
+
36
+    def __init__(
37
+            self,
38
+            config: CFG,
39
+            session: Session,
40
+            current_user: User=None
41
+    ):
42
+        """
43
+        :param current_user: the user that has triggered the notification
44
+        :return:
45
+        """
46
+        INotifier.__init__(self, config, session, current_user)
47
+        logger.info(self, 'Instantiating Email Notifier')
48
+
49
+        self._user = current_user
50
+        self.session = session
51
+        self.config = config
52
+        self._smtp_config = SmtpConfiguration(
53
+            self.config.EMAIL_NOTIFICATION_SMTP_SERVER,
54
+            self.config.EMAIL_NOTIFICATION_SMTP_PORT,
55
+            self.config.EMAIL_NOTIFICATION_SMTP_USER,
56
+            self.config.EMAIL_NOTIFICATION_SMTP_PASSWORD
57
+        )
58
+
59
+    def notify_content_update(self, content: Content):
60
+
61
+        if content.get_last_action().id not \
62
+                in self.config.EMAIL_NOTIFICATION_NOTIFIED_EVENTS:
63
+            logger.info(
64
+                self,
65
+                'Skip email notification for update of content {}'
66
+                'by user {} (the action is {})'.format(
67
+                    content.content_id,
68
+                    # below: 0 means "no user"
69
+                    self._user.user_id if self._user else 0,
70
+                    content.get_last_action().id
71
+                )
72
+            )
73
+            return
74
+
75
+        logger.info(self,
76
+                    'About to email-notify update'
77
+                    'of content {} by user {}'.format(
78
+                        content.content_id,
79
+                        # Below: 0 means "no user"
80
+                        self._user.user_id if self._user else 0
81
+                    )
82
+        )
83
+
84
+        if content.type not \
85
+                in self.config.EMAIL_NOTIFICATION_NOTIFIED_CONTENTS:
86
+            logger.info(
87
+                self,
88
+                'Skip email notification for update of content {}'
89
+                'by user {} (the content type is {})'.format(
90
+                    content.type,
91
+                    # below: 0 means "no user"
92
+                    self._user.user_id if self._user else 0,
93
+                    content.get_last_action().id
94
+                )
95
+            )
96
+            return
97
+
98
+        logger.info(self,
99
+                    'About to email-notify update'
100
+                    'of content {} by user {}'.format(
101
+                        content.content_id,
102
+                        # Below: 0 means "no user"
103
+                        self._user.user_id if self._user else 0
104
+                    )
105
+        )
106
+
107
+        ####
108
+        #
109
+        # INFO - D.A. - 2014-11-05 - Emails are sent through asynchronous jobs.
110
+        # For that reason, we do not give SQLAlchemy objects but ids only
111
+        # (SQLA objects are related to a given thread/session)
112
+        #
113
+        try:
114
+            if self.config.EMAIL_NOTIFICATION_PROCESSING_MODE.lower() == self.config.CST.ASYNC.lower():
115
+                logger.info(self, 'Sending email in ASYNC mode')
116
+                # TODO - D.A - 2014-11-06
117
+                # This feature must be implemented in order to be able to scale to large communities
118
+                raise NotImplementedError('Sending emails through ASYNC mode is not working yet')
119
+            else:
120
+                logger.info(self, 'Sending email in SYNC mode')
121
+                EmailManager(
122
+                    self._smtp_config,
123
+                    self.config,
124
+                    self.session,
125
+                ).notify_content_update(self._user.user_id, content.content_id)
126
+        except TypeError as e:
127
+            logger.error(self, 'Exception catched during email notification: {}'.format(e.__str__()))
128
+
129
+
130
+class EmailManager(object):
131
+    """
132
+    Compared to Notifier, this class is independant from the HTTP request thread
133
+    This class will build Email and send it for both created account and content
134
+    update
135
+    """
136
+
137
+    def __init__(
138
+            self,
139
+            smtp_config: SmtpConfiguration,
140
+            config: CFG,
141
+            session: Session
142
+    ) -> None:
143
+        self._smtp_config = smtp_config
144
+        self.config = config
145
+        self.session = session
146
+        # FIXME - G.M - We need to have a session for the emailNotifier
147
+
148
+        # if not self.session:
149
+        #     engine = get_engine(settings)
150
+        #     session_factory = get_session_factory(engine)
151
+        #     app_config = CFG(settings)
152
+
153
+    def _get_sender(self, user: User=None) -> str:
154
+        """
155
+        Return sender string like "Bob Dylan
156
+            (via Tracim) <notification@mail.com>"
157
+        :param user: user to extract display name
158
+        :return: sender string
159
+        """
160
+
161
+        email_template = self.config.EMAIL_NOTIFICATION_FROM_EMAIL
162
+        mail_sender_name = self.config.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL  # nopep8
163
+        if user:
164
+            mail_sender_name = '{name} via Tracim'.format(name=user.display_name)
165
+            email_address = email_template.replace('{user_id}', str(user.user_id))
166
+            # INFO - D.A. - 2017-08-04
167
+            # We use email_template.replace() instead of .format() because this
168
+            # method is more robust to errors in config file.
169
+            #
170
+            # For example, if the email is info+{userid}@tracim.fr
171
+            # email.format(user_id='bob') will raise an exception
172
+            # email.replace('{user_id}', 'bob') will just ignore {userid}
173
+        else:
174
+            email_address = email_template.replace('{user_id}', '0')
175
+
176
+        return formataddr((mail_sender_name, email_address))
177
+
178
+    # Content Notification
179
+
180
+    @staticmethod
181
+    def log_notification(
182
+            config: CFG,
183
+            action: str,
184
+            recipient: typing.Optional[str],
185
+            subject: typing.Optional[str],
186
+    ) -> None:
187
+        """Log notification metadata."""
188
+        log_path = config.EMAIL_NOTIFICATION_LOG_FILE_PATH
189
+        if log_path:
190
+            # TODO - A.P - 2017-09-06 - file logging inefficiency
191
+            # Updating a document with 100 users to notify will leads to open
192
+            # and close the file 100 times.
193
+            with open(log_path, 'a') as log_file:
194
+                print(
195
+                    datetime.datetime.now(),
196
+                    action,
197
+                    recipient,
198
+                    subject,
199
+                    sep='|',
200
+                    file=log_file,
201
+                )
202
+
203
+    def notify_content_update(
204
+            self,
205
+            event_actor_id: int,
206
+            event_content_id: int
207
+    ) -> None:
208
+        """
209
+        Look for all users to be notified about the new content and send them an
210
+        individual email
211
+        :param event_actor_id: id of the user that has triggered the event
212
+        :param event_content_id: related content_id
213
+        :return:
214
+        """
215
+        # FIXME - D.A. - 2014-11-05
216
+        # Dirty import. It's here in order to avoid circular import
217
+        from tracim.lib.core.content import ContentApi
218
+        from tracim.lib.core.user import UserApi
219
+        user = UserApi(
220
+            None,
221
+            config=self.config,
222
+            session=self.session,
223
+            ).get_one(event_actor_id)
224
+        logger.debug(self, 'Content: {}'.format(event_content_id))
225
+        content_api = ContentApi(
226
+            current_user=user,
227
+            session=self.session,
228
+            config=self.config,
229
+            )
230
+        content = ContentApi(
231
+            session=self.session,
232
+            current_user=user, # TODO - use a system user instead of the user that has triggered the event
233
+            config=self.config,
234
+            show_archived=True,
235
+            show_deleted=True,
236
+        ).get_one(event_content_id, ContentType.Any)
237
+        main_content = content.parent if content.type == ContentType.Comment else content
238
+        notifiable_roles = WorkspaceApi(
239
+            current_user=user,
240
+            session=self.session,
241
+            config=self.config,
242
+        ).get_notifiable_roles(content.workspace)
243
+
244
+        if len(notifiable_roles) <= 0:
245
+            logger.info(self, 'Skipping notification as nobody subscribed to in workspace {}'.format(content.workspace.label))
246
+            return
247
+
248
+
249
+        logger.info(self, 'Sending asynchronous emails to {} user(s)'.format(len(notifiable_roles)))
250
+        # INFO - D.A. - 2014-11-06
251
+        # The following email sender will send emails in the async task queue
252
+        # This allow to build all mails through current thread but really send them (including SMTP connection)
253
+        # In the other thread.
254
+        #
255
+        # This way, the webserver will return sooner (actually before notification emails are sent
256
+        async_email_sender = EmailSender(
257
+            self.config,
258
+            self._smtp_config,
259
+            self.config.EMAIL_NOTIFICATION_ACTIVATED
260
+        )
261
+        for role in notifiable_roles:
262
+            logger.info(self, 'Sending email to {}'.format(role.user.email))
263
+            to_addr = formataddr((role.user.display_name, role.user.email))
264
+            #
265
+            # INFO - G.M - 2017-11-15 - set content_id in header to permit reply
266
+            # references can have multiple values, but only one in this case.
267
+            replyto_addr = self.config.EMAIL_NOTIFICATION_REPLY_TO_EMAIL.replace( # nopep8
268
+                '{content_id}',str(content.content_id)
269
+            )
270
+
271
+            reference_addr = self.config.EMAIL_NOTIFICATION_REFERENCES_EMAIL.replace( #nopep8
272
+                '{content_id}',str(content.content_id)
273
+             )
274
+            #
275
+            #  INFO - D.A. - 2014-11-06
276
+            # We do not use .format() here because the subject defined in the .ini file
277
+            # may not include all required labels. In order to avoid partial format() (which result in an exception)
278
+            # we do use replace and force the use of .__str__() in order to process LazyString objects
279
+            #
280
+            subject = self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT
281
+            subject = subject.replace(EST.WEBSITE_TITLE, self.config.WEBSITE_TITLE.__str__())
282
+            subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
283
+            subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__())
284
+            subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
285
+            reply_to_label = l_('{username} & all members of {workspace}').format(
286
+                username=user.display_name,
287
+                workspace=main_content.workspace.label)
288
+
289
+            message = MIMEMultipart('alternative')
290
+            message['Subject'] = subject
291
+            message['From'] = self._get_sender(user)
292
+            message['To'] = to_addr
293
+            message['Reply-to'] = formataddr((reply_to_label, replyto_addr))
294
+            # INFO - G.M - 2017-11-15
295
+            # References can theorically have label, but in pratice, references
296
+            # contains only message_id from parents post in thread.
297
+            # To link this email to a content we create a virtual parent
298
+            # in reference who contain the content_id.
299
+            message['References'] = formataddr(('',reference_addr))
300
+            body_text = self._build_email_body_for_content(self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT, role, content, user)
301
+            body_html = self._build_email_body_for_content(self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML, role, content, user)
302
+
303
+            part1 = MIMEText(body_text, 'plain', 'utf-8')
304
+            part2 = MIMEText(body_html, 'html', 'utf-8')
305
+            # Attach parts into message container.
306
+            # According to RFC 2046, the last part of a multipart message, in this case
307
+            # the HTML message, is best and preferred.
308
+            message.attach(part1)
309
+            message.attach(part2)
310
+
311
+            self.log_notification(
312
+                action='CREATED',
313
+                recipient=message['To'],
314
+                subject=message['Subject'],
315
+                config=self.config,
316
+            )
317
+
318
+            send_email_through(
319
+                self.config,
320
+                async_email_sender.send_mail,
321
+                message
322
+            )
323
+
324
+    def notify_created_account(
325
+            self,
326
+            user: User,
327
+            password: str,
328
+    ) -> None:
329
+        """
330
+        Send created account email to given user.
331
+
332
+        :param password: choosed password
333
+        :param user: user to notify
334
+        """
335
+        # TODO BS 20160712: Cyclic import
336
+        logger.debug(self, 'user: {}'.format(user.user_id))
337
+        logger.info(self, 'Sending asynchronous email to 1 user ({0})'.format(
338
+            user.email,
339
+        ))
340
+
341
+        async_email_sender = EmailSender(
342
+            self.config,
343
+            self._smtp_config,
344
+            self.config.EMAIL_NOTIFICATION_ACTIVATED
345
+        )
346
+
347
+        subject = \
348
+            self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_SUBJECT \
349
+            .replace(
350
+                EST.WEBSITE_TITLE,
351
+                self.config.WEBSITE_TITLE.__str__()
352
+            )
353
+        message = MIMEMultipart('alternative')
354
+        message['Subject'] = subject
355
+        message['From'] = self._get_sender()
356
+        message['To'] = formataddr((user.get_display_name(), user.email))
357
+
358
+        text_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT  # nopep8
359
+        html_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML  # nopep8
360
+
361
+        context = {
362
+            'user': user,
363
+            'password': password,
364
+            # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for logo_url  # nopep8
365
+            'logo_url': '',
366
+            # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for login_url  # nopep8
367
+            'login_url': self.config.WEBSITE_BASE_URL,
368
+        }
369
+        body_text = self._render_template(
370
+            mako_template_filepath=text_template_file_path,
371
+            context=context
372
+        )
373
+
374
+        body_html = self._render_template(
375
+            mako_template_filepath=html_template_file_path,
376
+            context=context,
377
+        )
378
+
379
+        part1 = MIMEText(body_text, 'plain', 'utf-8')
380
+        part2 = MIMEText(body_html, 'html', 'utf-8')
381
+
382
+        # Attach parts into message container.
383
+        # According to RFC 2046, the last part of a multipart message,
384
+        # in this case the HTML message, is best and preferred.
385
+        message.attach(part1)
386
+        message.attach(part2)
387
+
388
+        send_email_through(
389
+            config=self.config,
390
+            sendmail_callable=async_email_sender.send_mail,
391
+            message=message
392
+        )
393
+
394
+    def _render_template(
395
+            self,
396
+            mako_template_filepath: str,
397
+            context: dict
398
+    ) -> str:
399
+        """
400
+        Render mako template with all needed current variables.
401
+
402
+        :param mako_template_filepath: file path of mako template
403
+        :param context: dict with template context
404
+        :return: template rendered string
405
+        """
406
+
407
+        template = Template(filename=mako_template_filepath)
408
+        return template.render(
409
+            _=_,
410
+            config=self.config,
411
+            **context
412
+        )
413
+
414
+    def _build_email_body_for_content(
415
+            self,
416
+            mako_template_filepath: str,
417
+            role: UserRoleInWorkspace,
418
+            content: Content,
419
+            actor: User
420
+    ) -> str:
421
+        """
422
+        Build an email body and return it as a string
423
+        :param mako_template_filepath: the absolute path to the mako template to be used for email body building
424
+        :param role: the role related to user to whom the email must be sent. The role is required (and not the user only) in order to show in the mail why the user receive the notification
425
+        :param content: the content item related to the notification
426
+        :param actor: the user at the origin of the action / notification (for example the one who wrote a comment
427
+        :param config: the global configuration
428
+        :return: the built email body as string. In case of multipart email, this method must be called one time for text and one time for html
429
+        """
430
+        logger.debug(self, 'Building email content from MAKO template {}'.format(mako_template_filepath))
431
+
432
+        main_title = content.label
433
+        content_intro = ''
434
+        content_text = ''
435
+        call_to_action_text = ''
436
+
437
+        # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for call_to_action_url  # nopep8
438
+        call_to_action_url =''
439
+        # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for status_icon_url  # nopep8
440
+        status_icon_url = ''
441
+        # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for workspace_url  # nopep8
442
+        workspace_url = ''
443
+        # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for logo_url  # nopep8
444
+        logo_url = ''
445
+
446
+        action = content.get_last_action().id
447
+        if ActionDescription.COMMENT == action:
448
+            content_intro = l_('<span id="content-intro-username">{}</span> added a comment:').format(actor.display_name)
449
+            content_text = content.description
450
+            call_to_action_text = l_('Answer')
451
+
452
+        elif ActionDescription.CREATION == action:
453
+
454
+            # Default values (if not overriden)
455
+            content_text = content.description
456
+            call_to_action_text = l_('View online')
457
+
458
+            if ContentType.Thread == content.type:
459
+                call_to_action_text = l_('Answer')
460
+                content_intro = l_('<span id="content-intro-username">{}</span> started a thread entitled:').format(actor.display_name)
461
+                content_text = '<p id="content-body-intro">{}</p>'.format(content.label) + \
462
+                               content.get_last_comment_from(actor).description
463
+
464
+            elif ContentType.File == content.type:
465
+                content_intro = l_('<span id="content-intro-username">{}</span> added a file entitled:').format(actor.display_name)
466
+                if content.description:
467
+                    content_text = content.description
468
+                else:
469
+                    content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
470
+
471
+            elif ContentType.Page == content.type:
472
+                content_intro = l_('<span id="content-intro-username">{}</span> added a page entitled:').format(actor.display_name)
473
+                content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
474
+
475
+        elif ActionDescription.REVISION == action:
476
+            content_text = content.description
477
+            call_to_action_text = l_('View online')
478
+
479
+            if ContentType.File == content.type:
480
+                content_intro = l_('<span id="content-intro-username">{}</span> uploaded a new revision.').format(actor.display_name)
481
+                content_text = ''
482
+
483
+            elif ContentType.Page == content.type:
484
+                content_intro = l_('<span id="content-intro-username">{}</span> updated this page.').format(actor.display_name)
485
+                previous_revision = content.get_previous_revision()
486
+                title_diff = ''
487
+                if previous_revision.label != content.label:
488
+                    title_diff = htmldiff(previous_revision.label, content.label)
489
+                content_text = str(l_('<p id="content-body-intro">Here is an overview of the changes:</p>'))+ \
490
+                    title_diff + \
491
+                    htmldiff(previous_revision.description, content.description)
492
+
493
+            elif ContentType.Thread == content.type:
494
+                content_intro = l_('<span id="content-intro-username">{}</span> updated the thread description.').format(actor.display_name)
495
+                previous_revision = content.get_previous_revision()
496
+                title_diff = ''
497
+                if previous_revision.label != content.label:
498
+                    title_diff = htmldiff(previous_revision.label, content.label)
499
+                content_text = str(l_('<p id="content-body-intro">Here is an overview of the changes:</p>'))+ \
500
+                    title_diff + \
501
+                    htmldiff(previous_revision.description, content.description)
502
+
503
+        elif ActionDescription.EDITION == action:
504
+            call_to_action_text = l_('View online')
505
+
506
+            if ContentType.File == content.type:
507
+                content_intro = l_('<span id="content-intro-username">{}</span> updated the file description.').format(actor.display_name)
508
+                content_text = '<p id="content-body-intro">{}</p>'.format(content.get_label()) + \
509
+                    content.description
510
+
511
+        elif ActionDescription.STATUS_UPDATE == action:
512
+            call_to_action_text = l_('View online')
513
+            intro_user_msg = l_(
514
+                '<span id="content-intro-username">{}</span> '
515
+                'updated the following status:'
516
+            )
517
+            content_intro = intro_user_msg.format(actor.display_name)
518
+            intro_body_msg = '<p id="content-body-intro">{}: {}</p>'
519
+            content_text = intro_body_msg.format(
520
+                content.get_label(),
521
+                content.get_status().label,
522
+            )
523
+
524
+        if '' == content_intro and content_text == '':
525
+            # Skip notification, but it's not normal
526
+            logger.error(
527
+                self, 'A notification is being sent but no content. '
528
+                      'Here are some debug informations: [content_id: {cid}]'
529
+                      '[action: {act}][author: {actor}]'.format(
530
+                    cid=content.content_id, act=action, actor=actor
531
+                )
532
+            )
533
+            raise ValueError('Unexpected empty notification')
534
+
535
+        context = {
536
+            'user': role.user,
537
+            'workspace': role.workspace,
538
+            'workspace_url': workspace_url,
539
+            'main_title': main_title,
540
+            'status_label': content.get_status().label,
541
+            'status_icon_url': status_icon_url,
542
+            'role_label': role.role_as_label(),
543
+            'content_intro': content_intro,
544
+            'content_text': content_text,
545
+            'call_to_action_text': call_to_action_text,
546
+            'call_to_action_url': call_to_action_url,
547
+            'logo_url': logo_url,
548
+        }
549
+        user = role.user
550
+        workspace = role.workspace
551
+        body_content = self._render_template(
552
+            mako_template_filepath=mako_template_filepath,
553
+            context=context,
554
+        )
555
+        return body_content
556
+
557
+
558
+def get_email_manager(config: CFG, session: Session):
559
+    """
560
+    :return: EmailManager instance
561
+    """
562
+    #  TODO: Find a way to import properly without cyclic import
563
+
564
+    smtp_config = SmtpConfiguration(
565
+        config.EMAIL_NOTIFICATION_SMTP_SERVER,
566
+        config.EMAIL_NOTIFICATION_SMTP_PORT,
567
+        config.EMAIL_NOTIFICATION_SMTP_USER,
568
+        config.EMAIL_NOTIFICATION_SMTP_PASSWORD
569
+    )
570
+
571
+    return EmailManager(config=config, smtp_config=smtp_config, session=session)

+ 114 - 0
backend/tracim/lib/mail_notifier/sender.py View File

@@ -0,0 +1,114 @@
1
+# -*- coding: utf-8 -*-
2
+import smtplib
3
+import typing
4
+from email.message import Message
5
+from email.mime.multipart import MIMEMultipart
6
+
7
+from tracim.config import CFG
8
+from tracim.lib.utils.logger import logger
9
+from tracim.lib.utils.utils import get_rq_queue
10
+from tracim.lib.utils.utils import get_redis_connection
11
+from tracim.lib.mail_notifier.utils import SmtpConfiguration
12
+
13
+def send_email_through(
14
+        config: CFG,
15
+        sendmail_callable: typing.Callable[[Message], None],
16
+        message: Message,
17
+) -> None:
18
+    """
19
+    Send mail encapsulation to send it in async or sync mode.
20
+
21
+    TODO BS 20170126: A global mail/sender management should be a good
22
+                      thing. Actually, this method is an fast solution.
23
+    :param config: system configuration
24
+    :param sendmail_callable: A callable who get message on first parameter
25
+    :param message: The message who have to be sent
26
+    """
27
+
28
+    if config.EMAIL_PROCESSING_MODE == config.CST.SYNC:
29
+        sendmail_callable(message)
30
+    elif config.EMAIL_PROCESSING_MODE == config.CST.ASYNC:
31
+        redis_connection = get_redis_connection(config)
32
+        queue = get_rq_queue(redis_connection, 'mail_sender')
33
+        queue.enqueue(sendmail_callable, message)
34
+    else:
35
+        raise NotImplementedError(
36
+            'Mail sender processing mode {} is not implemented'.format(
37
+                config.EMAIL_PROCESSING_MODE,
38
+            )
39
+        )
40
+
41
+
42
+class EmailSender(object):
43
+    """
44
+    Independent email sender class.
45
+
46
+    To allow its use in any thread, as an asyncjob_perform() call for
47
+    example, it has no dependencies on SQLAlchemy nor tg HTTP request.
48
+    """
49
+
50
+    def __init__(
51
+            self,
52
+            config: CFG,
53
+            smtp_config: SmtpConfiguration,
54
+            really_send_messages
55
+    ) -> None:
56
+        self._smtp_config = smtp_config
57
+        self.config = config
58
+        self._smtp_connection = None
59
+        self._is_active = really_send_messages
60
+
61
+    def connect(self):
62
+        if not self._smtp_connection:
63
+            log = 'Connecting from SMTP server {}'
64
+            logger.info(self, log.format(self._smtp_config.server))
65
+            self._smtp_connection = smtplib.SMTP(
66
+                self._smtp_config.server,
67
+                self._smtp_config.port
68
+            )
69
+            self._smtp_connection.ehlo()
70
+
71
+            if self._smtp_config.login:
72
+                try:
73
+                    starttls_result = self._smtp_connection.starttls()
74
+                    log = 'SMTP start TLS result: {}'
75
+                    logger.debug(self, log.format(starttls_result))
76
+                except Exception as e:
77
+                    log = 'SMTP start TLS error: {}'
78
+                    logger.debug(self, log.format(e.__str__()))
79
+
80
+            if self._smtp_config.login:
81
+                try:
82
+                    login_res = self._smtp_connection.login(
83
+                        self._smtp_config.login,
84
+                        self._smtp_config.password
85
+                    )
86
+                    log = 'SMTP login result: {}'
87
+                    logger.debug(self, log.format(login_res))
88
+                except Exception as e:
89
+                    log = 'SMTP login error: {}'
90
+                    logger.debug(self, log.format(e.__str__()))
91
+            logger.info(self, 'Connection OK')
92
+
93
+    def disconnect(self):
94
+        if self._smtp_connection:
95
+            log = 'Disconnecting from SMTP server {}'
96
+            logger.info(self, log.format(self._smtp_config.server))
97
+            self._smtp_connection.quit()
98
+            logger.info(self, 'Connection closed.')
99
+
100
+    def send_mail(self, message: MIMEMultipart):
101
+        if not self._is_active:
102
+            log = 'Not sending email to {} (service disabled)'
103
+            logger.info(self, log.format(message['To']))
104
+        else:
105
+            self.connect()  # Actually, this connects to SMTP only if required
106
+            logger.info(self, 'Sending email to {}'.format(message['To']))
107
+            self._smtp_connection.send_message(message)
108
+            from tracim.lib.mail_notifier.notifier import EmailManager
109
+            EmailManager.log_notification(
110
+                action='   SENT',
111
+                recipient=message['To'],
112
+                subject=message['Subject'],
113
+                config=self.config,
114
+            )

+ 33 - 0
backend/tracim/lib/mail_notifier/utils.py View File

@@ -0,0 +1,33 @@
1
+class SmtpConfiguration(object):
2
+    """Container class for SMTP configuration used in Tracim."""
3
+
4
+    def __init__(self, server: str, port: int, login: str, password: str):
5
+        self.server = server
6
+        self.port = port
7
+        self.login = login
8
+        self.password = password
9
+
10
+
11
+class EST(object):
12
+    """
13
+    EST = Email Subject Tags - this is a convenient class - no business logic
14
+    here
15
+    This class is intended to agregate all dynamic content that may be included
16
+    in email subjects
17
+    """
18
+
19
+    WEBSITE_TITLE = '{website_title}'
20
+    WORKSPACE_LABEL = '{workspace_label}'
21
+    CONTENT_LABEL = '{content_label}'
22
+    CONTENT_STATUS_LABEL = '{content_status_label}'
23
+
24
+    @classmethod
25
+    def all(cls):
26
+        return [
27
+            cls.CONTENT_LABEL,
28
+            cls.CONTENT_STATUS_LABEL,
29
+            cls.WEBSITE_TITLE,
30
+            cls.WORKSPACE_LABEL
31
+        ]
32
+
33
+

+ 1 - 0
backend/tracim/lib/utils/__init__.py View File

@@ -0,0 +1 @@
1
+# coding=utf-8

+ 57 - 0
backend/tracim/lib/utils/authentification.py View File

@@ -0,0 +1,57 @@
1
+import typing
2
+
3
+from pyramid.request import Request
4
+from sqlalchemy.orm.exc import NoResultFound
5
+
6
+from tracim import TracimRequest
7
+from tracim.exceptions import UserDoesNotExist
8
+from tracim.lib.core.user import UserApi
9
+from tracim.models import User
10
+
11
+BASIC_AUTH_WEBUI_REALM = "tracim"
12
+
13
+
14
+###
15
+# Pyramid HTTP Basic Auth
16
+###
17
+
18
+def basic_auth_check_credentials(
19
+        login: str,
20
+        cleartext_password: str,
21
+        request: TracimRequest
22
+) -> typing.Optional[list]:
23
+    """
24
+    Check credential for pyramid basic_auth
25
+    :param login: login of user
26
+    :param cleartext_password: user password in cleartext
27
+    :param request: Pyramid request
28
+    :return: None if auth failed, list of permissions if auth succeed
29
+    """
30
+
31
+    # Do not accept invalid user
32
+    user = _get_basic_auth_unsafe_user(request)
33
+    if not user \
34
+            or user.email != login \
35
+            or not user.is_active \
36
+            or not user.validate_password(cleartext_password):
37
+        return None
38
+    return []
39
+
40
+
41
+def _get_basic_auth_unsafe_user(
42
+    request: Request,
43
+) -> typing.Optional[User]:
44
+    """
45
+    :param request: pyramid request
46
+    :return: User or None
47
+    """
48
+    app_config = request.registry.settings['CFG']
49
+    uapi = UserApi(None, session=request.dbsession, config=app_config)
50
+    try:
51
+        login = request.unauthenticated_userid
52
+        if not login:
53
+            return None
54
+        user = uapi.get_one_by_email(login)
55
+    except UserDoesNotExist:
56
+        return None
57
+    return user

+ 185 - 0
backend/tracim/lib/utils/authorization.py View File

@@ -0,0 +1,185 @@
1
+# -*- coding: utf-8 -*-
2
+import typing
3
+from typing import TYPE_CHECKING
4
+import functools
5
+from pyramid.interfaces import IAuthorizationPolicy
6
+from zope.interface import implementer
7
+
8
+from tracim.models.contents import NewContentType
9
+from tracim.models.context_models import ContentInContext
10
+
11
+try:
12
+    from json.decoder import JSONDecodeError
13
+except ImportError:  # python3.4
14
+    JSONDecodeError = ValueError
15
+
16
+from tracim.models.contents import ContentTypeLegacy as ContentType
17
+from tracim.exceptions import InsufficientUserRoleInWorkspace
18
+from tracim.exceptions import ContentTypeNotAllowed
19
+from tracim.exceptions import InsufficientUserProfile
20
+if TYPE_CHECKING:
21
+    from tracim import TracimRequest
22
+###
23
+# Pyramid default permission/authorization mecanism
24
+
25
+# INFO - G.M - 12-04-2018 - Setiing a Default permission on view is
26
+#  needed to activate AuthentificationPolicy and
27
+# AuthorizationPolicy on pyramid request
28
+TRACIM_DEFAULT_PERM = 'tracim'
29
+
30
+
31
+@implementer(IAuthorizationPolicy)
32
+class AcceptAllAuthorizationPolicy(object):
33
+    """
34
+    Empty AuthorizationPolicy : Allow all request. As Pyramid need
35
+    a Authorization policy when we use AuthentificationPolicy, this
36
+    class permit use to disable pyramid authorization mecanism with
37
+    working a AuthentificationPolicy.
38
+    """
39
+    def permits(self, context, principals, permision):
40
+        return True
41
+
42
+    def principals_allowed_by_permission(self, context, permission):
43
+        raise NotImplementedError()
44
+
45
+###
46
+# Authorization decorators for views
47
+
48
+# INFO - G.M - 12-04-2018
49
+# Instead of relying on pyramid authorization mecanism
50
+# We prefer to use decorators
51
+
52
+
53
+def require_same_user_or_profile(group: int) -> typing.Callable:
54
+    """
55
+    Decorator for view to restrict access of tracim request if candidate user
56
+    is distinct from authenticated user and not with high enough profile.
57
+    :param group: value from Group Object
58
+    like Group.TIM_USER or Group.TIM_MANAGER
59
+    :return:
60
+    """
61
+    def decorator(func: typing.Callable) -> typing.Callable:
62
+        @functools.wraps(func)
63
+        def wrapper(self, context, request: 'TracimRequest') -> typing.Callable:
64
+            auth_user = request.current_user
65
+            candidate_user = request.candidate_user
66
+            if auth_user.user_id == candidate_user.user_id or \
67
+                    auth_user.profile.id >= group:
68
+                return func(self, context, request)
69
+            raise InsufficientUserProfile()
70
+        return wrapper
71
+    return decorator
72
+
73
+
74
+def require_profile(group: int) -> typing.Callable:
75
+    """
76
+    Decorator for view to restrict access of tracim request if profile is
77
+    not high enough
78
+    :param group: value from Group Object
79
+    like Group.TIM_USER or Group.TIM_MANAGER
80
+    :return:
81
+    """
82
+    def decorator(func: typing.Callable) -> typing.Callable:
83
+        @functools.wraps(func)
84
+        def wrapper(self, context, request: 'TracimRequest') -> typing.Callable:
85
+            user = request.current_user
86
+            if user.profile.id >= group:
87
+                return func(self, context, request)
88
+            raise InsufficientUserProfile()
89
+        return wrapper
90
+    return decorator
91
+
92
+
93
+def require_workspace_role(minimal_required_role: int) -> typing.Callable:
94
+    """
95
+    Restricts access to endpoint to minimal role or raise an exception.
96
+    Check role for current_workspace.
97
+    :param minimal_required_role: value from UserInWorkspace Object like
98
+    UserRoleInWorkspace.CONTRIBUTOR or UserRoleInWorkspace.READER
99
+    :return: decorator
100
+    """
101
+    def decorator(func: typing.Callable) -> typing.Callable:
102
+        @functools.wraps(func)
103
+        def wrapper(self, context, request: 'TracimRequest') -> typing.Callable:
104
+            user = request.current_user
105
+            workspace = request.current_workspace
106
+            if workspace.get_user_role(user) >= minimal_required_role:
107
+                return func(self, context, request)
108
+            raise InsufficientUserRoleInWorkspace()
109
+
110
+        return wrapper
111
+    return decorator
112
+
113
+
114
+def require_candidate_workspace_role(minimal_required_role: int) -> typing.Callable:  # nopep8
115
+    """
116
+    Restricts access to endpoint to minimal role or raise an exception.
117
+    Check role for candidate_workspace.
118
+    :param minimal_required_role: value from UserInWorkspace Object like
119
+    UserRoleInWorkspace.CONTRIBUTOR or UserRoleInWorkspace.READER
120
+    :return: decorator
121
+    """
122
+    def decorator(func: typing.Callable) -> typing.Callable:
123
+
124
+        def wrapper(self, context, request: 'TracimRequest') -> typing.Callable:
125
+            user = request.current_user
126
+            workspace = request.candidate_workspace
127
+
128
+            if workspace.get_user_role(user) >= minimal_required_role:
129
+                return func(self, context, request)
130
+            raise InsufficientUserRoleInWorkspace()
131
+
132
+        return wrapper
133
+    return decorator
134
+
135
+
136
+def require_content_types(content_types: typing.List['NewContentType']) -> typing.Callable:  # nopep8
137
+    """
138
+    Restricts access to specific file type or raise an exception.
139
+    Check role for candidate_workspace.
140
+    :param content_types: list of NewContentType object
141
+    :return: decorator
142
+    """
143
+    def decorator(func: typing.Callable) -> typing.Callable:
144
+        @functools.wraps(func)
145
+        def wrapper(self, context, request: 'TracimRequest') -> typing.Callable:
146
+            content = request.current_content
147
+            current_content_type_slug = ContentType(content.type).slug
148
+            content_types_slug = [content_type.slug for content_type in content_types]  # nopep8
149
+            if current_content_type_slug in content_types_slug:
150
+                return func(self, context, request)
151
+            raise ContentTypeNotAllowed()
152
+        return wrapper
153
+    return decorator
154
+
155
+
156
+def require_comment_ownership_or_role(
157
+        minimal_required_role_for_owner: int,
158
+        minimal_required_role_for_anyone: int,
159
+) -> typing.Callable:
160
+    """
161
+    Decorator for view to restrict access of tracim request if role is
162
+    not high enough and user is not owner of the current_content
163
+    :param minimal_required_role_for_owner: minimal role for owner
164
+    of current_content to access to this view
165
+    :param minimal_required_role_for_anyone: minimal role for anyone to
166
+    access to this view.
167
+    :return:
168
+    """
169
+    def decorator(func: typing.Callable) -> typing.Callable:
170
+        @functools.wraps(func)
171
+        def wrapper(self, context, request: 'TracimRequest') -> typing.Callable:
172
+            user = request.current_user
173
+            workspace = request.current_workspace
174
+            comment = request.current_comment
175
+            # INFO - G.M - 2018-06-178 - find minimal role required
176
+            if comment.owner.user_id == user.user_id:
177
+                minimal_required_role = minimal_required_role_for_owner
178
+            else:
179
+                minimal_required_role = minimal_required_role_for_anyone
180
+            # INFO - G.M - 2018-06-178 - normal role test
181
+            if workspace.get_user_role(user) >= minimal_required_role:
182
+                return func(self, context, request)
183
+            raise InsufficientUserRoleInWorkspace()
184
+        return wrapper
185
+    return decorator

+ 77 - 0
backend/tracim/lib/utils/cors.py View File

@@ -0,0 +1,77 @@
1
+# -*- coding: utf-8 -*-
2
+# INFO - G.M -17-05-2018 - CORS support
3
+# original code from https://gist.github.com/mmerickel/1afaf64154b335b596e4
4
+# see also
5
+# here : https://groups.google.com/forum/#!topic/pylons-discuss/2Sw4OkOnZcE
6
+from pyramid.events import NewResponse
7
+
8
+
9
+def add_cors_support(config):
10
+    # INFO - G.M - 17-05-2018 - CORS Preflight stuff (special requests)
11
+    config.add_directive(
12
+        'add_cors_preflight_handler',
13
+        add_cors_preflight_handler
14
+    )
15
+    config.add_route_predicate('cors_preflight', CorsPreflightPredicate)
16
+
17
+    # INFO - G.M - 17-05-2018 CORS Headers for all responses
18
+    config.add_subscriber(add_cors_to_response, NewResponse)
19
+
20
+
21
+class CorsPreflightPredicate(object):
22
+    def __init__(self, val, config):
23
+        self.val = val
24
+
25
+    def text(self):
26
+        return 'cors_preflight = %s' % bool(self.val)
27
+
28
+    phash = text
29
+
30
+    def __call__(self, context, request):
31
+        if not self.val:
32
+            return False
33
+        return (
34
+            request.method == 'OPTIONS' and
35
+            'Origin' in request.headers and
36
+            'Access-Control-Request-Method' in request.headers
37
+        )
38
+
39
+
40
+def add_cors_preflight_handler(config):
41
+    # INFO - G.M - 17-05-2018 - Add route for CORS preflight
42
+    # see https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request
43
+    # for more info about preflight
44
+
45
+    config.add_route(
46
+        'cors-options-preflight', '/{catch_all:.*}',
47
+        cors_preflight=True,
48
+    )
49
+    config.add_view(
50
+        cors_options_view,
51
+        route_name='cors-options-preflight',
52
+    )
53
+
54
+
55
+def cors_options_view(context, request):
56
+    response = request.response
57
+    if 'Access-Control-Request-Headers' in request.headers:
58
+        response.headers['Access-Control-Allow-Methods'] = (
59
+            'OPTIONS,HEAD,GET,POST,PUT,DELETE'
60
+        )
61
+    response.headers['Access-Control-Allow-Headers'] = (
62
+        'Content-Type,Accept,Accept-Language,Authorization,X-Request-ID'
63
+    )
64
+    return response
65
+
66
+
67
+def add_cors_to_response(event):
68
+    # INFO - G.M - 17-05-2018 - Add some CORS headers to all requests
69
+    request = event.request
70
+    response = event.response
71
+    if 'Origin' in request.headers:
72
+        response.headers['Access-Control-Expose-Headers'] = (
73
+            'Content-Type,Date,Content-Length,Authorization,X-Request-ID'
74
+        )
75
+        # TODO - G.M - 17-05-2018 - Allow to configure this header in config
76
+        response.headers['Access-Control-Allow-Origin'] = '*'
77
+        response.headers['Access-Control-Allow-Credentials'] = 'true'

+ 47 - 0
backend/tracim/lib/utils/logger.py View File

@@ -0,0 +1,47 @@
1
+# -*- coding: utf-8 -*-
2
+import logging
3
+
4
+
5
+class Logger(object):
6
+    """
7
+    Global logger
8
+    """
9
+    TPL = '[{cls}] {msg}'
10
+
11
+    def __init__(self, logger_name):
12
+        self._name = logger_name
13
+        self._logger = logging.getLogger(self._name)
14
+
15
+    @classmethod
16
+    def _txt(cls, instance_or_class):
17
+        if instance_or_class.__class__.__name__ in ('function', 'type'):
18
+            return instance_or_class.__name__
19
+        else:
20
+            return instance_or_class.__class__.__name__
21
+
22
+    def debug(self, instance_or_class, message):
23
+        self._logger.debug(
24
+            Logger.TPL.format(cls=self._txt(instance_or_class), msg=message)
25
+        )
26
+
27
+    def error(self, instance_or_class, message, exc_info=0):
28
+        self._logger.error(
29
+            Logger.TPL.format(
30
+                cls=self._txt(instance_or_class),
31
+                msg=message,
32
+                exc_info=exc_info
33
+            )
34
+        )
35
+
36
+    def info(self, instance_or_class, message):
37
+        self._logger.info(
38
+            Logger.TPL.format(cls=self._txt(instance_or_class), msg=message)
39
+        )
40
+
41
+    def warning(self, instance_or_class, message):
42
+        self._logger.warning(
43
+            Logger.TPL.format(cls=self._txt(instance_or_class), msg=message)
44
+        )
45
+
46
+
47
+logger = Logger('tracim')

+ 399 - 0
backend/tracim/lib/utils/request.py View File

@@ -0,0 +1,399 @@
1
+# -*- coding: utf-8 -*-
2
+from pyramid.request import Request
3
+from sqlalchemy.orm.exc import NoResultFound
4
+
5
+from tracim.exceptions import NotAuthenticated
6
+from tracim.exceptions import UserNotActive
7
+from tracim.exceptions import ContentNotFound
8
+from tracim.exceptions import InvalidUserId
9
+from tracim.exceptions import InvalidWorkspaceId
10
+from tracim.exceptions import InvalidContentId
11
+from tracim.exceptions import InvalidCommentId
12
+from tracim.exceptions import ContentNotFoundInTracimRequest
13
+from tracim.exceptions import WorkspaceNotFoundInTracimRequest
14
+from tracim.exceptions import UserNotFoundInTracimRequest
15
+from tracim.exceptions import UserDoesNotExist
16
+from tracim.exceptions import WorkspaceNotFound
17
+from tracim.exceptions import ImmutableAttribute
18
+from tracim.models.contents import ContentTypeLegacy as ContentType
19
+from tracim.lib.core.content import ContentApi
20
+from tracim.lib.core.user import UserApi
21
+from tracim.lib.core.workspace import WorkspaceApi
22
+from tracim.lib.utils.authorization import JSONDecodeError
23
+
24
+from tracim.models import User
25
+from tracim.models.data import Workspace
26
+from tracim.models.data import Content
27
+
28
+
29
+class TracimRequest(Request):
30
+    """
31
+    Request with tracim specific params/methods
32
+    """
33
+    def __init__(
34
+            self,
35
+            environ,
36
+            charset=None,
37
+            unicode_errors=None,
38
+            decode_param_names=None,
39
+            **kw
40
+    ):
41
+        super().__init__(
42
+            environ,
43
+            charset,
44
+            unicode_errors,
45
+            decode_param_names,
46
+            **kw
47
+        )
48
+        # Current comment, found in request path
49
+        self._current_comment = None  # type: Content
50
+
51
+        # Current content, found in request path
52
+        self._current_content = None  # type: Content
53
+
54
+        # Current workspace, found in request path
55
+        self._current_workspace = None  # type: Workspace
56
+
57
+        # Candidate workspace found in request body
58
+        self._candidate_workspace = None  # type: Workspace
59
+
60
+        # Authenticated user
61
+        self._current_user = None  # type: User
62
+
63
+        # User found from request headers, content, distinct from authenticated
64
+        # user
65
+        self._candidate_user = None  # type: User
66
+
67
+        # INFO - G.M - 18-05-2018 - Close db at the end of the request
68
+        self.add_finished_callback(self._cleanup)
69
+
70
+    @property
71
+    def current_workspace(self) -> Workspace:
72
+        """
73
+        Get current workspace of the request according to authentification and
74
+        request headers (to retrieve workspace). Setted by default value the
75
+        first time if not configured.
76
+        :return: Workspace of the request
77
+        """
78
+        if self._current_workspace is None:
79
+            self._current_workspace = self._get_current_workspace(self.current_user, self)   # nopep8
80
+        return self._current_workspace
81
+
82
+    @current_workspace.setter
83
+    def current_workspace(self, workspace: Workspace) -> None:
84
+        """
85
+        Setting current_workspace
86
+        :param workspace:
87
+        :return:
88
+        """
89
+        if self._current_workspace is not None:
90
+            raise ImmutableAttribute(
91
+                "Can't modify already setted current_workspace"
92
+            )
93
+        self._current_workspace = workspace
94
+
95
+    @property
96
+    def current_user(self) -> User:
97
+        """
98
+        Get user from authentication mecanism.
99
+        """
100
+        if self._current_user is None:
101
+            self.current_user = self._get_auth_safe_user(self)
102
+        return self._current_user
103
+
104
+    @current_user.setter
105
+    def current_user(self, user: User) -> None:
106
+        if self._current_user is not None:
107
+            raise ImmutableAttribute(
108
+                "Can't modify already setted current_user"
109
+            )
110
+        self._current_user = user
111
+
112
+    @property
113
+    def current_content(self) -> Content:
114
+        """
115
+        Get current  content from path
116
+        """
117
+        if self._current_content is None:
118
+            self._current_content = self._get_current_content(
119
+                self.current_user,
120
+                self.current_workspace,
121
+                self
122
+                )
123
+        return self._current_content
124
+
125
+    @current_content.setter
126
+    def current_content(self, content: Content) -> None:
127
+        if self._current_content is not None:
128
+            raise ImmutableAttribute(
129
+                "Can't modify already setted current_content"
130
+            )
131
+        self._current_content = content
132
+
133
+    @property
134
+    def current_comment(self) -> Content:
135
+        """
136
+        Get current comment from path
137
+        """
138
+        if self._current_comment is None:
139
+            self._current_comment = self._get_current_comment(
140
+                self.current_user,
141
+                self.current_workspace,
142
+                self.current_content,
143
+                self
144
+                )
145
+        return self._current_comment
146
+
147
+    @current_comment.setter
148
+    def current_comment(self, content: Content) -> None:
149
+        if self._current_comment is not None:
150
+            raise ImmutableAttribute(
151
+                "Can't modify already setted current_content"
152
+            )
153
+        self._current_comment = content
154
+    # TODO - G.M - 24-05-2018 - Find a better naming for this ?
155
+
156
+    @property
157
+    def candidate_user(self) -> User:
158
+        """
159
+        Get user from headers/body request. This user is not
160
+        the one found by authentication mecanism. This user
161
+        can help user to know about who one page is about in
162
+        a similar way as current_workspace.
163
+        """
164
+        if self._candidate_user is None:
165
+            self.candidate_user = self._get_candidate_user(self)
166
+        return self._candidate_user
167
+
168
+    @property
169
+    def candidate_workspace(self) -> Workspace:
170
+        """
171
+        Get workspace from headers/body request. This workspace is not
172
+        the one found from path. Its the one from json body.
173
+        """
174
+        if self._candidate_workspace is None:
175
+            self._candidate_workspace = self._get_candidate_workspace(
176
+                self.current_user,
177
+                self
178
+            )
179
+        return self._candidate_workspace
180
+
181
+    def _cleanup(self, request: 'TracimRequest') -> None:
182
+        """
183
+        Close dbsession at the end of the request in order to avoid exception
184
+        about not properly closed session or "object created in another thread"
185
+        issue
186
+        see https://github.com/tracim/tracim_backend/issues/62
187
+        :param request: same as self, request
188
+        :return: nothing.
189
+        """
190
+        self._current_user = None
191
+        self._current_workspace = None
192
+        self.dbsession.close()
193
+
194
+    @candidate_user.setter
195
+    def candidate_user(self, user: User) -> None:
196
+        if self._candidate_user is not None:
197
+            raise ImmutableAttribute(
198
+                "Can't modify already setted candidate_user"
199
+            )
200
+        self._candidate_user = user
201
+
202
+    ###
203
+    # Utils for TracimRequest
204
+    ###
205
+    def _get_current_comment(
206
+            self,
207
+            user: User,
208
+            workspace: Workspace,
209
+            content: Content,
210
+            request: 'TracimRequest'
211
+    ) -> Content:
212
+        """
213
+        Get current content from request
214
+        :param user: User who want to check the workspace
215
+        :param workspace: Workspace of the content
216
+        :param content: comment is related to this content
217
+        :param request: pyramid request
218
+        :return: current content
219
+        """
220
+        comment_id = ''
221
+        try:
222
+            if 'comment_id' in request.matchdict:
223
+                comment_id_str = request.matchdict['content_id']
224
+                if not isinstance(comment_id_str, str) or not comment_id_str.isdecimal():  # nopep8
225
+                    raise InvalidCommentId('comment_id is not a correct integer')  # nopep8
226
+                comment_id = int(request.matchdict['comment_id'])
227
+            if not comment_id:
228
+                raise ContentNotFoundInTracimRequest('No comment_id property found in request')  # nopep8
229
+            api = ContentApi(
230
+                current_user=user,
231
+                session=request.dbsession,
232
+                config=request.registry.settings['CFG']
233
+            )
234
+            comment = api.get_one(
235
+                comment_id,
236
+                content_type=ContentType.Comment,
237
+                workspace=workspace,
238
+                parent=content,
239
+            )
240
+        except NoResultFound as exc:
241
+            raise ContentNotFound(
242
+                'Comment {} does not exist '
243
+                'or is not visible for this user'.format(comment_id)
244
+            ) from exc
245
+        return comment
246
+
247
+    def _get_current_content(
248
+            self,
249
+            user: User,
250
+            workspace: Workspace,
251
+            request: 'TracimRequest'
252
+    ) -> Content:
253
+        """
254
+        Get current content from request
255
+        :param user: User who want to check the workspace
256
+        :param workspace: Workspace of the content
257
+        :param request: pyramid request
258
+        :return: current content
259
+        """
260
+        content_id = ''
261
+        try:
262
+            if 'content_id' in request.matchdict:
263
+                content_id_str = request.matchdict['content_id']
264
+                if not isinstance(content_id_str, str) or not content_id_str.isdecimal():  # nopep8
265
+                    raise InvalidContentId('content_id is not a correct integer')  # nopep8
266
+                content_id = int(request.matchdict['content_id'])
267
+            if not content_id:
268
+                raise ContentNotFoundInTracimRequest('No content_id property found in request')  # nopep8
269
+            api = ContentApi(
270
+                current_user=user,
271
+                session=request.dbsession,
272
+                config=request.registry.settings['CFG']
273
+            )
274
+            content = api.get_one(content_id=content_id, workspace=workspace, content_type=ContentType.Any)  # nopep8
275
+        except NoResultFound as exc:
276
+            raise ContentNotFound(
277
+                'Content {} does not exist '
278
+                'or is not visible for this user'.format(content_id)
279
+            ) from exc
280
+        return content
281
+
282
+    def _get_candidate_user(
283
+            self,
284
+            request: 'TracimRequest',
285
+    ) -> User:
286
+        """
287
+        Get candidate user
288
+        :param request: pyramid request
289
+        :return: user found from header/body
290
+        """
291
+        app_config = request.registry.settings['CFG']
292
+        uapi = UserApi(None, session=request.dbsession, config=app_config)
293
+        login = ''
294
+        try:
295
+            login = None
296
+            if 'user_id' in request.matchdict:
297
+                user_id_str = request.matchdict['user_id']
298
+                if not isinstance(user_id_str, str) or not user_id_str.isdecimal():
299
+                    raise InvalidUserId('user_id is not a correct integer')  # nopep8
300
+                login = int(request.matchdict['user_id'])
301
+            if not login:
302
+                raise UserNotFoundInTracimRequest('You request a candidate user but the context not permit to found one')  # nopep8
303
+            user = uapi.get_one(login)
304
+        except UserNotFoundInTracimRequest as exc:
305
+            raise UserDoesNotExist('User {} not found'.format(login)) from exc
306
+        return user
307
+
308
+    def _get_auth_safe_user(
309
+            self,
310
+            request: 'TracimRequest',
311
+    ) -> User:
312
+        """
313
+        Get current pyramid authenticated user from request
314
+        :param request: pyramid request
315
+        :return: current authenticated user
316
+        """
317
+        app_config = request.registry.settings['CFG']
318
+        uapi = UserApi(None, session=request.dbsession, config=app_config)
319
+        login = ''
320
+        try:
321
+            login = request.authenticated_userid
322
+            if not login:
323
+                raise UserNotFoundInTracimRequest('You request a current user but the context not permit to found one')  # nopep8
324
+            user = uapi.get_one_by_email(login)
325
+            if not user.is_active:
326
+                raise UserNotActive('User {} is not active'.format(login))
327
+        except (UserDoesNotExist, UserNotFoundInTracimRequest) as exc:
328
+            raise NotAuthenticated('User {} not found'.format(login)) from exc
329
+        return user
330
+
331
+    def _get_current_workspace(
332
+            self,
333
+            user: User,
334
+            request: 'TracimRequest'
335
+    ) -> Workspace:
336
+        """
337
+        Get current workspace from request
338
+        :param user: User who want to check the workspace
339
+        :param request: pyramid request
340
+        :return: current workspace
341
+        """
342
+        workspace_id = ''
343
+        try:
344
+            if 'workspace_id' in request.matchdict:
345
+                workspace_id_str = request.matchdict['workspace_id']
346
+                if not isinstance(workspace_id_str, str) or not workspace_id_str.isdecimal():  # nopep8
347
+                    raise InvalidWorkspaceId('workspace_id is not a correct integer')  # nopep8
348
+                workspace_id = int(request.matchdict['workspace_id'])
349
+            if not workspace_id:
350
+                raise WorkspaceNotFoundInTracimRequest('No workspace_id property found in request')  # nopep8
351
+            wapi = WorkspaceApi(
352
+                current_user=user,
353
+                session=request.dbsession,
354
+                config=request.registry.settings['CFG']
355
+            )
356
+            workspace = wapi.get_one(workspace_id)
357
+        except NoResultFound as exc:
358
+            raise WorkspaceNotFound(
359
+                'Workspace {} does not exist '
360
+                'or is not visible for this user'.format(workspace_id)
361
+            ) from exc
362
+        return workspace
363
+
364
+    def _get_candidate_workspace(
365
+            self,
366
+            user: User,
367
+            request: 'TracimRequest'
368
+    ) -> Workspace:
369
+        """
370
+        Get current workspace from request
371
+        :param user: User who want to check the workspace
372
+        :param request: pyramid request
373
+        :return: current workspace
374
+        """
375
+        workspace_id = ''
376
+        try:
377
+            if 'new_workspace_id' in request.json_body:
378
+                workspace_id = request.json_body['new_workspace_id']
379
+                if not isinstance(workspace_id, int):
380
+                    if workspace_id.isdecimal():
381
+                        workspace_id = int(workspace_id)
382
+                    else:
383
+                        raise InvalidWorkspaceId('workspace_id is not a correct integer')  # nopep8
384
+            if not workspace_id:
385
+                raise WorkspaceNotFoundInTracimRequest('No new_workspace_id property found in body')  # nopep8
386
+            wapi = WorkspaceApi(
387
+                current_user=user,
388
+                session=request.dbsession,
389
+                config=request.registry.settings['CFG']
390
+            )
391
+            workspace = wapi.get_one(workspace_id)
392
+        except JSONDecodeError as exc:
393
+            raise WorkspaceNotFound('Invalid JSON content') from exc
394
+        except NoResultFound as exc:
395
+            raise WorkspaceNotFound(
396
+                'Workspace {} does not exist '
397
+                'or is not visible for this user'.format(workspace_id)
398
+            ) from exc
399
+        return workspace

+ 12 - 0
backend/tracim/lib/utils/translation.py View File

@@ -0,0 +1,12 @@
1
+# -*- coding: utf-8 -*-
2
+from babel.core import default_locale
3
+
4
+
5
+def fake_translator(text: str):
6
+    # TODO - G.M - 27-03-2018 - [i18n] Reconnect true internationalization
7
+    return text
8
+
9
+
10
+def get_locale():
11
+    # TODO - G.M - 27-03-2018 - [i18n] Reconnect true internationalization
12
+    return default_locale('LC_TIME')

+ 74 - 0
backend/tracim/lib/utils/utils.py View File

@@ -0,0 +1,74 @@
1
+# -*- coding: utf-8 -*-
2
+import datetime
3
+from redis import Redis
4
+from rq import Queue
5
+
6
+from tracim.config import CFG
7
+
8
+DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
9
+DEFAULT_WEBDAV_CONFIG_FILE = "wsgidav.conf"
10
+DEFAULT_TRACIM_CONFIG_FILE = "development.ini"
11
+
12
+
13
+def get_redis_connection(config: CFG) -> Redis:
14
+    """
15
+    :param config: current app_config
16
+    :return: redis connection
17
+    """
18
+    return Redis(
19
+        host=config.EMAIL_SENDER_REDIS_HOST,
20
+        port=config.EMAIL_SENDER_REDIS_PORT,
21
+        db=config.EMAIL_SENDER_REDIS_DB,
22
+    )
23
+
24
+
25
+def get_rq_queue(redis_connection: Redis, queue_name: str ='default') -> Queue:
26
+    """
27
+    :param queue_name: name of queue
28
+    :return: wanted queue
29
+    """
30
+
31
+    return Queue(name=queue_name, connection=redis_connection)
32
+
33
+
34
+def cmp_to_key(mycmp):
35
+    """
36
+    List sort related function
37
+
38
+    Convert a cmp= function into a key= function
39
+    """
40
+    class K(object):
41
+        def __init__(self, obj, *args):
42
+            self.obj = obj
43
+
44
+        def __lt__(self, other):
45
+            return mycmp(self.obj, other.obj) < 0
46
+
47
+        def __gt__(self, other):
48
+            return mycmp(self.obj, other.obj) > 0
49
+
50
+        def __eq__(self, other):
51
+            return mycmp(self.obj, other.obj) == 0
52
+
53
+        def __le__(self, other):
54
+            return mycmp(self.obj, other.obj) <= 0
55
+
56
+        def __ge__(self, other):
57
+            return mycmp(self.obj, other.obj) >= 0
58
+
59
+        def __ne__(self, other):
60
+            return mycmp(self.obj, other.obj) != 0
61
+
62
+    return K
63
+
64
+
65
+def current_date_for_filename() -> str:
66
+    """
67
+    ISO8601 current date, adapted to be used in filename (for
68
+    webdav feature for example), with trouble-free characters.
69
+    :return: current date as string like "2018-03-19T15.49.27.246592"
70
+    """
71
+    # INFO - G.M - 19-03-2018 - As ':' is in transform_to_bdd method in
72
+    # webdav utils, it may cause trouble. So, it should be replaced to
73
+    # a character which will not change in bdd.
74
+    return datetime.datetime.now().isoformat().replace(':', '.')

+ 152 - 0
backend/tracim/lib/webdav/__init__.py View File

@@ -0,0 +1,152 @@
1
+import json
2
+import sys
3
+import os
4
+from pyramid.paster import get_appsettings
5
+from waitress import serve
6
+from wsgidav.wsgidav_app import DEFAULT_CONFIG
7
+from wsgidav.xml_tools import useLxml
8
+from wsgidav.wsgidav_app import WsgiDAVApp
9
+
10
+from tracim import CFG
11
+from tracim.lib.utils.utils import DEFAULT_TRACIM_CONFIG_FILE, \
12
+    DEFAULT_WEBDAV_CONFIG_FILE
13
+from tracim.lib.webdav.dav_provider import Provider
14
+from tracim.lib.webdav.authentification import TracimDomainController
15
+from wsgidav.dir_browser import WsgiDavDirBrowser
16
+from wsgidav.http_authenticator import HTTPAuthenticator
17
+from wsgidav.error_printer import ErrorPrinter
18
+from tracim.lib.webdav.middlewares import TracimWsgiDavDebugFilter, \
19
+    TracimEnforceHTTPS, TracimEnv, TracimUserSession
20
+
21
+from inspect import isfunction
22
+import traceback
23
+
24
+from tracim.models import get_engine, get_session_factory
25
+
26
+
27
+class WebdavAppFactory(object):
28
+
29
+    def __init__(self,
30
+                 tracim_config_file_path: str = None,
31
+                 ):
32
+        self.config = self._initConfig(
33
+            tracim_config_file_path
34
+        )
35
+
36
+    def _initConfig(self,
37
+                    tracim_config_file_path: str = None
38
+                    ):
39
+        """Setup configuration dictionary from default,
40
+         command line and configuration file."""
41
+        if not tracim_config_file_path:
42
+            tracim_config_file_path = DEFAULT_TRACIM_CONFIG_FILE
43
+
44
+        # Set config defaults
45
+        config = DEFAULT_CONFIG.copy()
46
+        temp_verbose = config["verbose"]
47
+        # Get pyramid Env
48
+        tracim_config_file_path = os.path.abspath(tracim_config_file_path)
49
+        config['tracim_config'] = tracim_config_file_path
50
+        settings = self._get_tracim_settings(config)
51
+        app_config = CFG(settings)
52
+
53
+        default_config_file = os.path.abspath(settings['wsgidav.config_path'])
54
+        webdav_config_file = self._readConfigFile(
55
+            default_config_file,
56
+            temp_verbose
57
+            )
58
+        # Configuration file overrides defaults
59
+        config.update(webdav_config_file)
60
+
61
+        if not useLxml and config["verbose"] >= 1:
62
+            print(
63
+                "WARNING: Could not import lxml: using xml instead (slower). "
64
+                "consider installing lxml from http://codespeak.net/lxml/."
65
+            )
66
+
67
+        config['middleware_stack'] = [
68
+            TracimEnforceHTTPS,
69
+            WsgiDavDirBrowser,
70
+            TracimUserSession,
71
+            HTTPAuthenticator,
72
+            ErrorPrinter,
73
+            TracimWsgiDavDebugFilter,
74
+            TracimEnv,
75
+
76
+        ]
77
+        config['provider_mapping'] = {
78
+            config['root_path']: Provider(
79
+                # TODO: Test to Re enabme archived and deleted
80
+                show_archived=False,  # config['show_archived'],
81
+                show_deleted=False,  # config['show_deleted'],
82
+                show_history=False,  # config['show_history'],
83
+                app_config=app_config,
84
+            )
85
+        }
86
+
87
+        config['domaincontroller'] = TracimDomainController(
88
+            presetdomain=None,
89
+            presetserver=None,
90
+            app_config=app_config,
91
+        )
92
+        return config
93
+
94
+    def _get_tracim_settings(
95
+            self,
96
+            default_config,
97
+    ):
98
+        """
99
+        Get tracim settings
100
+        """
101
+        global_conf = get_appsettings(default_config['tracim_config']).global_conf
102
+        local_conf = get_appsettings(default_config['tracim_config'], 'tracim_web')  # nopep8
103
+        settings = global_conf
104
+        settings.update(local_conf)
105
+        return settings
106
+
107
+    # INFO - G.M - 13-04-2018 - Copy from
108
+    # wsgidav.server.run_server._readConfigFile
109
+    def _readConfigFile(self, config_file, verbose):
110
+        """Read configuration file options into a dictionary."""
111
+
112
+        if not os.path.exists(config_file):
113
+            raise RuntimeError("Couldn't open configuration file '%s'." % config_file)
114
+
115
+        if config_file.endswith(".json"):
116
+            with open(config_file, mode="r", encoding="utf-8") as json_file:
117
+                return json.load(json_file)
118
+
119
+        try:
120
+            import imp
121
+            conf = {}
122
+            configmodule = imp.load_source("configuration_module", config_file)
123
+
124
+            for k, v in vars(configmodule).items():
125
+                if k.startswith("__"):
126
+                    continue
127
+                elif isfunction(v):
128
+                    continue
129
+                conf[k] = v
130
+        except Exception as e:
131
+            # if verbose >= 1:
132
+            #    traceback.print_exc()
133
+            exceptioninfo = traceback.format_exception_only(sys.exc_type, sys.exc_value)
134
+            exceptiontext = ""
135
+            for einfo in exceptioninfo:
136
+                exceptiontext += einfo + "\n"
137
+    #        raise RuntimeError("Failed to read configuration file: " + config_file + "\nDue to "
138
+    #            + exceptiontext)
139
+            print("Failed to read configuration file: " + config_file +
140
+                  "\nDue to " + exceptiontext, file=sys.stderr)
141
+            raise
142
+
143
+        return conf
144
+
145
+    def get_wsgi_app(self):
146
+        return WsgiDAVApp(self.config)
147
+
148
+
149
+if __name__ == '__main__':
150
+    app_factory = WebdavAppFactory()
151
+    app = app_factory.get_wsgi_app()
152
+    serve(app)

+ 49 - 0
backend/tracim/lib/webdav/authentification.py View File

@@ -0,0 +1,49 @@
1
+# coding: utf8
2
+from tracim.exceptions import DigestAuthNotImplemented
3
+from tracim.lib.core.user import UserApi
4
+
5
+DEFAULT_TRACIM_WEBDAV_REALM = '/'
6
+
7
+
8
+class TracimDomainController(object):
9
+    """
10
+    The domain controller is used by http_authenticator to authenticate the user every time a request is
11
+    sent
12
+    """
13
+    def __init__(self, app_config, presetdomain=None, presetserver=None):
14
+        self.app_config = app_config
15
+
16
+    def getDomainRealm(self, inputURL, environ):
17
+        return DEFAULT_TRACIM_WEBDAV_REALM
18
+
19
+    def getRealmUserPassword(self, realmname, username, environ):
20
+        """
21
+        This method is normally only use for digest auth. wsgidav need
22
+        plain password to deal with it. as we didn't
23
+        provide support for this kind of auth, this method raise an exception.
24
+        """
25
+        raise DigestAuthNotImplemented
26
+
27
+    def requireAuthentication(self, realmname, environ):
28
+        return True
29
+
30
+    def isRealmUser(self, realmname, username, environ):
31
+        """
32
+        Called to check if for a given root, the username exists (though here we don't make difference between
33
+        root as we're always starting at tracim's root
34
+        """
35
+        api = UserApi(None, environ['tracim_dbsession'], self.app_config)
36
+        try:
37
+             api.get_one_by_email(username)
38
+             return True
39
+        except:
40
+             return False
41
+
42
+    def authDomainUser(self, realmname, username, password, environ):
43
+        """
44
+        If you ever feel the need to send a request al-mano with a curl, this is the function that'll be called by
45
+        http_authenticator to validate the password sent
46
+        """
47
+        api = UserApi(None, environ['tracim_dbsession'], self.app_config)
48
+        return self.isRealmUser(realmname, username, environ) and \
49
+             api.get_one_by_email(username).validate_password(password)

+ 370 - 0
backend/tracim/lib/webdav/dav_provider.py View File

@@ -0,0 +1,370 @@
1
+# coding: utf8
2
+
3
+import re
4
+from os.path import basename, dirname
5
+
6
+from sqlalchemy.orm.exc import NoResultFound
7
+
8
+from tracim import CFG
9
+from tracim.lib.webdav.utils import transform_to_bdd, HistoryType, \
10
+    SpecialFolderExtension
11
+
12
+from wsgidav.dav_provider import DAVProvider
13
+from wsgidav.lock_manager import LockManager
14
+
15
+
16
+from tracim.lib.webdav.lock_storage import LockStorage
17
+from tracim.lib.core.content import ContentApi
18
+from tracim.lib.core.content import ContentRevisionRO
19
+from tracim.lib.core.workspace import WorkspaceApi
20
+from tracim.lib.webdav import resources
21
+from tracim.lib.webdav.utils import normpath
22
+from tracim.models.data import ContentType, Content, Workspace
23
+
24
+
25
+class Provider(DAVProvider):
26
+    """
27
+    This class' role is to provide to wsgidav _DAVResource. Wsgidav will then use them to execute action and send
28
+    informations to the client
29
+    """
30
+
31
+    def __init__(
32
+            self,
33
+            app_config: CFG,
34
+            show_history=True,
35
+            show_deleted=True,
36
+            show_archived=True,
37
+            manage_locks=True,
38
+    ):
39
+        super(Provider, self).__init__()
40
+
41
+        if manage_locks:
42
+            self.lockManager = LockManager(LockStorage())
43
+
44
+        self.app_config = app_config
45
+        self._show_archive = show_archived
46
+        self._show_delete = show_deleted
47
+        self._show_history = show_history
48
+
49
+    def show_history(self):
50
+        return self._show_history
51
+
52
+    def show_delete(self):
53
+        return self._show_delete
54
+
55
+    def show_archive(self):
56
+        return self._show_archive
57
+
58
+    #########################################################
59
+    # Everything override from DAVProvider
60
+    def getResourceInst(self, path: str, environ: dict):
61
+        """
62
+        Called by wsgidav whenever a request is called to get the _DAVResource corresponding to the path
63
+        """
64
+        user = environ['tracim_user']
65
+        session = environ['tracim_dbsession']
66
+        if not self.exists(path, environ):
67
+            return None
68
+        path = normpath(path)
69
+        root_path = environ['http_authenticator.realm']
70
+
71
+        # If the requested path is the root, then we return a RootResource resource
72
+        if path == root_path:
73
+            return resources.RootResource(
74
+                path=path,
75
+                environ=environ,
76
+                user=user,
77
+                session=session
78
+            )
79
+
80
+        workspace_api = WorkspaceApi(
81
+            current_user=user,
82
+            session=session,
83
+            config=self.app_config,
84
+        )
85
+        workspace = self.get_workspace_from_path(path, workspace_api)
86
+
87
+        # If the request path is in the form root/name, then we return a WorkspaceResource resource
88
+        parent_path = dirname(path)
89
+        if parent_path == root_path:
90
+            if not workspace:
91
+                return None
92
+            return resources.WorkspaceResource(
93
+                path=path,
94
+                environ=environ,
95
+                workspace=workspace,
96
+                user=user,
97
+                session=session,
98
+            )
99
+
100
+        # And now we'll work on the path to establish which type or resource is requested
101
+
102
+        content_api = ContentApi(
103
+            current_user=user,
104
+            session=session,
105
+            config=self.app_config,
106
+            show_archived=False,  # self._show_archive,
107
+            show_deleted=False,  # self._show_delete
108
+        )
109
+
110
+        content = self.get_content_from_path(
111
+            path=path,
112
+            content_api=content_api,
113
+            workspace=workspace
114
+        )
115
+
116
+
117
+        # Easy cases : path either end with /.deleted, /.archived or /.history, then we return corresponding resources
118
+        if path.endswith(SpecialFolderExtension.Archived) and self._show_archive:
119
+            return resources.ArchivedFolderResource(
120
+                path=path,
121
+                environ=environ,
122
+                workspace=workspace,
123
+                user=user,
124
+                content=content,
125
+                session=session,
126
+            )
127
+
128
+        if path.endswith(SpecialFolderExtension.Deleted) and self._show_delete:
129
+            return resources.DeletedFolderResource(
130
+                path=path,
131
+                environ=environ,
132
+                workspace=workspace,
133
+                user=user,
134
+                content=content,
135
+                session=session,
136
+            )
137
+
138
+        if path.endswith(SpecialFolderExtension.History) and self._show_history:
139
+            is_deleted_folder = re.search(r'/\.deleted/\.history$', path) is not None
140
+            is_archived_folder = re.search(r'/\.archived/\.history$', path) is not None
141
+
142
+            type = HistoryType.Deleted if is_deleted_folder \
143
+                else HistoryType.Archived if is_archived_folder \
144
+                else HistoryType.Standard
145
+
146
+            return resources.HistoryFolderResource(
147
+                path=path,
148
+                environ=environ,
149
+                workspace=workspace,
150
+                user=user,
151
+                content=content,
152
+                session=session,
153
+                type=type
154
+            )
155
+
156
+        # Now that's more complicated, we're trying to find out if the path end with /.history/file_name
157
+        is_history_file_folder = re.search(r'/\.history/([^/]+)$', path) is not None
158
+
159
+        if is_history_file_folder and self._show_history:
160
+            return resources.HistoryFileFolderResource(
161
+                path=path,
162
+                environ=environ,
163
+                user=user,
164
+                content=content,
165
+                session=session,
166
+            )
167
+        # And here next step :
168
+        is_history_file = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) .+', path) is not None
169
+
170
+        if self._show_history and is_history_file:
171
+
172
+            revision_id = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) ([^/].+)$', path).group(1)
173
+
174
+            content_revision = content_api.get_one_revision(revision_id)
175
+            content = self.get_content_from_revision(content_revision, content_api)
176
+
177
+            if content.type == ContentType.File:
178
+                return resources.HistoryFileResource(
179
+                    path=path,
180
+                    environ=environ,
181
+                    user=user,
182
+                    content=content,
183
+                    content_revision=content_revision,
184
+                    session=session,
185
+                )
186
+            else:
187
+                return resources.HistoryOtherFile(
188
+                    path=path,
189
+                    environ=environ,
190
+                    user=user,
191
+                    content=content,
192
+                    content_revision=content_revision,
193
+                    session=session,
194
+                )
195
+
196
+        # And if we're still going, the client is asking for a standard Folder/File/Page/Thread so we check the type7
197
+        # and return the corresponding resource
198
+
199
+        if content is None:
200
+            return None
201
+        if content.type == ContentType.Folder:
202
+            return resources.FolderResource(
203
+                path=path,
204
+                environ=environ,
205
+                workspace=content.workspace,
206
+                content=content,
207
+                session=session,
208
+                user=user,
209
+            )
210
+        elif content.type == ContentType.File:
211
+            return resources.FileResource(
212
+                path=path,
213
+                environ=environ,
214
+                content=content,
215
+                session=session,
216
+                user=user
217
+            )
218
+        else:
219
+            return resources.OtherFileResource(
220
+                path=path,
221
+                environ=environ,
222
+                content=content,
223
+                session=session,
224
+                user=user,
225
+            )
226
+
227
+    def exists(self, path, environ) -> bool:
228
+        """
229
+        Called by wsgidav to check if a certain path is linked to a _DAVResource
230
+        """
231
+
232
+        path = normpath(path)
233
+        working_path = self.reduce_path(path)
234
+        root_path = environ['http_authenticator.realm']
235
+        parent_path = dirname(working_path)
236
+        user = environ['tracim_user']
237
+        session = environ['tracim_dbsession']
238
+        if path == root_path:
239
+            return True
240
+
241
+        workspace = self.get_workspace_from_path(
242
+            path,
243
+            WorkspaceApi(
244
+                current_user=user,
245
+                session=session,
246
+                config=self.app_config,
247
+            )
248
+        )
249
+
250
+        if parent_path == root_path or workspace is None:
251
+            return workspace is not None
252
+
253
+        # TODO bastien: Arnaud avait mis a True, verif le comportement
254
+        # lorsque l'on explore les dossiers archive et deleted
255
+        content_api = ContentApi(
256
+            current_user=user,
257
+            session=session,
258
+            config=self.app_config,
259
+            show_archived=False,
260
+            show_deleted=False
261
+        )
262
+
263
+        revision_id = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) ([^/].+)$', path)
264
+
265
+        is_archived = self.is_path_archive(path)
266
+
267
+        is_deleted = self.is_path_delete(path)
268
+
269
+        if revision_id:
270
+            revision_id = revision_id.group(1)
271
+            content = content_api.get_one_revision(revision_id)
272
+        else:
273
+            content = self.get_content_from_path(working_path, content_api, workspace)
274
+
275
+        return content is not None \
276
+            and content.is_deleted == is_deleted \
277
+            and content.is_archived == is_archived
278
+
279
+    def is_path_archive(self, path):
280
+        """
281
+        This function will check if a given path is linked to a file that's archived or not. We're checking if the
282
+        given path end with one of these string :
283
+
284
+        ex:
285
+            - /a/b/.archived/my_file
286
+            - /a/b/.archived/.history/my_file
287
+            - /a/b/.archived/.history/my_file/(3615 - edition) my_file
288
+        """
289
+
290
+        return re.search(
291
+            r'/\.archived/(\.history/)?(?!\.history)[^/]*(/\.)?(history|deleted|archived)?$',
292
+            path
293
+        ) is not None
294
+
295
+    def is_path_delete(self, path):
296
+        """
297
+        This function will check if a given path is linked to a file that's deleted or not. We're checking if the
298
+        given path end with one of these string :
299
+
300
+        ex:
301
+            - /a/b/.deleted/my_file
302
+            - /a/b/.deleted/.history/my_file
303
+            - /a/b/.deleted/.history/my_file/(3615 - edition) my_file
304
+        """
305
+
306
+        return re.search(
307
+            r'/\.deleted/(\.history/)?(?!\.history)[^/]*(/\.)?(history|deleted|archived)?$',
308
+            path
309
+        ) is not None
310
+
311
+    def reduce_path(self, path: str) -> str:
312
+        """
313
+        As we use the given path to request the database
314
+
315
+        ex: if the path is /a/b/.deleted/c/.archived, we're trying to get the archived content of the 'c' resource,
316
+        we need to keep the path /a/b/c
317
+
318
+        ex: if the path is /a/b/.history/my_file, we're trying to get the history of the file my_file, thus we need
319
+        the path /a/b/my_file
320
+
321
+        ex: if the path is /a/b/.history/my_file/(1985 - edition) my_old_name, we're looking for,
322
+        thus we remove all useless information
323
+        """
324
+        path = re.sub(r'/\.archived', r'', path)
325
+        path = re.sub(r'/\.deleted', r'', path)
326
+        path = re.sub(r'/\.history/[^/]+/(\d+)-.+', r'/\1', path)
327
+        path = re.sub(r'/\.history/([^/]+)', r'/\1', path)
328
+        path = re.sub(r'/\.history', r'', path)
329
+
330
+        return path
331
+
332
+    def get_content_from_path(self, path, content_api: ContentApi, workspace: Workspace) -> Content:
333
+        """
334
+        Called whenever we want to get the Content item from the database for a given path
335
+        """
336
+        path = self.reduce_path(path)
337
+        parent_path = dirname(path)
338
+
339
+        relative_parents_path = parent_path[len(workspace.label)+1:]
340
+        parents = relative_parents_path.split('/')
341
+
342
+        try:
343
+            parents.remove('')
344
+        except ValueError:
345
+            pass
346
+        parents = [transform_to_bdd(x) for x in parents]
347
+
348
+        try:
349
+            return content_api.get_one_by_label_and_parent_labels(
350
+                content_label=transform_to_bdd(basename(path)),
351
+                content_parent_labels=parents,
352
+                workspace=workspace,
353
+            )
354
+        except NoResultFound:
355
+            return None
356
+
357
+    def get_content_from_revision(self, revision: ContentRevisionRO, api: ContentApi) -> Content:
358
+        try:
359
+            return api.get_one(revision.content_id, ContentType.Any)
360
+        except NoResultFound:
361
+            return None
362
+
363
+    def get_parent_from_path(self, path, api: ContentApi, workspace) -> Content:
364
+        return self.get_content_from_path(dirname(path), api, workspace)
365
+
366
+    def get_workspace_from_path(self, path: str, api: WorkspaceApi) -> Workspace:
367
+        try:
368
+            return api.get_one_by_label(transform_to_bdd(path.split('/')[1]))
369
+        except NoResultFound:
370
+            return None

+ 385 - 0
backend/tracim/lib/webdav/design.py View File

@@ -0,0 +1,385 @@
1
+#coding: utf8
2
+from datetime import datetime
3
+
4
+from tracim.models.data import VirtualEvent
5
+from tracim.models.data import ContentType
6
+from tracim.models import data
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
+    'copy' : 'Item copied',
125
+}
126
+
127
+
128
+def create_readable_date(created, delta_from_datetime: datetime = None):
129
+    if not delta_from_datetime:
130
+        delta_from_datetime = datetime.now()
131
+
132
+    delta = delta_from_datetime - created
133
+
134
+    if delta.days > 0:
135
+        if delta.days >= 365:
136
+            aff = '%d year%s ago' % (delta.days / 365, 's' if delta.days / 365 >= 2 else '')
137
+        elif delta.days >= 30:
138
+            aff = '%d month%s ago' % (delta.days / 30, 's' if delta.days / 30 >= 2 else '')
139
+        else:
140
+            aff = '%d day%s ago' % (delta.days, 's' if delta.days >= 2 else '')
141
+    else:
142
+        if delta.seconds < 60:
143
+            aff = '%d second%s ago' % (delta.seconds, 's' if delta.seconds > 1 else '')
144
+        elif delta.seconds / 60 < 60:
145
+            aff = '%d minute%s ago' % (delta.seconds / 60, 's' if delta.seconds / 60 >= 2 else '')
146
+        else:
147
+            aff = '%d hour%s ago' % (delta.seconds / 3600, 's' if delta.seconds / 3600 >= 2 else '')
148
+
149
+    return aff
150
+
151
+def designPage(content: data.Content, content_revision: data.ContentRevisionRO) -> str:
152
+    hist = content.get_history(drop_empty_revision=False)
153
+    histHTML = '<table class="table table-striped table-hover">'
154
+    for event in hist:
155
+        if isinstance(event, VirtualEvent):
156
+            date = event.create_readable_date()
157
+            label = _LABELS[event.type.id]
158
+
159
+            histHTML += '''
160
+                <tr class="%s">
161
+                    <td class="my-align"><span class="label label-default"><i class="fa %s"></i> %s</span></td>
162
+                    <td>%s</td>
163
+                    <td>%s</td>
164
+                    <td>%s</td>
165
+                </tr>
166
+                ''' % ('warning' if event.id == content_revision.revision_id else '',
167
+                       event.type.fa_icon,
168
+                       label,
169
+                       date,
170
+                       event.owner.display_name,
171
+                       # NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
172
+                       '<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>''' % (
173
+                       # content.label, event.id, event.type.id, event.ref_object.label) if event.type.id in ['revision', 'creation', 'edition'] else '')
174
+                   )
175
+    histHTML += '</table>'
176
+
177
+    page = '''
178
+<html>
179
+<head>
180
+	<meta charset="utf-8" />
181
+	<title>%s</title>
182
+	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
183
+	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css">
184
+	<style>%s</style>
185
+	<script type="text/javascript" src="/home/arnaud/Documents/css/script.js"></script>
186
+	<script
187
+			  src="https://code.jquery.com/jquery-3.1.0.min.js"
188
+			  integrity="sha256-cCueBR6CsyA4/9szpPfrX3s49M9vUU5BgtiJj06wt/s="
189
+			  crossorigin="anonymous"></script>
190
+</head>
191
+<body>
192
+    <div id="left" class="col-lg-8 col-md-12 col-sm-12 col-xs-12">
193
+        <div class="title page">
194
+            <div class="title-text">
195
+                <i class="fa fa-file-text-o title-icon page"></i>
196
+                <h1>%s</h1>
197
+                <h6>page created on <b>%s</b> by <b>%s</b></h6>
198
+            </div>
199
+            <div class="pull-right">
200
+                <div class="btn-group btn-group-vertical">
201
+                    <!-- NOTE: Not omplemented yet, don't display not working link
202
+                     <a class="btn btn-default">
203
+                         <i class="fa fa-external-link"></i> View in tracim</a>
204
+                     </a>-->
205
+                </div>
206
+            </div>
207
+        </div>
208
+        <div class="content col-xs-12 col-sm-12 col-md-12 col-lg-12">
209
+            %s
210
+        </div>
211
+    </div>
212
+    <div id="right" class="col-lg-4 col-md-12 col-sm-12 col-xs-12">
213
+        <h4>History</h4>
214
+        %s
215
+    </div>
216
+    <script type="text/javascript">
217
+        window.onload = function() {
218
+            file_location = window.location.href
219
+            file_location = file_location.replace(/\/[^/]*$/, '')
220
+            file_location = file_location.replace(/\/.history\/[^/]*$/, '')
221
+
222
+            // NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
223
+            // $('.revision-link').each(function() {
224
+            //    $(this).attr('href', file_location + $(this).attr('href'))
225
+            // });
226
+        }
227
+    </script>
228
+</body>
229
+</html>
230
+        ''' % (content_revision.label,
231
+               style,
232
+               content_revision.label,
233
+               content.created.strftime("%B %d, %Y at %H:%m"),
234
+               content.owner.display_name,
235
+               content_revision.description,
236
+               histHTML)
237
+
238
+    return page
239
+
240
+def designThread(content: data.Content, content_revision: data.ContentRevisionRO, comments) -> str:
241
+        hist = content.get_history(drop_empty_revision=False)
242
+
243
+        allT = []
244
+        allT += comments
245
+        allT += hist
246
+        allT.sort(key=lambda x: x.created, reverse=True)
247
+
248
+        disc = ''
249
+        participants = {}
250
+        for t in allT:
251
+            if t.type == ContentType.Comment:
252
+                disc += '''
253
+                    <div class="row comment comment-row">
254
+                        <i class="fa fa-comment-o comment-icon"></i>
255
+                            <div class="comment-content">
256
+                            <h5>
257
+                                <span class="comment-author"><b>%s</b> wrote :</span>
258
+                                <div class="pull-right text-right">%s</div>
259
+                            </h5>
260
+                            %s
261
+                        </div>
262
+                    </div>
263
+                    ''' % (t.owner.display_name, create_readable_date(t.created), t.description)
264
+
265
+                if t.owner.display_name not in participants:
266
+                    participants[t.owner.display_name] = [1, t.created]
267
+                else:
268
+                    participants[t.owner.display_name][0] += 1
269
+            else:
270
+                if isinstance(t, VirtualEvent) and t.type.id != 'comment':
271
+                    label = _LABELS[t.type.id]
272
+
273
+                    disc += '''
274
+                    <div class="%s row comment comment-row to-hide">
275
+                        <i class="fa %s comment-icon"></i>
276
+                            <div class="comment-content">
277
+                            <h5>
278
+                                <span class="comment-author"><b>%s</b></span>
279
+                                <div class="pull-right text-right">%s</div>
280
+                            </h5>
281
+                            %s %s
282
+                        </div>
283
+                    </div>
284
+                    ''' % ('warning' if t.id == content_revision.revision_id else '',
285
+                           t.type.fa_icon,
286
+                           t.owner.display_name,
287
+                           t.create_readable_date(),
288
+                           label,
289
+                            # NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
290
+                            '<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>''' % (
291
+                               # content.label,
292
+                               # t.id,
293
+                               # t.ref_object.label) if t.type.id in ['revision', 'creation', 'edition'] else '')
294
+                           )
295
+
296
+        thread = '''
297
+<html>
298
+<head>
299
+	<meta charset="utf-8" />
300
+	<title>%s</title>
301
+	<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js"></script>
302
+	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
303
+	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css">
304
+	<style>%s</style>
305
+	<script type="text/javascript" src="/home/arnaud/Documents/css/script.js"></script>
306
+</head>
307
+<body>
308
+    <div id="left" class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
309
+        <div class="title thread">
310
+            <div class="title-text">
311
+                <i class="fa fa-comments-o title-icon thread"></i>
312
+                <h1>%s</h1>
313
+                <h6>thread created on <b>%s</b> by <b>%s</b></h6>
314
+            </div>
315
+            <div class="pull-right">
316
+                <div class="btn-group btn-group-vertical">
317
+                    <!-- NOTE: Not omplemented yet, don't display not working link
318
+                    <a class="btn btn-default" onclick="hide_elements()">
319
+                       <i id="hideshow" class="fa fa-eye-slash"></i> <span id="hideshowtxt" >Hide history</span></a>
320
+                    </a>-->
321
+                    <a class="btn btn-default">
322
+                        <i class="fa fa-external-link"></i> View in tracim</a>
323
+                    </a>
324
+                </div>
325
+            </div>
326
+        </div>
327
+        <div class="content col-xs-12 col-sm-12 col-md-12 col-lg-12">
328
+            <div class="description">
329
+                <span class="description-text">%s</span>
330
+            </div>
331
+            %s
332
+        </div>
333
+    </div>
334
+    <script type="text/javascript">
335
+        window.onload = function() {
336
+            file_location = window.location.href
337
+            file_location = file_location.replace(/\/[^/]*$/, '')
338
+            file_location = file_location.replace(/\/.history\/[^/]*$/, '')
339
+
340
+            // NOTE: (WABDAV_HIST_DEL_DISABLED) Disabled for beta 1.0
341
+            // $('.revision-link').each(function() {
342
+            //     $(this).attr('href', file_location + $(this).attr('href'))
343
+            // });
344
+        }
345
+
346
+        function hide_elements() {
347
+            elems = document.getElementsByClassName('to-hide');
348
+            if (elems.length > 0) {
349
+                for(var i = 0; i < elems.length; i++) {
350
+                    $(elems[i]).addClass('to-show')
351
+                    $(elems[i]).hide();
352
+                }
353
+                while (elems.length>0) {
354
+                    $(elems[0]).removeClass('comment-row');
355
+                    $(elems[0]).removeClass('to-hide');
356
+                }
357
+                $('#hideshow').addClass('fa-eye').removeClass('fa-eye-slash');
358
+                $('#hideshowtxt').html('Show history');
359
+            }
360
+            else {
361
+                elems = document.getElementsByClassName('to-show');
362
+                for(var i = 0; i<elems.length; i++) {
363
+                    $(elems[0]).addClass('comment-row');
364
+                    $(elems[i]).addClass('to-hide');
365
+                    $(elems[i]).show();
366
+                }
367
+                while (elems.length>0) {
368
+                    $(elems[0]).removeClass('to-show');
369
+                }
370
+                $('#hideshow').removeClass('fa-eye').addClass('fa-eye-slash');
371
+                $('#hideshowtxt').html('Hide history');
372
+            }
373
+        }
374
+    </script>
375
+</body>
376
+</html>
377
+        ''' % (content_revision.label,
378
+               style,
379
+               content_revision.label,
380
+               content.created.strftime("%B %d, %Y at %H:%m"),
381
+               content.owner.display_name,
382
+               content_revision.description,
383
+               disc)
384
+
385
+        return thread

+ 275 - 0
backend/tracim/lib/webdav/lock_storage.py View File

@@ -0,0 +1,275 @@
1
+import time
2
+
3
+from tracim.lib.webdav.model import Lock, Url2Token
4
+from wsgidav import util
5
+from wsgidav.lock_manager import normalizeLockRoot, lockString, generateLockToken, validateLock
6
+from wsgidav.rw_lock import ReadWriteLock
7
+
8
+_logger = util.getModuleLogger(__name__)
9
+
10
+
11
+def from_dict_to_base(lock):
12
+    return Lock(
13
+        token=lock["token"],
14
+        depth=lock["depth"],
15
+        root=lock["root"],
16
+        type=lock["type"],
17
+        scopre=lock["scope"],
18
+        owner=lock["owner"],
19
+        timeout=lock["timeout"],
20
+        principal=lock["principal"],
21
+        expire=lock["expire"]
22
+    )
23
+
24
+
25
+def from_base_to_dict(lock):
26
+    return {
27
+        'token': lock.token,
28
+        'depth': lock.depth,
29
+        'root': lock.root,
30
+        'type': lock.type,
31
+        'scope': lock.scope,
32
+        'owner': lock.owner,
33
+        'timeout': lock.timeout,
34
+        'principal': lock.principal,
35
+        'expire': lock.expire
36
+    }
37
+
38
+
39
+class LockStorage(object):
40
+    LOCK_TIME_OUT_DEFAULT = 604800  # 1 week, in seconds
41
+    LOCK_TIME_OUT_MAX = 4 * 604800  # 1 month, in seconds
42
+
43
+    def __init__(self):
44
+        self._session = None# todo Session()
45
+        self._lock = ReadWriteLock()
46
+
47
+    def __repr__(self):
48
+        return "C'est bien mon verrou..."
49
+
50
+    def __del__(self):
51
+        pass
52
+
53
+    def get_lock_db_from_token(self, token):
54
+        return self._session.query(Lock).filter(Lock.token == token).one_or_none()
55
+
56
+    def _flush(self):
57
+        """Overloaded by Shelve implementation."""
58
+        pass
59
+
60
+    def open(self):
61
+        """Called before first use.
62
+
63
+        May be implemented to initialize a storage.
64
+        """
65
+        pass
66
+
67
+    def close(self):
68
+        """Called on shutdown."""
69
+        pass
70
+
71
+    def cleanup(self):
72
+        """Purge expired locks (optional)."""
73
+        pass
74
+
75
+    def clear(self):
76
+        """Delete all entries."""
77
+        self._session.query(Lock).all().delete(synchronize_session=False)
78
+        self._session.commit()
79
+
80
+    def get(self, token):
81
+        """Return a lock dictionary for a token.
82
+
83
+        If the lock does not exist or is expired, None is returned.
84
+
85
+        token:
86
+            lock token
87
+        Returns:
88
+            Lock dictionary or <None>
89
+
90
+        Side effect: if lock is expired, it will be purged and None is returned.
91
+        """
92
+        self._lock.acquireRead()
93
+        try:
94
+            lock_base = self._session.query(Lock).filter(Lock.token == token).one_or_none()
95
+            if lock_base is None:
96
+                # Lock not found: purge dangling URL2TOKEN entries
97
+                _logger.debug("Lock purged dangling: %s" % token)
98
+                self.delete(token)
99
+                return None
100
+            expire = float(lock_base.expire)
101
+            if 0 <= expire < time.time():
102
+                _logger.debug("Lock timed-out(%s): %s" % (expire, lockString(from_base_to_dict(lock_base))))
103
+                self.delete(token)
104
+                return None
105
+            return from_base_to_dict(lock_base)
106
+        finally:
107
+            self._lock.release()
108
+
109
+    def create(self, path, lock):
110
+        """Create a direct lock for a resource path.
111
+
112
+        path:
113
+            Normalized path (utf8 encoded string, no trailing '/')
114
+        lock:
115
+            lock dictionary, without a token entry
116
+        Returns:
117
+            New unique lock token.: <lock
118
+
119
+        **Note:** the lock dictionary may be modified on return:
120
+
121
+        - lock['root'] is ignored and set to the normalized <path>
122
+        - lock['timeout'] may be normalized and shorter than requested
123
+        - lock['token'] is added
124
+        """
125
+        self._lock.acquireWrite()
126
+        try:
127
+            # We expect only a lock definition, not an existing lock
128
+            assert lock.get("token") is None
129
+            assert lock.get("expire") is None, "Use timeout instead of expire"
130
+            assert path and "/" in path
131
+
132
+            # Normalize root: /foo/bar
133
+            org_path = path
134
+            path = normalizeLockRoot(path)
135
+            lock["root"] = path
136
+
137
+            # Normalize timeout from ttl to expire-date
138
+            timeout = float(lock.get("timeout"))
139
+            if timeout is None:
140
+                timeout = LockStorage.LOCK_TIME_OUT_DEFAULT
141
+            elif timeout < 0 or timeout > LockStorage.LOCK_TIME_OUT_MAX:
142
+                timeout = LockStorage.LOCK_TIME_OUT_MAX
143
+
144
+            lock["timeout"] = timeout
145
+            lock["expire"] = time.time() + timeout
146
+
147
+            validateLock(lock)
148
+
149
+            token = generateLockToken()
150
+            lock["token"] = token
151
+
152
+            # Store lock
153
+            lock_db = from_dict_to_base(lock)
154
+
155
+            self._session.add(lock_db)
156
+
157
+            # Store locked path reference
158
+            url2token = Url2Token(
159
+                path=path,
160
+                token=token
161
+            )
162
+
163
+            self._session.add(url2token)
164
+            self._session.commit()
165
+
166
+            self._flush()
167
+            _logger.debug("LockStorageDict.set(%r): %s" % (org_path, lockString(lock)))
168
+            #            print("LockStorageDict.set(%r): %s" % (org_path, lockString(lock)))
169
+            return lock
170
+        finally:
171
+            self._lock.release()
172
+
173
+    def refresh(self, token, timeout):
174
+        """Modify an existing lock's timeout.
175
+
176
+        token:
177
+            Valid lock token.
178
+        timeout:
179
+            Suggested lifetime in seconds (-1 for infinite).
180
+            The real expiration time may be shorter than requested!
181
+        Returns:
182
+            Lock dictionary.
183
+            Raises ValueError, if token is invalid.
184
+        """
185
+        lock_db = self._session.query(Lock).filter(Lock.token == token).one_or_none()
186
+        assert lock_db is not None, "Lock must exist"
187
+        assert timeout == -1 or timeout > 0
188
+        if timeout < 0 or timeout > LockStorage.LOCK_TIME_OUT_MAX:
189
+            timeout = LockStorage.LOCK_TIME_OUT_MAX
190
+
191
+        self._lock.acquireWrite()
192
+        try:
193
+            # Note: shelve dictionary returns copies, so we must reassign values:
194
+            lock_db.timeout = timeout
195
+            lock_db.expire = time.time() + timeout
196
+            self._session.commit()
197
+            self._flush()
198
+        finally:
199
+            self._lock.release()
200
+        return from_base_to_dict(lock_db)
201
+
202
+    def delete(self, token):
203
+        """Delete lock.
204
+
205
+        Returns True on success. False, if token does not exist, or is expired.
206
+        """
207
+        self._lock.acquireWrite()
208
+        try:
209
+            lock_db = self._session.query(Lock).filter(Lock.token == token).one_or_none()
210
+            _logger.debug("delete %s" % lockString(from_base_to_dict(lock_db)))
211
+            if lock_db is None:
212
+                return False
213
+            # Remove url to lock mapping
214
+            url2token = self._session.query(Url2Token).filter(
215
+                Url2Token.path == lock_db.root,
216
+                Url2Token.token == token).one_or_none()
217
+            if url2token is not None:
218
+                self._session.delete(url2token)
219
+            # Remove the lock
220
+            self._session.delete(lock_db)
221
+            self._session.commit()
222
+
223
+            self._flush()
224
+        finally:
225
+            self._lock.release()
226
+        return True
227
+
228
+    def getLockList(self, path, includeRoot, includeChildren, tokenOnly):
229
+        """Return a list of direct locks for <path>.
230
+
231
+        Expired locks are *not* returned (but may be purged).
232
+
233
+        path:
234
+            Normalized path (utf8 encoded string, no trailing '/')
235
+        includeRoot:
236
+            False: don't add <path> lock (only makes sense, when includeChildren
237
+            is True).
238
+        includeChildren:
239
+            True: Also check all sub-paths for existing locks.
240
+        tokenOnly:
241
+            True: only a list of token is returned. This may be implemented
242
+            more efficiently by some providers.
243
+        Returns:
244
+            List of valid lock dictionaries (may be empty).
245
+        """
246
+        assert path and path.startswith("/")
247
+        assert includeRoot or includeChildren
248
+
249
+        def __appendLocks(toklist):
250
+            # Since we can do this quickly, we use self.get() even if
251
+            # tokenOnly is set, so expired locks are purged.
252
+            for token in toklist:
253
+                lock_db = self.get_lock_db_from_token(token)
254
+                if lock_db:
255
+                    if tokenOnly:
256
+                        lockList.append(lock_db.token)
257
+                    else:
258
+                        lockList.append(from_base_to_dict(lock_db))
259
+
260
+        path = normalizeLockRoot(path)
261
+        self._lock.acquireRead()
262
+        try:
263
+            tokList = self._session.query(Url2Token.token).filter(Url2Token.path == path).all()
264
+            lockList = []
265
+            if includeRoot:
266
+                __appendLocks(tokList)
267
+
268
+            if includeChildren:
269
+                for url, in self._session.query(Url2Token.path).group_by(Url2Token.path):
270
+                    if util.isChildUri(path, url):
271
+                        __appendLocks(self._session.query(Url2Token.token).filter(Url2Token.path == url))
272
+
273
+            return lockList
274
+        finally:
275
+            self._lock.release()

+ 295 - 0
backend/tracim/lib/webdav/middlewares.py View File

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

+ 28 - 0
backend/tracim/lib/webdav/model.py View File

@@ -0,0 +1,28 @@
1
+#coding: utf8
2
+
3
+from sqlalchemy import Column
4
+from sqlalchemy import ForeignKey
5
+from sqlalchemy.types import Unicode, UnicodeText, Float
6
+
7
+from wsgidav.compat import to_unicode
8
+
9
+
10
+class Lock(object):
11
+    __tablename__ = 'my_locks'
12
+
13
+    token = Column(UnicodeText, primary_key=True, unique=True, nullable=False)
14
+    depth = Column(Unicode(32), unique=False, nullable=False, default=to_unicode('infinity'))
15
+    root = Column(UnicodeText, unique=False, nullable=False)
16
+    type = Column(Unicode(32), unique=False, nullable=False, default=to_unicode('write'))
17
+    scope = Column(Unicode(32), unique=False, nullable=False, default=to_unicode('exclusive'))
18
+    owner = Column(UnicodeText, unique=False, nullable=False)
19
+    expire = Column(Float, unique=False, nullable=False)
20
+    principal = Column(Unicode(255), ForeignKey('my_users.display_name', ondelete="CASCADE"))
21
+    timeout = Column(Float, unique=False, nullable=False)
22
+
23
+
24
+class Url2Token(object):
25
+    __tablename__ = 'my_url2token'
26
+
27
+    token = Column(UnicodeText, primary_key=True, unique=True, nullable=False)
28
+    path = Column(UnicodeText, primary_key=True, unique=False, nullable=False)

File diff suppressed because it is too large
+ 1478 - 0
backend/tracim/lib/webdav/resources.py


+ 212 - 0
backend/tracim/lib/webdav/utils.py View File

@@ -0,0 +1,212 @@
1
+# -*- coding: utf-8 -*-
2
+
3
+import transaction
4
+from os.path import normpath as base_normpath
5
+
6
+from sqlalchemy.orm import Session
7
+from wsgidav import util
8
+from wsgidav import compat
9
+
10
+from tracim.lib.core.content import ContentApi
11
+from tracim.models.data import Workspace, Content, ContentType, \
12
+    ActionDescription
13
+from tracim.models.revision_protection import new_revision
14
+
15
+
16
+def transform_to_display(string: str) -> str:
17
+    """
18
+    As characters that Windows does not support may have been inserted
19
+    through Tracim in names, before displaying information we update path
20
+    so that all these forbidden characters are replaced with similar
21
+    shape character that are allowed so that the user isn't trouble and
22
+    isn't limited in his naming choice
23
+    """
24
+    _TO_DISPLAY = {
25
+        '/': '⧸',
26
+        '\\': '⧹',
27
+        ':': '∶',
28
+        '*': '∗',
29
+        '?': 'ʔ',
30
+        '"': 'ʺ',
31
+        '<': '❮',
32
+        '>': '❯',
33
+        '|': '∣'
34
+    }
35
+
36
+    for key, value in _TO_DISPLAY.items():
37
+        string = string.replace(key, value)
38
+
39
+    return string
40
+
41
+
42
+def transform_to_bdd(string: str) -> str:
43
+    """
44
+    Called before sending request to the database to recover the right names
45
+    """
46
+    _TO_BDD = {
47
+        '⧸': '/',
48
+        '⧹': '\\',
49
+        '∶': ':',
50
+        '∗': '*',
51
+        'ʔ': '?',
52
+        'ʺ': '"',
53
+        '❮': '<',
54
+        '❯': '>',
55
+        '∣': '|'
56
+    }
57
+
58
+    for key, value in _TO_BDD.items():
59
+        string = string.replace(key, value)
60
+
61
+    return string
62
+
63
+
64
+def normpath(path):
65
+    if path == b'':
66
+        path = b'/'
67
+    elif path == '':
68
+        path = '/'
69
+    return base_normpath(path)
70
+
71
+
72
+class HistoryType(object):
73
+    Deleted = 'deleted'
74
+    Archived = 'archived'
75
+    Standard = 'standard'
76
+    All = 'all'
77
+
78
+
79
+class SpecialFolderExtension(object):
80
+    Deleted = '/.deleted'
81
+    Archived = '/.archived'
82
+    History = '/.history'
83
+
84
+
85
+class FakeFileStream(object):
86
+    """
87
+    Fake a FileStream that we're giving to wsgidav to receive data and create files / new revisions
88
+
89
+    There's two scenarios :
90
+    - when a new file is created, wsgidav will call the method createEmptyResource and except to get a _DAVResource
91
+    which should have both 'beginWrite' and 'endWrite' method implemented
92
+    - when a file which already exists is updated, he's going to call the 'beginWrite' function of the _DAVResource
93
+    to get a filestream and write content in it
94
+
95
+    In the first case scenario, the transfer takes two part : it first create the resource (createEmptyResource)
96
+    then add its content (beginWrite, write, close..). If we went without this class, we would create two revision
97
+    of the file upon creating a new file, which is not what we want.
98
+    """
99
+
100
+    def __init__(
101
+            self,
102
+            session: Session,
103
+            content_api: ContentApi,
104
+            workspace: Workspace,
105
+            path: str,
106
+            file_name: str='',
107
+            content: Content=None,
108
+            parent: Content=None
109
+    ):
110
+        """
111
+
112
+        :param content_api:
113
+        :param workspace:
114
+        :param path:
115
+        :param file_name:
116
+        :param content:
117
+        :param parent:
118
+        """
119
+        self._file_stream = compat.BytesIO()
120
+        self._session = session
121
+        self._file_name = file_name if file_name != '' else self._content.file_name
122
+        self._content = content
123
+        self._api = content_api
124
+        self._workspace = workspace
125
+        self._parent = parent
126
+        self._path = path
127
+
128
+    def getRefUrl(self) -> str:
129
+        """
130
+        As wsgidav expect to receive a _DAVResource upon creating a new resource, this method's result is used
131
+        by Windows client to establish both file's path and file's name
132
+        """
133
+        return self._path
134
+
135
+    def beginWrite(self, contentType) -> 'FakeFileStream':
136
+        """
137
+        Called by wsgidav, it expect a filestream which possess both 'write' and 'close' operation to write
138
+        the file content.
139
+        """
140
+        return self
141
+
142
+    def endWrite(self, withErrors: bool):
143
+        """
144
+        Called by request_server when finished writing everything.
145
+        As we call operation to create new content or revision in the close operation, called before endWrite, there
146
+        is nothing to do here.
147
+        """
148
+        pass
149
+
150
+    def write(self, s: str):
151
+        """
152
+        Called by request_server when writing content to files, we put it inside a filestream
153
+        """
154
+        self._file_stream.write(s)
155
+
156
+    def close(self):
157
+        """
158
+        Called by request_server when the file content has been written. We either add a new content or create
159
+        a new revision
160
+        """
161
+
162
+        self._file_stream.seek(0)
163
+
164
+        if self._content is None:
165
+            self.create_file()
166
+        else:
167
+            self.update_file()
168
+
169
+        transaction.commit()
170
+
171
+    def create_file(self):
172
+        """
173
+        Called when this is a new file; will create a new Content initialized with the correct content
174
+        """
175
+
176
+        is_temporary = self._file_name.startswith('.~') or self._file_name.startswith('~')
177
+
178
+        file = self._api.create(
179
+            filename=self._file_name,
180
+            content_type=ContentType.File,
181
+            workspace=self._workspace,
182
+            parent=self._parent,
183
+            is_temporary=is_temporary
184
+        )
185
+
186
+        self._api.update_file_data(
187
+            file,
188
+            self._file_name,
189
+            util.guessMimeType(self._file_name),
190
+            self._file_stream.read()
191
+        )
192
+
193
+        self._api.save(file, ActionDescription.CREATION)
194
+
195
+    def update_file(self):
196
+        """
197
+        Called when we're updating an existing content; we create a new revision and update the file content
198
+        """
199
+
200
+        with new_revision(
201
+                session=self._session,
202
+                content=self._content,
203
+                tm=transaction.manager,
204
+        ):
205
+            self._api.update_file_data(
206
+                self._content,
207
+                self._file_name,
208
+                util.guessMimeType(self._content.file_name),
209
+                self._file_stream.read()
210
+            )
211
+
212
+            self._api.save(self._content, ActionDescription.REVISION)

+ 78 - 0
backend/tracim/migration/env.py View File

@@ -0,0 +1,78 @@
1
+# -*- coding: utf-8 -*-
2
+
3
+from __future__ import with_statement
4
+from alembic import context
5
+from sqlalchemy import engine_from_config, pool
6
+from tracim import models
7
+# from logging.config import fileConfig
8
+
9
+# this is the Alembic Config object, which provides
10
+# access to the values within the .ini file in use.
11
+config = context.config
12
+# Interpret the config file for Python logging.
13
+# This line sets up loggers basically.
14
+# fileConfig(config.config_file_name)
15
+
16
+# add your model's MetaData object here
17
+# for 'autogenerate' support
18
+# from myapp import mymodel
19
+# target_metadata = mymodel.Base.metadata
20
+
21
+target_metadata = models.meta.metadata
22
+
23
+# other values from the config, defined by the needs of env.py,
24
+# can be acquired:
25
+# my_important_option = config.get_main_option("my_important_option")
26
+# ... etc.
27
+
28
+
29
+def run_migrations_offline():
30
+    """Run migrations in 'offline' mode.
31
+
32
+    This configures the context with just a URL
33
+    and not an Engine, though an Engine is acceptable
34
+    here as well.  By skipping the Engine creation
35
+    we don't even need a DBAPI to be available.
36
+
37
+    Calls to context.execute() here emit the given string to the
38
+    script output.
39
+
40
+    """
41
+    url = config.get_main_option("sqlalchemy.url")
42
+    context.configure(url=url, version_table='migrate_version')
43
+
44
+    with context.begin_transaction():
45
+        context.run_migrations()
46
+
47
+
48
+def run_migrations_online():
49
+    """Run migrations in 'online' mode.
50
+
51
+    In this scenario we need to create an Engine
52
+    and associate a connection with the context.
53
+
54
+    """
55
+    engine = engine_from_config(
56
+                config.get_section(config.config_ini_section),
57
+                prefix='sqlalchemy.',
58
+                poolclass=pool.NullPool)
59
+
60
+    connection = engine.connect()
61
+    context.configure(
62
+                connection=connection,
63
+                target_metadata=target_metadata,
64
+                version_table='migrate_version'
65
+                )
66
+
67
+    try:
68
+        with context.begin_transaction():
69
+            context.run_migrations()
70
+    finally:
71
+        connection.close()
72
+        engine.dispose()
73
+
74
+
75
+if context.is_offline_mode():
76
+    run_migrations_offline()
77
+else:
78
+    run_migrations_online()

+ 22 - 0
backend/tracim/migration/script.py.mako View File

@@ -0,0 +1,22 @@
1
+"""${message}
2
+
3
+Revision ID: ${up_revision}
4
+Revises: ${down_revision}
5
+Create Date: ${create_date}
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = ${repr(up_revision)}
11
+down_revision = ${repr(down_revision)}
12
+
13
+from alembic import op
14
+import sqlalchemy as sa
15
+${imports if imports else ""}
16
+
17
+def upgrade():
18
+    ${upgrades if upgrades else "pass"}
19
+
20
+
21
+def downgrade():
22
+    ${downgrades if downgrades else "pass"}

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

@@ -0,0 +1,26 @@
1
+"""remove webdav_right_digest_response_hash from database
2
+
3
+Revision ID: 2b4043fa2502
4
+Revises: f3852e1349c4
5
+Create Date: 2018-03-13 14:41:38.590375
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = '2b4043fa2502'
11
+down_revision = None
12
+
13
+from alembic import op
14
+from sqlalchemy import Column, Unicode
15
+
16
+
17
+def upgrade():
18
+    with op.batch_alter_table('users') as batch_op:
19
+        batch_op.drop_column('webdav_left_digest_response_hash')
20
+
21
+
22
+def downgrade():
23
+    with op.batch_alter_table('users') as batch_op:
24
+        batch_op.add_column(
25
+            Column('webdav_left_digest_response_hash', Unicode(128))
26
+        )

+ 26 - 0
backend/tracim/migration/versions/ad79f58ec2bf_tracim_v2.py View File

@@ -0,0 +1,26 @@
1
+"""Tracim V2
2
+
3
+Revision ID: ad79f58ec2bf
4
+Revises: 2b4043fa2502
5
+Create Date: 2018-03-29 17:05:41.735260
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = 'ad79f58ec2bf'
11
+down_revision = '2b4043fa2502'
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
+    pass
20
+    # ### end Alembic commands ###
21
+
22
+
23
+def downgrade():
24
+    # ### commands auto generated by Alembic - please adjust! ###
25
+    pass
26
+    # ### end Alembic commands ###

+ 95 - 0
backend/tracim/models/__init__.py View File

@@ -0,0 +1,95 @@
1
+# -*- coding: utf-8 -*-
2
+from sqlalchemy import engine_from_config
3
+from sqlalchemy.event import listen
4
+from sqlalchemy.orm import sessionmaker
5
+from sqlalchemy.orm import configure_mappers
6
+import zope.sqlalchemy
7
+from .meta import DeclarativeBase
8
+from .revision_protection import prevent_content_revision_delete
9
+# import or define all models here to ensure they are attached to the
10
+# Base.metadata prior to any initialization routines
11
+from tracim.models.auth import User, Group, Permission
12
+from tracim.models.data import Content, ContentRevisionRO
13
+
14
+# run configure_mappers after defining all of the models to ensure
15
+# all relationships can be setup
16
+configure_mappers()
17
+
18
+
19
+def get_engine(settings, prefix='sqlalchemy.'):
20
+    return engine_from_config(settings, prefix)
21
+
22
+
23
+def get_session_factory(engine):
24
+    factory = sessionmaker(expire_on_commit=False)
25
+    factory.configure(bind=engine)
26
+    return factory
27
+
28
+
29
+def get_tm_session(session_factory, transaction_manager):
30
+    """
31
+    Get a ``sqlalchemy.orm.Session`` instance backed by a transaction.
32
+
33
+    This function will hook the _session to the transaction manager which
34
+    will take care of committing any changes.
35
+
36
+    - When using pyramid_tm it will automatically be committed or aborted
37
+      depending on whether an exception is raised.
38
+
39
+    - When using scripts you should wrap the _session in a manager yourself.
40
+      For example::
41
+
42
+          import transaction
43
+
44
+          engine = get_engine(settings)
45
+          session_factory = get_session_factory(engine)
46
+          with transaction.manager:
47
+              dbsession = get_tm_session(session_factory, transaction.manager)
48
+
49
+    """
50
+    dbsession = session_factory()
51
+    # FIXME - G.M - 02-05-2018 - Check Zope/Sqlalchemy session conf.
52
+    # We use both keep_session=True for zope and
53
+    # expire_on_commit=False for sessionmaker to keep session alive after
54
+    # commit ( in order  to not have trouble like
55
+    # https://github.com/tracim/tracim_backend/issues/52
56
+    # or detached objects problems).
57
+    # These problem happened because we use "commit" in our current code.
58
+    # Understand what those params really mean and check if it can cause
59
+    # troubles somewhere else.
60
+    # see https://stackoverflow.com/questions/16152241/how-to-get-a-sqlalchemy-session-managed-by-zope-transaction-that-has-the-same-sc  # nopep8
61
+    zope.sqlalchemy.register(
62
+        dbsession,
63
+        transaction_manager=transaction_manager,
64
+        keep_session=True,
65
+    )
66
+    listen(dbsession, 'before_flush', prevent_content_revision_delete)
67
+    return dbsession
68
+
69
+
70
+def includeme(config):
71
+    """
72
+    Initialize the model for a Pyramid app.
73
+
74
+    Activate this setup using ``config.include('tracim.models')``.
75
+
76
+    """
77
+    settings = config.get_settings()
78
+    settings['tm.manager_hook'] = 'pyramid_tm.explicit_manager'
79
+
80
+    # use pyramid_tm to hook the transaction lifecycle to the request
81
+    config.include('pyramid_tm')
82
+
83
+    # use pyramid_retry to retry a request when transient exceptions occur
84
+    config.include('pyramid_retry')
85
+
86
+    session_factory = get_session_factory(get_engine(settings))
87
+    config.registry['dbsession_factory'] = session_factory
88
+
89
+    # make request.dbsession available for use in Pyramid
90
+    config.add_request_method(
91
+        # r.tm is the transaction manager used by pyramid_tm
92
+        lambda r: get_tm_session(session_factory, r.tm),
93
+        'dbsession',
94
+        reify=True
95
+    )

+ 99 - 0
backend/tracim/models/applications.py View File

@@ -0,0 +1,99 @@
1
+# coding=utf-8
2
+import typing
3
+
4
+
5
+class Application(object):
6
+    """
7
+    Application class with data needed for frontend
8
+    """
9
+    def __init__(
10
+            self,
11
+            label: str,
12
+            slug: str,
13
+            fa_icon: str,
14
+            hexcolor: str,
15
+            is_active: bool,
16
+            config: typing.Dict[str, str],
17
+            main_route: str,
18
+    ) -> None:
19
+        """
20
+        @param label: public label of application
21
+        @param slug: identifier of application
22
+        @param icon: font awesome icon class
23
+        @param hexcolor: hexa color of application main color
24
+        @param is_active: True if application enable, False if inactive
25
+        @param config: a dict with eventual application config
26
+        @param main_route: the route of the frontend "home" screen of
27
+        the application. For exemple, if you have an application
28
+        called "calendar", the main route will be something
29
+        like /#/workspace/{wid}/calendar.
30
+        """
31
+        self.label = label
32
+        self.slug = slug
33
+        self.fa_icon = fa_icon
34
+        self.hexcolor = hexcolor
35
+        self.is_active = is_active
36
+        self.config = config
37
+        self.main_route = main_route
38
+
39
+
40
+# default apps
41
+calendar = Application(
42
+    label='Calendar',
43
+    slug='calendar',
44
+    fa_icon='calendar',
45
+    hexcolor='#757575',
46
+    is_active=True,
47
+    config={},
48
+    main_route='/#/workspaces/{workspace_id}/calendar',
49
+)
50
+
51
+thread = Application(
52
+    label='Threads',
53
+    slug='contents/threads',
54
+    fa_icon='comments-o',
55
+    hexcolor='#ad4cf9',
56
+    is_active=True,
57
+    config={},
58
+    main_route='/#/workspaces/{workspace_id}/contents?type=thread',
59
+
60
+)
61
+
62
+_file = Application(
63
+    label='Files',
64
+    slug='contents/files',
65
+    fa_icon='paperclip',
66
+    hexcolor='#FF9900',
67
+    is_active=True,
68
+    config={},
69
+    main_route='/#/workspaces/{workspace_id}/contents?type=file',
70
+)
71
+
72
+markdownpluspage = Application(
73
+    label='Markdown Plus Documents',  # TODO - G.M - 24-05-2018 - Check label
74
+    slug='contents/markdownpluspage',
75
+    fa_icon='file-code-o',
76
+    hexcolor='#f12d2d',
77
+    is_active=True,
78
+    config={},
79
+    main_route='/#/workspaces/{workspace_id}/contents?type=markdownpluspage',
80
+)
81
+
82
+html_documents = Application(
83
+    label='Text Documents',  # TODO - G.M - 24-05-2018 - Check label
84
+    slug='contents/html-documents',
85
+    fa_icon='file-text-o',
86
+    hexcolor='#3f52e3',
87
+    is_active=True,
88
+    config={},
89
+    main_route='/#/workspaces/{workspace_id}/contents?type=html-documents',
90
+)
91
+# TODO - G.M - 08-06-2018 - This is hardcoded lists of app, make this dynamic.
92
+# List of applications
93
+applications = [
94
+    html_documents,
95
+    markdownpluspage,
96
+    _file,
97
+    thread,
98
+    calendar,
99
+]

+ 330 - 0
backend/tracim/models/auth.py View File

@@ -0,0 +1,330 @@
1
+# -*- coding: utf-8 -*-
2
+"""
3
+Auth* related model.
4
+
5
+This is where the models used by the authentication stack are defined.
6
+
7
+It's perfectly fine to re-use this definition in the tracim application,
8
+though.
9
+"""
10
+import os
11
+import time
12
+import uuid
13
+
14
+from datetime import datetime
15
+from hashlib import sha256
16
+from typing import TYPE_CHECKING
17
+
18
+from sqlalchemy import Column
19
+from sqlalchemy import ForeignKey
20
+from sqlalchemy import Sequence
21
+from sqlalchemy import Table
22
+from sqlalchemy.ext.hybrid import hybrid_property
23
+from sqlalchemy.orm import relation
24
+from sqlalchemy.orm import relationship
25
+from sqlalchemy.orm import synonym
26
+from sqlalchemy.types import Boolean
27
+from sqlalchemy.types import DateTime
28
+from sqlalchemy.types import Integer
29
+from sqlalchemy.types import Unicode
30
+
31
+from tracim.lib.utils.translation import fake_translator as l_
32
+from tracim.models.meta import DeclarativeBase
33
+from tracim.models.meta import metadata
34
+if TYPE_CHECKING:
35
+    from tracim.models.data import Workspace
36
+    from tracim.models.data import UserRoleInWorkspace
37
+__all__ = ['User', 'Group', 'Permission']
38
+
39
+# This is the association table for the many-to-many relationship between
40
+# groups and permissions.
41
+group_permission_table = Table('group_permission', metadata,
42
+    Column('group_id', Integer, ForeignKey('groups.group_id',
43
+        onupdate="CASCADE", ondelete="CASCADE"), primary_key=True),
44
+    Column('permission_id', Integer, ForeignKey('permissions.permission_id',
45
+        onupdate="CASCADE", ondelete="CASCADE"), primary_key=True)
46
+)
47
+
48
+# This is the association table for the many-to-many relationship between
49
+# groups and members - this is, the memberships.
50
+user_group_table = Table('user_group', metadata,
51
+    Column('user_id', Integer, ForeignKey('users.user_id',
52
+        onupdate="CASCADE", ondelete="CASCADE"), primary_key=True),
53
+    Column('group_id', Integer, ForeignKey('groups.group_id',
54
+        onupdate="CASCADE", ondelete="CASCADE"), primary_key=True)
55
+)
56
+
57
+
58
+class Group(DeclarativeBase):
59
+
60
+    TIM_NOBODY = 0
61
+    TIM_USER = 1
62
+    TIM_MANAGER = 2
63
+    TIM_ADMIN = 3
64
+
65
+    TIM_NOBODY_GROUPNAME = 'nobody'
66
+    TIM_USER_GROUPNAME = 'users'
67
+    TIM_MANAGER_GROUPNAME = 'managers'
68
+    TIM_ADMIN_GROUPNAME = 'administrators'
69
+
70
+    __tablename__ = 'groups'
71
+
72
+    group_id = Column(Integer, Sequence('seq__groups__group_id'), autoincrement=True, primary_key=True)
73
+    group_name = Column(Unicode(16), unique=True, nullable=False)
74
+    display_name = Column(Unicode(255))
75
+    created = Column(DateTime, default=datetime.utcnow)
76
+
77
+    users = relationship('User', secondary=user_group_table, backref='groups')
78
+
79
+    def __repr__(self):
80
+        return '<Group: name=%s>' % repr(self.group_name)
81
+
82
+    def __unicode__(self):
83
+        return self.group_name
84
+
85
+    @classmethod
86
+    def by_group_name(cls, group_name, dbsession):
87
+        """Return the user object whose email address is ``email``."""
88
+        return dbsession.query(cls).filter_by(group_name=group_name).first()
89
+
90
+
91
+class Profile(object):
92
+    """This model is the "max" group associated to a given user."""
93
+
94
+    _NAME = [
95
+        Group.TIM_NOBODY_GROUPNAME,
96
+        Group.TIM_USER_GROUPNAME,
97
+        Group.TIM_MANAGER_GROUPNAME,
98
+        Group.TIM_ADMIN_GROUPNAME,
99
+    ]
100
+
101
+    _IDS = [
102
+        Group.TIM_NOBODY,
103
+        Group.TIM_USER,
104
+        Group.TIM_MANAGER,
105
+        Group.TIM_ADMIN,
106
+    ]
107
+
108
+    # TODO - G.M - 18-04-2018 [Cleanup] Drop this
109
+    # _LABEL = [l_('Nobody'),
110
+    #           l_('Users'),
111
+    #           l_('Global managers'),
112
+    #           l_('Administrators')]
113
+
114
+    def __init__(self, profile_id):
115
+        assert isinstance(profile_id, int)
116
+        self.id = profile_id
117
+        self.name = Profile._NAME[profile_id]
118
+        # TODO - G.M - 18-04-2018 [Cleanup] Drop this
119
+        # self.label = Profile._LABEL[profile_id]
120
+
121
+
122
+class User(DeclarativeBase):
123
+    """
124
+    User definition.
125
+
126
+    This is the user definition used by :mod:`repoze.who`, which requires at
127
+    least the ``email`` column.
128
+    """
129
+
130
+    __tablename__ = 'users'
131
+
132
+    user_id = Column(Integer, Sequence('seq__users__user_id'), autoincrement=True, primary_key=True)
133
+    email = Column(Unicode(255), unique=True, nullable=False)
134
+    display_name = Column(Unicode(255))
135
+    _password = Column('password', Unicode(128))
136
+    created = Column(DateTime, default=datetime.utcnow)
137
+    is_active = Column(Boolean, default=True, nullable=False)
138
+    imported_from = Column(Unicode(32), nullable=True)
139
+    timezone = Column(Unicode(255), nullable=False, server_default='')
140
+    # TODO - G.M - 04-04-2018 - [auth] Check if this is already needed
141
+    # with new auth system
142
+    auth_token = Column(Unicode(255))
143
+    auth_token_created = Column(DateTime)
144
+
145
+    @hybrid_property
146
+    def email_address(self):
147
+        return self.email
148
+
149
+    def __repr__(self):
150
+        return '<User: email=%s, display=%s>' % (
151
+                repr(self.email), repr(self.display_name))
152
+
153
+    def __unicode__(self):
154
+        return self.display_name or self.email
155
+
156
+    @property
157
+    def permissions(self):
158
+        """Return a set with all permissions granted to the user."""
159
+        perms = set()
160
+        for g in self.groups:
161
+            perms = perms | set(g.permissions)
162
+        return perms
163
+
164
+    @property
165
+    def profile(self) -> Profile:
166
+        profile_id = 0
167
+        if len(self.groups) > 0:
168
+            profile_id = max(group.group_id for group in self.groups)
169
+        return Profile(profile_id)
170
+
171
+    # TODO - G-M - 20-04-2018 - [Calendar] Replace this in context model object
172
+    # @property
173
+    # def calendar_url(self) -> str:
174
+    #     # TODO - 20160531 - Bastien: Cyclic import if import in top of file
175
+    #     from tracim.lib.calendar import CalendarManager
176
+    #     calendar_manager = CalendarManager(None)
177
+    #
178
+    #     return calendar_manager.get_user_calendar_url(self.user_id)
179
+
180
+    @classmethod
181
+    def by_email_address(cls, email, dbsession):
182
+        """Return the user object whose email address is ``email``."""
183
+        return dbsession.query(cls).filter_by(email=email).first()
184
+
185
+    @classmethod
186
+    def by_user_name(cls, username, dbsession):
187
+        """Return the user object whose user name is ``username``."""
188
+        return dbsession.query(cls).filter_by(email=username).first()
189
+
190
+    @classmethod
191
+    def _hash_password(cls, cleartext_password: str) -> str:
192
+        salt = sha256()
193
+        salt.update(os.urandom(60))
194
+        salt = salt.hexdigest()
195
+
196
+        hash = sha256()
197
+        # Make sure password is a str because we cannot hash unicode objects
198
+        hash.update((cleartext_password + salt).encode('utf-8'))
199
+        hash = hash.hexdigest()
200
+
201
+        ciphertext_password = salt + hash
202
+
203
+        # Make sure the hashed password is a unicode object at the end of the
204
+        # process because SQLAlchemy _wants_ unicode objects for Unicode cols
205
+        # FIXME - D.A. - 2013-11-20 - The following line has been removed since using python3. Is this normal ?!
206
+        # password = password.decode('utf-8')
207
+
208
+        return ciphertext_password
209
+
210
+    def _set_password(self, cleartext_password: str) -> None:
211
+        """
212
+        Set ciphertext password from cleartext password.
213
+
214
+        Hash cleartext password on the fly,
215
+        Store its ciphertext version,
216
+        """
217
+        self._password = self._hash_password(cleartext_password)
218
+
219
+    def _get_password(self) -> str:
220
+        """Return the hashed version of the password."""
221
+        return self._password
222
+
223
+    password = synonym('_password', descriptor=property(_get_password,
224
+                                                        _set_password))
225
+
226
+    def validate_password(self, cleartext_password: str) -> bool:
227
+        """
228
+        Check the password against existing credentials.
229
+
230
+        :param cleartext_password: the password that was provided by the user
231
+            to try and authenticate. This is the clear text version that we
232
+            will need to match against the hashed one in the database.
233
+        :type cleartext_password: unicode object.
234
+        :return: Whether the password is valid.
235
+        :rtype: bool
236
+
237
+        """
238
+        result = False
239
+        if self.password:
240
+            hash = sha256()
241
+            hash.update((cleartext_password + self.password[:64]).encode('utf-8'))
242
+            result = self.password[64:] == hash.hexdigest()
243
+        return result
244
+
245
+    def get_display_name(self, remove_email_part: bool=False) -> str:
246
+        """
247
+        Get a name to display from corresponding member or email.
248
+
249
+        :param remove_email_part: If True and display name based on email,
250
+            remove @xxx.xxx part of email in returned value
251
+        :return: display name based on user name or email.
252
+        """
253
+        if self.display_name:
254
+            return self.display_name
255
+        else:
256
+            if remove_email_part:
257
+                at_pos = self.email.index('@')
258
+                return self.email[0:at_pos]
259
+            return self.email
260
+
261
+    def get_role(self, workspace: 'Workspace') -> int:
262
+        for role in self.roles:
263
+            if role.workspace == workspace:
264
+                return role.role
265
+
266
+        return UserRoleInWorkspace.NOT_APPLICABLE
267
+
268
+    def get_active_roles(self) -> ['UserRoleInWorkspace']:
269
+        """
270
+        :return: list of roles of the user for all not-deleted workspaces
271
+        """
272
+        roles = []
273
+        for role in self.roles:
274
+            if not role.workspace.is_deleted:
275
+                roles.append(role)
276
+        return roles
277
+
278
+    # TODO - G.M - 04-04-2018 - [auth] Check if this is already needed
279
+    # with new auth system
280
+    def ensure_auth_token(self, validity_seconds, session) -> None:
281
+        """
282
+        Create auth_token if None, regenerate auth_token if too much old.
283
+
284
+        auth_token validity is set in
285
+        :return:
286
+        """
287
+
288
+        if not self.auth_token or not self.auth_token_created:
289
+            self.auth_token = str(uuid.uuid4())
290
+            self.auth_token_created = datetime.utcnow()
291
+            session.flush()
292
+            return
293
+
294
+        now_seconds = time.mktime(datetime.utcnow().timetuple())
295
+        auth_token_seconds = time.mktime(self.auth_token_created.timetuple())
296
+        difference = now_seconds - auth_token_seconds
297
+
298
+        if difference > validity_seconds:
299
+            self.auth_token = str(uuid.uuid4())
300
+            self.auth_token_created = datetime.utcnow()
301
+            session.flush()
302
+
303
+
304
+class Permission(DeclarativeBase):
305
+    """
306
+    Permission definition.
307
+
308
+    Only the ``permission_name`` column is required.
309
+
310
+    """
311
+
312
+    __tablename__ = 'permissions'
313
+
314
+    permission_id = Column(
315
+        Integer,
316
+        Sequence('seq__permissions__permission_id'),
317
+        autoincrement=True,
318
+        primary_key=True
319
+    )
320
+    permission_name = Column(Unicode(63), unique=True, nullable=False)
321
+    description = Column(Unicode(255))
322
+
323
+    groups = relation(Group, secondary=group_permission_table,
324
+                      backref='permissions')
325
+
326
+    def __repr__(self):
327
+        return '<Permission: name=%s>' % repr(self.permission_name)
328
+
329
+    def __unicode__(self):
330
+        return self.permission_name

+ 302 - 0
backend/tracim/models/contents.py View File

@@ -0,0 +1,302 @@
1
+# -*- coding: utf-8 -*-
2
+import typing
3
+from enum import Enum
4
+
5
+from tracim.exceptions import ContentTypeNotExist
6
+from tracim.exceptions import ContentStatusNotExist
7
+from tracim.models.applications import html_documents
8
+from tracim.models.applications import _file
9
+from tracim.models.applications import thread
10
+from tracim.models.applications import markdownpluspage
11
+
12
+
13
+####
14
+# Content Status
15
+
16
+
17
+class GlobalStatus(Enum):
18
+    OPEN = 'open'
19
+    CLOSED = 'closed'
20
+
21
+
22
+class NewContentStatus(object):
23
+    """
24
+    Future ContentStatus object class
25
+    """
26
+    def __init__(
27
+            self,
28
+            slug: str,
29
+            global_status: str,
30
+            label: str,
31
+            fa_icon: str,
32
+            hexcolor: str,
33
+    ):
34
+        self.slug = slug
35
+        self.global_status = global_status
36
+        self.label = label
37
+        self.fa_icon = fa_icon
38
+        self.hexcolor = hexcolor
39
+
40
+
41
+open_status = NewContentStatus(
42
+    slug='open',
43
+    global_status=GlobalStatus.OPEN.value,
44
+    label='Open',
45
+    fa_icon='square-o',
46
+    hexcolor='#3f52e3',
47
+)
48
+
49
+closed_validated_status = NewContentStatus(
50
+    slug='closed-validated',
51
+    global_status=GlobalStatus.CLOSED.value,
52
+    label='Validated',
53
+    fa_icon='check-square-o',
54
+    hexcolor='#008000',
55
+)
56
+
57
+closed_unvalidated_status = NewContentStatus(
58
+    slug='closed-unvalidated',
59
+    global_status=GlobalStatus.CLOSED.value,
60
+    label='Cancelled',
61
+    fa_icon='close',
62
+    hexcolor='#f63434',
63
+)
64
+
65
+closed_deprecated_status = NewContentStatus(
66
+    slug='closed-deprecated',
67
+    global_status=GlobalStatus.CLOSED.value,
68
+    label='Deprecated',
69
+    fa_icon='warning',
70
+    hexcolor='#ababab',
71
+)
72
+
73
+
74
+CONTENT_DEFAULT_STATUS = [
75
+    open_status,
76
+    closed_validated_status,
77
+    closed_unvalidated_status,
78
+    closed_deprecated_status,
79
+]
80
+
81
+
82
+class ContentStatusLegacy(NewContentStatus):
83
+    """
84
+    Temporary remplacement object for Legacy ContentStatus Object
85
+    """
86
+    OPEN = open_status.slug
87
+    CLOSED_VALIDATED = closed_validated_status.slug
88
+    CLOSED_UNVALIDATED = closed_unvalidated_status.slug
89
+    CLOSED_DEPRECATED = closed_deprecated_status.slug
90
+
91
+    def __init__(self, slug: str):
92
+        for status in CONTENT_DEFAULT_STATUS:
93
+            if slug == status.slug:
94
+                super(ContentStatusLegacy, self).__init__(
95
+                    slug=status.slug,
96
+                    global_status=status.global_status,
97
+                    label=status.label,
98
+                    fa_icon=status.fa_icon,
99
+                    hexcolor=status.hexcolor,
100
+                )
101
+                return
102
+        raise ContentStatusNotExist()
103
+
104
+    @classmethod
105
+    def all(cls, type='') -> ['NewContentStatus']:
106
+        return CONTENT_DEFAULT_STATUS
107
+
108
+    @classmethod
109
+    def allowed_values(cls):
110
+        return [status.slug for status in CONTENT_DEFAULT_STATUS]
111
+
112
+
113
+####
114
+# ContentType
115
+
116
+
117
+class NewContentType(object):
118
+    """
119
+    Future ContentType object class
120
+    """
121
+    def __init__(
122
+            self,
123
+            slug: str,
124
+            fa_icon: str,
125
+            hexcolor: str,
126
+            label: str,
127
+            creation_label: str,
128
+            available_statuses: typing.List[NewContentStatus],
129
+
130
+    ):
131
+        self.slug = slug
132
+        self.fa_icon = fa_icon
133
+        self.hexcolor = hexcolor
134
+        self.label = label
135
+        self.creation_label = creation_label
136
+        self.available_statuses = available_statuses
137
+
138
+
139
+thread_type = NewContentType(
140
+    slug='thread',
141
+    fa_icon=thread.fa_icon,
142
+    hexcolor=thread.hexcolor,
143
+    label='Thread',
144
+    creation_label='Discuss about a topic',
145
+    available_statuses=CONTENT_DEFAULT_STATUS,
146
+)
147
+
148
+file_type = NewContentType(
149
+    slug='file',
150
+    fa_icon=_file.fa_icon,
151
+    hexcolor=_file.hexcolor,
152
+    label='File',
153
+    creation_label='Upload a file',
154
+    available_statuses=CONTENT_DEFAULT_STATUS,
155
+)
156
+
157
+markdownpluspage_type = NewContentType(
158
+    slug='markdownpage',
159
+    fa_icon=markdownpluspage.fa_icon,
160
+    hexcolor=markdownpluspage.hexcolor,
161
+    label='Rich Markdown File',
162
+    creation_label='Create a Markdown document',
163
+    available_statuses=CONTENT_DEFAULT_STATUS,
164
+)
165
+
166
+html_documents_type = NewContentType(
167
+    slug='html-documents',
168
+    fa_icon=html_documents.fa_icon,
169
+    hexcolor=html_documents.hexcolor,
170
+    label='Text Document',
171
+    creation_label='Write a document',
172
+    available_statuses=CONTENT_DEFAULT_STATUS,
173
+)
174
+
175
+# TODO - G.M - 31-05-2018 - Set Better folder params
176
+folder_type = NewContentType(
177
+    slug='folder',
178
+    fa_icon=thread.fa_icon,
179
+    hexcolor=thread.hexcolor,
180
+    label='Folder',
181
+    creation_label='Create collection of any documents',
182
+    available_statuses=CONTENT_DEFAULT_STATUS,
183
+)
184
+
185
+CONTENT_DEFAULT_TYPE = [
186
+    thread_type,
187
+    file_type,
188
+    markdownpluspage_type,
189
+    html_documents_type,
190
+    folder_type,
191
+]
192
+
193
+# TODO - G.M - 31-05-2018 - Set Better Event params
194
+event_type = NewContentType(
195
+    slug='event',
196
+    fa_icon=thread.fa_icon,
197
+    hexcolor=thread.hexcolor,
198
+    label='Event',
199
+    creation_label='Event',
200
+    available_statuses=CONTENT_DEFAULT_STATUS,
201
+)
202
+
203
+# TODO - G.M - 31-05-2018 - Set Better Event params
204
+comment_type = NewContentType(
205
+    slug='comment',
206
+    fa_icon=thread.fa_icon,
207
+    hexcolor=thread.hexcolor,
208
+    label='Comment',
209
+    creation_label='Comment',
210
+    available_statuses=CONTENT_DEFAULT_STATUS,
211
+)
212
+
213
+CONTENT_DEFAULT_TYPE_SPECIAL = [
214
+    event_type,
215
+    comment_type,
216
+]
217
+
218
+ALL_CONTENTS_DEFAULT_TYPES = CONTENT_DEFAULT_TYPE + CONTENT_DEFAULT_TYPE_SPECIAL
219
+
220
+
221
+class ContentTypeLegacy(NewContentType):
222
+    """
223
+    Temporary remplacement object for Legacy ContentType Object
224
+    """
225
+
226
+    # special type
227
+    Any = 'any'
228
+    Folder = 'folder'
229
+    Event = 'event'
230
+    Comment = 'comment'
231
+
232
+    File = file_type.slug
233
+    Thread = thread_type.slug
234
+    Page = html_documents_type.slug
235
+    PageLegacy = 'page'
236
+    MarkdownPage = markdownpluspage_type.slug
237
+
238
+    def __init__(self, slug: str):
239
+        if slug == 'page':
240
+            slug = ContentTypeLegacy.Page
241
+        for content_type in ALL_CONTENTS_DEFAULT_TYPES:
242
+            if slug == content_type.slug:
243
+                super(ContentTypeLegacy, self).__init__(
244
+                    slug=content_type.slug,
245
+                    fa_icon=content_type.fa_icon,
246
+                    hexcolor=content_type.hexcolor,
247
+                    label=content_type.label,
248
+                    creation_label=content_type.creation_label,
249
+                    available_statuses=content_type.available_statuses
250
+                )
251
+                return
252
+        raise ContentTypeNotExist()
253
+
254
+    def get_slug_aliases(self) -> typing.List[str]:
255
+        """
256
+        Get all slug aliases of a content,
257
+        useful for legacy code convertion
258
+        """
259
+        # TODO - G.M - 2018-07-05 - Remove this legacy compat code
260
+        # when possible.
261
+        page_alias = [self.Page, self.PageLegacy]
262
+        if self.slug in page_alias:
263
+            return page_alias
264
+        else:
265
+            return [self.slug]
266
+
267
+    @classmethod
268
+    def all(cls) -> typing.List[str]:
269
+        return cls.allowed_types()
270
+
271
+    @classmethod
272
+    def allowed_types(cls) -> typing.List[str]:
273
+        contents_types = [status.slug for status in ALL_CONTENTS_DEFAULT_TYPES]
274
+        return contents_types
275
+
276
+    @classmethod
277
+    def allowed_type_values(cls) -> typing.List[str]:
278
+        """
279
+        All content type slug + special values like any
280
+        """
281
+        content_types = cls.allowed_types()
282
+        content_types.append(ContentTypeLegacy.Any)
283
+        return content_types
284
+
285
+    @classmethod
286
+    def allowed_types_for_folding(cls):
287
+        # This method is used for showing only "main"
288
+        # types in the left-side treeview
289
+        contents_types = [status.slug for status in CONTENT_DEFAULT_TYPE]
290
+        return contents_types
291
+
292
+    # TODO - G.M - 30-05-2018 - This method don't do anything.
293
+    @classmethod
294
+    def sorted(cls, types: ['ContentType']) -> ['ContentType']:
295
+        return types
296
+
297
+    @property
298
+    def id(self):
299
+        return self.slug
300
+
301
+    def toDict(self):
302
+        raise NotImplementedError()

+ 810 - 0
backend/tracim/models/context_models.py View File

@@ -0,0 +1,810 @@
1
+# coding=utf-8
2
+import typing
3
+from datetime import datetime
4
+from enum import Enum
5
+
6
+from slugify import slugify
7
+from sqlalchemy.orm import Session
8
+from tracim import CFG
9
+from tracim.config import PreviewDim
10
+from tracim.models import User
11
+from tracim.models.auth import Profile
12
+from tracim.models.data import Content
13
+from tracim.models.data import ContentRevisionRO
14
+from tracim.models.data import Workspace
15
+from tracim.models.data import UserRoleInWorkspace
16
+from tracim.models.roles import WorkspaceRoles
17
+from tracim.models.workspace_menu_entries import default_workspace_menu_entry
18
+from tracim.models.workspace_menu_entries import WorkspaceMenuEntry
19
+from tracim.models.contents import ContentTypeLegacy as ContentType
20
+
21
+
22
+class PreviewAllowedDim(object):
23
+
24
+    def __init__(
25
+            self,
26
+            restricted:bool,
27
+            dimensions: typing.List[PreviewDim]
28
+    ) -> None:
29
+        self.restricted = restricted
30
+        self.dimensions = dimensions
31
+
32
+
33
+class MoveParams(object):
34
+    """
35
+    Json body params for move action model
36
+    """
37
+    def __init__(self, new_parent_id: str, new_workspace_id: str = None) -> None:  # nopep8
38
+        self.new_parent_id = new_parent_id
39
+        self.new_workspace_id = new_workspace_id
40
+
41
+
42
+class LoginCredentials(object):
43
+    """
44
+    Login credentials model for login model
45
+    """
46
+
47
+    def __init__(self, email: str, password: str) -> None:
48
+        self.email = email
49
+        self.password = password
50
+
51
+
52
+class SetEmail(object):
53
+    """
54
+    Just an email
55
+    """
56
+    def __init__(self, loggedin_user_password: str, email: str) -> None:
57
+        self.loggedin_user_password = loggedin_user_password
58
+        self.email = email
59
+
60
+
61
+class SetPassword(object):
62
+    """
63
+    Just an password
64
+    """
65
+    def __init__(self,
66
+        loggedin_user_password: str,
67
+        new_password: str,
68
+        new_password2: str
69
+    ) -> None:
70
+        self.loggedin_user_password = loggedin_user_password
71
+        self.new_password = new_password
72
+        self.new_password2 = new_password2
73
+
74
+
75
+class UserInfos(object):
76
+    """
77
+    Just some user infos
78
+    """
79
+    def __init__(self, timezone: str, public_name: str) -> None:
80
+        self.timezone = timezone
81
+        self.public_name = public_name
82
+
83
+
84
+class UserProfile(object):
85
+    """
86
+    Just some user infos
87
+    """
88
+    def __init__(self, profile: str) -> None:
89
+        self.profile = profile
90
+
91
+
92
+class UserCreation(object):
93
+    """
94
+    Just some user infos
95
+    """
96
+    def __init__(
97
+            self,
98
+            email: str,
99
+            password: str,
100
+            public_name: str,
101
+            timezone: str,
102
+            profile: str,
103
+            email_notification: str,
104
+    ) -> None:
105
+        self.email = email
106
+        self.password = password
107
+        self.public_name = public_name
108
+        self.timezone = timezone
109
+        self.profile = profile
110
+        self.email_notification = email_notification
111
+
112
+
113
+class WorkspaceAndContentPath(object):
114
+    """
115
+    Paths params with workspace id and content_id model
116
+    """
117
+    def __init__(self, workspace_id: int, content_id: int) -> None:
118
+        self.content_id = content_id
119
+        self.workspace_id = workspace_id
120
+
121
+
122
+class WorkspaceAndContentRevisionPath(object):
123
+    """
124
+    Paths params with workspace id and content_id model
125
+    """
126
+    def __init__(self, workspace_id: int, content_id: int, revision_id) -> None:
127
+        self.content_id = content_id
128
+        self.revision_id = revision_id
129
+        self.workspace_id = workspace_id
130
+
131
+
132
+class ContentPreviewSizedPath(object):
133
+    """
134
+    Paths params with workspace id and content_id, width, heigth
135
+    """
136
+    def __init__(self, workspace_id: int, content_id: int, width: int, height: int) -> None:  # nopep8
137
+        self.content_id = content_id
138
+        self.workspace_id = workspace_id
139
+        self.width = width
140
+        self.height = height
141
+
142
+
143
+class RevisionPreviewSizedPath(object):
144
+    """
145
+    Paths params with workspace id and content_id, revision_id width, heigth
146
+    """
147
+    def __init__(self, workspace_id: int, content_id: int, revision_id: int, width: int, height: int) -> None:  # nopep8
148
+        self.content_id = content_id
149
+        self.revision_id = revision_id
150
+        self.workspace_id = workspace_id
151
+        self.width = width
152
+        self.height = height
153
+
154
+
155
+class WorkspaceAndUserPath(object):
156
+    """
157
+    Paths params with workspace id and user_id
158
+    """
159
+    def __init__(self, workspace_id: int, user_id: int):
160
+        self.workspace_id = workspace_id
161
+        self.user_id = workspace_id
162
+
163
+
164
+class UserWorkspaceAndContentPath(object):
165
+    """
166
+    Paths params with user_id, workspace id and content_id model
167
+    """
168
+    def __init__(self, user_id: int, workspace_id: int, content_id: int) -> None:  # nopep8
169
+        self.content_id = content_id
170
+        self.workspace_id = workspace_id
171
+        self.user_id = user_id
172
+
173
+
174
+class CommentPath(object):
175
+    """
176
+    Paths params with workspace id and content_id and comment_id model
177
+    """
178
+    def __init__(
179
+        self,
180
+        workspace_id: int,
181
+        content_id: int,
182
+        comment_id: int
183
+    ) -> None:
184
+        self.content_id = content_id
185
+        self.workspace_id = workspace_id
186
+        self.comment_id = comment_id
187
+
188
+
189
+class PageQuery(object):
190
+    """
191
+    Page query model
192
+    """
193
+    def __init__(
194
+            self,
195
+            page: int = 0
196
+    ):
197
+        self.page = page
198
+
199
+
200
+class ContentFilter(object):
201
+    """
202
+    Content filter model
203
+    """
204
+    def __init__(
205
+            self,
206
+            workspace_id: int = None,
207
+            parent_id: int = None,
208
+            show_archived: int = 0,
209
+            show_deleted: int = 0,
210
+            show_active: int = 1,
211
+            content_type: str = None,
212
+            offset: int = None,
213
+            limit: int = None,
214
+    ) -> None:
215
+        self.parent_id = parent_id
216
+        self.workspace_id = workspace_id
217
+        self.show_archived = bool(show_archived)
218
+        self.show_deleted = bool(show_deleted)
219
+        self.show_active = bool(show_active)
220
+        self.limit = limit
221
+        self.offset = offset
222
+        self.content_type = content_type
223
+
224
+
225
+class ActiveContentFilter(object):
226
+    def __init__(
227
+            self,
228
+            limit: int = None,
229
+            before_datetime: datetime = None,
230
+    ):
231
+        self.limit = limit
232
+        self.before_datetime = before_datetime
233
+
234
+
235
+class ContentIdsQuery(object):
236
+    def __init__(
237
+            self,
238
+            contents_ids: typing.List[int] = None,
239
+    ):
240
+        self.contents_ids = contents_ids
241
+
242
+
243
+class RoleUpdate(object):
244
+    """
245
+    Update role
246
+    """
247
+    def __init__(
248
+        self,
249
+        role: str,
250
+    ):
251
+        self.role = role
252
+
253
+
254
+class WorkspaceMemberInvitation(object):
255
+    """
256
+    Workspace Member Invitation
257
+    """
258
+    def __init__(
259
+        self,
260
+        user_id: int,
261
+        user_email_or_public_name: str,
262
+        role: str,
263
+    ):
264
+        self.role = role
265
+        self.user_email_or_public_name = user_email_or_public_name
266
+        self.user_id = user_id
267
+
268
+
269
+class WorkspaceUpdate(object):
270
+    """
271
+    Update workspace
272
+    """
273
+    def __init__(
274
+        self,
275
+        label: str,
276
+        description: str,
277
+    ):
278
+        self.label = label
279
+        self.description = description
280
+
281
+
282
+class ContentCreation(object):
283
+    """
284
+    Content creation model
285
+    """
286
+    def __init__(
287
+            self,
288
+            label: str,
289
+            content_type: str,
290
+            parent_id: typing.Optional[int] = None,
291
+    ) -> None:
292
+        self.label = label
293
+        self.content_type = content_type
294
+        self.parent_id = parent_id
295
+
296
+
297
+class CommentCreation(object):
298
+    """
299
+    Comment creation model
300
+    """
301
+    def __init__(
302
+            self,
303
+            raw_content: str,
304
+    ) -> None:
305
+        self.raw_content = raw_content
306
+
307
+
308
+class SetContentStatus(object):
309
+    """
310
+    Set content status
311
+    """
312
+    def __init__(
313
+            self,
314
+            status: str,
315
+    ) -> None:
316
+        self.status = status
317
+
318
+
319
+class TextBasedContentUpdate(object):
320
+    """
321
+    TextBasedContent update model
322
+    """
323
+    def __init__(
324
+            self,
325
+            label: str,
326
+            raw_content: str,
327
+    ) -> None:
328
+        self.label = label
329
+        self.raw_content = raw_content
330
+
331
+
332
+class TypeUser(Enum):
333
+    """Params used to find user"""
334
+    USER_ID = 'found_id'
335
+    EMAIL = 'found_email'
336
+    PUBLIC_NAME = 'found_public_name'
337
+
338
+
339
+class UserInContext(object):
340
+    """
341
+    Interface to get User data and User data related to context.
342
+    """
343
+
344
+    def __init__(self, user: User, dbsession: Session, config: CFG):
345
+        self.user = user
346
+        self.dbsession = dbsession
347
+        self.config = config
348
+
349
+    # Default
350
+
351
+    @property
352
+    def email(self) -> str:
353
+        return self.user.email
354
+
355
+    @property
356
+    def user_id(self) -> int:
357
+        return self.user.user_id
358
+
359
+    @property
360
+    def public_name(self) -> str:
361
+        return self.display_name
362
+
363
+    @property
364
+    def display_name(self) -> str:
365
+        return self.user.display_name
366
+
367
+    @property
368
+    def created(self) -> datetime:
369
+        return self.user.created
370
+
371
+    @property
372
+    def is_active(self) -> bool:
373
+        return self.user.is_active
374
+
375
+    @property
376
+    def timezone(self) -> str:
377
+        return self.user.timezone
378
+
379
+    @property
380
+    def profile(self) -> Profile:
381
+        return self.user.profile.name
382
+
383
+    # Context related
384
+
385
+    @property
386
+    def calendar_url(self) -> typing.Optional[str]:
387
+        # TODO - G-M - 20-04-2018 - [Calendar] Replace calendar code to get
388
+        # url calendar url.
389
+        #
390
+        # from tracim.lib.calendar import CalendarManager
391
+        # calendar_manager = CalendarManager(None)
392
+        # return calendar_manager.get_workspace_calendar_url(self.workspace_id)
393
+        return None
394
+
395
+    @property
396
+    def avatar_url(self) -> typing.Optional[str]:
397
+        # TODO - G-M - 20-04-2018 - [Avatar] Add user avatar feature
398
+        return None
399
+
400
+
401
+class WorkspaceInContext(object):
402
+    """
403
+    Interface to get Workspace data and Workspace data related to context.
404
+    """
405
+
406
+    def __init__(self, workspace: Workspace, dbsession: Session, config: CFG):
407
+        self.workspace = workspace
408
+        self.dbsession = dbsession
409
+        self.config = config
410
+
411
+    @property
412
+    def workspace_id(self) -> int:
413
+        """
414
+        numeric id of the workspace.
415
+        """
416
+        return self.workspace.workspace_id
417
+
418
+    @property
419
+    def id(self) -> int:
420
+        """
421
+        alias of workspace_id
422
+        """
423
+        return self.workspace_id
424
+
425
+    @property
426
+    def label(self) -> str:
427
+        """
428
+        get workspace label
429
+        """
430
+        return self.workspace.label
431
+
432
+    @property
433
+    def description(self) -> str:
434
+        """
435
+        get workspace description
436
+        """
437
+        return self.workspace.description
438
+
439
+    @property
440
+    def slug(self) -> str:
441
+        """
442
+        get workspace slug
443
+        """
444
+        return slugify(self.workspace.label)
445
+
446
+    @property
447
+    def sidebar_entries(self) -> typing.List[WorkspaceMenuEntry]:
448
+        """
449
+        get sidebar entries, those depends on activated apps.
450
+        """
451
+        # TODO - G.M - 22-05-2018 - Rework on this in
452
+        # order to not use hardcoded list
453
+        # list should be able to change (depending on activated/disabled
454
+        # apps)
455
+        return default_workspace_menu_entry(self.workspace)
456
+
457
+
458
+class UserRoleWorkspaceInContext(object):
459
+    """
460
+    Interface to get UserRoleInWorkspace data and related content
461
+
462
+    """
463
+    def __init__(
464
+            self,
465
+            user_role: UserRoleInWorkspace,
466
+            dbsession: Session,
467
+            config: CFG,
468
+            # Extended params
469
+            newly_created: bool = None,
470
+            email_sent: bool = None
471
+    )-> None:
472
+        self.user_role = user_role
473
+        self.dbsession = dbsession
474
+        self.config = config
475
+        # Extended params
476
+        self.newly_created = newly_created
477
+        self.email_sent = email_sent
478
+
479
+    @property
480
+    def user_id(self) -> int:
481
+        """
482
+        User who has the role has this id
483
+        :return: user id as integer
484
+        """
485
+        return self.user_role.user_id
486
+
487
+    @property
488
+    def workspace_id(self) -> int:
489
+        """
490
+        This role apply only on the workspace with this workspace_id
491
+        :return: workspace id as integer
492
+        """
493
+        return self.user_role.workspace_id
494
+
495
+    # TODO - G.M - 23-05-2018 - Check the API spec for this this !
496
+
497
+    @property
498
+    def role_id(self) -> int:
499
+        """
500
+        role as int id, each value refer to a different role.
501
+        """
502
+        return self.user_role.role
503
+
504
+    @property
505
+    def role(self) -> str:
506
+        return self.role_slug
507
+
508
+    @property
509
+    def role_slug(self) -> str:
510
+        """
511
+        simple name of the role of the user.
512
+        can be anything from UserRoleInWorkspace SLUG, like
513
+        'not_applicable', 'reader',
514
+        'contributor', 'content-manager', 'workspace-manager'
515
+        :return: user workspace role as slug.
516
+        """
517
+        return WorkspaceRoles.get_role_from_level(self.user_role.role).slug
518
+
519
+    @property
520
+    def is_active(self) -> bool:
521
+        return self.user.is_active
522
+
523
+    @property
524
+    def user(self) -> UserInContext:
525
+        """
526
+        User who has this role, with context data
527
+        :return: UserInContext object
528
+        """
529
+        return UserInContext(
530
+            self.user_role.user,
531
+            self.dbsession,
532
+            self.config
533
+        )
534
+
535
+    @property
536
+    def workspace(self) -> WorkspaceInContext:
537
+        """
538
+        Workspace related to this role, with his context data
539
+        :return: WorkspaceInContext object
540
+        """
541
+        return WorkspaceInContext(
542
+            self.user_role.workspace,
543
+            self.dbsession,
544
+            self.config
545
+        )
546
+
547
+
548
+class ContentInContext(object):
549
+    """
550
+    Interface to get Content data and Content data related to context.
551
+    """
552
+
553
+    def __init__(self, content: Content, dbsession: Session, config: CFG, user: User=None):  # nopep8
554
+        self.content = content
555
+        self.dbsession = dbsession
556
+        self.config = config
557
+        self._user = user
558
+
559
+    # Default
560
+    @property
561
+    def content_id(self) -> int:
562
+        return self.content.content_id
563
+
564
+    @property
565
+    def parent_id(self) -> int:
566
+        """
567
+        Return parent_id of the content
568
+        """
569
+        return self.content.parent_id
570
+
571
+    @property
572
+    def workspace_id(self) -> int:
573
+        return self.content.workspace_id
574
+
575
+    @property
576
+    def label(self) -> str:
577
+        return self.content.label
578
+
579
+    @property
580
+    def content_type(self) -> str:
581
+        content_type = ContentType(self.content.type)
582
+        return content_type.slug
583
+
584
+    @property
585
+    def sub_content_types(self) -> typing.List[str]:
586
+        return [_type.slug for _type in self.content.get_allowed_content_types()]  # nopep8
587
+
588
+    @property
589
+    def status(self) -> str:
590
+        return self.content.status
591
+
592
+    @property
593
+    def is_archived(self):
594
+        return self.content.is_archived
595
+
596
+    @property
597
+    def is_deleted(self):
598
+        return self.content.is_deleted
599
+
600
+    @property
601
+    def raw_content(self):
602
+        return self.content.description
603
+
604
+    @property
605
+    def author(self):
606
+        return UserInContext(
607
+            dbsession=self.dbsession,
608
+            config=self.config,
609
+            user=self.content.first_revision.owner
610
+        )
611
+
612
+    @property
613
+    def current_revision_id(self):
614
+        return self.content.revision_id
615
+
616
+    @property
617
+    def created(self):
618
+        return self.content.created
619
+
620
+    @property
621
+    def modified(self):
622
+        return self.updated
623
+
624
+    @property
625
+    def updated(self):
626
+        return self.content.updated
627
+
628
+    @property
629
+    def last_modifier(self):
630
+        return UserInContext(
631
+            dbsession=self.dbsession,
632
+            config=self.config,
633
+            user=self.content.last_revision.owner
634
+        )
635
+
636
+    # Context-related
637
+    @property
638
+    def show_in_ui(self):
639
+        # TODO - G.M - 31-05-2018 - Enable Show_in_ui params
640
+        # if false, then do not show content in the treeview.
641
+        # This may his maybe used for specific contents or for sub-contents.
642
+        # Default is True.
643
+        # In first version of the API, this field is always True
644
+        return True
645
+
646
+    @property
647
+    def slug(self):
648
+        return slugify(self.content.label)
649
+
650
+    @property
651
+    def read_by_user(self):
652
+        assert self._user
653
+        return not self.content.has_new_information_for(self._user)
654
+
655
+
656
+class RevisionInContext(object):
657
+    """
658
+    Interface to get Content data and Content data related to context.
659
+    """
660
+
661
+    def __init__(self, content_revision: ContentRevisionRO, dbsession: Session, config: CFG):
662
+        assert content_revision is not None
663
+        self.revision = content_revision
664
+        self.dbsession = dbsession
665
+        self.config = config
666
+
667
+    # Default
668
+    @property
669
+    def content_id(self) -> int:
670
+        return self.revision.content_id
671
+
672
+    @property
673
+    def parent_id(self) -> int:
674
+        """
675
+        Return parent_id of the content
676
+        """
677
+        return self.revision.parent_id
678
+
679
+    @property
680
+    def workspace_id(self) -> int:
681
+        return self.revision.workspace_id
682
+
683
+    @property
684
+    def label(self) -> str:
685
+        return self.revision.label
686
+
687
+    @property
688
+    def revision_type(self) -> str:
689
+        return self.revision.revision_type
690
+
691
+    @property
692
+    def content_type(self) -> str:
693
+        content_type = ContentType(self.revision.type)
694
+        if content_type:
695
+            return content_type.slug
696
+        else:
697
+            return None
698
+
699
+    @property
700
+    def sub_content_types(self) -> typing.List[str]:
701
+        return [_type.slug for _type
702
+                in self.revision.node.get_allowed_content_types()]
703
+
704
+    @property
705
+    def status(self) -> str:
706
+        return self.revision.status
707
+
708
+    @property
709
+    def is_archived(self) -> bool:
710
+        return self.revision.is_archived
711
+
712
+    @property
713
+    def is_deleted(self) -> bool:
714
+        return self.revision.is_deleted
715
+
716
+    @property
717
+    def raw_content(self) -> str:
718
+        return self.revision.description
719
+
720
+    @property
721
+    def author(self) -> UserInContext:
722
+        return UserInContext(
723
+            dbsession=self.dbsession,
724
+            config=self.config,
725
+            user=self.revision.owner
726
+        )
727
+
728
+    @property
729
+    def revision_id(self) -> int:
730
+        return self.revision.revision_id
731
+
732
+    @property
733
+    def created(self) -> datetime:
734
+        return self.updated
735
+
736
+    @property
737
+    def modified(self) -> datetime:
738
+        return self.updated
739
+
740
+    @property
741
+    def updated(self) -> datetime:
742
+        return self.revision.updated
743
+
744
+    @property
745
+    def next_revision(self) -> typing.Optional[ContentRevisionRO]:
746
+        """
747
+        Get next revision (later revision)
748
+        :return: next_revision
749
+        """
750
+        next_revision = None
751
+        revisions = self.revision.node.revisions
752
+        # INFO - G.M - 2018-06-177 - Get revisions more recent that
753
+        # current one
754
+        next_revisions = [
755
+            revision for revision in revisions
756
+            if revision.revision_id > self.revision.revision_id
757
+        ]
758
+        if next_revisions:
759
+            # INFO - G.M - 2018-06-177 -sort revisions by date
760
+            sorted_next_revisions = sorted(
761
+                next_revisions,
762
+                key=lambda revision: revision.updated
763
+            )
764
+            # INFO - G.M - 2018-06-177 - return only next revision
765
+            return sorted_next_revisions[0]
766
+        else:
767
+            return None
768
+
769
+    @property
770
+    def comment_ids(self) -> typing.List[int]:
771
+        """
772
+        Get list of ids of all current revision related comments
773
+        :return: list of comments ids
774
+        """
775
+        comments = self.revision.node.get_comments()
776
+        # INFO - G.M - 2018-06-177 - Get comments more recent than revision.
777
+        revision_comments = [
778
+            comment for comment in comments
779
+            if comment.created > self.revision.updated
780
+        ]
781
+        if self.next_revision:
782
+            # INFO - G.M - 2018-06-177 - if there is a revision more recent
783
+            # than current remove comments from theses rev (comments older
784
+            # than next_revision.)
785
+            revision_comments = [
786
+                comment for comment in revision_comments
787
+                if comment.created < self.next_revision.updated
788
+            ]
789
+        sorted_revision_comments = sorted(
790
+            revision_comments,
791
+            key=lambda revision: revision.created
792
+        )
793
+        comment_ids = []
794
+        for comment in sorted_revision_comments:
795
+            comment_ids.append(comment.content_id)
796
+        return comment_ids
797
+
798
+    # Context-related
799
+    @property
800
+    def show_in_ui(self) -> bool:
801
+        # TODO - G.M - 31-05-2018 - Enable Show_in_ui params
802
+        # if false, then do not show content in the treeview.
803
+        # This may his maybe used for specific contents or for sub-contents.
804
+        # Default is True.
805
+        # In first version of the API, this field is always True
806
+        return True
807
+
808
+    @property
809
+    def slug(self) -> str:
810
+        return slugify(self.revision.label)

File diff suppressed because it is too large
+ 1522 - 0
backend/tracim/models/data.py


+ 19 - 0
backend/tracim/models/meta.py View File

@@ -0,0 +1,19 @@
1
+# -*- coding: utf-8 -*-
2
+from sqlalchemy.ext.declarative import declarative_base
3
+from sqlalchemy.schema import MetaData
4
+
5
+# Recommended naming convention used by Alembic, as various different database
6
+# providers will autogenerate vastly different names making migrations more
7
+# difficult. See: http://alembic.zzzcomputing.com/en/latest/naming.html
8
+NAMING_CONVENTION = {
9
+    "ix": "ix_%(column_0_label)s",
10
+    "uq": "uq__%(table_name)s__%(column_0_name)s",  # Unique constrains
11
+    # TODO - G.M - 28-03-2018 - [Database] Convert database to allow naming convention
12
+    # for ck contraint.
13
+    # "ck": "ck_%(table_name)s_%(constraint_name)s",
14
+    "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
15
+    "pk": "pk_%(table_name)s"
16
+}
17
+
18
+metadata = MetaData(naming_convention=NAMING_CONVENTION)
19
+DeclarativeBase = declarative_base(metadata=metadata)

+ 54 - 0
backend/tracim/models/organisational.py View File

@@ -0,0 +1,54 @@
1
+# -*- coding: utf-8 -*-
2
+from tracim.models import User
3
+from tracim.models.data import UserRoleInWorkspace
4
+
5
+
6
+CALENDAR_PERMISSION_READ = 'r'
7
+CALENDAR_PERMISSION_WRITE = 'w'
8
+
9
+
10
+class Calendar(object):
11
+    def __init__(self, related_object, path):
12
+        self._related_object = related_object
13
+        self._path = path
14
+
15
+    @property
16
+    def related_object(self):
17
+        return self._related_object
18
+
19
+    def user_can_read(self, user: User) -> bool:
20
+        raise NotImplementedError()
21
+
22
+    def user_can_write(self, user: User) -> bool:
23
+        raise NotImplementedError()
24
+
25
+
26
+class UserCalendar(Calendar):
27
+    def user_can_write(self, user: User) -> bool:
28
+        return self._related_object.user_id == user.user_id
29
+
30
+    def user_can_read(self, user: User) -> bool:
31
+        return self._related_object.user_id == user.user_id
32
+
33
+
34
+class WorkspaceCalendar(Calendar):
35
+    _workspace_rights = {
36
+        UserRoleInWorkspace.NOT_APPLICABLE:
37
+            [],
38
+        UserRoleInWorkspace.READER:
39
+            [CALENDAR_PERMISSION_READ],
40
+        UserRoleInWorkspace.CONTRIBUTOR:
41
+            [CALENDAR_PERMISSION_READ, CALENDAR_PERMISSION_WRITE],
42
+        UserRoleInWorkspace.CONTENT_MANAGER:
43
+            [CALENDAR_PERMISSION_READ, CALENDAR_PERMISSION_WRITE],
44
+        UserRoleInWorkspace.WORKSPACE_MANAGER:
45
+            [CALENDAR_PERMISSION_READ, CALENDAR_PERMISSION_WRITE],
46
+    }
47
+
48
+    def user_can_write(self, user: User) -> bool:
49
+        role = user.get_role(self._related_object)
50
+        return CALENDAR_PERMISSION_WRITE in self._workspace_rights[role]
51
+
52
+    def user_can_read(self, user: User) -> bool:
53
+        role = user.get_role(self._related_object)
54
+        return CALENDAR_PERMISSION_READ in self._workspace_rights[role]

+ 97 - 0
backend/tracim/models/revision_protection.py View File

@@ -0,0 +1,97 @@
1
+# -*- coding: utf-8 -*-
2
+from sqlalchemy.orm import Session
3
+from sqlalchemy import inspect
4
+from sqlalchemy.orm.unitofwork import UOWTransaction
5
+from transaction import TransactionManager
6
+from contextlib import contextmanager
7
+
8
+from tracim.exceptions import ContentRevisionDeleteError
9
+from tracim.exceptions import ContentRevisionUpdateError
10
+from tracim.exceptions import SameValueError
11
+
12
+from tracim.models.data import ContentRevisionRO
13
+from tracim.models.data import Content
14
+from tracim.models.meta import DeclarativeBase
15
+
16
+
17
+def prevent_content_revision_delete(
18
+        session: Session,
19
+        flush_context: UOWTransaction,
20
+        instances: [DeclarativeBase]
21
+) -> None:
22
+    for instance in session.deleted:
23
+        if isinstance(instance, ContentRevisionRO) \
24
+                and instance.revision_id is not None:
25
+            raise ContentRevisionDeleteError(
26
+                "ContentRevision is not deletable. " +
27
+                "You must make a new revision with" +
28
+                "is_deleted set to True. Look at " +
29
+                "tracim.model.new_revision context " +
30
+                "manager to make a new revision"
31
+            )
32
+
33
+
34
+class RevisionsIntegrity(object):
35
+    """
36
+    Simple static used class to manage a list with list of ContentRevisionRO
37
+    who are allowed to be updated.
38
+
39
+    When modify an already existing (understood have an identity in databse)
40
+    ContentRevisionRO, if it's not in RevisionsIntegrity._updatable_revisions
41
+    list, a ContentRevisionUpdateError thrown.
42
+
43
+    This class is used by tracim.model.new_revision context manager.
44
+    """
45
+    _updatable_revisions = []
46
+
47
+    @classmethod
48
+    def add_to_updatable(cls, revision: 'ContentRevisionRO') -> None:
49
+        if inspect(revision).has_identity:
50
+            raise ContentRevisionUpdateError("ContentRevision is not updatable. %s already have identity." % revision)  # nopep8
51
+
52
+        if revision not in cls._updatable_revisions:
53
+            cls._updatable_revisions.append(revision)
54
+
55
+    @classmethod
56
+    def remove_from_updatable(cls, revision: 'ContentRevisionRO') -> None:
57
+        if revision in cls._updatable_revisions:
58
+            cls._updatable_revisions.remove(revision)
59
+
60
+    @classmethod
61
+    def is_updatable(cls, revision: 'ContentRevisionRO') -> bool:
62
+        return revision in cls._updatable_revisions
63
+
64
+
65
+@contextmanager
66
+def new_revision(
67
+        session: Session,
68
+        tm: TransactionManager,
69
+        content: Content,
70
+        force_create_new_revision: bool=False,
71
+) -> Content:
72
+    """
73
+    Prepare context to update a Content. It will add a new updatable revision
74
+    to the content.
75
+    :param session: Database _session
76
+    :param tm: TransactionManager
77
+    :param content: Content instance to update
78
+    :param force_create_new_revision: Decide if new_rev should or should not
79
+    be forced.
80
+    :return:
81
+    """
82
+    with session.no_autoflush:
83
+        try:
84
+            if force_create_new_revision \
85
+                    or inspect(content.revision).has_identity:
86
+                content.new_revision()
87
+            RevisionsIntegrity.add_to_updatable(content.revision)
88
+            yield content
89
+        except SameValueError or ValueError as e:
90
+            # INFO - 20-03-2018 - renew transaction when error happened
91
+            # This avoid bad _session data like new "temporary" revision
92
+            # to be add when problem happen.
93
+            tm.abort()
94
+            tm.begin()
95
+            raise e
96
+        finally:
97
+            RevisionsIntegrity.remove_from_updatable(content.revision)

+ 61 - 0
backend/tracim/models/roles.py View File

@@ -0,0 +1,61 @@
1
+import typing
2
+from enum import Enum
3
+
4
+from tracim.exceptions import RoleDoesNotExist
5
+
6
+
7
+class WorkspaceRoles(Enum):
8
+    """
9
+    Available role for workspace.
10
+    All roles should have a unique level and unique slug.
11
+    level is role value store in database and is also use for
12
+    permission check.
13
+    slug is for http endpoints and other place where readability is
14
+    needed.
15
+    """
16
+    NOT_APPLICABLE = (0, 'not-applicable')
17
+    READER = (1, 'reader')
18
+    CONTRIBUTOR = (2, 'contributor')
19
+    CONTENT_MANAGER = (4, 'content-manager')
20
+    WORKSPACE_MANAGER = (8, 'workspace-manager')
21
+
22
+    def __init__(self, level, slug):
23
+        self.level = level
24
+        self.slug = slug
25
+    
26
+    @property
27
+    def label(self):
28
+        """ Return valid label associated to role"""
29
+        # TODO - G.M - 2018-06-180 - Make this work correctly
30
+        return self.slug
31
+
32
+    @classmethod
33
+    def get_all_valid_role(cls) -> typing.List['WorkspaceRoles']:
34
+        """
35
+        Return all valid role value
36
+        """
37
+        return [item for item in list(WorkspaceRoles) if item.level > 0]
38
+
39
+    @classmethod
40
+    def get_role_from_level(cls, level: int) -> 'WorkspaceRoles':
41
+        """
42
+        Obtain Workspace role from a level value
43
+        :param level: level value as int
44
+        :return: correct workspace role related
45
+        """
46
+        roles = [item for item in list(WorkspaceRoles) if item.level == level]
47
+        if len(roles) != 1:
48
+            raise RoleDoesNotExist()
49
+        return roles[0]
50
+
51
+    @classmethod
52
+    def get_role_from_slug(cls, slug: str) -> 'WorkspaceRoles':
53
+        """
54
+        Obtain Workspace role from a slug value
55
+        :param slug: slug value as str
56
+        :return: correct workspace role related
57
+        """
58
+        roles = [item for item in list(WorkspaceRoles) if item.slug == slug]
59
+        if len(roles) != 1:
60
+            raise RoleDoesNotExist()
61
+        return roles[0]

+ 71 - 0
backend/tracim/models/workspace_menu_entries.py View File

@@ -0,0 +1,71 @@
1
+# coding=utf-8
2
+import typing
3
+from copy import copy
4
+
5
+from tracim.models.applications import applications
6
+from tracim.models.data import Workspace
7
+
8
+
9
+class WorkspaceMenuEntry(object):
10
+    """
11
+    Application class with data needed for frontend
12
+    """
13
+    def __init__(
14
+            self,
15
+            label: str,
16
+            slug: str,
17
+            fa_icon: str,
18
+            hexcolor: str,
19
+            route: str,
20
+    ) -> None:
21
+        self.slug = slug
22
+        self.label = label
23
+        self.route = route
24
+        self.hexcolor = hexcolor
25
+        self.fa_icon = fa_icon
26
+
27
+dashboard_menu_entry = WorkspaceMenuEntry(
28
+  slug='dashboard',
29
+  label='Dashboard',
30
+  route='/#/workspaces/{workspace_id}/dashboard',
31
+  hexcolor='#252525',
32
+  fa_icon="signal",
33
+)
34
+all_content_menu_entry = WorkspaceMenuEntry(
35
+  slug="contents/all",
36
+  label="All Contents",
37
+  route="/#/workspaces/{workspace_id}/contents",
38
+  hexcolor="#fdfdfd",
39
+  fa_icon="th",
40
+)
41
+
42
+# TODO - G.M - 08-06-2018 - This is hardcoded default menu entry,
43
+#  of app, make this dynamic (and loaded from application system)
44
+def default_workspace_menu_entry(
45
+    workspace: Workspace,
46
+)-> typing.List[WorkspaceMenuEntry]:
47
+    """
48
+    Get default menu entry for a workspace
49
+    """
50
+    menu_entries = [
51
+        copy(dashboard_menu_entry),
52
+        copy(all_content_menu_entry),
53
+    ]
54
+    for app in applications:
55
+        if app.main_route:
56
+            new_entry = WorkspaceMenuEntry(
57
+                slug=app.slug,
58
+                label=app.label,
59
+                hexcolor=app.hexcolor,
60
+                fa_icon=app.fa_icon,
61
+                route=app.main_route
62
+            )
63
+            menu_entries.append(new_entry)
64
+
65
+    for entry in menu_entries:
66
+        entry.route = entry.route.replace(
67
+            '{workspace_id}',
68
+            str(workspace.workspace_id)
69
+        )
70
+
71
+    return menu_entries

+ 0 - 0
backend/tracim/templates/mail/__init__.py View File


+ 73 - 0
backend/tracim/templates/mail/content_update_body_html.mak View File

@@ -0,0 +1,73 @@
1
+## -*- coding: utf-8 -*-
2
+<html>
3
+  <head>
4
+    <style>
5
+      a { color: #3465af;}
6
+      a.call-to-action {
7
+        background: #3465af;
8
+        padding: 3px 4px 5px 4px;
9
+        border: 1px solid #12438d;
10
+        font-weight: bold;
11
+        color: #FFF;
12
+        text-decoration: none;
13
+        margin-left: 5px;
14
+      }
15
+      a.call-to-action img { vertical-align: middle;}
16
+      th { vertical-align: top;}
17
+      
18
+      #content-intro-username { font-size: 1.5em; color: #666; font-weight: bold; }
19
+      #content-intro { margin: 0; border: 1em solid #DDD; border-width: 0 0 0 0em; padding: 1em 1em 1em 1em; }
20
+      #content-body { margin: 0em; border: 2em solid #DDD; border-width: 0 0 0 4em; padding: 0.5em 2em 1em 1em; }
21
+      #content-body-intro { font-size: 2em; color: #666; }
22
+      #content-body-only-title { font-size: 1.5em; }
23
+
24
+      #content-body ins { background-color: #AFA; }
25
+      #content-body del { background-color: #FAA; }
26
+
27
+
28
+      #call-to-action-button { background-color: #5CB85C; border: 1px solid #4CAE4C; color: #FFF; text-decoration: none; font-weight: bold; border-radius: 3px; font-size: 2em; padding: 4px 0.3em;}
29
+      #call-to-action-container { text-align: right; margin-top: 2em; }
30
+
31
+      #footer hr { border: 0px solid #CCC; border-top-width: 1px; width: 8em; max-width:25%; margin-left: 0;}
32
+      #footer { color: #999; margin: 4em auto auto 0.5em; }
33
+      #footer a { color: #999; }
34
+    </style>
35
+  </head>
36
+  <body style="font-family: Arial; font-size: 12px; max-width: 600px; margin: 0; padding: 0;">
37
+
38
+    <table style="width: 100%; cell-padding: 0; border-collapse: collapse; margin: 0">
39
+      <tr style="background-color: F5F5F5; border-bottom: 1px solid #CCC;" >
40
+        <td style="background-color: #666;">
41
+            <img alt="logo" src="${logo_url}" style="vertical-align: middle;">
42
+        </td>
43
+        <td style="padding: 0.5em; background-color: #666; text-align: left;">
44
+          <span style="font-size: 1.3em; color: #FFF; font-weight: bold;">
45
+            ${main_title}
46
+            &mdash;&nbsp;<span style="font-weight: bold; color: #999; font-weight: bold;">
47
+              ${status_label|n}
48
+              <img alt="status_icon" src="${status_icon_url}" style="vertical-align: middle;">
49
+            </span>
50
+        </td>
51
+      </tr>
52
+    </table>
53
+
54
+    <p id="content-intro">${content_intro|n}</p>
55
+    <div id="content-body">
56
+        <div>${content_text|n}</div>
57
+        <div href='' id="call-to-action-container">
58
+        </div>
59
+    </div>
60
+    
61
+    <div id="footer">
62
+        <p>
63
+
64
+            ${_('{user_display_name}, you receive this email because you are registered on <i>{website_title}</i> and you are <i>{user_role_label}</i> in the workspace <a href="{workspace_url}">{workspace_label}</a>.').format(user_display_name=user.display_name, user_role_label=role_label, workspace_url=workspace_url, workspace_label=workspace.label, website_title=config.WEBSITE_TITLE)|n}
65
+        </p>
66
+        <hr/>
67
+        <p>
68
+            ${_('This email was automatically sent by <i>Tracim</i>, a collaborative software developped by Algoo.')}<br/>
69
+            Algoo SAS &mdash; 340 Rue de l'Eygala, 38430 Moirans, France &mdash; <a style="text-decoration: none;" href="http://algoo.fr">www.algoo.fr</a>
70
+        </p>
71
+    </div>
72
+  </body>
73
+</html>

+ 31 - 0
backend/tracim/templates/mail/content_update_body_text.mak View File

@@ -0,0 +1,31 @@
1
+## -*- coding: utf-8 -*-
2
+
3
+Dear ${user.display_name},
4
+
5
+This email is intended to be read as HTML content.
6
+Please configure your email client to get the best of Tracim notifications.
7
+
8
+We understand that Email was originally intended to carry raw text only.
9
+And you probably understand on your own that we are a decades after email
10
+was created ;)
11
+
12
+Hope you'll switch your mail client configuration and enjoy Tracim :)
13
+
14
+
15
+--------------------------------------------------------------------------------
16
+
17
+
18
+You receive this email because you are registered on /${config.WEBSITE_TITLE}/
19
+and you are /${role_label}/ in the workspace /${workspace.label}/
20
+
21
+----
22
+
23
+This email was automatically sent by *Tracim*,
24
+a collaborative software developped by Algoo.
25
+
26
+**Algoo SAS**
27
+340 Rue de l'Eygala
28
+38430 Moirans
29
+France
30
+http://algoo.fr
31
+

+ 88 - 0
backend/tracim/templates/mail/created_account_body_html.mak View File

@@ -0,0 +1,88 @@
1
+## -*- coding: utf-8 -*-
2
+<html>
3
+  <head>
4
+    <style>
5
+      a { color: #3465af;}
6
+      a.call-to-action {
7
+        background: #3465af;
8
+        padding: 3px 4px 5px 4px;
9
+        border: 1px solid #12438d;
10
+        font-weight: bold;
11
+        color: #FFF;
12
+        text-decoration: none;
13
+        margin-left: 5px;
14
+      }
15
+      a.call-to-action img { vertical-align: middle;}
16
+      th { vertical-align: top;}
17
+      
18
+      #content-intro-username { font-size: 1.5em; color: #666; font-weight: bold; }
19
+      #content-intro { margin: 0; border: 1em solid #DDD; border-width: 0 0 0 0em; padding: 1em 1em 1em 1em; }
20
+      #content-body { margin: 0em; border: 2em solid #DDD; border-width: 0 0 0 4em; padding: 0.5em 2em 1em 1em; }
21
+      #content-body-intro { font-size: 2em; color: #666; }
22
+      #content-body-only-title { font-size: 1.5em; }
23
+
24
+      #content-body ins { background-color: #AFA; }
25
+      #content-body del { background-color: #FAA; }
26
+
27
+
28
+      #call-to-action-button { background-color: #5CB85C; border: 1px solid #4CAE4C; color: #FFF; text-decoration: none; font-weight: bold; border-radius: 3px; font-size: 2em; padding: 4px 0.3em;}
29
+      #call-to-action-container { text-align: right; margin-top: 2em; }
30
+
31
+      #footer hr { border: 0px solid #CCC; border-top-width: 1px; width: 8em; max-width:25%; margin-left: 0;}
32
+      #footer { color: #999; margin: 4em auto auto 0.5em; }
33
+      #footer a { color: #999; }
34
+    </style>
35
+  </head>
36
+  <body style="font-family: Arial; font-size: 12px; max-width: 600px; margin: 0; padding: 0;">
37
+
38
+    <table style="width: 100%; cell-padding: 0; border-collapse: collapse; margin: 0">
39
+      <tr style="background-color: F5F5F5; border-bottom: 1px solid #CCC;" >
40
+        <td style="background-color: #666;">
41
+            <img alt="logo" src="${logo_url}" style="vertical-align: middle;">
42
+        </td>
43
+        <td style="padding: 0.5em; background-color: #666; text-align: left;">
44
+          <span style="font-size: 1.3em; color: #FFF; font-weight: bold;">
45
+
46
+            ${config.WEBSITE_TITLE}: ${_('Created account')}
47
+
48
+          </span>
49
+        </td>
50
+      </tr>
51
+    </table>
52
+
53
+    <div id="content-body">
54
+        <div>
55
+            ${_('An administrator just create account for you on {website_title}'.format(
56
+                website_title=config.WEBSITE_TITLE
57
+            ))}
58
+
59
+            <ul>
60
+                <li>
61
+                    <b>${_('Login')}</b>: ${user.email}
62
+                </li>
63
+                <li>
64
+                    <b>${_('Password')}</b>: ${password}
65
+                </li>
66
+            </ul>
67
+
68
+        </div>
69
+        <div id="call-to-action-container">
70
+
71
+            ${_('To go to {website_title}, please click on following link'.format(
72
+                website_title=config.WEBSITE_TITLE
73
+            ))}
74
+
75
+            <span style="">
76
+                <a href="${login_url}" id='call-to-action-button'>${login_url}</a>
77
+            </span>
78
+        </div>
79
+    </div>
80
+    
81
+    <div id="footer">
82
+        <p>
83
+            ${_('This email was sent by <i>Tracim</i>, a collaborative software developped by Algoo.')}<br/>
84
+            Algoo SAS &mdash; 340 Rue de l'Eygala, 38430 Moirans, France &mdash; <a style="text-decoration: none;" href="http://algoo.fr">www.algoo.fr</a>
85
+        </p>
86
+    </div>
87
+  </body>
88
+</html>

+ 25 - 0
backend/tracim/templates/mail/created_account_body_text.mak View File

@@ -0,0 +1,25 @@
1
+## -*- coding: utf-8 -*-
2
+
3
+${_('An administrator just create account for you on {website_title}'.format(
4
+    website_title=config.WEBSITE_TITLE
5
+))}
6
+
7
+* ${_('Login')}: ${user.email}
8
+* ${_('Password')}: ${password}
9
+
10
+${_('To go to {website_title}, please click on following link'.format(
11
+    website_title=config.WEBSITE_TITLE
12
+))}
13
+
14
+${login_url}
15
+
16
+--------------------------------------------------------------------------------
17
+
18
+This email was sent by *Tracim*,
19
+a collaborative software developped by Algoo.
20
+
21
+**Algoo SAS**
22
+340 Rue de l'Eygala
23
+38430 Moirans
24
+France
25
+http://algoo.fr

+ 293 - 0
backend/tracim/tests/__init__.py View File

@@ -0,0 +1,293 @@
1
+# -*- coding: utf-8 -*-
2
+import unittest
3
+
4
+import plaster
5
+import requests
6
+import transaction
7
+from depot.manager import DepotManager
8
+from pyramid import testing
9
+from sqlalchemy.exc import IntegrityError
10
+
11
+from tracim.lib.core.content import ContentApi
12
+from tracim.lib.core.workspace import WorkspaceApi
13
+from tracim.models import get_engine
14
+from tracim.models import DeclarativeBase
15
+from tracim.models import get_session_factory
16
+from tracim.models import get_tm_session
17
+from tracim.models.data import Workspace
18
+from tracim.models.data import ContentType
19
+from tracim.models.data import ContentRevisionRO
20
+from tracim.models.data import Content
21
+from tracim.lib.utils.logger import logger
22
+from tracim.fixtures import FixturesLoader
23
+from tracim.fixtures.users_and_groups import Base as BaseFixture
24
+from tracim.config import CFG
25
+from tracim.extensions import hapic
26
+from tracim import web
27
+from webtest import TestApp
28
+from io import BytesIO
29
+from PIL import Image
30
+
31
+
32
+def eq_(a, b, msg=None):
33
+    # TODO - G.M - 05-04-2018 - Remove this when all old nose code is removed
34
+    assert a == b, msg or "%r != %r" % (a, b)
35
+
36
+# TODO - G.M - 2018-06-179 - Refactor slug change function
37
+#  as a kind of pytest fixture ?
38
+
39
+
40
+def set_html_document_slug_to_legacy(session_factory) -> None:
41
+    """
42
+    Simple function to help some functional test. This modify "html-documents"
43
+    type content in database to legacy "page" slug.
44
+    :param session_factory: session factory of the test
45
+    :return: Nothing.
46
+    """
47
+    dbsession = get_tm_session(
48
+        session_factory,
49
+        transaction.manager
50
+    )
51
+    content_query = dbsession.query(ContentRevisionRO).filter(ContentRevisionRO.type == 'page').filter(ContentRevisionRO.content_id == 6)  # nopep8
52
+    assert content_query.count() == 0
53
+    html_documents_query = dbsession.query(ContentRevisionRO).filter(ContentRevisionRO.type == 'html-documents')  # nopep8
54
+    html_documents_query.update({ContentRevisionRO.type: 'page'})
55
+    transaction.commit()
56
+    assert content_query.count() > 0
57
+
58
+
59
+def create_1000px_png_test_image():
60
+    file = BytesIO()
61
+    image = Image.new('RGBA', size=(1000, 1000), color=(0, 0, 0))
62
+    image.save(file, 'png')
63
+    file.name = 'test_image.png'
64
+    file.seek(0)
65
+    return file
66
+
67
+
68
+class FunctionalTest(unittest.TestCase):
69
+
70
+    fixtures = [BaseFixture]
71
+    sqlalchemy_url = 'sqlite:///tracim_test.sqlite'
72
+
73
+    def setUp(self):
74
+        logger._logger.setLevel('WARNING')
75
+        DepotManager._clear()
76
+        self.settings = {
77
+            'sqlalchemy.url': self.sqlalchemy_url,
78
+            'user.auth_token.validity': '604800',
79
+            'depot_storage_dir': '/tmp/test/depot',
80
+            'depot_storage_name': 'test',
81
+            'preview_cache_dir': '/tmp/test/preview_cache',
82
+            'preview.jpg.restricted_dims': True,
83
+            'email.notification.activated': 'false',
84
+        }
85
+        hapic.reset_context()
86
+        self.engine = get_engine(self.settings)
87
+        DeclarativeBase.metadata.create_all(self.engine)
88
+        self.session_factory = get_session_factory(self.engine)
89
+        self.app_config = CFG(self.settings)
90
+        self.app_config.configure_filedepot()
91
+        self.init_database(self.settings)
92
+        DepotManager._clear()
93
+        self.run_app()
94
+
95
+    def run_app(self):
96
+        app = web({}, **self.settings)
97
+        self.testapp = TestApp(app)
98
+
99
+    def init_database(self, settings):
100
+        with transaction.manager:
101
+            dbsession = get_tm_session(self.session_factory, transaction.manager)
102
+            try:
103
+                fixtures_loader = FixturesLoader(dbsession, self.app_config)
104
+                fixtures_loader.loads(self.fixtures)
105
+                transaction.commit()
106
+                print("Database initialized.")
107
+            except IntegrityError:
108
+                print('Warning, there was a problem when adding default data'
109
+                      ', it may have already been added:')
110
+                import traceback
111
+                print(traceback.format_exc())
112
+                transaction.abort()
113
+                print('Database initialization failed')
114
+
115
+    def tearDown(self):
116
+        logger.debug(self, 'TearDown Test...')
117
+        from tracim.models.meta import DeclarativeBase
118
+
119
+        testing.tearDown()
120
+        transaction.abort()
121
+        DeclarativeBase.metadata.drop_all(self.engine)
122
+        DepotManager._clear()
123
+
124
+
125
+class FunctionalTestEmptyDB(FunctionalTest):
126
+    fixtures = []
127
+
128
+
129
+class FunctionalTestNoDB(FunctionalTest):
130
+    sqlalchemy_url = 'sqlite://'
131
+
132
+    def init_database(self, settings):
133
+        self.engine = get_engine(settings)
134
+
135
+
136
+class CommandFunctionalTest(FunctionalTest):
137
+
138
+    def run_app(self):
139
+        self.session = get_tm_session(self.session_factory, transaction.manager)
140
+
141
+
142
+class BaseTest(unittest.TestCase):
143
+    """
144
+    Pyramid default test.
145
+    """
146
+
147
+    config_uri = 'tests_configs.ini'
148
+    config_section = 'base_test'
149
+
150
+    def setUp(self):
151
+        logger._logger.setLevel('WARNING')
152
+        logger.debug(self, 'Setup Test...')
153
+        self.settings = plaster.get_settings(
154
+            self.config_uri,
155
+            self.config_section
156
+        )
157
+        self.config = testing.setUp(settings = self.settings)
158
+        self.config.include('tracim.models')
159
+        DepotManager._clear()
160
+        DepotManager.configure(
161
+            'test', {'depot.backend': 'depot.io.memory.MemoryFileStorage'}
162
+        )
163
+        settings = self.config.get_settings()
164
+        self.app_config = CFG(settings)
165
+        from tracim.models import (
166
+            get_engine,
167
+            get_session_factory,
168
+            get_tm_session,
169
+        )
170
+
171
+        self.engine = get_engine(settings)
172
+        session_factory = get_session_factory(self.engine)
173
+
174
+        self.session = get_tm_session(session_factory, transaction.manager)
175
+        self.init_database()
176
+
177
+    def init_database(self):
178
+        logger.debug(self, 'Init Database Schema...')
179
+        from tracim.models.meta import DeclarativeBase
180
+        DeclarativeBase.metadata.create_all(self.engine)
181
+
182
+    def tearDown(self):
183
+        logger.debug(self, 'TearDown Test...')
184
+        from tracim.models.meta import DeclarativeBase
185
+
186
+        testing.tearDown()
187
+        transaction.abort()
188
+        DeclarativeBase.metadata.drop_all(self.engine)
189
+
190
+
191
+class StandardTest(BaseTest):
192
+    """
193
+    BaseTest with default fixtures
194
+    """
195
+    fixtures = [BaseFixture]
196
+
197
+    def init_database(self):
198
+        BaseTest.init_database(self)
199
+        fixtures_loader = FixturesLoader(
200
+            session=self.session,
201
+            config=CFG(self.config.get_settings()))
202
+        fixtures_loader.loads(self.fixtures)
203
+
204
+
205
+class DefaultTest(StandardTest):
206
+
207
+    def _create_workspace_and_test(self, name, user) -> Workspace:
208
+        """
209
+        All extra parameters (*args, **kwargs) are for Workspace init
210
+        :return: Created workspace instance
211
+        """
212
+        WorkspaceApi(
213
+            current_user=user,
214
+            session=self.session,
215
+            config=self.app_config,
216
+        ).create_workspace(name, save_now=True)
217
+
218
+        eq_(
219
+            1,
220
+            self.session.query(Workspace).filter(
221
+                Workspace.label == name
222
+            ).count()
223
+        )
224
+        return self.session.query(Workspace).filter(
225
+            Workspace.label == name
226
+        ).one()
227
+
228
+    def _create_content_and_test(
229
+            self,
230
+            name,
231
+            workspace,
232
+            *args,
233
+            **kwargs
234
+    ) -> Content:
235
+        """
236
+        All extra parameters (*args, **kwargs) are for Content init
237
+        :return: Created Content instance
238
+        """
239
+        content = Content(*args, **kwargs)
240
+        content.label = name
241
+        content.workspace = workspace
242
+        self.session.add(content)
243
+        self.session.flush()
244
+
245
+        content_api = ContentApi(
246
+            current_user=None,
247
+            session=self.session,
248
+            config=self.app_config,
249
+        )
250
+        eq_(
251
+            1,
252
+            content_api.get_canonical_query().filter(
253
+                Content.label == name
254
+            ).count()
255
+        )
256
+        return content_api.get_canonical_query().filter(
257
+            Content.label == name
258
+        ).one()
259
+
260
+    def _create_thread_and_test(self,
261
+                                user,
262
+                                workspace_name='workspace_1',
263
+                                folder_name='folder_1',
264
+                                thread_name='thread_1') -> Content:
265
+        """
266
+        :return: Thread
267
+        """
268
+        workspace = self._create_workspace_and_test(workspace_name, user)
269
+        folder = self._create_content_and_test(
270
+            folder_name, workspace,
271
+            type=ContentType.Folder,
272
+            owner=user
273
+        )
274
+        thread = self._create_content_and_test(
275
+            thread_name,
276
+            workspace,
277
+            type=ContentType.Thread,
278
+            parent=folder,
279
+            owner=user
280
+        )
281
+        return thread
282
+
283
+
284
+class MailHogTest(DefaultTest):
285
+    """
286
+    Theses test need a working mailhog
287
+    """
288
+
289
+    config_section = 'mail_test'
290
+
291
+    def tearDown(self):
292
+        logger.debug(self, 'Cleanup MailHog list...')
293
+        requests.delete('http://127.0.0.1:8025/api/v1/messages')

+ 1 - 0
backend/tracim/tests/commands/__init__.py View File

@@ -0,0 +1 @@
1
+# coding=utf-8

+ 200 - 0
backend/tracim/tests/commands/test_commands.py View File

@@ -0,0 +1,200 @@
1
+# -*- coding: utf-8 -*-
2
+import os
3
+import subprocess
4
+import pytest
5
+import tracim
6
+from tracim.command import TracimCLI
7
+from tracim.exceptions import UserAlreadyExistError
8
+from tracim.exceptions import BadCommandError
9
+from tracim.exceptions import GroupDoesNotExist
10
+from tracim.exceptions import UserDoesNotExist
11
+from tracim.lib.core.user import UserApi
12
+from tracim.tests import CommandFunctionalTest
13
+
14
+
15
+class TestCommands(CommandFunctionalTest):
16
+    """
17
+    Test tracimcli command line ui.
18
+    """
19
+
20
+    config_section = 'app:command_test'
21
+
22
+    def test_func__check_commands_list__ok__nominal_case(self) -> None:
23
+        """
24
+        Test listing of tracimcli command: Tracim commands must be listed
25
+        :return:
26
+        """
27
+        os.chdir(os.path.dirname(tracim.__file__) + '/../')
28
+
29
+        output = subprocess.check_output(["tracimcli", "-h"])
30
+        output = output.decode('utf-8')
31
+
32
+        assert output.find('user create') > 0
33
+        assert output.find('user update') > 0
34
+        assert output.find('db init') > 0
35
+        assert output.find('db delete') > 0
36
+        assert output.find('webdav start') > 0
37
+
38
+    def test_func__user_create_command__ok__nominal_case(self) -> None:
39
+        """
40
+        Test User creation
41
+        """
42
+        api = UserApi(
43
+            current_user=None,
44
+            session=self.session,
45
+            config=self.app_config,
46
+        )
47
+        with pytest.raises(UserDoesNotExist):
48
+            api.get_one_by_email('command_test@user')
49
+        app = TracimCLI()
50
+        result = app.run([
51
+            'user', 'create',
52
+            '-c', 'tests_configs.ini#command_test',
53
+            '-l', 'command_test@user',
54
+            '-p', 'new_password',
55
+            '--debug',
56
+        ])
57
+        new_user = api.get_one_by_email('command_test@user')
58
+        assert new_user.email == 'command_test@user'
59
+        assert new_user.validate_password('new_password')
60
+        assert new_user.profile.name == 'users'
61
+
62
+    def test_func__user_create_command__ok__in_admin_group(self) -> None:
63
+        """
64
+        Test User creation with admin as group
65
+        """
66
+        api = UserApi(
67
+            current_user=None,
68
+            session=self.session,
69
+            config=self.app_config,
70
+        )
71
+        with pytest.raises(UserDoesNotExist):
72
+            api.get_one_by_email('command_test@user')
73
+        app = TracimCLI()
74
+        result = app.run([
75
+            'user', 'create',
76
+            '-c', 'tests_configs.ini#command_test',
77
+            '-l', 'command_test@user',
78
+            '-p', 'new_password',
79
+            '-g', 'administrators',
80
+            '--debug',
81
+        ])
82
+        new_user = api.get_one_by_email('command_test@user')
83
+        assert new_user.email == 'command_test@user'
84
+        assert new_user.validate_password('new_password')
85
+        assert new_user.profile.name == 'administrators'
86
+
87
+    def test_func__user_create_command__err__in_unknown_group(self) -> None:
88
+        """
89
+        Test User creation with an unknown group
90
+        """
91
+        api = UserApi(
92
+            current_user=None,
93
+            session=self.session,
94
+            config=self.app_config,
95
+        )
96
+        app = TracimCLI()
97
+        with pytest.raises(GroupDoesNotExist):
98
+            result = app.run([
99
+                'user', 'create',
100
+                '-c', 'tests_configs.ini#command_test',
101
+                '-l', 'command_test@user',
102
+                '-p', 'new_password',
103
+                '-g', 'unknown',
104
+                '--debug',
105
+            ])
106
+
107
+    def test_func__user_create_command__err_user_already_exist(self) -> None:
108
+        """
109
+        Test User creation with existing user login
110
+        """
111
+        api = UserApi(
112
+            current_user=None,
113
+            session=self.session,
114
+            config=self.app_config,
115
+        )
116
+        app = TracimCLI()
117
+        with pytest.raises(UserAlreadyExistError):
118
+            result = app.run([
119
+                '--debug',
120
+                'user', 'create',
121
+                '-c', 'tests_configs.ini#command_test',
122
+                '-l', 'admin@admin.admin',
123
+                '-p', 'new_password',
124
+                '--debug',
125
+            ])
126
+
127
+    def test_func__user_create_command__err__password_required(self) -> None:
128
+        """
129
+        Test User creation without filling password
130
+        """
131
+        api = UserApi(
132
+            current_user=None,
133
+            session=self.session,
134
+            config=self.app_config,
135
+        )
136
+        app = TracimCLI()
137
+        with pytest.raises(BadCommandError):
138
+            result = app.run([
139
+                '--debug',
140
+                'user', 'create',
141
+                '-c', 'tests_configs.ini#command_test',
142
+                '-l', 'admin@admin.admin',
143
+                '--debug',
144
+            ])
145
+
146
+    def test_func__user_update_command__ok__nominal_case(self) -> None:
147
+        """
148
+        Test user password update
149
+        """
150
+        api = UserApi(
151
+            current_user=None,
152
+            session=self.session,
153
+            config=self.app_config,
154
+        )
155
+        user = api.get_one_by_email('admin@admin.admin')
156
+        assert user.email == 'admin@admin.admin'
157
+        assert user.validate_password('admin@admin.admin')
158
+        assert not user.validate_password('new_password')
159
+
160
+        app = TracimCLI()
161
+        result = app.run([
162
+            'user', 'update',
163
+            '-c', 'tests_configs.ini#command_test',
164
+            '-l', 'admin@admin.admin',
165
+            '-p', 'new_password',
166
+            '--debug',
167
+        ])
168
+        new_user = api.get_one_by_email('admin@admin.admin')
169
+        assert new_user.email == 'admin@admin.admin'
170
+        assert new_user.validate_password('new_password')
171
+        assert not new_user.validate_password('admin@admin.admin')
172
+
173
+    def test_func__user_update_command__ok__remove_group(self) -> None:
174
+        """
175
+        Test user password update
176
+        """
177
+        api = UserApi(
178
+            current_user=None,
179
+            session=self.session,
180
+            config=self.app_config,
181
+        )
182
+        user = api.get_one_by_email('admin@admin.admin')
183
+        assert user.email == 'admin@admin.admin'
184
+        assert user.validate_password('admin@admin.admin')
185
+        assert not user.validate_password('new_password')
186
+        assert user.profile.name == 'administrators'
187
+        app = TracimCLI()
188
+        result = app.run([
189
+            'user', 'update',
190
+            '-c', 'tests_configs.ini#command_test',
191
+            '-l', 'admin@admin.admin',
192
+            '-p', 'new_password',
193
+            '-rmg', 'administrators',
194
+            '--debug',
195
+        ])
196
+        new_user = api.get_one_by_email('admin@admin.admin')
197
+        assert new_user.email == 'admin@admin.admin'
198
+        assert new_user.validate_password('new_password')
199
+        assert not new_user.validate_password('admin@admin.admin')
200
+        assert new_user.profile.name == 'managers'

+ 0 - 0
backend/tracim/tests/functional/__init__.py View File


+ 304 - 0
backend/tracim/tests/functional/test_comments.py View File

@@ -0,0 +1,304 @@
1
+# -*- coding: utf-8 -*-
2
+from tracim.tests import FunctionalTest
3
+from tracim.fixtures.content import Content as ContentFixtures
4
+from tracim.fixtures.users_and_groups import Base as BaseFixture
5
+
6
+
7
+class TestCommentsEndpoint(FunctionalTest):
8
+    """
9
+    Tests for /api/v2/workspaces/{workspace_id}/contents/{content_id}/comments
10
+    endpoint
11
+    """
12
+
13
+    fixtures = [BaseFixture, ContentFixtures]
14
+
15
+    def test_api__get_contents_comments__ok_200__nominal_case(self) -> None:
16
+        """
17
+        Get alls comments of a content
18
+        """
19
+        self.testapp.authorization = (
20
+            'Basic',
21
+            (
22
+                'admin@admin.admin',
23
+                'admin@admin.admin'
24
+            )
25
+        )
26
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)   # nopep8
27
+        assert len(res.json_body) == 3
28
+        comment = res.json_body[0]
29
+        assert comment['content_id'] == 18
30
+        assert comment['parent_id'] == 7
31
+        assert comment['raw_content'] == '<p>What is for you the best cake ever? </br> I personnally vote for Chocolate cupcake!</p>'  # nopep8
32
+        assert comment['author']
33
+        assert comment['author']['user_id'] == 1
34
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
35
+        assert comment['author']['avatar_url'] == None
36
+        assert comment['author']['public_name'] == 'Global manager'
37
+
38
+        comment = res.json_body[1]
39
+        assert comment['content_id'] == 19
40
+        assert comment['parent_id'] == 7
41
+        assert comment['raw_content'] == '<p>What about Apple Pie? There are Awesome!</p>'  # nopep8
42
+        assert comment['author']
43
+        assert comment['author']['user_id'] == 3
44
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
45
+        assert comment['author']['avatar_url'] == None
46
+        assert comment['author']['public_name'] == 'Bob i.'
47
+        # TODO - G.M - 2018-06-179 - better check for datetime
48
+        assert comment['created']
49
+
50
+        comment = res.json_body[2]
51
+        assert comment['content_id'] == 20
52
+        assert comment['parent_id'] == 7
53
+        assert comment['raw_content'] == '<p>You are right, but Kouign-amann are clearly better.</p>'  # nopep8
54
+        assert comment['author']
55
+        assert comment['author']['user_id'] == 4
56
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
57
+        assert comment['author']['avatar_url'] == None
58
+        assert comment['author']['public_name'] == 'John Reader'
59
+        # TODO - G.M - 2018-06-179 - better check for datetime
60
+        assert comment['created']
61
+
62
+    def test_api__post_content_comment__ok_200__nominal_case(self) -> None:
63
+        """
64
+        Get alls comments of a content
65
+        """
66
+        self.testapp.authorization = (
67
+            'Basic',
68
+            (
69
+                'admin@admin.admin',
70
+                'admin@admin.admin'
71
+            )
72
+        )
73
+        params = {
74
+            'raw_content': 'I strongly disagree, Tiramisu win!'
75
+        }
76
+        res = self.testapp.post_json(
77
+            '/api/v2/workspaces/2/contents/7/comments',
78
+            params=params,
79
+            status=200
80
+        )
81
+        comment = res.json_body
82
+        assert comment['content_id']
83
+        assert comment['parent_id'] == 7
84
+        assert comment['raw_content'] == 'I strongly disagree, Tiramisu win!'
85
+        assert comment['author']
86
+        assert comment['author']['user_id'] == 1
87
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
88
+        assert comment['author']['avatar_url'] is None
89
+        assert comment['author']['public_name'] == 'Global manager'
90
+        # TODO - G.M - 2018-06-179 - better check for datetime
91
+        assert comment['created']
92
+
93
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)  # nopep8
94
+        assert len(res.json_body) == 4
95
+        assert comment == res.json_body[3]
96
+
97
+    def test_api__post_content_comment__err_400__empty_raw_content(self) -> None:
98
+        """
99
+        Get alls comments of a content
100
+        """
101
+        self.testapp.authorization = (
102
+            'Basic',
103
+            (
104
+                'admin@admin.admin',
105
+                'admin@admin.admin'
106
+            )
107
+        )
108
+        params = {
109
+            'raw_content': ''
110
+        }
111
+        res = self.testapp.post_json(
112
+            '/api/v2/workspaces/2/contents/7/comments',
113
+            params=params,
114
+            status=400
115
+        )
116
+
117
+    def test_api__delete_content_comment__ok_200__user_is_owner_and_workspace_manager(self) -> None:  # nopep8
118
+        """
119
+        delete comment (user is workspace_manager and owner)
120
+        """
121
+        self.testapp.authorization = (
122
+            'Basic',
123
+            (
124
+                'admin@admin.admin',
125
+                'admin@admin.admin'
126
+            )
127
+        )
128
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)
129
+        assert len(res.json_body) == 3
130
+        comment = res.json_body[0]
131
+        assert comment['content_id'] == 18
132
+        assert comment['parent_id'] == 7
133
+        assert comment['raw_content'] == '<p>What is for you the best cake ever? </br> I personnally vote for Chocolate cupcake!</p>'   # nopep8
134
+        assert comment['author']
135
+        assert comment['author']['user_id'] == 1
136
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
137
+        assert comment['author']['avatar_url'] is None
138
+        assert comment['author']['public_name'] == 'Global manager'
139
+        # TODO - G.M - 2018-06-179 - better check for datetime
140
+        assert comment['created']
141
+
142
+        res = self.testapp.delete(
143
+            '/api/v2/workspaces/2/contents/7/comments/18',
144
+            status=204
145
+        )
146
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)
147
+        assert len(res.json_body) == 2
148
+        assert not [content for content in res.json_body if content['content_id'] == 18]  # nopep8
149
+
150
+    def test_api__delete_content_comment__ok_200__user_is_workspace_manager(self) -> None:  # nopep8
151
+        """
152
+        delete comment (user is workspace_manager)
153
+        """
154
+        self.testapp.authorization = (
155
+            'Basic',
156
+            (
157
+                'admin@admin.admin',
158
+                'admin@admin.admin'
159
+            )
160
+        )
161
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)
162
+        assert len(res.json_body) == 3
163
+        comment = res.json_body[1]
164
+        assert comment['content_id'] == 19
165
+        assert comment['parent_id'] == 7
166
+        assert comment['raw_content'] == '<p>What about Apple Pie? There are Awesome!</p>'   # nopep8
167
+        assert comment['author']
168
+        assert comment['author']['user_id'] == 3
169
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
170
+        assert comment['author']['avatar_url'] is None
171
+        assert comment['author']['public_name'] == 'Bob i.'
172
+        # TODO - G.M - 2018-06-179 - better check for datetime
173
+        assert comment['created']
174
+
175
+        res = self.testapp.delete(
176
+            '/api/v2/workspaces/2/contents/7/comments/19',
177
+            status=204
178
+        )
179
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)
180
+        assert len(res.json_body) == 2
181
+        assert not [content for content in res.json_body if content['content_id'] == 19]  # nopep8
182
+
183
+    def test_api__delete_content_comment__ok_200__user_is_owner_and_content_manager(self) -> None:   # nopep8
184
+        """
185
+        delete comment (user is content-manager and owner)
186
+        """
187
+        self.testapp.authorization = (
188
+            'Basic',
189
+            (
190
+                'admin@admin.admin',
191
+                'admin@admin.admin'
192
+            )
193
+        )
194
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)
195
+        assert len(res.json_body) == 3
196
+        comment = res.json_body[1]
197
+        assert comment['content_id'] == 19
198
+        assert comment['parent_id'] == 7
199
+        assert comment['raw_content'] == '<p>What about Apple Pie? There are Awesome!</p>'   # nopep8
200
+        assert comment['author']
201
+        assert comment['author']['user_id'] == 3
202
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
203
+        assert comment['author']['avatar_url'] is None
204
+        assert comment['author']['public_name'] == 'Bob i.'
205
+        # TODO - G.M - 2018-06-179 - better check for datetime
206
+        assert comment['created']
207
+
208
+        res = self.testapp.delete(
209
+            '/api/v2/workspaces/2/contents/7/comments/19',
210
+            status=204
211
+        )
212
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)
213
+        assert len(res.json_body) == 2
214
+        assert not [content for content in res.json_body if content['content_id'] == 19]  # nopep8
215
+
216
+    def test_api__delete_content_comment__err_403__user_is_content_manager(self) -> None:  # nopep8
217
+        """
218
+        delete comment (user is content-manager)
219
+        """
220
+        self.testapp.authorization = (
221
+            'Basic',
222
+            (
223
+                'bob@fsf.local',
224
+                'foobarbaz'
225
+            )
226
+        )
227
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)  #  nopep8
228
+        assert len(res.json_body) == 3
229
+        comment = res.json_body[2]
230
+        assert comment['content_id'] == 20
231
+        assert comment['parent_id'] == 7
232
+        assert comment['raw_content'] == '<p>You are right, but Kouign-amann are clearly better.</p>'   # nopep8
233
+        assert comment['author']
234
+        assert comment['author']['user_id'] == 4
235
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
236
+        assert comment['author']['avatar_url'] is None
237
+        assert comment['author']['public_name'] == 'John Reader'
238
+        # TODO - G.M - 2018-06-179 - better check for datetime
239
+        assert comment['created']
240
+
241
+        res = self.testapp.delete(
242
+            '/api/v2/workspaces/2/contents/7/comments/20',
243
+            status=403
244
+        )
245
+
246
+    def test_api__delete_content_comment__err_403__user_is_owner_and_reader(self) -> None:
247
+        """
248
+        delete comment (user is reader and owner)
249
+        """
250
+        self.testapp.authorization = (
251
+            'Basic',
252
+            (
253
+                'bob@fsf.local',
254
+                'foobarbaz'
255
+            )
256
+        )
257
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)
258
+        assert len(res.json_body) == 3
259
+        comment = res.json_body[2]
260
+        assert comment['content_id'] == 20
261
+        assert comment['parent_id'] == 7
262
+        assert comment['raw_content'] == '<p>You are right, but Kouign-amann are clearly better.</p>'   # nopep8
263
+        assert comment['author']
264
+        assert comment['author']['user_id'] == 4
265
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
266
+        assert comment['author']['avatar_url'] is None
267
+        assert comment['author']['public_name'] == 'John Reader'
268
+        # TODO - G.M - 2018-06-179 - better check for datetime
269
+        assert comment['created']
270
+
271
+        res = self.testapp.delete(
272
+            '/api/v2/workspaces/2/contents/7/comments/20',
273
+            status=403
274
+        )
275
+
276
+    def test_api__delete_content_comment__err_403__user_is_reader(self) -> None:
277
+        """
278
+        delete comment (user is reader)
279
+        """
280
+        self.testapp.authorization = (
281
+            'Basic',
282
+            (
283
+                'bob@fsf.local',
284
+                'foobarbaz'
285
+            )
286
+        )
287
+        res = self.testapp.get('/api/v2/workspaces/2/contents/7/comments', status=200)
288
+        assert len(res.json_body) == 3
289
+        comment = res.json_body[2]
290
+        assert comment['content_id'] == 20
291
+        assert comment['parent_id'] == 7
292
+        assert comment['raw_content'] == '<p>You are right, but Kouign-amann are clearly better.</p>'   # nopep8
293
+        assert comment['author']
294
+        assert comment['author']['user_id'] == 4
295
+        # TODO - G.M - 2018-06-172 - [avatar] setup avatar url
296
+        assert comment['author']['avatar_url'] is None
297
+        assert comment['author']['public_name'] == 'John Reader'
298
+        # TODO - G.M - 2018-06-179 - better check for datetime
299
+        assert comment['created']
300
+
301
+        res = self.testapp.delete(
302
+            '/api/v2/workspaces/2/contents/7/comments/20',
303
+            status=403
304
+        )

File diff suppressed because it is too large
+ 2134 - 0
backend/tracim/tests/functional/test_contents.py


+ 44 - 0
backend/tracim/tests/functional/test_doc.py View File

@@ -0,0 +1,44 @@
1
+from tracim.tests import FunctionalTest
2
+
3
+
4
+class TestDoc(FunctionalTest):
5
+
6
+    def test_api__check_doc_index_html_page__ok_200__nominal_case(self):
7
+        res = self.testapp.get('/api/v2/doc/', status=200)
8
+        assert res.content_type == 'text/html'
9
+
10
+    def test_api__check_spec_yaml_file__ok_200__nominal_case(self):
11
+        res = self.testapp.get('/api/v2/doc/spec.yml', status=200)
12
+        assert res.content_type == 'text/x-yaml'
13
+
14
+    def test_api__check_docs_assets__ok_200__nominal_case(self):
15
+        res = self.testapp.get(
16
+            '/api/v2/doc/favicon-32x32.png',
17
+            status=200,
18
+        )
19
+        assert res.content_type == 'image/png'
20
+        res = self.testapp.get(
21
+            '/api/v2/doc/favicon-16x16.png',
22
+            status=200
23
+        )
24
+        assert res.content_type == 'image/png'
25
+        res = self.testapp.get(
26
+            '/api/v2/doc/swagger-ui.js',
27
+            status=200
28
+        )
29
+        assert res.content_type == 'application/javascript'
30
+        res = self.testapp.get(
31
+            '/api/v2/doc/swagger-ui-standalone-preset.js',
32
+            status=200
33
+        )
34
+        assert res.content_type == 'application/javascript'
35
+        res = self.testapp.get(
36
+            '/api/v2/doc/swagger-ui-bundle.js',
37
+            status=200
38
+        )
39
+        assert res.content_type == 'application/javascript'
40
+        res = self.testapp.get(
41
+            '/api/v2/doc/swagger-ui.css',
42
+            status=200
43
+        )
44
+        assert res.content_type == 'text/css'

+ 267 - 0
backend/tracim/tests/functional/test_mail_notification.py View File

@@ -0,0 +1,267 @@
1
+# coding=utf-8
2
+# INFO - G.M - 09-06-2018 - Those test need a working MailHog
3
+
4
+from email.mime.multipart import MIMEMultipart
5
+from email.mime.text import MIMEText
6
+
7
+import requests
8
+from rq import SimpleWorker
9
+
10
+from tracim.fixtures.users_and_groups import Base as BaseFixture
11
+from tracim.fixtures.content import Content as ContentFixture
12
+from tracim.lib.utils.utils import get_redis_connection
13
+from tracim.lib.utils.utils import get_rq_queue
14
+from tracim.models.data import ContentType
15
+
16
+from tracim.lib.core.content import ContentApi
17
+from tracim.lib.core.user import UserApi
18
+from tracim.lib.core.workspace import WorkspaceApi
19
+from tracim.lib.mail_notifier.sender import EmailSender
20
+from tracim.lib.mail_notifier.utils import SmtpConfiguration
21
+from tracim.tests import MailHogTest
22
+
23
+
24
+class TestEmailSender(MailHogTest):
25
+
26
+    def test__func__connect_disconnect__ok__nominal_case(self):
27
+        smtp_config = SmtpConfiguration(
28
+            self.app_config.EMAIL_NOTIFICATION_SMTP_SERVER,
29
+            self.app_config.EMAIL_NOTIFICATION_SMTP_PORT,
30
+            self.app_config.EMAIL_NOTIFICATION_SMTP_USER,
31
+            self.app_config.EMAIL_NOTIFICATION_SMTP_PASSWORD
32
+        )
33
+        sender = EmailSender(
34
+            self.app_config,
35
+            smtp_config,
36
+            True,
37
+        )
38
+        sender.connect()
39
+        sender.disconnect()
40
+
41
+    def test__func__send_email__ok__nominal_case(self):
42
+        smtp_config = SmtpConfiguration(
43
+            self.app_config.EMAIL_NOTIFICATION_SMTP_SERVER,
44
+            self.app_config.EMAIL_NOTIFICATION_SMTP_PORT,
45
+            self.app_config.EMAIL_NOTIFICATION_SMTP_USER,
46
+            self.app_config.EMAIL_NOTIFICATION_SMTP_PASSWORD
47
+        )
48
+        sender = EmailSender(
49
+            self.app_config,
50
+            smtp_config,
51
+            True,
52
+        )
53
+
54
+        # Create test_mail
55
+        msg = MIMEMultipart()
56
+        msg['Subject'] = 'test__func__send_email__ok__nominal_case'
57
+        msg['From'] = 'test_send_mail@localhost'
58
+        msg['To'] = 'receiver_test_send_mail@localhost'
59
+        text = "test__func__send_email__ok__nominal_case"
60
+        html = """\
61
+        <html>
62
+          <head></head>
63
+          <body>
64
+            <p>test__func__send_email__ok__nominal_case</p>
65
+          </body>
66
+        </html>
67
+        """.replace(' ', '').replace('\n', '')
68
+        part1 = MIMEText(text, 'plain')
69
+        part2 = MIMEText(html, 'html')
70
+        msg.attach(part1)
71
+        msg.attach(part2)
72
+
73
+        sender.send_mail(msg)
74
+        sender.disconnect()
75
+
76
+        # check mail received
77
+        response = requests.get('http://127.0.0.1:8025/api/v1/messages')
78
+        response = response.json()
79
+        headers = response[0]['Content']['Headers']
80
+        assert headers['From'][0] == 'test_send_mail@localhost'
81
+        assert headers['To'][0] == 'receiver_test_send_mail@localhost'
82
+        assert headers['Subject'][0] == 'test__func__send_email__ok__nominal_case'  # nopep8
83
+        assert response[0]['MIME']['Parts'][0]['Body'] == text
84
+        assert response[0]['MIME']['Parts'][1]['Body'] == html
85
+
86
+
87
+class TestNotificationsSync(MailHogTest):
88
+
89
+    fixtures = [BaseFixture, ContentFixture]
90
+
91
+    def test_func__create_user_with_mail_notification__ok__nominal_case(self):
92
+        api = UserApi(
93
+            current_user=None,
94
+            session=self.session,
95
+            config=self.app_config,
96
+        )
97
+        u = api.create_user(
98
+            email='bob@bob',
99
+            password='pass',
100
+            name='bob',
101
+            timezone='+2',
102
+            do_save=True,
103
+            do_notify=True,
104
+        )
105
+        assert u is not None
106
+        assert u.email == "bob@bob"
107
+        assert u.validate_password('pass')
108
+        assert u.display_name == 'bob'
109
+        assert u.timezone == '+2'
110
+
111
+        # check mail received
112
+        response = requests.get('http://127.0.0.1:8025/api/v1/messages')
113
+        response = response.json()
114
+        headers = response[0]['Content']['Headers']
115
+        assert headers['From'][0] == 'Tracim Notifications <test_user_from+0@localhost>'  # nopep8
116
+        assert headers['To'][0] == 'bob <bob@bob>'
117
+        assert headers['Subject'][0] == '[TRACIM] Created account'
118
+
119
+    def test_func__create_new_content_with_notification__ok__nominal_case(self):
120
+        uapi = UserApi(
121
+            current_user=None,
122
+            session=self.session,
123
+            config=self.app_config,
124
+        )
125
+        current_user = uapi.get_one_by_email('admin@admin.admin')
126
+        # Create new user with notification enabled on w1 workspace
127
+        wapi = WorkspaceApi(
128
+            current_user=current_user,
129
+            session=self.session,
130
+            config=self.app_config,
131
+        )
132
+        workspace = wapi.get_one_by_label('Recipes')
133
+        user = uapi.get_one_by_email('bob@fsf.local')
134
+        wapi.enable_notifications(user, workspace)
135
+
136
+        api = ContentApi(
137
+            current_user=user,
138
+            session=self.session,
139
+            config=self.app_config,
140
+        )
141
+        item = api.create(
142
+            ContentType.Folder,
143
+            workspace,
144
+            None,
145
+            'parent',
146
+            do_save=True,
147
+            do_notify=False,
148
+        )
149
+        item2 = api.create(
150
+            ContentType.File,
151
+            workspace,
152
+            item,
153
+            'file1',
154
+            do_save=True,
155
+            do_notify=True,
156
+        )
157
+
158
+        # check mail received
159
+        response = requests.get('http://127.0.0.1:8025/api/v1/messages')
160
+        response = response.json()
161
+        headers = response[0]['Content']['Headers']
162
+        assert headers['From'][0] == '"Bob i. via Tracim" <test_user_from+3@localhost>'  # nopep8
163
+        assert headers['To'][0] == 'Global manager <admin@admin.admin>'
164
+        assert headers['Subject'][0] == '[TRACIM] [Recipes] file1 (Open)'
165
+        assert headers['References'][0] == 'test_user_refs+22@localhost'
166
+        assert headers['Reply-to'][0] == '"Bob i. & all members of Recipes" <test_user_reply+22@localhost>'  # nopep8
167
+
168
+
169
+class TestNotificationsAsync(MailHogTest):
170
+    fixtures = [BaseFixture, ContentFixture]
171
+    config_section = 'mail_test_async'
172
+
173
+    def test_func__create_user_with_mail_notification__ok__nominal_case(self):
174
+        api = UserApi(
175
+            current_user=None,
176
+            session=self.session,
177
+            config=self.app_config,
178
+        )
179
+        u = api.create_user(
180
+            email='bob@bob',
181
+            password='pass',
182
+            name='bob',
183
+            timezone='+2',
184
+            do_save=True,
185
+            do_notify=True,
186
+        )
187
+        assert u is not None
188
+        assert u.email == "bob@bob"
189
+        assert u.validate_password('pass')
190
+        assert u.display_name == 'bob'
191
+        assert u.timezone == '+2'
192
+
193
+        # Send mail async from redis queue
194
+        redis = get_redis_connection(
195
+            self.app_config
196
+        )
197
+        queue = get_rq_queue(
198
+            redis,
199
+            'mail_sender',
200
+        )
201
+        worker = SimpleWorker([queue], connection=queue.connection)
202
+        worker.work(burst=True)
203
+        # check mail received
204
+        response = requests.get('http://127.0.0.1:8025/api/v1/messages')
205
+        response = response.json()
206
+        headers = response[0]['Content']['Headers']
207
+        assert headers['From'][0] == 'Tracim Notifications <test_user_from+0@localhost>'  # nopep8
208
+        assert headers['To'][0] == 'bob <bob@bob>'
209
+        assert headers['Subject'][0] == '[TRACIM] Created account'
210
+
211
+    def test_func__create_new_content_with_notification__ok__nominal_case(self):
212
+        uapi = UserApi(
213
+            current_user=None,
214
+            session=self.session,
215
+            config=self.app_config,
216
+        )
217
+        current_user = uapi.get_one_by_email('admin@admin.admin')
218
+        # Create new user with notification enabled on w1 workspace
219
+        wapi = WorkspaceApi(
220
+            current_user=current_user,
221
+            session=self.session,
222
+            config=self.app_config,
223
+        )
224
+        workspace = wapi.get_one_by_label('Recipes')
225
+        user = uapi.get_one_by_email('bob@fsf.local')
226
+        wapi.enable_notifications(user, workspace)
227
+
228
+        api = ContentApi(
229
+            current_user=user,
230
+            session=self.session,
231
+            config=self.app_config,
232
+        )
233
+        item = api.create(
234
+            ContentType.Folder,
235
+            workspace,
236
+            None,
237
+            'parent',
238
+            do_save=True,
239
+            do_notify=False,
240
+        )
241
+        item2 = api.create(
242
+            ContentType.File,
243
+            workspace,
244
+            item,
245
+            'file1',
246
+            do_save=True,
247
+            do_notify=True,
248
+        )
249
+        # Send mail async from redis queue
250
+        redis = get_redis_connection(
251
+            self.app_config
252
+        )
253
+        queue = get_rq_queue(
254
+            redis,
255
+            'mail_sender',
256
+        )
257
+        worker = SimpleWorker([queue], connection=queue.connection)
258
+        worker.work(burst=True)
259
+        # check mail received
260
+        response = requests.get('http://127.0.0.1:8025/api/v1/messages')
261
+        response = response.json()
262
+        headers = response[0]['Content']['Headers']
263
+        assert headers['From'][0] == '"Bob i. via Tracim" <test_user_from+3@localhost>'  # nopep8
264
+        assert headers['To'][0] == 'Global manager <admin@admin.admin>'
265
+        assert headers['Subject'][0] == '[TRACIM] [Recipes] file1 (Open)'
266
+        assert headers['References'][0] == 'test_user_refs+22@localhost'
267
+        assert headers['Reply-to'][0] == '"Bob i. & all members of Recipes" <test_user_reply+22@localhost>'  # nopep8

+ 214 - 0
backend/tracim/tests/functional/test_session.py View File

@@ -0,0 +1,214 @@
1
+# coding=utf-8
2
+import datetime
3
+import pytest
4
+import transaction
5
+from sqlalchemy.exc import OperationalError
6
+
7
+from tracim import models
8
+from tracim.lib.core.group import GroupApi
9
+from tracim.lib.core.user import UserApi
10
+from tracim.models import get_tm_session
11
+from tracim.tests import FunctionalTest
12
+from tracim.tests import FunctionalTestNoDB
13
+
14
+
15
+class TestLogoutEndpoint(FunctionalTest):
16
+
17
+    def test_api__access_logout_get_enpoint__ok__nominal_case(self):
18
+        res = self.testapp.post_json('/api/v2/sessions/logout', status=204)
19
+
20
+    def test_api__access_logout_post_enpoint__ok__nominal_case(self):
21
+        res = self.testapp.get('/api/v2/sessions/logout', status=204)
22
+
23
+
24
+class TestLoginEndpointUnititedDB(FunctionalTestNoDB):
25
+
26
+    def test_api__try_login_enpoint__err_500__no_inited_db(self):
27
+        params = {
28
+            'email': 'admin@admin.admin',
29
+            'password': 'admin@admin.admin',
30
+        }
31
+        res = self.testapp.post_json(
32
+            '/api/v2/sessions/login',
33
+            params=params,
34
+            status=500,
35
+        )
36
+        assert isinstance(res.json, dict)
37
+        assert 'code' in res.json.keys()
38
+        assert 'message' in res.json.keys()
39
+        assert 'details' in res.json.keys()
40
+
41
+
42
+class TestLoginEndpoint(FunctionalTest):
43
+
44
+    def test_api__try_login_enpoint__ok_200__nominal_case(self):
45
+        params = {
46
+            'email': 'admin@admin.admin',
47
+            'password': 'admin@admin.admin',
48
+        }
49
+        res = self.testapp.post_json(
50
+            '/api/v2/sessions/login',
51
+            params=params,
52
+            status=200,
53
+        )
54
+        assert res.json_body['created']
55
+        datetime.datetime.strptime(
56
+            res.json_body['created'],
57
+            '%Y-%m-%dT%H:%M:%SZ'
58
+        )
59
+        assert res.json_body['public_name'] == 'Global manager'
60
+        assert res.json_body['email'] == 'admin@admin.admin'
61
+        assert res.json_body['is_active']
62
+        assert res.json_body['profile']
63
+        assert res.json_body['profile'] == 'administrators'
64
+        assert res.json_body['caldav_url'] is None
65
+        assert res.json_body['avatar_url'] is None
66
+
67
+    def test_api__try_login_enpoint__err_401__user_not_activated(self):
68
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
69
+        admin = dbsession.query(models.User) \
70
+            .filter(models.User.email == 'admin@admin.admin') \
71
+            .one()
72
+        uapi = UserApi(
73
+            current_user=admin,
74
+            session=dbsession,
75
+            config=self.app_config,
76
+        )
77
+        gapi = GroupApi(
78
+            current_user=admin,
79
+            session=dbsession,
80
+            config=self.app_config,
81
+        )
82
+        groups = [gapi.get_one_with_name('users')]
83
+        test_user = uapi.create_user(
84
+            email='test@test.test',
85
+            password='pass',
86
+            name='bob',
87
+            groups=groups,
88
+            timezone='Europe/Paris',
89
+            do_save=True,
90
+            do_notify=False,
91
+        )
92
+        uapi.save(test_user)
93
+        uapi.disable(test_user)
94
+        transaction.commit()
95
+
96
+        params = {
97
+            'email': 'test@test.test',
98
+            'password': 'test@test.test',
99
+        }
100
+        res = self.testapp.post_json(
101
+            '/api/v2/sessions/login',
102
+            params=params,
103
+            status=403,
104
+        )
105
+
106
+    def test_api__try_login_enpoint__err_403__bad_password(self):
107
+        params = {
108
+            'email': 'admin@admin.admin',
109
+            'password': 'bad_password',
110
+        }
111
+        res = self.testapp.post_json(
112
+            '/api/v2/sessions/login',
113
+            status=403,
114
+            params=params,
115
+        )
116
+        assert isinstance(res.json, dict)
117
+        assert 'code' in res.json.keys()
118
+        assert 'message' in res.json.keys()
119
+        assert 'details' in res.json.keys()
120
+
121
+    def test_api__try_login_enpoint__err_403__unregistered_user(self):
122
+        params = {
123
+            'email': 'unknown_user@unknown.unknown',
124
+            'password': 'bad_password',
125
+        }
126
+        res = self.testapp.post_json(
127
+            '/api/v2/sessions/login',
128
+            status=403,
129
+            params=params,
130
+        )
131
+        assert isinstance(res.json, dict)
132
+        assert 'code' in res.json.keys()
133
+        assert 'message' in res.json.keys()
134
+        assert 'details' in res.json.keys()
135
+
136
+    def test_api__try_login_enpoint__err_400__no_json_body(self):
137
+        res = self.testapp.post_json('/api/v2/sessions/login', status=400)
138
+        assert isinstance(res.json, dict)
139
+        assert 'code' in res.json.keys()
140
+        assert 'message' in res.json.keys()
141
+        assert 'details' in res.json.keys()
142
+
143
+
144
+class TestWhoamiEndpoint(FunctionalTest):
145
+
146
+    def test_api__try_whoami_enpoint__ok_200__nominal_case(self):
147
+        self.testapp.authorization = (
148
+            'Basic',
149
+            (
150
+                'admin@admin.admin',
151
+                'admin@admin.admin'
152
+            )
153
+        )
154
+        res = self.testapp.get('/api/v2/sessions/whoami', status=200)
155
+        assert res.json_body['public_name'] == 'Global manager'
156
+        assert res.json_body['email'] == 'admin@admin.admin'
157
+        assert res.json_body['created']
158
+        assert res.json_body['is_active']
159
+        assert res.json_body['profile']
160
+        assert res.json_body['profile'] == 'administrators'
161
+        assert res.json_body['caldav_url'] is None
162
+        assert res.json_body['avatar_url'] is None
163
+
164
+    def test_api__try_whoami_enpoint__err_401__user_is_not_active(self):
165
+        dbsession = get_tm_session(self.session_factory, transaction.manager)
166
+        admin = dbsession.query(models.User) \
167
+            .filter(models.User.email == 'admin@admin.admin') \
168
+            .one()
169
+        uapi = UserApi(
170
+            current_user=admin,
171
+            session=dbsession,
172
+            config=self.app_config,
173
+        )
174
+        gapi = GroupApi(
175
+            current_user=admin,
176
+            session=dbsession,
177
+            config=self.app_config,
178
+        )
179
+        groups = [gapi.get_one_with_name('users')]
180
+        test_user = uapi.create_user(
181
+            email='test@test.test',
182
+            password='pass',
183
+            name='bob',
184
+            groups=groups,
185
+            timezone='Europe/Paris',
186
+            do_save=True,
187
+            do_notify=False,
188
+        )
189
+        uapi.save(test_user)
190
+        uapi.disable(test_user)
191
+        transaction.commit()
192
+        self.testapp.authorization = (
193
+            'Basic',
194
+            (
195
+                'test@test.test',
196
+                'pass'
197
+            )
198
+        )
199
+
200
+        res = self.testapp.get('/api/v2/sessions/whoami', status=401)
201
+
202
+    def test_api__try_whoami_enpoint__err_401__unauthenticated(self):
203
+        self.testapp.authorization = (
204
+            'Basic',
205
+            (
206
+                'john@doe.doe',
207
+                'lapin'
208
+            )
209
+        )
210
+        res = self.testapp.get('/api/v2/sessions/whoami', status=401)
211
+        assert isinstance(res.json, dict)
212
+        assert 'code' in res.json.keys()
213
+        assert 'message' in res.json.keys()
214
+        assert 'details' in res.json.keys()

+ 152 - 0
backend/tracim/tests/functional/test_system.py View File

@@ -0,0 +1,152 @@
1
+# coding=utf-8
2
+from tracim.tests import FunctionalTest
3
+
4
+"""
5
+Tests for /api/v2/system subpath endpoints.
6
+"""
7
+
8
+class TestApplicationEndpoint(FunctionalTest):
9
+    """
10
+    Tests for /api/v2/system/applications
11
+    """
12
+
13
+    def test_api__get_applications__ok_200__nominal_case(self):
14
+        """
15
+        Get applications list with a registered user.
16
+        """
17
+        self.testapp.authorization = (
18
+            'Basic',
19
+            (
20
+                'admin@admin.admin',
21
+                'admin@admin.admin'
22
+            )
23
+        )
24
+        res = self.testapp.get('/api/v2/system/applications', status=200)
25
+        res = res.json_body
26
+        application = res[0]
27
+        assert application['label'] == "Text Documents"
28
+        assert application['slug'] == 'contents/html-documents'
29
+        assert application['fa_icon'] == 'file-text-o'
30
+        assert application['hexcolor'] == '#3f52e3'
31
+        assert application['is_active'] is True
32
+        assert 'config' in application
33
+        application = res[1]
34
+        assert application['label'] == "Markdown Plus Documents"
35
+        assert application['slug'] == 'contents/markdownpluspage'
36
+        assert application['fa_icon'] == 'file-code-o'
37
+        assert application['hexcolor'] == '#f12d2d'
38
+        assert application['is_active'] is True
39
+        assert 'config' in application
40
+        application = res[2]
41
+        assert application['label'] == "Files"
42
+        assert application['slug'] == 'contents/files'
43
+        assert application['fa_icon'] == 'paperclip'
44
+        assert application['hexcolor'] == '#FF9900'
45
+        assert application['is_active'] is True
46
+        assert 'config' in application
47
+        application = res[3]
48
+        assert application['label'] == "Threads"
49
+        assert application['slug'] == 'contents/threads'
50
+        assert application['fa_icon'] == 'comments-o'
51
+        assert application['hexcolor'] == '#ad4cf9'
52
+        assert application['is_active'] is True
53
+        assert 'config' in application
54
+        application = res[4]
55
+        assert application['label'] == "Calendar"
56
+        assert application['slug'] == 'calendar'
57
+        assert application['fa_icon'] == 'calendar'
58
+        assert application['hexcolor'] == '#757575'
59
+        assert application['is_active'] is True
60
+        assert 'config' in application
61
+
62
+    def test_api__get_applications__err_401__unregistered_user(self):
63
+        """
64
+        Get applications list with an unregistered user (bad auth)
65
+        """
66
+        self.testapp.authorization = (
67
+            'Basic',
68
+            (
69
+                'john@doe.doe',
70
+                'lapin'
71
+            )
72
+        )
73
+        res = self.testapp.get('/api/v2/system/applications', status=401)
74
+        assert isinstance(res.json, dict)
75
+        assert 'code' in res.json.keys()
76
+        assert 'message' in res.json.keys()
77
+        assert 'details' in res.json.keys()
78
+
79
+
80
+class TestContentsTypesEndpoint(FunctionalTest):
81
+    """
82
+    Tests for /api/v2/system/content_types
83
+    """
84
+
85
+    def test_api__get_content_types__ok_200__nominal_case(self):
86
+        """
87
+        Get system content_types list with a registered user.
88
+        """
89
+        self.testapp.authorization = (
90
+            'Basic',
91
+            (
92
+                'admin@admin.admin',
93
+                'admin@admin.admin'
94
+            )
95
+        )
96
+        res = self.testapp.get('/api/v2/system/content_types', status=200)
97
+        res = res.json_body
98
+
99
+        content_type = res[0]
100
+        assert content_type['slug'] == 'thread'
101
+        assert content_type['fa_icon'] == 'comments-o'
102
+        assert content_type['hexcolor'] == '#ad4cf9'
103
+        assert content_type['label'] == 'Thread'
104
+        assert content_type['creation_label'] == 'Discuss about a topic'
105
+        assert 'available_statuses' in content_type
106
+        assert len(content_type['available_statuses']) == 4
107
+
108
+        content_type = res[1]
109
+        assert content_type['slug'] == 'file'
110
+        assert content_type['fa_icon'] == 'paperclip'
111
+        assert content_type['hexcolor'] == '#FF9900'
112
+        assert content_type['label'] == 'File'
113
+        assert content_type['creation_label'] == 'Upload a file'
114
+        assert 'available_statuses' in content_type
115
+        assert len(content_type['available_statuses']) == 4
116
+
117
+        content_type = res[2]
118
+        assert content_type['slug'] == 'markdownpage'
119
+        assert content_type['fa_icon'] == 'file-code-o'
120
+        assert content_type['hexcolor'] == '#f12d2d'
121
+        assert content_type['label'] == 'Rich Markdown File'
122
+        assert content_type['creation_label'] == 'Create a Markdown document'
123
+        assert 'available_statuses' in content_type
124
+        assert len(content_type['available_statuses']) == 4
125
+
126
+        content_type = res[3]
127
+        assert content_type['slug'] == 'html-documents'
128
+        assert content_type['fa_icon'] == 'file-text-o'
129
+        assert content_type['hexcolor'] == '#3f52e3'
130
+        assert content_type['label'] == 'Text Document'
131
+        assert content_type['creation_label'] == 'Write a document'
132
+        assert 'available_statuses' in content_type
133
+        assert len(content_type['available_statuses']) == 4
134
+        # TODO - G.M - 31-05-2018 - Check Folder type
135
+        # TODO - G.M - 29-05-2018 - Better check for available_statuses
136
+
137
+    def test_api__get_content_types__err_401__unregistered_user(self):
138
+        """
139
+        Get system content_types list with an unregistered user (bad auth)
140
+        """
141
+        self.testapp.authorization = (
142
+            'Basic',
143
+            (
144
+                'john@doe.doe',
145
+                'lapin'
146
+            )
147
+        )
148
+        res = self.testapp.get('/api/v2/system/content_types', status=401)
149
+        assert isinstance(res.json, dict)
150
+        assert 'code' in res.json.keys()
151
+        assert 'message' in res.json.keys()
152
+        assert 'details' in res.json.keys()

File diff suppressed because it is too large
+ 2290 - 0
backend/tracim/tests/functional/test_user.py


File diff suppressed because it is too large
+ 1984 - 0
backend/tracim/tests/functional/test_workspaces.py


+ 2 - 0
backend/tracim/tests/library/__init__.py View File

@@ -0,0 +1,2 @@
1
+# -*- coding: utf-8 -*-
2
+"""Unit test suite for the library of the application."""

File diff suppressed because it is too large
+ 2668 - 0
backend/tracim/tests/library/test_content_api.py


+ 74 - 0
backend/tracim/tests/library/test_group_api.py View File

@@ -0,0 +1,74 @@
1
+# coding=utf-8
2
+import pytest
3
+
4
+from tracim.exceptions import GroupDoesNotExist
5
+from tracim.lib.core.group import GroupApi
6
+from tracim.tests import DefaultTest
7
+from tracim.fixtures.users_and_groups import Base as BaseFixture
8
+from tracim.fixtures.content import Content as ContentFixture
9
+
10
+
11
+class TestGroupApi(DefaultTest):
12
+    fixtures = [BaseFixture, ContentFixture]
13
+
14
+    def test_unit__get_one__ok_nominal_case(self) -> None:
15
+        """
16
+        Get one group by id
17
+        """
18
+        api = GroupApi(
19
+            current_user=None,
20
+            session=self.session,
21
+            config=self.app_config,
22
+        )
23
+        group = api.get_one(1)
24
+        assert group.group_id == 1
25
+        assert group.group_name == 'users'
26
+
27
+    def test_unit__get_one__err__group_not_exist(self) -> None:
28
+        """
29
+        Get one group who does not exist by id
30
+        """
31
+        api = GroupApi(
32
+            current_user=None,
33
+            session=self.session,
34
+            config=self.app_config,
35
+        )
36
+        with pytest.raises(GroupDoesNotExist):
37
+            group = api.get_one(10)
38
+
39
+    def test_unit__get_one_group_with_name__nominal_case(self) -> None:
40
+        """
41
+        get one group by name
42
+        """
43
+        api = GroupApi(
44
+            current_user=None,
45
+            session=self.session,
46
+            config=self.app_config,
47
+        )
48
+        group = api.get_one_with_name('administrators')
49
+        assert group.group_id == 3
50
+        assert group.group_name == 'administrators'
51
+
52
+    def test_unit__get_one_with_name__err__group_not_exist(self) -> None:
53
+        """
54
+        get one group by name who does not exist
55
+        """
56
+        api = GroupApi(
57
+            current_user=None,
58
+            session=self.session,
59
+            config=self.app_config,
60
+        )
61
+        with pytest.raises(GroupDoesNotExist):
62
+            group = api.get_one_with_name('unknown_group')
63
+
64
+    def test_unit__get_all__ok__nominal_case(self):
65
+        """
66
+        get all groups
67
+        """
68
+        api = GroupApi(
69
+            current_user=None,
70
+            session=self.session,
71
+            config=self.app_config,
72
+        )
73
+        groups = api.get_all()
74
+        assert ['users', 'managers', 'administrators'] == [group.group_name for group in groups]  # nopep8

+ 41 - 0
backend/tracim/tests/library/test_notification.py View File

@@ -0,0 +1,41 @@
1
+# -*- coding: utf-8 -*-
2
+import os
3
+import re
4
+
5
+
6
+from tracim.lib.core.notifications import DummyNotifier
7
+
8
+from tracim.lib.core.notifications import NotifierFactory
9
+from tracim.lib.mail_notifier.notifier import EmailNotifier
10
+from tracim.models.auth import User
11
+from tracim.models.data import Content
12
+from tracim.tests import DefaultTest
13
+from tracim.tests import eq_
14
+
15
+
16
+class TestDummyNotifier(DefaultTest):
17
+
18
+    def test_dummy_notifier__notify_content_update(self):
19
+        c = Content()
20
+        notifier = DummyNotifier(self.app_config, self.session)
21
+        notifier.notify_content_update(c)
22
+        # INFO - D.A. - 2014-12-09 -
23
+        # Old notification_content_update raised an exception
24
+
25
+
26
+class TestNotifierFactory(DefaultTest):
27
+    def test_notifier_factory_method(self):
28
+        u = User()
29
+
30
+        self.app_config.EMAIL_NOTIFICATION_ACTIVATED = True
31
+        notifier = NotifierFactory.create(self.app_config, u)
32
+        eq_(EmailNotifier, notifier.__class__)
33
+
34
+        self.app_config.EMAIL_NOTIFICATION_ACTIVATED = False
35
+        notifier = NotifierFactory.create(self.app_config, u)
36
+        eq_(DummyNotifier, notifier.__class__)
37
+
38
+
39
+class TestEmailNotifier(DefaultTest):
40
+    # TODO - G.M - 04-03-2017 -  [emailNotif] - Restore test for email Notif
41
+    pass

+ 77 - 0
backend/tracim/tests/library/test_role_api.py View File

@@ -0,0 +1,77 @@
1
+# coding=utf-8
2
+import pytest
3
+from sqlalchemy.orm.exc import NoResultFound
4
+
5
+from tracim.lib.core.userworkspace import RoleApi
6
+from tracim.models import User
7
+from tracim.models.roles import WorkspaceRoles
8
+from tracim.tests import DefaultTest
9
+from tracim.fixtures.users_and_groups import Base as BaseFixture
10
+from tracim.fixtures.content import Content as ContentFixture
11
+
12
+
13
+class TestRoleApi(DefaultTest):
14
+
15
+    fixtures = [BaseFixture, ContentFixture]
16
+
17
+    def test_unit__get_one__ok__nominal_case(self):
18
+        admin = self.session.query(User)\
19
+            .filter(User.email == 'admin@admin.admin').one()
20
+        rapi = RoleApi(
21
+            current_user=admin,
22
+            session=self.session,
23
+            config=self.config,
24
+        )
25
+        rapi.get_one(admin.user_id, 1)
26
+
27
+    def test_unit__get_one__err__role_does_not_exist(self):
28
+        admin = self.session.query(User)\
29
+            .filter(User.email == 'admin@admin.admin').one()
30
+        rapi = RoleApi(
31
+            current_user=admin,
32
+            session=self.session,
33
+            config=self.config,
34
+        )
35
+        with pytest.raises(NoResultFound):
36
+            rapi.get_one(admin.user_id, 100)  # workspace 100 does not exist
37
+
38
+    def test_unit__create_one__nominal_case(self):
39
+        admin = self.session.query(User)\
40
+            .filter(User.email == 'admin@admin.admin').one()
41
+        workspace = self._create_workspace_and_test(
42
+            'workspace_1',
43
+            admin
44
+        )
45
+        bob = self.session.query(User)\
46
+            .filter(User.email == 'bob@fsf.local').one()
47
+        rapi = RoleApi(
48
+            current_user=admin,
49
+            session=self.session,
50
+            config=self.config,
51
+        )
52
+        created_role = rapi.create_one(
53
+            user=bob,
54
+            workspace=workspace,
55
+            role_level=WorkspaceRoles.CONTENT_MANAGER.level,
56
+            with_notif=False,
57
+        )
58
+        obtain_role = rapi.get_one(bob.user_id, workspace.workspace_id)
59
+        assert created_role == obtain_role
60
+
61
+    def test_unit__get_all_for_usages(self):
62
+        admin = self.session.query(User)\
63
+            .filter(User.email == 'admin@admin.admin').one()
64
+        rapi = RoleApi(
65
+            current_user=admin,
66
+            session=self.session,
67
+            config=self.config,
68
+        )
69
+        workspace = self._create_workspace_and_test(
70
+            'workspace_1',
71
+            admin
72
+        )
73
+        roles = rapi.get_all_for_workspace(workspace)
74
+        len(roles) == 1
75
+        roles[0].user_id == admin.user_id
76
+        roles[0].role == WorkspaceRoles.WORKSPACE_MANAGER.level
77
+

+ 216 - 0
backend/tracim/tests/library/test_user_api.py View File

@@ -0,0 +1,216 @@
1
+# -*- coding: utf-8 -*-
2
+import pytest
3
+import transaction
4
+
5
+from tracim.exceptions import AuthenticationFailed
6
+from tracim.exceptions import UserDoesNotExist
7
+from tracim.exceptions import UserNotActive
8
+from tracim.lib.core.group import GroupApi
9
+from tracim.lib.core.user import UserApi
10
+from tracim.models import User
11
+from tracim.models.context_models import UserInContext
12
+from tracim.tests import DefaultTest
13
+from tracim.tests import eq_
14
+
15
+
16
+class TestUserApi(DefaultTest):
17
+
18
+    def test_unit__create_minimal_user__ok__nominal_case(self):
19
+        api = UserApi(
20
+            current_user=None,
21
+            session=self.session,
22
+            config=self.config,
23
+        )
24
+        u = api.create_minimal_user('bob@bob')
25
+        assert u.email == 'bob@bob'
26
+        assert u.display_name == 'bob'
27
+
28
+    def test_unit__create_minimal_user_and_update__ok__nominal_case(self):
29
+        api = UserApi(
30
+            current_user=None,
31
+            session=self.session,
32
+            config=self.config,
33
+        )
34
+        u = api.create_minimal_user('bob@bob')
35
+        api.update(u, 'bob', 'bob@bob', 'pass', do_save=True)
36
+        nu = api.get_one_by_email('bob@bob')
37
+        assert nu is not None
38
+        assert nu.email == 'bob@bob'
39
+        assert nu.display_name == 'bob'
40
+        assert nu.validate_password('pass')
41
+
42
+    def test__unit__create__user__ok_nominal_case(self):
43
+        api = UserApi(
44
+            current_user=None,
45
+            session=self.session,
46
+            config=self.config,
47
+        )
48
+        u = api.create_user(
49
+            email='bob@bob',
50
+            password='pass',
51
+            name='bob',
52
+            timezone='+2',
53
+            do_save=True,
54
+            do_notify=False,
55
+        )
56
+        assert u is not None
57
+        assert u.email == "bob@bob"
58
+        assert u.validate_password('pass')
59
+        assert u.display_name == 'bob'
60
+        assert u.timezone == '+2'
61
+
62
+    def test_unit__user_with_email_exists__ok__nominal_case(self):
63
+        api = UserApi(
64
+            current_user=None,
65
+            session=self.session,
66
+            config=self.config,
67
+        )
68
+        u = api.create_minimal_user('bibi@bibi')
69
+        api.update(u, 'bibi', 'bibi@bibi', 'pass', do_save=True)
70
+        transaction.commit()
71
+
72
+        eq_(True, api.user_with_email_exists('bibi@bibi'))
73
+        eq_(False, api.user_with_email_exists('unknown'))
74
+
75
+    def test_get_one_by_email(self):
76
+        api = UserApi(
77
+            current_user=None,
78
+            session=self.session,
79
+            config=self.config,
80
+        )
81
+        u = api.create_minimal_user('bibi@bibi')
82
+        self.session.flush()
83
+        api.update(u, 'bibi', 'bibi@bibi', 'pass', do_save=True)
84
+        uid = u.user_id
85
+        transaction.commit()
86
+
87
+        eq_(uid, api.get_one_by_email('bibi@bibi').user_id)
88
+
89
+    def test_unit__get_one_by_email__err__user_does_not_exist(self):
90
+        api = UserApi(
91
+            current_user=None,
92
+            session=self.session,
93
+            config=self.config,
94
+        )
95
+        with pytest.raises(UserDoesNotExist):
96
+            api.get_one_by_email('unknown')
97
+
98
+    def test_unit__get_all__ok__nominal_case(self):
99
+        api = UserApi(
100
+            current_user=None,
101
+            session=self.session,
102
+            config=self.config,
103
+        )
104
+        u1 = api.create_minimal_user('bibi@bibi')
105
+
106
+        users = api.get_all()
107
+        # u1 + Admin user from BaseFixture
108
+        assert 2 == len(users)
109
+
110
+    def test_unit__get_one__ok__nominal_case(self):
111
+        api = UserApi(
112
+            current_user=None,
113
+            session=self.session,
114
+            config=self.config,
115
+        )
116
+        u = api.create_minimal_user('titi@titi')
117
+        api.update(u, 'titi', 'titi@titi', 'pass', do_save=True)
118
+        one = api.get_one(u.user_id)
119
+        eq_(u.user_id, one.user_id)
120
+
121
+    def test_unit__get_user_with_context__nominal_case(self):
122
+        user = User(
123
+            email='admin@tracim.tracim',
124
+            display_name='Admin',
125
+            is_active=True,
126
+        )
127
+        api = UserApi(
128
+            current_user=None,
129
+            session=self.session,
130
+            config=self.config,
131
+        )
132
+        new_user = api.get_user_with_context(user)
133
+        assert isinstance(new_user, UserInContext)
134
+        assert new_user.user == user
135
+        assert new_user.profile == 'nobody'
136
+        assert new_user.user_id == user.user_id
137
+        assert new_user.email == 'admin@tracim.tracim'
138
+        assert new_user.display_name == 'Admin'
139
+        assert new_user.is_active is True
140
+        # TODO - G.M - 03-05-2018 - [avatar][calendar] Should test this
141
+        # with true value when those param will be available.
142
+        assert new_user.avatar_url is None
143
+        assert new_user.calendar_url is None
144
+
145
+    def test_unit__get_current_user_ok__nominal_case(self):
146
+        user = User(email='admin@tracim.tracim')
147
+        api = UserApi(
148
+            current_user=user,
149
+            session=self.session,
150
+            config=self.config,
151
+        )
152
+        new_user = api.get_current_user()
153
+        assert isinstance(new_user, User)
154
+        assert user == new_user
155
+
156
+    def test_unit__get_current_user__err__user_not_exist(self):
157
+        api = UserApi(
158
+            current_user=None,
159
+            session=self.session,
160
+            config=self.config,
161
+        )
162
+        with pytest.raises(UserDoesNotExist):
163
+            api.get_current_user()
164
+
165
+    def test_unit__authenticate_user___ok__nominal_case(self):
166
+        api = UserApi(
167
+            current_user=None,
168
+            session=self.session,
169
+            config=self.config,
170
+        )
171
+        user = api.authenticate_user('admin@admin.admin', 'admin@admin.admin')
172
+        assert isinstance(user, User)
173
+        assert user.email == 'admin@admin.admin'
174
+
175
+    def test_unit__authenticate_user___err__user_not_active(self):
176
+        api = UserApi(
177
+            current_user=None,
178
+            session=self.session,
179
+            config=self.config,
180
+        )
181
+        gapi = GroupApi(
182
+            current_user=None,
183
+            session=self.session,
184
+            config=self.config,
185
+        )
186
+        groups = [gapi.get_one_with_name('users')]
187
+        user = api.create_user(
188
+            email='test@test.test',
189
+            password='pass',
190
+            name='bob',
191
+            groups=groups,
192
+            timezone='Europe/Paris',
193
+            do_save=True,
194
+            do_notify=False,
195
+        )
196
+        api.disable(user)
197
+        with pytest.raises(UserNotActive):
198
+            api.authenticate_user('test@test.test', 'test@test.test')
199
+
200
+    def test_unit__authenticate_user___err__wrong_password(self):
201
+        api = UserApi(
202
+            current_user=None,
203
+            session=self.session,
204
+            config=self.config,
205
+        )
206
+        with pytest.raises(AuthenticationFailed):
207
+            api.authenticate_user('admin@admin.admin', 'wrong_password')
208
+
209
+    def test_unit__authenticate_user___err__wrong_user(self):
210
+        api = UserApi(
211
+            current_user=None,
212
+            session=self.session,
213
+            config=self.config,
214
+        )
215
+        with pytest.raises(AuthenticationFailed):
216
+            api.authenticate_user('admin@admin.admin', 'wrong_password')

+ 660 - 0
backend/tracim/tests/library/test_webdav.py View File

@@ -0,0 +1,660 @@
1
+# -*- coding: utf-8 -*-
2
+import io
3
+
4
+import pytest
5
+from sqlalchemy.exc import InvalidRequestError
6
+from wsgidav.wsgidav_app import DEFAULT_CONFIG
7
+from tracim import WebdavAppFactory
8
+from tracim.lib.core.user import UserApi
9
+from tracim.lib.webdav import TracimDomainController
10
+from tracim.tests import eq_
11
+from tracim.lib.core.notifications import DummyNotifier
12
+from tracim.lib.webdav.dav_provider import Provider
13
+from tracim.lib.webdav.resources import RootResource
14
+from tracim.models import Content
15
+from tracim.models import ContentRevisionRO
16
+from tracim.tests import StandardTest
17
+from tracim.fixtures.content import Content as ContentFixtures
18
+from tracim.fixtures.users_and_groups import Base as BaseFixture
19
+from wsgidav import util
20
+from unittest.mock import MagicMock
21
+
22
+
23
+class TestWebdavFactory(StandardTest):
24
+
25
+    def test_unit__initConfig__ok__nominal_case(self):
26
+        """
27
+        Check if config is correctly modify for wsgidav using mocked
28
+        wsgidav and tracim conf (as dict)
29
+        :return:
30
+        """
31
+        tracim_settings = {
32
+            'sqlalchemy.url': 'sqlite:///:memory:',
33
+            'user.auth_token.validity': '604800',
34
+            'depot_storage_dir': '/tmp/test/depot',
35
+            'depot_storage_name': 'test',
36
+            'preview_cache_dir': '/tmp/test/preview_cache',
37
+            'wsgidav.config_path': 'development.ini'
38
+
39
+        }
40
+        wsgidav_setting = DEFAULT_CONFIG.copy()
41
+        wsgidav_setting.update(
42
+            {
43
+               'root_path':  '',
44
+               'acceptbasic': True,
45
+               'acceptdigest': False,
46
+               'defaultdigest': False,
47
+            }
48
+        )
49
+        mock = MagicMock()
50
+        mock._initConfig = WebdavAppFactory._initConfig
51
+        mock._readConfigFile.return_value = wsgidav_setting
52
+        mock._get_tracim_settings.return_value = tracim_settings
53
+        config = mock._initConfig(mock)
54
+        assert config
55
+        assert config['acceptbasic'] is True
56
+        assert config['acceptdigest'] is False
57
+        assert config['defaultdigest'] is False
58
+        # TODO - G.M - 25-05-2018 - Better check for middleware stack config
59
+        assert 'middleware_stack' in config
60
+        assert len(config['middleware_stack']) == 7
61
+        assert 'root_path' in config
62
+        assert 'provider_mapping' in config
63
+        assert config['root_path'] in config['provider_mapping']
64
+        assert isinstance(config['provider_mapping'][config['root_path']], Provider)  # nopep8
65
+        assert 'domaincontroller' in config
66
+        assert isinstance(config['domaincontroller'], TracimDomainController)
67
+
68
+
69
+class TestWebDav(StandardTest):
70
+    fixtures = [BaseFixture, ContentFixtures]
71
+
72
+    def _get_provider(self, config):
73
+        return Provider(
74
+            show_archived=False,
75
+            show_deleted=False,
76
+            show_history=False,
77
+            app_config=config,
78
+        )
79
+
80
+    def _get_environ(
81
+            self,
82
+            provider: Provider,
83
+            username: str,
84
+    ) -> dict:
85
+        return {
86
+            'http_authenticator.username': username,
87
+            'http_authenticator.realm': '/',
88
+            'wsgidav.provider': provider,
89
+            'tracim_user': self._get_user(username),
90
+            'tracim_dbsession': self.session,
91
+        }
92
+
93
+    def _get_user(self, email):
94
+        return UserApi(None,
95
+                       self.session,
96
+                       self.app_config
97
+                       ).get_one_by_email(email)
98
+
99
+    def _put_new_text_file(
100
+            self,
101
+            provider,
102
+            environ,
103
+            file_path,
104
+            file_content,
105
+    ):
106
+        # This part id a reproduction of
107
+        # wsgidav.request_server.RequestServer#doPUT
108
+
109
+        # Grab parent folder where create file
110
+        parentRes = provider.getResourceInst(
111
+            util.getUriParent(file_path),
112
+            environ,
113
+        )
114
+        assert parentRes, 'we should found folder for {0}'.format(file_path)
115
+
116
+        new_resource = parentRes.createEmptyResource(
117
+            util.getUriName(file_path),
118
+        )
119
+        write_object = new_resource.beginWrite(
120
+            contentType='application/octet-stream',
121
+        )
122
+        write_object.write(file_content)
123
+        write_object.close()
124
+        new_resource.endWrite(withErrors=False)
125
+
126
+        # Now file should exist
127
+        return provider.getResourceInst(
128
+            file_path,
129
+            environ,
130
+        )
131
+
132
+    def test_unit__get_root__ok(self):
133
+        provider = self._get_provider(self.app_config)
134
+        root = provider.getResourceInst(
135
+            '/',
136
+            self._get_environ(
137
+                provider,
138
+                'bob@fsf.local',
139
+            )
140
+        )
141
+        assert root, 'Path / should return a RootResource instance'
142
+        assert isinstance(root, RootResource)
143
+
144
+    def test_unit__list_workspaces_with_user__ok(self):
145
+        provider = self._get_provider(self.app_config)
146
+        root = provider.getResourceInst(
147
+            '/',
148
+            self._get_environ(
149
+                provider,
150
+                'bob@fsf.local',
151
+            )
152
+        )
153
+        assert root, 'Path / should return a RootResource instance'
154
+        assert isinstance(root, RootResource), 'Path / should return a RootResource instance'
155
+
156
+        children = root.getMemberList()
157
+        eq_(
158
+            2,
159
+            len(children),
160
+            msg='RootResource should return 2 workspaces instead {0}'.format(
161
+                len(children),
162
+            )
163
+        )
164
+
165
+        workspaces_names = [w.name for w in children]
166
+        assert 'Recipes' in workspaces_names, \
167
+            'Recipes should be in names ({0})'.format(
168
+                workspaces_names,
169
+        )
170
+        assert 'Others' in workspaces_names, 'Others should be in names ({0})'.format(
171
+            workspaces_names,
172
+        )
173
+
174
+    def test_unit__list_workspaces_with_admin__ok(self):
175
+        provider = self._get_provider(self.app_config)
176
+        root = provider.getResourceInst(
177
+            '/',
178
+            self._get_environ(
179
+                provider,
180
+                'admin@admin.admin',
181
+            )
182
+        )
183
+        assert root, 'Path / should return a RootResource instance'
184
+        assert isinstance(root, RootResource), 'Path / should return a RootResource instance'
185
+
186
+        children = root.getMemberList()
187
+        eq_(
188
+            2,
189
+            len(children),
190
+            msg='RootResource should return 3 workspaces instead {0}'.format(
191
+                len(children),
192
+            )
193
+        )
194
+
195
+        workspaces_names = [w.name for w in children]
196
+        assert 'Recipes' in workspaces_names, 'Recipes should be in names ({0})'.format(
197
+            workspaces_names,
198
+        )
199
+        assert 'Business' in workspaces_names, 'Business should be in names ({0})'.format(
200
+            workspaces_names,
201
+        )
202
+
203
+    def test_unit__list_workspace_folders__ok(self):
204
+        provider = self._get_provider(self.app_config)
205
+        Recipes = provider.getResourceInst(
206
+            '/Recipes/',
207
+            self._get_environ(
208
+                provider,
209
+                'bob@fsf.local',
210
+            )
211
+        )
212
+        assert Recipes, 'Path /Recipes should return a Wrkspace instance'
213
+
214
+        children = Recipes.getMemberList()
215
+        eq_(
216
+            2,
217
+            len(children),
218
+            msg='Recipes should list 2 folders instead {0}'.format(
219
+                len(children),
220
+            ),
221
+        )
222
+
223
+        folders_names = [f.name for f in children]
224
+        assert 'Salads' in folders_names, 'Salads should be in names ({0})'.format(
225
+                folders_names,
226
+        )
227
+        assert 'Desserts' in folders_names, 'Desserts should be in names ({0})'.format(
228
+                folders_names,
229
+        )
230
+
231
+    def test_unit__list_content__ok(self):
232
+        provider = self._get_provider(self.app_config)
233
+        Salads = provider.getResourceInst(
234
+            '/Recipes/Desserts',
235
+            self._get_environ(
236
+                provider,
237
+                'bob@fsf.local',
238
+            )
239
+        )
240
+        assert Salads, 'Path /Salads should return a Wrkspace instance'
241
+
242
+        children = Salads.getMemberList()
243
+        eq_(
244
+            5,
245
+            len(children),
246
+            msg='Salads should list 5 Files instead {0}'.format(
247
+                len(children),
248
+            ),
249
+        )
250
+
251
+        content_names = [c.name for c in children]
252
+        assert 'Brownie Recipe.html' in content_names, \
253
+            'Brownie Recipe.html should be in names ({0})'.format(
254
+                content_names,
255
+        )
256
+
257
+        assert 'Best Cakesʔ.html' in content_names,\
258
+            'Best Cakesʔ.html should be in names ({0})'.format(
259
+                content_names,
260
+        )
261
+        assert 'Apple_Pie.txt' in content_names,\
262
+            'Apple_Pie.txt should be in names ({0})'.format(content_names,)
263
+
264
+        assert 'Fruits Desserts' in content_names, \
265
+            'Fruits Desserts should be in names ({0})'.format(
266
+                content_names,
267
+        )
268
+
269
+        assert 'Tiramisu Recipe.html' in content_names,\
270
+            'Tiramisu Recipe.html should be in names ({0})'.format(
271
+                content_names,
272
+        )
273
+
274
+    def test_unit__get_content__ok(self):
275
+        provider = self._get_provider(self.app_config)
276
+        pie = provider.getResourceInst(
277
+            '/Recipes/Desserts/Apple_Pie.txt',
278
+            self._get_environ(
279
+                provider,
280
+                'bob@fsf.local',
281
+            )
282
+        )
283
+
284
+        assert pie, 'Apple_Pie should be found'
285
+        eq_('Apple_Pie.txt', pie.name)
286
+
287
+    def test_unit__delete_content__ok(self):
288
+        provider = self._get_provider(self.app_config)
289
+        pie = provider.getResourceInst(
290
+            '/Recipes/Desserts/Apple_Pie.txt',
291
+            self._get_environ(
292
+                provider,
293
+                'bob@fsf.local',
294
+            )
295
+        )
296
+        
297
+        content_pie = self.session.query(ContentRevisionRO) \
298
+            .filter(Content.label == 'Apple_Pie') \
299
+            .one()  # It must exist only one revision, cf fixtures
300
+        eq_(
301
+            False,
302
+            content_pie.is_deleted,
303
+            msg='Content should not be deleted !'
304
+        )
305
+        content_pie_id = content_pie.content_id
306
+
307
+        pie.delete()
308
+
309
+        self.session.flush()
310
+        content_pie = self.session.query(ContentRevisionRO) \
311
+            .filter(Content.content_id == content_pie_id) \
312
+            .order_by(Content.revision_id.desc()) \
313
+            .first()
314
+        eq_(
315
+            True,
316
+            content_pie.is_deleted,
317
+            msg='Content should be deleted!'
318
+        )
319
+
320
+        result = provider.getResourceInst(
321
+            '/Recipes/Desserts/Apple_Pie.txt',
322
+            self._get_environ(
323
+                provider,
324
+                'bob@fsf.local',
325
+            )
326
+        )
327
+        eq_(None, result, msg='Result should be None instead {0}'.format(
328
+            result
329
+        ))
330
+
331
+    def test_unit__create_content__ok(self):
332
+        provider = self._get_provider(self.app_config)
333
+        environ = self._get_environ(
334
+            provider,
335
+            'bob@fsf.local',
336
+        )
337
+        result = provider.getResourceInst(
338
+            '/Recipes/Salads/greek_salad.txt',
339
+            environ,
340
+        )
341
+
342
+        eq_(None, result, msg='Result should be None instead {0}'.format(
343
+            result
344
+        ))
345
+
346
+        result = self._put_new_text_file(
347
+            provider,
348
+            environ,
349
+            '/Recipes/Salads/greek_salad.txt',
350
+            b'Greek Salad\n',
351
+        )
352
+
353
+        assert result, 'Result should not be None instead {0}'.format(
354
+            result
355
+        )
356
+        eq_(
357
+            b'Greek Salad\n',
358
+            result.content.depot_file.file.read(),
359
+            msg='fiel content should be "Greek Salad\n" but it is {0}'.format(
360
+                result.content.depot_file.file.read()
361
+            )
362
+        )
363
+
364
+    def test_unit__create_delete_and_create_file__ok(self):
365
+        provider = self._get_provider(self.app_config)
366
+        environ = self._get_environ(
367
+            provider,
368
+            'bob@fsf.local',
369
+        )
370
+        new_file = provider.getResourceInst(
371
+            '/Recipes/Salads/greek_salad.txt',
372
+            environ,
373
+        )
374
+
375
+        eq_(None, new_file, msg='Result should be None instead {0}'.format(
376
+            new_file
377
+        ))
378
+
379
+        # create it
380
+        new_file = self._put_new_text_file(
381
+            provider,
382
+            environ,
383
+            '/Recipes/Salads/greek_salad.txt',
384
+            b'Greek Salad\n',
385
+        )
386
+        assert new_file, 'Result should not be None instead {0}'.format(
387
+            new_file
388
+        )
389
+
390
+        content_new_file = self.session.query(ContentRevisionRO) \
391
+            .filter(Content.label == 'greek_salad') \
392
+            .one()  # It must exist only one revision
393
+        eq_(
394
+            False,
395
+            content_new_file.is_deleted,
396
+            msg='Content should not be deleted!'
397
+        )
398
+        content_new_file_id = content_new_file.content_id
399
+
400
+        # Delete if
401
+        new_file.delete()
402
+
403
+        self.session.flush()
404
+        content_pie = self.session.query(ContentRevisionRO) \
405
+            .filter(Content.content_id == content_new_file_id) \
406
+            .order_by(Content.revision_id.desc()) \
407
+            .first()
408
+        eq_(
409
+            True,
410
+            content_pie.is_deleted,
411
+            msg='Content should be deleted!'
412
+        )
413
+
414
+        result = provider.getResourceInst(
415
+            '/Recipes/Salads/greek_salad.txt',
416
+            self._get_environ(
417
+                provider,
418
+                'bob@fsf.local',
419
+            )
420
+        )
421
+        eq_(None, result, msg='Result should be None instead {0}'.format(
422
+            result
423
+        ))
424
+
425
+        # Then create it again
426
+        new_file = self._put_new_text_file(
427
+            provider,
428
+            environ,
429
+            '/Recipes/Salads/greek_salad.txt',
430
+            b'greek_salad\n',
431
+        )
432
+        assert new_file, 'Result should not be None instead {0}'.format(
433
+            new_file
434
+        )
435
+
436
+        # Previous file is still dleeted
437
+        self.session.flush()
438
+        content_pie = self.session.query(ContentRevisionRO) \
439
+            .filter(Content.content_id == content_new_file_id) \
440
+            .order_by(Content.revision_id.desc()) \
441
+            .first()
442
+        eq_(
443
+            True,
444
+            content_pie.is_deleted,
445
+            msg='Content should be deleted!'
446
+        )
447
+
448
+        # And an other file exist for this name
449
+        content_new_new_file = self.session.query(ContentRevisionRO) \
450
+            .filter(Content.label == 'greek_salad') \
451
+            .order_by(Content.revision_id.desc()) \
452
+            .first()
453
+        assert content_new_new_file.content_id != content_new_file_id,\
454
+            'Contents ids should not be same!'
455
+
456
+        eq_(
457
+            False,
458
+            content_new_new_file.is_deleted,
459
+            msg='Content should not be deleted!'
460
+        )
461
+
462
+    def test_unit__rename_content__ok(self):
463
+        provider = self._get_provider(self.app_config)
464
+        environ = self._get_environ(
465
+            provider,
466
+            'bob@fsf.local',
467
+        )
468
+        pie = provider.getResourceInst(
469
+            '/Recipes/Desserts/Apple_Pie.txt',
470
+            environ,
471
+        )
472
+
473
+        content_pie = self.session.query(ContentRevisionRO) \
474
+            .filter(Content.label == 'Apple_Pie') \
475
+            .one()  # It must exist only one revision, cf fixtures
476
+        assert content_pie, 'Apple_Pie should be exist'
477
+        content_pie_id = content_pie.content_id
478
+
479
+        pie.moveRecursive('/Recipes/Desserts/Apple_Pie_RENAMED.txt')
480
+
481
+        # Database content is renamed
482
+        content_pie = self.session.query(ContentRevisionRO) \
483
+            .filter(ContentRevisionRO.content_id == content_pie_id) \
484
+            .order_by(ContentRevisionRO.revision_id.desc()) \
485
+            .first()
486
+        eq_(
487
+            'Apple_Pie_RENAMED',
488
+            content_pie.label,
489
+            msg='File should be labeled Apple_Pie_RENAMED, not {0}'.format(
490
+                content_pie.label
491
+            )
492
+        )
493
+
494
+    def test_unit__move_content__ok(self):
495
+        provider = self._get_provider(self.app_config)
496
+        environ = self._get_environ(
497
+            provider,
498
+            'bob@fsf.local',
499
+        )
500
+        pie = provider.getResourceInst(
501
+            '/Recipes/Desserts/Apple_Pie.txt',
502
+            environ,
503
+        )
504
+
505
+        content_pie = self.session.query(ContentRevisionRO) \
506
+            .filter(Content.label == 'Apple_Pie') \
507
+            .one()  # It must exist only one revision, cf fixtures
508
+        assert content_pie, 'Apple_Pie should be exist'
509
+        content_pie_id = content_pie.content_id
510
+        content_pie_parent = content_pie.parent
511
+        eq_(
512
+            content_pie_parent.label,
513
+            'Desserts',
514
+            msg='field parent should be Desserts',
515
+        )
516
+
517
+        pie.moveRecursive('/Recipes/Salads/Apple_Pie.txt')  # move in f2
518
+
519
+        # Database content is moved
520
+        content_pie = self.session.query(ContentRevisionRO) \
521
+            .filter(ContentRevisionRO.content_id == content_pie_id) \
522
+            .order_by(ContentRevisionRO.revision_id.desc()) \
523
+            .first()
524
+
525
+        assert content_pie.parent.label != content_pie_parent.label,\
526
+            'file should be moved in Salads but is in {0}'.format(
527
+                content_pie.parent.label
528
+        )
529
+
530
+    def test_unit__move_and_rename_content__ok(self):
531
+        provider = self._get_provider(self.app_config)
532
+        environ = self._get_environ(
533
+            provider,
534
+            'bob@fsf.local',
535
+        )
536
+        pie = provider.getResourceInst(
537
+            '/Recipes/Desserts/Apple_Pie.txt',
538
+            environ,
539
+        )
540
+
541
+        content_pie = self.session.query(ContentRevisionRO) \
542
+            .filter(Content.label == 'Apple_Pie') \
543
+            .one()  # It must exist only one revision, cf fixtures
544
+        assert content_pie, 'Apple_Pie should be exist'
545
+        content_pie_id = content_pie.content_id
546
+        content_pie_parent = content_pie.parent
547
+        eq_(
548
+            content_pie_parent.label,
549
+            'Desserts',
550
+            msg='field parent should be Desserts',
551
+        )
552
+
553
+        pie.moveRecursive('/Others/Infos/Apple_Pie_RENAMED.txt')
554
+
555
+        # Database content is moved
556
+        content_pie = self.session.query(ContentRevisionRO) \
557
+            .filter(ContentRevisionRO.content_id == content_pie_id) \
558
+            .order_by(ContentRevisionRO.revision_id.desc()) \
559
+            .first()
560
+        assert content_pie.parent.label != content_pie_parent.label,\
561
+            'file should be moved in Recipesf2 but is in {0}'.format(
562
+                content_pie.parent.label
563
+        )
564
+        eq_(
565
+            'Apple_Pie_RENAMED',
566
+            content_pie.label,
567
+            msg='File should be labeled Apple_Pie_RENAMED, not {0}'.format(
568
+                content_pie.label
569
+            )
570
+        )
571
+
572
+    def test_unit__move_content__ok__another_workspace(self):
573
+        provider = self._get_provider(self.app_config)
574
+        environ = self._get_environ(
575
+            provider,
576
+            'bob@fsf.local',
577
+        )
578
+        content_to_move_res = provider.getResourceInst(
579
+            '/Recipes/Desserts/Apple_Pie.txt',
580
+            environ,
581
+        )
582
+
583
+        content_to_move = self.session.query(ContentRevisionRO) \
584
+            .filter(Content.label == 'Apple_Pie') \
585
+            .one()  # It must exist only one revision, cf fixtures
586
+        assert content_to_move, 'Apple_Pie should be exist'
587
+        content_to_move_id = content_to_move.content_id
588
+        content_to_move_parent = content_to_move.parent
589
+        eq_(
590
+            content_to_move_parent.label,
591
+            'Desserts',
592
+            msg='field parent should be Desserts',
593
+        )
594
+
595
+        content_to_move_res.moveRecursive('/Others/Infos/Apple_Pie.txt')  # move in Business, f1
596
+
597
+        # Database content is moved
598
+        content_to_move = self.session.query(ContentRevisionRO) \
599
+            .filter(ContentRevisionRO.content_id == content_to_move_id) \
600
+            .order_by(ContentRevisionRO.revision_id.desc()) \
601
+            .first()
602
+
603
+        assert content_to_move.parent, 'Content should have a parent'
604
+
605
+        assert content_to_move.parent.label == 'Infos',\
606
+            'file should be moved in Infos but is in {0}'.format(
607
+                content_to_move.parent.label
608
+        )
609
+
610
+    def test_unit__update_content__ok(self):
611
+        provider = self._get_provider(self.app_config)
612
+        environ = self._get_environ(
613
+            provider,
614
+            'bob@fsf.local',
615
+        )
616
+        result = provider.getResourceInst(
617
+            '/Recipes/Salads/greek_salad.txt',
618
+            environ,
619
+        )
620
+
621
+        eq_(None, result, msg='Result should be None instead {0}'.format(
622
+            result
623
+        ))
624
+
625
+        result = self._put_new_text_file(
626
+            provider,
627
+            environ,
628
+            '/Recipes/Salads/greek_salad.txt',
629
+            b'hello\n',
630
+        )
631
+
632
+        assert result, 'Result should not be None instead {0}'.format(
633
+            result
634
+        )
635
+        eq_(
636
+            b'hello\n',
637
+            result.content.depot_file.file.read(),
638
+            msg='fiel content should be "hello\n" but it is {0}'.format(
639
+                result.content.depot_file.file.read()
640
+            )
641
+        )
642
+
643
+        # ReInit DummyNotifier counter
644
+        DummyNotifier.send_count = 0
645
+
646
+        # Update file content
647
+        write_object = result.beginWrite(
648
+            contentType='application/octet-stream',
649
+        )
650
+        write_object.write(b'An other line')
651
+        write_object.close()
652
+        result.endWrite(withErrors=False)
653
+
654
+        eq_(
655
+            1,
656
+            DummyNotifier.send_count,
657
+            msg='DummyNotifier should send 1 mail, not {}'.format(
658
+                DummyNotifier.send_count
659
+            ),
660
+        )

+ 115 - 0
backend/tracim/tests/library/test_workspace.py View File

@@ -0,0 +1,115 @@
1
+# -*- coding: utf-8 -*-
2
+
3
+from tracim.lib.core.content import ContentApi
4
+from tracim.lib.core.group import GroupApi
5
+from tracim.lib.core.user import UserApi
6
+from tracim.lib.core.userworkspace import RoleApi
7
+from tracim.lib.core.workspace import WorkspaceApi
8
+from tracim.models import Content
9
+from tracim.models import User
10
+from tracim.models.auth import Group
11
+from tracim.models.data import UserRoleInWorkspace
12
+from tracim.models.data import Workspace
13
+#from tracim.tests import BaseTestThread
14
+from tracim.tests import DefaultTest
15
+from tracim.tests import eq_
16
+
17
+class TestThread(DefaultTest):
18
+
19
+    def test_children(self):
20
+        admin = self.session.query(User).filter(
21
+            User.email == 'admin@admin.admin'
22
+        ).one()
23
+        self._create_thread_and_test(
24
+            workspace_name='workspace_1',
25
+            folder_name='folder_1',
26
+            thread_name='thread_1',
27
+            user=admin
28
+        )
29
+        workspace = self.session.query(Workspace).filter(
30
+            Workspace.label == 'workspace_1'
31
+        ).one()
32
+        content_api = ContentApi(
33
+            session=self.session,
34
+            current_user=admin,
35
+            config=self.app_config,
36
+        )
37
+        folder = content_api.get_canonical_query().filter(
38
+            Content.label == 'folder_1'
39
+        ).one()
40
+        eq_([folder, ], list(workspace.get_valid_children()))
41
+
42
+    def test_get_notifiable_roles(self):
43
+        admin = self.session.query(User) \
44
+            .filter(User.email == 'admin@admin.admin').one()
45
+        wapi = WorkspaceApi(
46
+            session=self.session,
47
+            config=self.app_config,
48
+            current_user=admin,
49
+        )
50
+        w = wapi.create_workspace(label='workspace w', save_now=True)
51
+        uapi = UserApi(
52
+            session=self.session,
53
+            current_user=admin,
54
+            config=self.config
55
+        )
56
+        u = uapi.create_minimal_user(email='u.u@u.u', save_now=True)
57
+        eq_([], wapi.get_notifiable_roles(workspace=w))
58
+        rapi = RoleApi(
59
+            session=self.session,
60
+            current_user=admin,
61
+            config=self.app_config,
62
+        )
63
+        r = rapi.create_one(u, w, UserRoleInWorkspace.READER, with_notif=True)
64
+        eq_([r, ], wapi.get_notifiable_roles(workspace=w))
65
+        u.is_active = False
66
+        eq_([], wapi.get_notifiable_roles(workspace=w))
67
+
68
+    def test_unit__get_all_manageable(self):
69
+        admin = self.session.query(User) \
70
+            .filter(User.email == 'admin@admin.admin').one()
71
+        uapi = UserApi(
72
+            session=self.session,
73
+            current_user=admin,
74
+            config=self.config,
75
+        )
76
+        # Checks a case without workspaces.
77
+        wapi = WorkspaceApi(
78
+            session=self.session,
79
+            current_user=admin,
80
+            config=self.app_config,
81
+        )
82
+        eq_([], wapi.get_all_manageable())
83
+        # Checks an admin gets all workspaces.
84
+        w4 = wapi.create_workspace(label='w4')
85
+        w3 = wapi.create_workspace(label='w3')
86
+        w2 = wapi.create_workspace(label='w2')
87
+        w1 = wapi.create_workspace(label='w1')
88
+        eq_([w1, w2, w3, w4], wapi.get_all_manageable())
89
+        # Checks a regular user gets none workspace.
90
+        gapi = GroupApi(
91
+            session=self.session,
92
+            current_user=None,
93
+            config=self.app_config,
94
+        )
95
+        u = uapi.create_minimal_user('u.s@e.r', [gapi.get_one(Group.TIM_USER)], True)
96
+        wapi = WorkspaceApi(
97
+            session=self.session,
98
+            current_user=u,
99
+            config=self.app_config,
100
+        )
101
+        rapi = RoleApi(
102
+            session=self.session,
103
+            current_user=u,
104
+            config=self.app_config,
105
+        )
106
+        rapi.create_one(u, w4, UserRoleInWorkspace.READER, False)
107
+        rapi.create_one(u, w3, UserRoleInWorkspace.CONTRIBUTOR, False)
108
+        rapi.create_one(u, w2, UserRoleInWorkspace.CONTENT_MANAGER, False)
109
+        rapi.create_one(u, w1, UserRoleInWorkspace.WORKSPACE_MANAGER, False)
110
+        eq_([], wapi.get_all_manageable())
111
+        # Checks a manager gets only its own workspaces.
112
+        u.groups.append(gapi.get_one(Group.TIM_MANAGER))
113
+        rapi.delete_one(u.user_id, w2.workspace_id)
114
+        rapi.create_one(u, w2, UserRoleInWorkspace.WORKSPACE_MANAGER, False)
115
+        eq_([w1, w2], wapi.get_all_manageable())

+ 2 - 0
backend/tracim/tests/models/__init__.py View File

@@ -0,0 +1,2 @@
1
+# -*- coding: utf-8 -*-
2
+"""Unit test suite for the models of the application."""

+ 0 - 0
backend/tracim/tests/models/test_content.py View File


Some files were not shown because too many files changed in this diff