Browse Source

A lot of optimization in database calls, new way to get content from path to limit calls, a lot of comments and integrated css

Nonolost 8 years ago
parent
commit
08a086ed26

+ 1 - 1
install/requirements.txt View File

@@ -59,7 +59,7 @@ unicode-slugify==0.1.3
59 59
 vobject==0.9.2
60 60
 waitress==0.8.9
61 61
 who-ldap==3.1.0
62
--e git+git@github.com:Nonolost/wsgidav.git@73f832e6bd94ac21ae608af43a9516ff31abc9a2#egg=wsgidav
62
+-e git+git@github.com:Nonolost/wsgidav.git@a5df223d5c66c252baa89ec5de8073f9f1494a8f#egg=wsgidav
63 63
 zope.interface==4.1.3
64 64
 zope.sqlalchemy==0.7.6
65 65
 

+ 38 - 8
tracim/tracim/lib/content.py View File

@@ -408,14 +408,44 @@ class ContentApi(object):
408 408
 
409 409
         resultset = resultset.filter(Content.parent_id == parent_id)
410 410
 
411
-        try:
412
-            return resultset.filter(Content.label == content_label).one()
413
-        except:
414
-            try:
415
-                return resultset.filter(Content.file_name == content_label).one()
416
-            except:
417
-                import re
418
-                return resultset.filter(Content.label == re.sub(r'\.[^.]+$', '', content_label)).one()
411
+        return resultset.filter(or_(
412
+            Content.label == content_label,
413
+            Content.file_name == content_label,
414
+            Content.label == re.sub(r'\.[^.]+$', '', content_label)
415
+        )).one()
416
+
417
+    def get_one_by_label_and_parent_label(self, content_label: str, content_parent_label: [str]=None, workspace: Workspace=None):
418
+        assert content_label is not None  # DYN_REMOVE
419
+        resultset = self._base_query(workspace)
420
+
421
+        res =  resultset.filter(or_(
422
+            Content.label == content_label,
423
+            Content.file_name == content_label,
424
+            Content.label == re.sub(r'\.[^.]+$', '', content_label)
425
+        )).all()
426
+
427
+        if content_parent_label:
428
+            tmp = dict()
429
+            for content in res:
430
+                tmp[content] = content.parent
431
+
432
+            for parent_label in reversed(content_parent_label):
433
+                a = []
434
+                tmp = {content: parent.parent for content, parent in tmp.items()
435
+                       if parent and parent.label == parent_label}
436
+
437
+                if len(tmp) == 1:
438
+                    content, last_parent = tmp.popitem()
439
+                    return content
440
+                elif len(tmp) == 0:
441
+                    return None
442
+
443
+            for content, parent_content in tmp.items():
444
+                if not parent_content:
445
+                    return content
446
+
447
+            return None
448
+        return res[0]
419 449
 
420 450
     def get_all(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> [Content]:
421 451
         assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE

+ 11 - 25
tracim/tracim/lib/daemons.py View File

@@ -231,7 +231,7 @@ from wsgidav.wsgidav_app import WsgiDAVApp
231 231
 from wsgidav._version import __version__
232 232
 
233 233
 from tracim.lib.webdav.sql_dav_provider import Provider
234
-from tracim.lib.webdav.sql_domain_controller import SQLDomainController
234
+from tracim.lib.webdav.sql_domain_controller import TracimDomainController
235 235
 
236 236
 from inspect import isfunction
237 237
 import traceback
@@ -239,17 +239,6 @@ import traceback
239 239
 DEFAULT_CONFIG_FILE = "wsgidav.conf"
240 240
 PYTHON_VERSION = "%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])
241 241
 
242
-def _get_checked_path(path, mustExist=True, allowNone=True):
243
-    """Convert path to absolute if not None."""
244
-    if path in (None, ""):
245
-        if allowNone:
246
-            return None
247
-        else:
248
-            raise ValueError("Invalid path %r" % path)
249
-    path = os.path.abspath(path)
250
-    if mustExist and not os.path.exists(path):
251
-        raise ValueError("Invalid path %r" % path)
252
-    return path
253 242
 
254 243
 class WsgiDavDaemon(Daemon):
255 244
 
@@ -280,19 +269,16 @@ class WsgiDavDaemon(Daemon):
280 269
 
281 270
         config['middleware_stack'] = [ WsgiDavDirBrowser, TracimHTTPAuthenticator, ErrorPrinter, WsgiDavDebugFilter ]
282 271
 
283
-        config['provider_mapping'] = \
284
-            {
285
-                config['root_path']: Provider(
286
-                    show_archived=config['show_archived'],
287
-                    show_deleted=config['show_deleted'],
288
-                    show_history=config['show_history'],
289
-                    manage_locks=config['manager_locks']
290
-                )
291
-            }
292
-
293
-        config['domaincontroller'] = SQLDomainController(presetdomain=None, presetserver=None)
294
-        config['defaultdigest'] = True
295
-        config['acceptdigest'] = True
272
+        config['provider_mapping'] = {
273
+            config['root_path']: Provider(
274
+                show_archived=config['show_archived'],
275
+                show_deleted=config['show_deleted'],
276
+                show_history=config['show_history'],
277
+                manage_locks=config['manager_locks']
278
+            )
279
+        }
280
+
281
+        config['domaincontroller'] = TracimDomainController(presetdomain=None, presetserver=None)
296 282
 
297 283
         return config
298 284
 

+ 77 - 28
tracim/tracim/lib/webdav/__init__.py View File

@@ -1,12 +1,16 @@
1
-from wsgidav.compat import to_bytes
1
+# coding: utf8
2
+
3
+import transaction
4
+from wsgidav import util
5
+from wsgidav import compat
2 6
 
3 7
 from tracim.lib.content import ContentApi
4 8
 from tracim.model import new_revision
5
-from tracim.model.data import ActionDescription, ContentType, Content, Workspace
6
-from wsgidav import util
9
+from tracim.model.data import ActionDescription
10
+from tracim.model.data import ContentType
11
+from tracim.model.data import Content
12
+from tracim.model.data import Workspace
7 13
 
8
-import transaction
9
-from wsgidav import compat
10 14
 
11 15
 class HistoryType(object):
12 16
     Deleted = 'deleted'
@@ -15,51 +19,95 @@ class HistoryType(object):
15 19
     All = 'all'
16 20
 
17 21
 
22
+class SpecialFolderExtension(object):
23
+    Deleted = '/.deleted'
24
+    Archived = '/.archived'
25
+    History = '/.history'
26
+
27
+
18 28
 class FakeFileStream(object):
19
-    """Fake a FileStream object that is needed by wsgidav to create or update existing files
20
-    with new content"""
29
+    """
30
+    Fake a FileStream that we're giving to wsgidav to receive data and create files / new revisions
21 31
 
22
-    def __init__(self, content_api: ContentApi, workspace: Workspace,
32
+    There's two scenarios :
33
+    - when a new file is created, wsgidav will call the method createEmptyResource and except to get a _DAVResource
34
+    which should have both 'beginWrite' and 'endWrite' method implemented
35
+    - when a file which already exists is updated, he's going to call the 'beginWrite' function of the _DAVResource
36
+    to get a filestream and write content in it
37
+
38
+    In the first case scenario, the transfer takes two part : it first create the resource (createEmptyResource)
39
+    then add its content (beginWrite, write, close..). If we went without this class, we would create two revision
40
+    of the file upon creating a new file, which is not what we want.
41
+    """
42
+
43
+    def __init__(self, content_api: ContentApi, workspace: Workspace, path: str,
23 44
                  file_name: str='', content: Content=None, parent: Content=None):
24 45
         """
25 46
 
26
-        :param file_name: the filename if the file is new
27 47
         :param content_api:
28
-        :param workspace: content's workspace, necessary if the file is new as we've got no other way to get it
29
-        :param content: either the content to be updated or None if it's a new file
48
+        :param workspace:
49
+        :param path:
50
+        :param file_name:
51
+        :param content:
52
+        :param parent:
30 53
         """
31
-        self._buff = compat.BytesIO()
54
+        self._file_stream = compat.BytesIO()
32 55
 
33 56
         self._file_name = file_name if file_name != '' else self._content.file_name
34 57
         self._content = content
35 58
         self._api = content_api
36 59
         self._workspace = workspace
37 60
         self._parent = parent
61
+        self._path = path
62
+
63
+    def getRefUrl(self) -> str:
64
+        """
65
+        As wsgidav expect to receive a _DAVResource upon creating a new resource, this method's result is used
66
+        by Windows client to establish both file's path and file's name
67
+        """
68
+        return self._path
38 69
 
39 70
     def beginWrite(self, contentType) -> 'FakeFileStream':
40
-        """Called by request_server to user as a file stream to write bits by bits content into a filestream"""
71
+        """
72
+        Called by wsgidav, it expect a filestream which possess both 'write' and 'close' operation to write
73
+        the file content.
74
+        """
41 75
         return self
42 76
 
43 77
     def endWrite(self, withErrors: bool):
44
-        """Called by request_server when finished writing everythin, wdc"""
78
+        """
79
+        Called by request_server when finished writing everything.
80
+        As we call operation to create new content or revision in the close operation, called before endWrite, there
81
+        is nothing to do here.
82
+        """
45 83
         pass
46 84
 
47 85
     def write(self, s: str):
48
-        """Called by request_server when writing content to files, we stock it in our file"""
49
-        self._buff.write(s)
86
+        """
87
+        Called by request_server when writing content to files, we put it inside a filestream
88
+        """
89
+        self._file_stream.write(s)
50 90
 
51 91
     def close(self):
52
-        """Called by request_server when everything has been written and we either update the file or
53
-        create a new file"""
92
+        """
93
+        Called by request_server when the file content has been written. We either add a new content or create
94
+        a new revision
95
+        """
54 96
 
55
-        self._buff.seek(0)
97
+        self._file_stream.seek(0)
56 98
 
57 99
         if self._content is None:
58
-            self.create_file(self._buff)
100
+            self.create_file()
59 101
         else:
60
-            self.update_file(self._buff)
102
+            self.update_file()
103
+
104
+        transaction.commit()
105
+
106
+    def create_file(self):
107
+        """
108
+        Called when this is a new file; will create a new Content initialized with the correct content
109
+        """
61 110
 
62
-    def create_file(self, item_content):
63 111
         is_temporary = self._file_name.startswith('.~') or self._file_name.startswith('~')
64 112
 
65 113
         file = self._api.create(
@@ -73,21 +121,22 @@ class FakeFileStream(object):
73 121
             file,
74 122
             self._file_name,
75 123
             util.guessMimeType(self._file_name),
76
-            item_content.read()
124
+            self._file_stream.read()
77 125
         )
78 126
 
79 127
         self._api.save(file, ActionDescription.CREATION)
80 128
 
81
-        transaction.commit()
129
+    def update_file(self):
130
+        """
131
+        Called when we're updating an existing content; we create a new revision and update the file content
132
+        """
82 133
 
83
-    def update_file(self, item_content):
84 134
         with new_revision(self._content):
85 135
             self._api.update_file_data(
86 136
                 self._content,
87 137
                 self._file_name,
88 138
                 util.guessMimeType(self._content.file_name),
89
-                item_content.read()
139
+                self._file_stream.read()
90 140
             )
91
-            self._api.save(self._content, ActionDescription.EDITION)
92 141
 
93
-        transaction.commit()
142
+            self._api.save(self._content, ActionDescription.EDITION)

+ 17 - 12
tracim/tracim/lib/webdav/design.py View File

@@ -1,11 +1,11 @@
1
+#coding: utf8
2
+from datetime import datetime
3
+
1 4
 from tracim.model.data import VirtualEvent
2
-from tracim.model import data
3 5
 from tracim.model.data import ContentType
4
-from datetime import datetime
6
+from tracim.model import data
5 7
 
6 8
 def create_readable_date(created, delta_from_datetime: datetime = None):
7
-    aff = ''
8
-
9 9
     if not delta_from_datetime:
10 10
         delta_from_datetime = datetime.now()
11 11
 
@@ -29,9 +29,9 @@ def create_readable_date(created, delta_from_datetime: datetime = None):
29 29
     return aff
30 30
 
31 31
 def designPage(content: data.Content, content_revision: data.ContentRevisionRO) -> str:
32
-    # f = open('wsgidav/addons/webdav/style.css', 'r')
33
-    style = ''  # f.read()
34
-    # f.close()
32
+    f = open('tracim/lib/webdav/style.css', 'r')
33
+    style = f.read()
34
+    f.close()
35 35
 
36 36
     hist = content.get_history()
37 37
     histHTML = '<table class="table table-striped table-hover">'
@@ -65,8 +65,8 @@ def designPage(content: data.Content, content_revision: data.ContentRevisionRO)
65 65
                        label,
66 66
                        date,
67 67
                        event.owner.display_name,
68
-                       '<i class="fa fa-caret-left"></i> shown' if event.id == content_revision.revision_id else '''<span><a class="revision-link" href="/.history/%s/%s-%s">(View revision)</a></span>''' % (
69
-                       content.label, event.id, event.ref_object.label) if event.type.id in ['revision', 'creation', 'edition'] else '')
68
+                       '<i class="fa fa-caret-left"></i> shown' if event.id == content_revision.revision_id else '''<span><a class="revision-link" href="/.history/%s/(%s - %s) %s.html">(View revision)</a></span>''' % (
69
+                       content.label, event.id, event.type.id, event.ref_object.label) if event.type.id in ['revision', 'creation', 'edition'] else '')
70 70
 
71 71
     histHTML += '</table>'
72 72
 
@@ -76,7 +76,7 @@ def designPage(content: data.Content, content_revision: data.ContentRevisionRO)
76 76
 	<title>%s</title>
77 77
 	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
78 78
 	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css">
79
-	<link rel="stylesheet" href="/home/arnaud/Documents/css/style.css">
79
+	<style>%s</style>
80 80
 	<script type="text/javascript" src="/home/arnaud/Documents/css/script.js"></script>
81 81
 	<script
82 82
 			  src="https://code.jquery.com/jquery-3.1.0.min.js"
@@ -121,6 +121,7 @@ def designPage(content: data.Content, content_revision: data.ContentRevisionRO)
121 121
 </body>
122 122
 </html>
123 123
         ''' % (content_revision.label,
124
+               style,
124 125
                content_revision.label,
125 126
                content.created.strftime("%B %d, %Y at %H:%m"),
126 127
                content.owner.display_name,
@@ -130,6 +131,9 @@ def designPage(content: data.Content, content_revision: data.ContentRevisionRO)
130 131
     return file
131 132
 
132 133
 def designThread(content: data.Content, content_revision: data.ContentRevisionRO, comments) -> str:
134
+        f = open('tracim/lib/webdav/style.css', 'r')
135
+        style = f.read()
136
+        f.close()
133 137
 
134 138
         hist = content.get_history()
135 139
 
@@ -205,7 +209,7 @@ def designThread(content: data.Content, content_revision: data.ContentRevisionRO
205 209
 	<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js"></script>
206 210
 	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
207 211
 	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css">
208
-	<link rel="stylesheet" href="/home/arnaud/Documents/css/style.css">
212
+	<style>%s</style>
209 213
 	<script type="text/javascript" src="/home/arnaud/Documents/css/script.js"></script>
210 214
 </head>
211 215
 <body>
@@ -277,10 +281,11 @@ def designThread(content: data.Content, content_revision: data.ContentRevisionRO
277 281
 </body>
278 282
 </html>
279 283
         ''' % (content_revision.label,
284
+               style,
280 285
                content_revision.label,
281 286
                content.created.strftime("%B %d, %Y at %H:%m"),
282 287
                content.owner.display_name,
283 288
                content_revision.description,
284 289
                disc)
285 290
 
286
-        return page
291
+        return page

+ 0 - 0
tracim/tracim/lib/webdav/lock_manager.py View File


+ 155 - 202
tracim/tracim/lib/webdav/sql_dav_provider.py View File

@@ -1,41 +1,29 @@
1 1
 # coding: utf8
2 2
 
3
-from tracim.lib.webdav import HistoryType
4
-from tracim.lib.webdav.lock_storage import LockStorage
5
-
6 3
 import re
7 4
 from os.path import basename, dirname, normpath
8
-from tracim.lib.content import ContentApi
9
-from tracim.lib.webdav import sql_resources
10
-from tracim.lib.user import UserApi
11
-from tracim.lib.workspace import WorkspaceApi
12
-from wsgidav import util
5
+
13 6
 from wsgidav.dav_provider import DAVProvider
14 7
 from wsgidav.lock_manager import LockManager
15
-from tracim.model.data import ContentType
16
-
17
-from tracim.lib.content import ContentRevisionRO
18
-######################################
19
-
20
-__docformat__ = "reStructuredText"
21
-_logger = util.getModuleLogger(__name__)
22
-
23
-
24
-def wsgi_decode(s):
25
-    return s.encode('latin1').decode()
26
-
27
-def wsgi_encode(s):
28
-    if isinstance(s, bytes):
29
-        return s.decode('latin1')
30
-    return s.encode().decode('latin1')
31 8
 
9
+from tracim.lib.webdav import HistoryType, SpecialFolderExtension
10
+from tracim.lib.webdav import sql_resources
11
+from tracim.lib.webdav.lock_storage import LockStorage
32 12
 
13
+from tracim.lib.content import ContentApi
14
+from tracim.lib.content import ContentRevisionRO
15
+from tracim.lib.user import UserApi
16
+from tracim.lib.workspace import WorkspaceApi
17
+from tracim.model.data import Content, Workspace
18
+from tracim.model.data import ContentType
33 19
 
34 20
 
35
-# ============================================================
36
-# PostgreSQLProvider
37
-# ============================================================
38 21
 class Provider(DAVProvider):
22
+    """
23
+    This class' role is to provide to wsgidav _DAVResource. Wsgidav will then use them to execute action and send
24
+    informations to the client
25
+    """
26
+
39 27
     def __init__(self, show_history=True, show_deleted=True, show_archived=True, manage_locks=True):
40 28
         super(Provider, self).__init__()
41 29
 
@@ -55,168 +43,180 @@ class Provider(DAVProvider):
55 43
     def show_archive(self):
56 44
         return self._show_archive
57 45
 
58
-    def __repr__(self):
59
-        return 'Provider'
60
-
61 46
     #########################################################
62 47
     # Everything override from DAVProvider
63
-    def getResourceInst(self, path, environ):
64
-        #if not self.exists(path, environ):
65
-        #    return None
66
-        if not self.exists(path, environ):
67
-            return None
68
-
69
-        norm_path = normpath(path)
70
-
48
+    def getResourceInst(self, path: str, environ: dict):
49
+        """
50
+        Called by wsgidav whenever a request is called to get the _DAVResource corresponding to the path
51
+        """
52
+        path = normpath(path)
71 53
         root_path = environ['http_authenticator.realm']
72
-        parent_path = dirname(norm_path)
73 54
 
74
-        user = UserApi(None).get_one_by_email(environ['http_authenticator.username'])
55
+        # If the requested path is the root, then we return a Root resource
56
+        if path == root_path:
57
+            return sql_resources.Root(path, environ)
75 58
 
59
+        user = UserApi(None).get_one_by_email(environ['http_authenticator.username'])
76 60
         workspace_api = WorkspaceApi(user)
61
+        workspace = self.get_workspace_from_path(path, workspace_api)
62
+
63
+        # If the request path is in the form root/name, then we return a Workspace resource
64
+        parent_path = dirname(path)
65
+        if parent_path == root_path:
66
+            return sql_resources.Workspace(path, environ, workspace)
67
+
68
+        # And now we'll work on the path to establish which type or resource is requested
69
+
77 70
         content_api = ContentApi(
78 71
             user,
79 72
             show_archived=self._show_archive,
80 73
             show_deleted=self._show_delete
81 74
         )
82 75
 
83
-        # case we're requesting the root racine of webdav
84
-        if path == root_path:
85
-            return sql_resources.Root(path, environ)
86
-        # case we're at the root racine of a workspace
87
-        elif parent_path == root_path:
88
-            return sql_resources.Workspace(
89
-                path=norm_path,
90
-                environ=environ,
91
-                workspace=self.get_workspace_from_path(
92
-                    norm_path,
93
-                    workspace_api
94
-                )
95
-            )
96
-
97 76
         content = self.get_content_from_path(
98
-            path=norm_path,
77
+            path=path,
99 78
             content_api=content_api,
100
-            workspace_api=workspace_api
79
+            workspace=workspace
101 80
         )
102 81
 
103
-        # is archive
104
-        is_archived_folder = re.search(r'/\.archived$', norm_path) is not None
105 82
 
106
-        if self._show_archive and is_archived_folder:
107
-            return sql_resources.ArchivedFolder(
108
-                path=norm_path,
109
-                environ=environ,
110
-                content=content,
111
-                workspace=self.get_workspace_from_path(norm_path, workspace_api)
112
-            )
113
-
114
-        # is delete
115
-        is_deleted_folder = re.search(r'/\.deleted$', norm_path) is not None
116
-
117
-        if self._show_delete and is_deleted_folder:
118
-            return sql_resources.DeletedFolder(
119
-                path=norm_path,
120
-                environ=environ,
121
-                content=content,
122
-                workspace=self.get_workspace_from_path(norm_path, workspace_api)
123
-            )
83
+        # Easy cases : path either end with /.deleted, /.archived or /.history, then we return corresponding resources
84
+        if path.endswith(SpecialFolderExtension.Archived) and self._show_archive:
85
+            return sql_resources.ArchivedFolder(path, environ, workspace, content)
124 86
 
125
-        # is history
126
-        is_history_folder = re.search(r'/\.history$', norm_path) is not None
87
+        if path.endswith(SpecialFolderExtension.Deleted) and self._show_delete:
88
+            return sql_resources.DeletedFolder(path, environ, workspace, content)
127 89
 
128
-        if self._show_history and is_history_folder:
129
-            is_deleted_folder = re.search(r'/\.deleted/\.history$', norm_path) is not None
130
-            is_archived_folder = re.search(r'/\.archived/\.history$', norm_path) is not None
90
+        if path.endswith(SpecialFolderExtension.History) and self._show_history:
91
+            is_deleted_folder = re.search(r'/\.deleted/\.history$', path) is not None
92
+            is_archived_folder = re.search(r'/\.archived/\.history$', path) is not None
131 93
 
132 94
             type = HistoryType.Deleted if is_deleted_folder \
133 95
                 else HistoryType.Archived if is_archived_folder \
134 96
                 else HistoryType.Standard
135 97
 
136
-            return sql_resources.HistoryFolder(
137
-                path=norm_path,
138
-                environ=environ,
139
-                content=content,
140
-                type=type,
141
-                workspace=self.get_workspace_from_path(norm_path, workspace_api)
142
-            )
98
+            return sql_resources.HistoryFolder(path, environ, workspace, content, type)
143 99
 
144
-        # is history
145
-        is_history_file_folder = re.search(r'/\.history/([^/]+)$', norm_path) is not None
100
+        # Now that's more complicated, we're trying to find out if the path end with /.history/file_name
101
+        is_history_file_folder = re.search(r'/\.history/([^/]+)$', path) is not None
146 102
 
147
-        if self._show_history and is_history_file_folder:
103
+        if is_history_file_folder and self._show_history:
148 104
             return sql_resources.HistoryFileFolder(
149
-                path=norm_path,
105
+                path=path,
150 106
                 environ=environ,
151 107
                 content=content
152 108
             )
153 109
 
154
-        # is history
155
-        is_history_file = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) .+', norm_path) is not None
110
+        # And here next step :
111
+        is_history_file = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) .+', path) is not None
156 112
 
157 113
         if self._show_history and is_history_file:
158
-            content_revision = content_api.get_one_revision(re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) .+', norm_path).group(1))
114
+
115
+            revision_id = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) ([^/].+)$', path).group(1)
116
+
117
+            content_revision = content_api.get_one_revision(revision_id)
159 118
             content = self.get_content_from_revision(content_revision, content_api)
160 119
 
161 120
             if content.type == ContentType.File:
162
-                return sql_resources.HistoryFile(norm_path, environ, content, content_revision)
121
+                return sql_resources.HistoryFile(path, environ, content, content_revision)
163 122
             else:
164
-                return sql_resources.HistoryOtherFile(norm_path, environ, content, content_revision)
123
+                return sql_resources.HistoryOtherFile(path, environ, content, content_revision)
124
+
125
+        # And if we're still going, the client is asking for a standard Folder/File/Page/Thread so we check the type7
126
+        # and return the corresponding resource
165 127
 
166
-        # other
167 128
         if content is None:
168 129
             return None
169 130
         if content.type == ContentType.Folder:
170
-            return sql_resources.Folder(norm_path, environ, content, content.workspace)
131
+            return sql_resources.Folder(path, environ, content.workspace, content)
171 132
         elif content.type == ContentType.File:
172
-            return sql_resources.File(norm_path, environ, content)
173
-        elif content.type in [ContentType.Page, ContentType.Thread]:
174
-            return sql_resources.OtherFile(norm_path, environ, content)
133
+            return sql_resources.File(path, environ, content)
175 134
         else:
176
-            return None
135
+            return sql_resources.OtherFile(path, environ, content)
177 136
 
178
-    def exists(self, path, environ):
179
-        norm_path = normpath(path)
180
-        parent_path = dirname(norm_path)
181
-        root_path = environ['http_authenticator.realm']
137
+    def exists(self, path, environ) -> bool:
138
+        """
139
+        Called by wsgidav to check if a certain path is linked to a _DAVResource
140
+        """
182 141
 
183
-        user = UserApi(None).get_one_by_email(environ['http_authenticator.username'])
184
-        workspace_api = WorkspaceApi(user)
185
-        content_api = ContentApi(
186
-            current_user=user,
187
-            show_archived=True,
188
-            show_deleted=True
189
-        )
142
+        path = normpath(path)
143
+        working_path = self.reduce_path(path)
144
+        root_path = environ['http_authenticator.realm']
145
+        parent_path = dirname(working_path)
190 146
 
191 147
         if path == root_path:
192 148
             return True
193
-        elif parent_path == root_path:
194
-            return self.get_workspace_from_path(
195
-                    norm_path,
196
-                    workspace_api
197
-                ) is not None
198 149
 
199
-        is_archived = re.search(r'/\.archived/(\.history/)?(?!\.history)[^/]*(/\.)?(history|deleted|archived)?$', norm_path) is not None
150
+        user = UserApi(None).get_one_by_email(environ['http_authenticator.username'])
200 151
 
201
-        is_deleted = re.search(r'/\.deleted/(\.history/)?(?!\.history)[^/]*(/\.)?(history|deleted|archived)?$', norm_path) is not None
152
+        workspace = self.get_workspace_from_path(path, WorkspaceApi(user))
202 153
 
203
-        revision_id = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) ([^/].+)$', norm_path)
154
+        if parent_path == root_path:
155
+            return workspace is not None
204 156
 
205
-        blbl = self.reduce_path(norm_path)
206
-        if dirname(blbl) == '/':
207
-            return self.get_workspace_from_path(norm_path, workspace_api) is not None
157
+        content_api = ContentApi(user, show_archived=True, show_deleted=True)
158
+
159
+        revision_id = re.search(r'/\.history/[^/]+/\((\d+) - [a-zA-Z]+\) ([^/].+)$', path)
160
+
161
+        is_archived = self.is_path_archive(path)
162
+
163
+        is_deleted = self.is_path_delete(path)
208 164
 
209 165
         if revision_id:
210 166
             revision_id = revision_id.group(1)
211 167
             content = content_api.get_one_revision(revision_id)
212 168
         else:
213
-            content = self.get_content_from_path(norm_path, content_api, workspace_api)
169
+            content = self.get_content_from_path(working_path, content_api, workspace)
214 170
 
215 171
         return content is not None \
216 172
             and content.is_deleted == is_deleted \
217 173
             and content.is_archived == is_archived
218 174
 
219
-    def reduce_path(self, path):
175
+    def is_path_archive(self, path):
176
+        """
177
+        This function will check if a given path is linked to a file that's archived or not. We're checking if the
178
+        given path end with one of these string :
179
+
180
+        ex:
181
+            - /a/b/.archived/my_file
182
+            - /a/b/.archived/.history/my_file
183
+            - /a/b/.archived/.history/my_file/(3615 - edition) my_file
184
+        """
185
+
186
+        return re.search(
187
+            r'/\.archived/(\.history/)?(?!\.history)[^/]*(/\.)?(history|deleted|archived)?$',
188
+            path
189
+        ) is not None
190
+
191
+    def is_path_delete(self, path):
192
+        """
193
+        This function will check if a given path is linked to a file that's deleted or not. We're checking if the
194
+        given path end with one of these string :
195
+
196
+        ex:
197
+            - /a/b/.deleted/my_file
198
+            - /a/b/.deleted/.history/my_file
199
+            - /a/b/.deleted/.history/my_file/(3615 - edition) my_file
200
+        """
201
+
202
+        return re.search(
203
+            r'/\.deleted/(\.history/)?(?!\.history)[^/]*(/\.)?(history|deleted|archived)?$',
204
+            path
205
+        ) is not None
206
+
207
+    def reduce_path(self, path: str) -> str:
208
+        """
209
+        As we use the given path to request the database
210
+
211
+        ex: if the path is /a/b/.deleted/c/.archived, we're trying to get the archived content of the 'c' resource,
212
+        we need to keep the path /a/b/c
213
+
214
+        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
215
+        the path /a/b/my_file
216
+
217
+        ex: if the path is /a/b/.history/my_file/(1985 - edition) my_old_name, we're looking for,
218
+        thus we remove all useless information
219
+        """
220 220
         path = re.sub(r'/\.archived', r'', path)
221 221
         path = re.sub(r'/\.deleted', r'', path)
222 222
         path = re.sub(r'/\.history/[^/]+/(\d+)-.+', r'/\1', path)
@@ -225,44 +225,50 @@ class Provider(DAVProvider):
225 225
 
226 226
         return path
227 227
 
228
-    def get_content_from_path(self, path, content_api: ContentApi, workspace_api: WorkspaceApi):
228
+    def get_content_from_path(self, path, content_api: ContentApi, workspace: Workspace) -> Content:
229
+        """
230
+        Called whenever we want to get the Content item from the database for a given path
231
+        """
229 232
         path = self.reduce_path(path)
233
+        parent_path = dirname(path)
230 234
 
231
-        workspace = self.get_workspace_from_path(path, workspace_api)
235
+        blbl = parent_path.replace('/'+workspace.label, '')
236
+
237
+        parents = blbl.split('/')
238
+
239
+        parents.remove('')
240
+        parents = [self.transform_to_bdd(x) for x in parents]
232 241
 
233 242
         try:
234
-            if dirname(dirname(path)) == '/':
235
-                return content_api.get_one_by_label_and_parent(
236
-                    self.transform_to_bdd(basename(path)),
237
-                    workspace=workspace
238
-                )
239
-            else:
240
-                parent = self.get_parent_from_path(path, content_api, workspace_api)
241
-                if parent is not None:
242
-                    return content_api.get_one_by_label_and_parent(self.transform_to_bdd(basename(path)), content_parent=parent)
243
-                return None
243
+            return content_api.get_one_by_label_and_parent_label(
244
+                self.transform_to_bdd(basename(path)),
245
+                parents,
246
+                workspace
247
+            )
244 248
         except:
245 249
             return None
246 250
 
247
-    def get_content_from_revision(self, revision: ContentRevisionRO, api:ContentApi):
251
+    def get_content_from_revision(self, revision: ContentRevisionRO, api: ContentApi) -> Content:
248 252
         try:
249 253
             return api.get_one(revision.content_id, ContentType.Any)
250 254
         except:
251 255
             return None
252 256
 
253
-    def get_parent_from_path(self, path, api: ContentApi, workspace_api: WorkspaceApi):
254
-
255
-        return self.get_content_from_path(dirname(path), api, workspace_api)
256
-
257
-    def get_workspace_from_path(self, path: str, api: WorkspaceApi):
258
-        assert path.startswith('/')
257
+    def get_parent_from_path(self, path, api: ContentApi, workspace) -> Content:
258
+        return self.get_content_from_path(dirname(path), api, workspace)
259 259
 
260
+    def get_workspace_from_path(self, path: str, api: WorkspaceApi) -> Workspace:
260 261
         try:
261 262
             return api.get_one_by_label(self.transform_to_bdd(path.split('/')[1]))
262 263
         except:
263 264
             return None
264 265
 
265 266
     def transform_to_display(self, string):
267
+        """
268
+        As characters that Windows does not support may have been inserted through Tracim in names, before displaying
269
+        information we update path so that all these forbidden characters are replaced with similar shape character
270
+        that are allowed so that the user isn't trouble and isn't limited in his naming choice
271
+        """
266 272
         _TO_DISPLAY = {
267 273
             '/':'⧸',
268 274
             '\\': '⧹',
@@ -281,6 +287,9 @@ class Provider(DAVProvider):
281 287
         return string
282 288
 
283 289
     def transform_to_bdd(self, string):
290
+        """
291
+        Called before sending request to the database to recover the right names
292
+        """
284 293
         _TO_BDD = {
285 294
             '⧸': '/',
286 295
             '⧹': '\\',
@@ -297,59 +306,3 @@ class Provider(DAVProvider):
297 306
             string = string.replace(key, value)
298 307
 
299 308
         return string
300
-
301
-
302
-"""
303
-
304
-{'wsgidav.dump_request_body': False,
305
- 'wsgi.run_once': False,
306
- 'wsgi.multiprocess': False,
307
- 'wsgi.multithread': True,
308
- 'QUERY_STRING': '',
309
- 'REQUEST_URI': b'/nouveau/',
310
- 'wsgidav.dump_response_body': False,
311
- 'SERVER_PROTOCOL': 'HTTP/1.1',
312
- 'REMOTE_ADDR': '127.0.0.1',
313
- 'wsgidav.verbose': 1,
314
- 'wsgi.version': (1, 0),
315
- 'wsgidav.config': {
316
-     'middleware_stack':[],
317
-                       'propsmanager': None,
318
-                       'add_header_MS_Author_Via': True,
319
-                       'acceptbasic': True,
320
-                       'user_mapping': {},
321
-                       'enable_loggers': [],
322
-                       'locksmanager': True,
323
-                       'mount_path': None,
324
-                       'catchall': False,
325
-                       'unquote_path_info': False,
326
-                       'provider_mapping': {'': Provider},
327
-                       'port': 3030,
328
-                       'Provider': [],
329
-                       'verbose': 1,
330
-                       'SQLDomainController': [],
331
-                       'domaincontroller': [],
332
-     'acceptdigest': True,
333
-     'dir_browser': {
334
-         'ms_sharepoint_urls': False,
335
-         'ms_mount': False,
336
-         'davmount': False,
337
-         'enable': True,
338
-         'ms_sharepoint_plugin': True,
339
-         'response_trailer': ''
340
-     },
341
-     'defaultdigest': True,
342
-     'host': '0.0.0.0',
343
-     'ext_servers': ['cherrypy-bundled', 'wsgidav']
344
- },
345
- 'http_authenticator.realm': '/',
346
- 'HTTP_AUTHORIZATION': 'Digest username="admin@admin.admin",
347
- realm="/", nonce="=",
348
- uri="/nouveau/",
349
- algorithm=MD5,
350
- response="9c78c484263409b3385ead95ea7bf65b", '
351
- 'cnonce="MHgyMzNkZjkwOjQ4OTU6MTQ2OTc3OTI1NQ==", nc=00000471, qop=auth',
352
- 'HTTP_ACCEPT_ENCODING': 'gzip, deflate',
353
- 'HTTP_USER_AGENT': 'gvfs/1.22.2', 'wsgidav.debug_break': False,
354
- 'HTTP_CONNECTION': 'Keep-Alive', 'SERVER_PORT': '3030', 'CONTENT_LENGTH': '235', 'HTTP_HOST': '127.0.0.1:3030', 'REQUEST_METHOD': 'PROPFIND', 'HTTP_APPLY_TO_REDIRECT_REF': 'T', 'SERVER_NAME': 'WsgiDAV/3.0.0pre1 CherryPy/3.2.4 Python/3.4.2', 'wsgi.errors': <_io.TextIOWrapper name='<stderr>' mode='w' encoding='UTF-8'>, 'wsgi.url_scheme': 'http', 'user': <User: email='admin@admin.admin', display='Global manager'>, 'HTTP_ACCEPT_LANGUAGE': 'en-us, en;q=0.9', 'ACTUAL_SERVER_PROTOCOL': 'HTTP/1.1', 'REMOTE_PORT': '48375', 'CONTENT_TYPE': 'application/xml', 'SCRIPT_NAME': '', 'wsgi.input': <wsgidav.server.cherrypy.wsgiserver.wsgiserver3.KnownLengthRFile object at 0x7fbc8410ce48>, 'wsgidav.username': 'admin@admin.admin', 'http_authenticator.username': 'admin@admin.admin', 'wsgidav.provider': Provider, 'PATH_INFO': '/nouveau/', 'HTTP_DEPTH': '1', 'SERVER_SOFTWARE': 'WsgiDAV/3.0.0pre1 CherryPy/3.2.4 Python/3.4.2 Server'}
355
-"""

+ 17 - 19
tracim/tracim/lib/webdav/sql_domain_controller.py View File

@@ -2,49 +2,47 @@
2 2
 
3 3
 from tracim.lib.user import UserApi
4 4
 
5
-class SQLDomainController(object):
6
-
5
+class TracimDomainController(object):
6
+    """
7
+    The domain controller is used by http_authenticator to authenticate the user every time a request is
8
+    sent
9
+    """
7 10
     def __init__(self, presetdomain = None, presetserver = None):
8 11
         self._api = UserApi(None)
9 12
 
10 13
     def getDomainRealm(self, inputURL, environ):
11
-
12
-        '''On va récupérer le workspace de travail pour travailler sur les droits'''
13
-
14 14
         return '/'
15 15
 
16 16
     def requireAuthentication(self, realmname, environ):
17 17
         return True
18 18
 
19 19
     def isRealmUser(self, realmname, username, environ):
20
-
21
-        '''travailler dans la bdd pour vérifier si utilisateur existe'''
22
-
20
+        """
21
+        Called to check if for a given root, the username exists (though here we don't make difference between
22
+        root as we're always starting at tracim's root
23
+        """
23 24
         try:
24 25
             self._api.get_one_by_email(username)
25 26
             return True
26 27
         except:
27 28
             return False
28 29
 
29
-    def getRealmUserPassword(self, realmname, username, environ):
30
-        '''Retourne le mdp pour l'utilisateur pour ce real'''
31
-        try:
32
-            user = self._api.get_one_by_email(username)
33
-            return user.password
34
-        except:
35
-            return None
36
-
37 30
     def get_left_digest_response_hash(self, realmname, username, environ):
31
+        """
32
+        Called by our http_authenticator to get the hashed md5 digest for the current user that is also sent by
33
+        the webdav client
34
+        """
38 35
         try:
39 36
             user = self._api.get_one_by_email(username)
40
-            environ['user_api'] = UserApi(user)
41
-            print("hey ! ", realmname)
42 37
             return user.webdav_left_digest_response_hash
43 38
         except:
44 39
             return None
45 40
 
46 41
     def authDomainUser(self, realmname, username, password, environ):
47
-        '''Vérifier que l'utilisateur est valide pour ce domaine'''
42
+        """
43
+        If you ever feel the need to send a request al-mano with a curl, this is the function that'll be called by
44
+        http_authenticator to validate the password sent
45
+        """
48 46
 
49 47
         return self.isRealmUser(realmname, username, environ) and \
50 48
             self._api.get_one_by_email(username).validate_password(password)

+ 3 - 64
tracim/tracim/lib/webdav/sql_model.py View File

@@ -1,71 +1,10 @@
1
-from datetime import datetime
1
+#coding: utf8
2 2
 
3 3
 from sqlalchemy import Column
4 4
 from sqlalchemy import ForeignKey
5
-from sqlalchemy import Sequence
6
-from sqlalchemy.orm import deferred
7
-from sqlalchemy.types import DateTime, Integer, LargeBinary, Unicode, UnicodeText, Float
8
-
9
-from wsgidav.compat import to_unicode, to_bytes
10
-
11
-# ==================================================
12
-# Content
13
-# ==================================================
14
-class Workspace(object):
15
-    __tablename__ = 'my_workspaces'
16
-
17
-    workspace_id = Column(Integer, Sequence('my_seq__workspace__id'), autoincrement=True, primary_key=True)
18
-    label = Column(Unicode(255), unique=False, nullable=False, default=to_unicode(''))
19
-
20
-    created = Column(DateTime, unique=False, nullable=False, default=datetime.now)
21
-    updated = Column(DateTime, unique=False, nullable=False, default=datetime.now)
22
-
23
-    def __repr__(self):
24
-        return "<Workspace %s : %s>" % (self.workspace_id, self.label)
25
-
26
-
27
-class User(object):
28
-    __tablename__ = 'my_users'
29
-
30
-    user_id = Column(Integer, Sequence('my_seq__users__id'), autoincrement=True, primary_key=True)
31
-    display_name = Column(Unicode(255), unique=True, nullable=False, default=to_unicode(''))
32
-    password = Column(Unicode(255), unique=False, nullable=False, default=to_unicode(''))
33
-
34
-    def __repr__(self):
35
-        return "<User %s : %s>" % (self.user_id, self.display_name)
36
-
37
-
38
-class UserWorkspace(object):
39
-    __tablename__ = 'my_user_workspace'
40
-
41
-    workspace_id = Column(Integer, ForeignKey('my_workspaces.workspace_id', ondelete="CASCADE"), nullable=False, primary_key=True)
42
-    user_id = Column(Integer, ForeignKey('my_users.user_id', ondelete="CASCADE"), nullable=False, primary_key=True)
43
-    role = Column(Unicode(255), unique=False, nullable=False, default=u'NOT_APPLICABLE')
44
-
45
-    def __repr__(self):
46
-        return "<Role (W:%s, U:%s) : %s" % (self.workspace_id, self.user_id, self.role)
47
-
48
-
49
-class ItemRevision(object):
50
-    __tablename__ = 'my_items_revisions'
51
-
52
-    id = Column(Integer, Sequence('my_seq__items__id'), autoincrement=True, primary_key=True)
53
-    workspace_id = Column(Integer, ForeignKey('my_workspaces.workspace_id', ondelete="CASCADE"), nullable=False)
54
-    parent_id = Column(Integer, ForeignKey('my_items_revisions.id', ondelete="CASCADE"), nullable=True, default=None)
55
-
56
-    item_type = Column(Unicode(32), unique=False, nullable=False)
57
-    item_name = Column(Unicode(255), unique=False, nullable=False, default=to_unicode(''))
58
-    item_content = deferred(Column(LargeBinary(), unique=False, nullable=True, default=to_bytes('')))
59
-
60
-    created = Column(DateTime, unique=False, nullable=False, default=datetime.now)
61
-    updated = Column(DateTime, unique=False, nullable=False, default=datetime.now)
62
-
63
-    parent_revision_id = Column(Integer, ForeignKey('my_items_revisions.id', ondelete="CASCADE"), nullable=True, default=None)
64
-    child_revision_id = Column(Integer, ForeignKey('my_items_revisions.id', ondelete="CASCADE"), nullable=True, default=None)
65
-
66
-    def __repr__(self):
67
-        return "<Content %s : %s in %s>" % (self.id, self.item_name, self.parent_id)
5
+from sqlalchemy.types import Unicode, UnicodeText, Float
68 6
 
7
+from wsgidav.compat import to_unicode
69 8
 
70 9
 class Lock(object):
71 10
     __tablename__ = 'my_locks'

+ 194 - 126
tracim/tracim/lib/webdav/sql_resources.py View File

@@ -23,8 +23,10 @@ from wsgidav.dav_provider import _DAVResource
23 23
 
24 24
 from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound
25 25
 
26
-class Manage(object):
27
-    """Objet qui sert à encapsuler l'exécution des actions de l'api archive/delete..."""
26
+class ManageActions(object):
27
+    """
28
+    This object is used to encapsulate all Deletion/Archiving related method as to not duplicate too much code
29
+    """
28 30
     def __init__(self, action_type: str, api: ContentApi, content: Content):
29 31
         self.content_api = api
30 32
         self.content = content
@@ -46,10 +48,11 @@ class Manage(object):
46 48
 
47 49
     def action(self):
48 50
         try:
51
+            # When undeleting/unarchiving we except a content with the new name to not exist, thus if we
52
+            # don't get an error and the database request send back a result, we stop the action
49 53
             self.content_api.get_one_by_label_and_parent(self._new_name, self.content.parent, self.content.workspace)
50 54
             raise DAVError(HTTP_FORBIDDEN)
51 55
         except NoResultFound:
52
-            """Exécute l'action"""
53 56
             with new_revision(self.content):
54 57
                 self.content_api.update_content(self.content, self._new_name)
55 58
                 self._actions[self._type](self.content)
@@ -57,13 +60,17 @@ class Manage(object):
57 60
 
58 61
             transaction.commit()
59 62
 
60
-    def make_name(self):
61
-        """Créer le nouveau nom : rajoute de - archive date / retrait de - archive date suivant l'action"""
63
+    def make_name(self) -> str:
64
+        """
65
+        Will create the new name, either by adding '- deleted the [date]' after the name when archiving/deleting or
66
+        removing this string when undeleting/unarchiving
67
+        """
62 68
         new_name = self.content.get_label()
63 69
         extension = ''
64
-        is_file_name = self.content.label == ''
65
-
66 70
 
71
+        # if the content has no label, the last .ext is important
72
+        # thus we want to rename a file from 'file.txt' to 'file - deleted... .txt' and not 'file.txt - deleted...'
73
+        is_file_name = self.content.label == ''
67 74
         if is_file_name:
68 75
             extension = re.search(r'(\.[^.]+)$', new_name).group(0)
69 76
             new_name = re.sub(r'(\.[^.]+)$', '', new_name)
@@ -71,7 +78,11 @@ class Manage(object):
71 78
         if self._type in [ActionDescription.ARCHIVING, ActionDescription.DELETION]:
72 79
             new_name += ' - %s the %s' % (self._to_name[self._type], datetime.now().strftime('%d-%m-%Y at %H:%M'))
73 80
         else:
74
-            new_name = re.sub(r'( - (%s|%s) the .*)$' % (self._to_name[ActionDescription.DELETION], self._to_name[ActionDescription.ARCHIVING]), '', new_name)
81
+            new_name = re.sub(
82
+                r'( - (%s|%s) the .*)$' % (self._to_name[ActionDescription.DELETION], self._to_name[ActionDescription.ARCHIVING]),
83
+                '',
84
+                new_name
85
+            )
75 86
 
76 87
         new_name += extension
77 88
 
@@ -79,7 +90,9 @@ class Manage(object):
79 90
 
80 91
 
81 92
 class Root(DAVCollection):
82
-    """Root ressource that represents tracim's home, which contains all workspaces"""
93
+    """
94
+    Root ressource that represents tracim's home, which contains all workspaces
95
+    """
83 96
 
84 97
     def __init__(self, path: str, environ: dict):
85 98
         super(Root, self).__init__(path, environ)
@@ -93,12 +106,16 @@ class Root(DAVCollection):
93 106
     def getMemberNames(self) -> [str]:
94 107
         """
95 108
         This method returns the names (here workspace's labels) of all its children
109
+
110
+        Though for perfomance issue, we're not using this function anymore
96 111
         """
97 112
         return [workspace.label for workspace in self.workspace_api.get_all()]
98 113
 
99 114
     def getMember(self, label: str) -> DAVCollection:
100 115
         """
101 116
         This method returns the child Workspace that corresponds to a given name
117
+
118
+        Though for perfomance issue, we're not using this function anymore
102 119
         """
103 120
         try:
104 121
             workspace = self.workspace_api.get_one_by_label(label)
@@ -113,7 +130,8 @@ class Root(DAVCollection):
113 130
         This method is called whenever the user wants to create a DAVNonCollection resource (files in our case).
114 131
 
115 132
         There we don't allow to create files at the root;
116
-        only workspaces (thus collection) can be created."""
133
+        only workspaces (thus collection) can be created.
134
+        """
117 135
         raise DAVError(HTTP_FORBIDDEN)
118 136
 
119 137
     def createCollection(self, name: str):
@@ -123,7 +141,8 @@ class Root(DAVCollection):
123 141
 
124 142
         [For now] we don't allow to create new workspaces through
125 143
         webdav client. Though if we come to allow it, deleting the error's raise will
126
-        make it possible."""
144
+        make it possible.
145
+        """
127 146
         # TODO : remove comment here
128 147
         # raise DAVError(HTTP_FORBIDDEN)
129 148
 
@@ -137,8 +156,10 @@ class Root(DAVCollection):
137 156
         return Workspace(workspace_path, self.environ, new_workspace)
138 157
 
139 158
     def getMemberList(self):
140
-        # De base on appellerait getMemberNames puis getMember, mais ça fait trop de requête alors on optimise :
141
-        # on fait une seule requête en BDD; get_all et voilà !
159
+        """
160
+        This method is called by wsgidav when requesting with a depth > 0, it will return a list of _DAVResource
161
+        of all its direct children
162
+        """
142 163
 
143 164
         members = []
144 165
         for workspace in self.workspace_api.get_all():
@@ -149,8 +170,10 @@ class Root(DAVCollection):
149 170
 
150 171
 
151 172
 class Workspace(DAVCollection):
152
-    """Workspace resource corresponding to tracim's workspaces.
153
-    Direct children can only be folders, though files might come later on"""
173
+    """
174
+    Workspace resource corresponding to tracim's workspaces.
175
+    Direct children can only be folders, though files might come later on and are supported
176
+    """
154 177
 
155 178
     def __init__(self, path: str, environ: dict, workspace: data.Workspace):
156 179
         super(Workspace, self).__init__(path, environ)
@@ -202,8 +225,10 @@ class Workspace(DAVCollection):
202 225
         )
203 226
 
204 227
     def createEmptyResource(self, file_name: str):
205
-        """[For now] we don't allow to create files right under workspaces.
206
-        Though if we come to allow it, deleting the error's raise will make it possible."""
228
+        """
229
+        [For now] we don't allow to create files right under workspaces.
230
+        Though if we come to allow it, deleting the error's raise will make it possible.
231
+        """
207 232
         # TODO : remove commentary here raise DAVError(HTTP_FORBIDDEN)
208 233
         if '/.deleted/' in self.path or '/.archived/' in self.path:
209 234
             raise DAVError(HTTP_FORBIDDEN)
@@ -213,14 +238,17 @@ class Workspace(DAVCollection):
213 238
             content_api=self.content_api,
214 239
             workspace=self.workspace,
215 240
             content=None,
216
-            parent=self.content
241
+            parent=self.content,
242
+            path=self.path + '/' + file_name
217 243
         )
218 244
 
219 245
     def createCollection(self, label: str) -> 'Folder':
220
-        """Create a new folder for the current workspace. As it's not possible for the user to choose
246
+        """
247
+        Create a new folder for the current workspace. As it's not possible for the user to choose
221 248
         which types of content are allowed in this folder, we allow allow all of them.
222 249
 
223
-        This method return the DAVCollection created."""
250
+        This method return the DAVCollection created.
251
+        """
224 252
 
225 253
         if '/.deleted/' in self.path or '/.archived/' in self.path:
226 254
             raise DAVError(HTTP_FORBIDDEN)
@@ -256,7 +284,7 @@ class Workspace(DAVCollection):
256 284
         return True
257 285
 
258 286
     def moveRecursive(self, destpath):
259
-        if dirname(normpath(destpath)) == self.provider.root:
287
+        if dirname(normpath(destpath)) == self.environ['http_authenticator.realm']:
260 288
             self.workspace.label = basename(normpath(destpath))
261 289
             transaction.commit()
262 290
         else:
@@ -264,15 +292,14 @@ class Workspace(DAVCollection):
264 292
 
265 293
     def getMemberList(self) -> [_DAVResource]:
266 294
         members = []
267
-        parent_id = None if self.content is None else self.content.id
268 295
 
269
-        childs = self.content_api.get_all(parent_id, ContentType.Any, self.workspace)
296
+        children = self.content_api.get_all(None, ContentType.Any, self.workspace)
270 297
 
271
-        for content in childs:
298
+        for content in children:
272 299
             content_path = '%s/%s' % (self.path, self.provider.transform_to_display(content.get_label()))
273 300
 
274 301
             if content.type == ContentType.Folder:
275
-                members.append(Folder(content_path, self.environ, content, self.workspace))
302
+                members.append(Folder(content_path, self.environ, self.workspace, content))
276 303
             elif content.type == ContentType.File:
277 304
                 self._file_count += 1
278 305
                 members.append(File(content_path, self.environ, content))
@@ -310,15 +337,18 @@ class Workspace(DAVCollection):
310 337
                     workspace=self.workspace
311 338
                 )
312 339
             )
340
+
313 341
         return members
314 342
 
315 343
 
316 344
 class Folder(Workspace):
317
-    """Folder resource corresponding to tracim's folders.
345
+    """
346
+    Folder resource corresponding to tracim's folders.
318 347
     Direct children can only be either folder, files, pages or threads
319
-    By default when creating new folders, we allow them to contain all types of content"""
348
+    By default when creating new folders, we allow them to contain all types of content
349
+    """
320 350
 
321
-    def __init__(self, path: str, environ: dict, content: data.Content, workspace: data.Workspace):
351
+    def __init__(self, path: str, environ: dict, workspace: data.Workspace, content: data.Content):
322 352
         super(Folder, self).__init__(path, environ, workspace)
323 353
 
324 354
         self.content = content
@@ -336,14 +366,16 @@ class Folder(Workspace):
336 366
         return mktime(self.content.updated.timetuple())
337 367
 
338 368
     def delete(self):
339
-        Manage(ActionDescription.DELETION, self.content_api, self.content).action()
369
+        ManageActions(ActionDescription.DELETION, self.content_api, self.content).action()
340 370
 
341 371
     def supportRecursiveMove(self, destpath: str):
342 372
         return True
343 373
 
344 374
     def moveRecursive(self, destpath: str):
345
-        """As we support recursive move, copymovesingle won't be called, though with copy it'll be called
346
-        but i have to check if the client ever call that function..."""
375
+        """
376
+        As we support recursive move, copymovesingle won't be called, though with copy it'll be called
377
+        but i have to check if the client ever call that function...
378
+        """
347 379
         destpath = normpath(destpath)
348 380
 
349 381
         invalid_path = False
@@ -357,7 +389,7 @@ class Folder(Workspace):
357 389
             current_path = re.sub(r'/\.(deleted|archived)', '', self.path)
358 390
 
359 391
             if current_path == destpath:
360
-                Manage(
392
+                ManageActions(
361 393
                     ActionDescription.UNDELETION if self.content.is_deleted else ActionDescription.UNARCHIVING,
362 394
                     self.content_api,
363 395
                     self.content
@@ -371,7 +403,7 @@ class Folder(Workspace):
371 403
             dest_path = re.sub(r'/\.(deleted|archived)', '', destpath)
372 404
 
373 405
             if dest_path == self.path:
374
-                Manage(
406
+                ManageActions(
375 407
                     ActionDescription.DELETION if '.deleted' in destpath else ActionDescription.ARCHIVING,
376 408
                     self.content_api,
377 409
                     self.content
@@ -392,15 +424,16 @@ class Folder(Workspace):
392 424
             raise DAVError(HTTP_FORBIDDEN)
393 425
 
394 426
     def move_folder(self, destpath):
395
-        parent = self.provider.get_parent_from_path(
396
-            normpath(destpath),
397
-            self.content_api,
398
-            WorkspaceApi(self.user)
399
-        )
400 427
 
428
+        workspace_api = WorkspaceApi(self.user)
401 429
         workspace = self.provider.get_workspace_from_path(
430
+            normpath(destpath), workspace_api
431
+        )
432
+
433
+        parent = self.provider.get_parent_from_path(
402 434
             normpath(destpath),
403
-            WorkspaceApi(self.user)
435
+            self.content_api,
436
+            workspace
404 437
         )
405 438
 
406 439
         with new_revision(self.content):
@@ -408,28 +441,71 @@ class Folder(Workspace):
408 441
                 self.content_api.update_content(self.content, self.provider.transform_to_bdd(basename(destpath)))
409 442
                 self.content_api.save(self.content)
410 443
             else:
411
-                try:
412
-                    workspace_id = parent.workspace.workspace_id
413
-                except AttributeError:
414
-                    workspace_id = self.provider.get_workspace_from_path(
415
-                        destpath, WorkspaceApi(self.user)
416
-                    ).workspace_id
417
-
418
-                if workspace_id == self.content.workspace.workspace_id:
444
+                if workspace.workspace_id == self.content.workspace.workspace_id:
419 445
                     self.content_api.move(self.content, parent)
420 446
                 else:
421
-                    try:
422
-                        self.content_api.move_recursively(self.content, parent, parent.workspace)
423
-                    except AttributeError:
424
-                        self.content_api.move_recursively(self.content, parent, workspace)
447
+                    self.content_api.move_recursively(self.content, parent, workspace)
425 448
 
426 449
         transaction.commit()
427 450
 
451
+    def getMemberList(self) -> [_DAVResource]:
452
+        members = []
453
+
454
+        for content in self.content.children:
455
+            content_path = '%s/%s' % (self.path, self.provider.transform_to_display(content.get_label()))
428 456
 
429
-class HistoryFolder(Folder):
457
+            if content.type == ContentType.Folder:
458
+                members.append(Folder(content_path, self.environ, self.workspace, content))
459
+            elif content.type == ContentType.File:
460
+                self._file_count += 1
461
+                members.append(File(content_path, self.environ, content))
462
+            else:
463
+                self._file_count += 1
464
+                members.append(OtherFile(content_path, self.environ, content))
465
+
466
+        if self._file_count > 0 and self.provider.show_history():
467
+            members.append(
468
+                HistoryFolder(
469
+                    path=self.path + '/' + ".history",
470
+                    environ=self.environ,
471
+                    content=self.content,
472
+                    workspace=self.workspace,
473
+                    type=HistoryType.Standard
474
+                )
475
+            )
476
+
477
+        if self.provider.show_delete():
478
+            members.append(
479
+                DeletedFolder(
480
+                    path=self.path + '/' + ".deleted",
481
+                    environ=self.environ,
482
+                    content=self.content,
483
+                    workspace=self.workspace
484
+                )
485
+            )
486
+
487
+        if self.provider.show_archive():
488
+            members.append(
489
+                ArchivedFolder(
490
+                    path=self.path + '/' + ".archived",
491
+                    environ=self.environ,
492
+                    content=self.content,
493
+                    workspace=self.workspace
494
+                )
495
+            )
496
+
497
+        return members
430 498
 
431
-    def __init__(self, path, environ, workspace: data.Workspace, type: str, content: data.Content=None):
432
-        super(HistoryFolder, self).__init__(path, environ, content, workspace)
499
+
500
+class HistoryFolder(Folder):
501
+    """
502
+    A virtual resource which contains a sub-folder for every files (DAVNonCollection) contained in the parent
503
+    folder
504
+    """
505
+    
506
+    def __init__(self, path, environ, workspace: data.Workspace,
507
+                 content: data.Content=None, type: str=HistoryType.Standard):
508
+        super(HistoryFolder, self).__init__(path, environ, workspace, content)
433 509
 
434 510
         self._is_archived = type == HistoryType.Archived
435 511
         self._is_deleted = type == HistoryType.Deleted
@@ -496,10 +572,13 @@ class HistoryFolder(Folder):
496 572
 
497 573
     def getMemberList(self) -> [_DAVResource]:
498 574
         members = []
499
-
500
-        parent_id = None if self.content is None else self.content.id
501
-
502
-        for content in self.content_api.get_all_with_filter(parent_id, ContentType.Any, self.workspace):
575
+        
576
+        if self.content:
577
+            children = self.content.children
578
+        else:
579
+            children = self.content_api.get_all(None, ContentType.Any, self.workspace)
580
+        
581
+        for content in children:
503 582
             members.append(HistoryFileFolder(
504 583
                 path='%s/%s' % (self.path, content.get_label()),
505 584
                 environ=self.environ,
@@ -509,8 +588,12 @@ class HistoryFolder(Folder):
509 588
 
510 589
 
511 590
 class DeletedFolder(HistoryFolder):
591
+    """
592
+    A virtual resources which exists for every folder or workspaces which contains their deleted children
593
+    """
594
+
512 595
     def __init__(self, path: str, environ: dict, workspace: data.Workspace, content: data.Content=None):
513
-        super(DeletedFolder, self).__init__(path, environ, workspace, HistoryType.Deleted, content)
596
+        super(DeletedFolder, self).__init__(path, environ, workspace, content, HistoryType.Deleted)
514 597
 
515 598
         self._file_count = 0
516 599
 
@@ -541,8 +624,12 @@ class DeletedFolder(HistoryFolder):
541 624
     def getMemberNames(self) -> [str]:
542 625
         retlist = []
543 626
 
544
-        for content in self.content_api.get_all(
545
-                parent_id=self.content if self.content is None else self.content.id, content_type=ContentType.Any):
627
+        if self.content:
628
+            children = self.content.children
629
+        else:
630
+            children = self.content_api.get_all(None, ContentType.Any, self.workspace)
631
+
632
+        for content in children:
546 633
             if content.is_deleted:
547 634
                 retlist.append(content.get_label())
548 635
 
@@ -551,25 +638,19 @@ class DeletedFolder(HistoryFolder):
551 638
 
552 639
         return retlist
553 640
 
554
-    def createEmptyResource(self, name: str):
555
-        raise DAVError(HTTP_FORBIDDEN)
556
-
557
-    def createCollection(self, name: str):
558
-        raise DAVError(HTTP_FORBIDDEN)
559
-
560
-    def delete(self):
561
-        raise DAVError(HTTP_FORBIDDEN)
562
-
563 641
     def getMemberList(self) -> [_DAVResource]:
564 642
         members = []
565 643
 
566
-        parent_id = None if self.content is None else self.content.id
644
+        if self.content:
645
+            children = self.content.children
646
+        else:
647
+            children = self.content_api.get_all(None, ContentType.Any, self.workspace)
567 648
 
568
-        for content in self.content_api.get_all_with_filter(parent_id, ContentType.Any, self.workspace):
649
+        for content in children:
569 650
             content_path = '%s/%s' % (self.path, self.provider.transform_to_display(content.get_label()))
570 651
 
571 652
             if content.type == ContentType.Folder:
572
-                members.append(Folder(content_path, self.environ, content, self.workspace))
653
+                members.append(Folder(content_path, self.environ, self.workspace, content))
573 654
             elif content.type == ContentType.File:
574 655
                 self._file_count += 1
575 656
                 members.append(File(content_path, self.environ, content))
@@ -592,8 +673,11 @@ class DeletedFolder(HistoryFolder):
592 673
 
593 674
 
594 675
 class ArchivedFolder(HistoryFolder):
676
+    """
677
+    A virtual resources which exists for every folder or workspaces which contains their archived children
678
+    """
595 679
     def __init__(self, path: str, environ: dict, workspace: data.Workspace, content: data.Content=None):
596
-        super(ArchivedFolder, self).__init__(path, environ, workspace, HistoryType.Archived, content)
680
+        super(ArchivedFolder, self).__init__(path, environ, workspace, content, HistoryType.Archived)
597 681
 
598 682
         self._file_count = 0
599 683
 
@@ -633,25 +717,19 @@ class ArchivedFolder(HistoryFolder):
633 717
 
634 718
         return retlist
635 719
 
636
-    def createEmptyResource(self, name):
637
-        raise DAVError(HTTP_FORBIDDEN)
638
-
639
-    def createCollection(self, name):
640
-        raise DAVError(HTTP_FORBIDDEN)
641
-
642
-    def delete(self):
643
-        raise DAVError(HTTP_FORBIDDEN)
644
-
645 720
     def getMemberList(self) -> [_DAVResource]:
646 721
         members = []
647 722
 
648
-        parent_id = None if self.content is None else self.content.id
723
+        if self.content:
724
+            children = self.content.children
725
+        else:
726
+            children = self.content_api.get_all(None, ContentType.Any, self.workspace)
649 727
 
650
-        for content in self.content_api.get_all_with_filter(parent_id, ContentType.Any, self.workspace):
728
+        for content in children:
651 729
             content_path = '%s/%s' % (self.path, self.provider.transform_to_display(content.get_label()))
652 730
 
653 731
             if content.type == ContentType.Folder:
654
-                members.append(Folder(content_path, self.environ, content, self.workspace))
732
+                members.append(Folder(content_path, self.environ, self.workspace, content))
655 733
             elif content.type == ContentType.File:
656 734
                 self._file_count += 1
657 735
                 members.append(File(content_path, self.environ, content))
@@ -674,8 +752,12 @@ class ArchivedFolder(HistoryFolder):
674 752
 
675 753
 
676 754
 class HistoryFileFolder(HistoryFolder):
755
+    """
756
+    A virtual resource that contains for a given content (file/page/thread) all its revisions
757
+    """
758
+
677 759
     def __init__(self, path: str, environ: dict, content: data.Content):
678
-        super(HistoryFileFolder, self).__init__(path, environ, content.workspace, HistoryType.All, content)
760
+        super(HistoryFileFolder, self).__init__(path, environ, content.workspace, content, HistoryType.All)
679 761
 
680 762
     def __repr__(self) -> str:
681 763
         return "<DAVCollection: HistoryFileFolder (%s)" % self.content.file_name
@@ -686,16 +768,11 @@ class HistoryFileFolder(HistoryFolder):
686 768
     def createCollection(self, name):
687 769
         raise DAVError(HTTP_FORBIDDEN)
688 770
 
689
-    def createEmptyResource(self, name) ->FakeFileStream:
690
-        return FakeFileStream(
691
-            content=self.content,
692
-            content_api=self.content_api,
693
-            file_name=name,
694
-            workspace=self.content.workspace
695
-        )
696
-
697 771
     def getMemberNames(self) -> [int]:
698
-        """ Usually we would return a string, but here as it can be the same name because that's history, we get the id"""
772
+        """
773
+        Usually we would return a string, but here as we're working with different
774
+        revisions of the same content, we'll work with revision_id
775
+        """
699 776
         ret = []
700 777
 
701 778
         for content in self.content.revisions:
@@ -722,9 +799,6 @@ class HistoryFileFolder(HistoryFolder):
722 799
                 content=self.content,
723 800
                 content_revision=revision)
724 801
 
725
-    def delete(self):
726
-        raise DAVError(HTTP_FORBIDDEN)
727
-
728 802
     def getMemberList(self) -> [_DAVResource]:
729 803
         members = []
730 804
 
@@ -751,6 +825,9 @@ class HistoryFileFolder(HistoryFolder):
751 825
 
752 826
 
753 827
 class File(DAVNonCollection):
828
+    """
829
+    File resource corresponding to tracim's files
830
+    """
754 831
     def __init__(self, path: str, environ: dict, content: Content):
755 832
         super(File, self).__init__(path, environ)
756 833
 
@@ -758,6 +835,9 @@ class File(DAVNonCollection):
758 835
         self.user = UserApi(None).get_one_by_email(environ['http_authenticator.username'])
759 836
         self.content_api = ContentApi(self.user)
760 837
 
838
+        # this is the property that windows client except to check if the file is read-write or read-only,
839
+        # but i wasn't able to set this property so you'll have to look into it >.>
840
+        # self.setPropertyValue('Win32FileAttributes', '00000021')
761 841
 
762 842
     def getPreferredPath(self):
763 843
         fix_txt = '.txt' if self.getContentType() == 'text/plain' else mimetypes.guess_extension(self.getContentType())
@@ -797,7 +877,8 @@ class File(DAVNonCollection):
797 877
             content=self.content,
798 878
             content_api=self.content_api,
799 879
             file_name=self.content.get_label(),
800
-            workspace=self.content.workspace
880
+            workspace=self.content.workspace,
881
+            path=self.path
801 882
         )
802 883
 
803 884
     def moveRecursive(self, destpath):
@@ -816,7 +897,7 @@ class File(DAVNonCollection):
816 897
             current_path = re.sub(r'/\.(deleted|archived)', '', self.path)
817 898
 
818 899
             if current_path == destpath:
819
-                Manage(
900
+                ManageActions(
820 901
                     ActionDescription.UNDELETION if self.content.is_deleted else ActionDescription.UNARCHIVING,
821 902
                     self.content_api,
822 903
                     self.content
@@ -830,7 +911,7 @@ class File(DAVNonCollection):
830 911
             dest_path = re.sub(r'/\.(deleted|archived)', '', destpath)
831 912
 
832 913
             if dest_path == self.path:
833
-                Manage(
914
+                ManageActions(
834 915
                     ActionDescription.DELETION if '.deleted' in destpath else ActionDescription.ARCHIVING,
835 916
                     self.content_api,
836 917
                     self.content
@@ -880,10 +961,13 @@ class File(DAVNonCollection):
880 961
         return True
881 962
 
882 963
     def delete(self):
883
-        Manage(ActionDescription.DELETION, self.content_api, self.content).action()
964
+        ManageActions(ActionDescription.DELETION, self.content_api, self.content).action()
884 965
 
885 966
 
886 967
 class HistoryFile(File):
968
+    """
969
+    A virtual resource corresponding to a specific tracim's revision's file
970
+    """
887 971
     def __init__(self, path: str, environ: dict, content: data.Content, content_revision: data.ContentRevisionRO):
888 972
         super(HistoryFile, self).__init__(path, environ, content)
889 973
         self.content_revision = content_revision
@@ -914,20 +998,14 @@ class HistoryFile(File):
914 998
     def delete(self):
915 999
         raise DAVError(HTTP_FORBIDDEN)
916 1000
 
917
-    def handleDelete(self):
918
-        return False
919
-
920
-    def handleCopy(self, destpath, depth_infinity):
921
-        return True
922
-
923
-    def handleMove(self, destpath):
924
-        return True
925
-
926 1001
     def copyMoveSingle(self, destpath, ismove):
927 1002
         raise DAVError(HTTP_FORBIDDEN)
928 1003
 
929 1004
 
930 1005
 class OtherFile(File):
1006
+    """
1007
+    File resource corresponding to tracim's page and thread
1008
+    """
931 1009
     def __init__(self, path: str, environ: dict, content: data.Content):
932 1010
         super(OtherFile, self).__init__(path, environ, content)
933 1011
 
@@ -935,7 +1013,7 @@ class OtherFile(File):
935 1013
 
936 1014
         self.content_designed = self.design()
937 1015
 
938
-        # workaroung for consistent request as we have to return a resource with a path ending with .html
1016
+        # workaround for consistent request as we have to return a resource with a path ending with .html
939 1017
         # when entering folder for windows, but only once because when we select it again it would have .html.html
940 1018
         # which is no good
941 1019
         if not self.path.endswith('.html'):
@@ -975,6 +1053,9 @@ class OtherFile(File):
975 1053
 
976 1054
 
977 1055
 class HistoryOtherFile(OtherFile):
1056
+    """
1057
+    A virtual resource corresponding to a specific tracim's revision's page and thread
1058
+    """
978 1059
     def __init__(self, path: str, environ: dict, content: data.Content, content_revision: data.ContentRevisionRO):
979 1060
         super(HistoryOtherFile, self).__init__(path, environ, content)
980 1061
         self.content_revision = content_revision
@@ -995,21 +1076,8 @@ class HistoryOtherFile(OtherFile):
995 1076
 
996 1077
         return filestream
997 1078
 
998
-    def beginWrite(self, contentType=None):
999
-        raise DAVError(HTTP_FORBIDDEN)
1000
-
1001 1079
     def delete(self):
1002 1080
         raise DAVError(HTTP_FORBIDDEN)
1003 1081
 
1004
-    def handleDelete(self):
1005
-        return True
1006
-
1007
-    def handleCopy(self, destpath, depth_infinity):
1008
-        return True
1009
-
1010
-    def handleMove(self, destpath):
1011
-        return True
1012
-
1013 1082
     def copyMoveSingle(self, destpath, ismove):
1014 1083
         raise DAVError(HTTP_FORBIDDEN)
1015
-

+ 99 - 2
tracim/tracim/lib/webdav/style.css View File

@@ -1,3 +1,100 @@
1
-body {
2
-    background: red
1
+.title {
2
+	background:#F5F5F5;
3
+	padding-right:15px;
4
+	padding-left:15px;
5
+	padding-top:10px;
6
+	border-bottom:1px solid #CCCCCC;
7
+	overflow:auto;
8
+} .title h1 { margin-top:0; }
9
+
10
+.content {
11
+	padding: 15px;
12
+}
13
+
14
+#left{ padding:0; }
15
+
16
+#right {
17
+	background:#F5F5F5;
18
+	border-left:1px solid #CCCCCC;
19
+	border-bottom: 1px solid #CCCCCC;
20
+	padding-top:15px;
21
+}
22
+@media (max-width: 1200px) {
23
+	#right {
24
+		border-top:1px solid #CCCCCC;
25
+		border-left: none;
26
+		border-bottom: none;
27
+	}
28
+}
29
+
30
+body { overflow:auto; }
31
+
32
+.btn {
33
+	text-align: left;
34
+}
35
+
36
+.table tbody tr .my-align {
37
+	vertical-align:middle;
38
+}
39
+
40
+.title-icon {
41
+	font-size:2.5em;
42
+	float:left;
43
+	margin-right:10px;
44
+}
45
+.title.page, .title-icon.page { color:#00CC00; }
46
+.title.thread, .title-icon.thread { color:#428BCA; }
47
+
48
+/* ****************************** */
49
+.description-icon {
50
+	color:#999;
51
+	font-size:3em;
52
+}
53
+
54
+.description {
55
+	border-left: 5px solid #999;
56
+	padding-left: 10px;
57
+	margin-left: 10px;
58
+	margin-bottom:10px;
59
+}
60
+
61
+.description-text {
62
+	display:block;
63
+	overflow:hidden;
64
+	color:#999;
65
+}
66
+
67
+.comment-row:nth-child(2n) {
68
+	background-color:#F5F5F5;
69
+}
70
+
71
+.comment-row:nth-child(2n+1) {
72
+	background-color:#FFF;
73
+}
74
+
75
+.comment-icon {
76
+	color:#CCC;
77
+	font-size:3em;
78
+	display:inline-block;
79
+	margin-right: 10px;
80
+	float:left;
81
+}
82
+
83
+.comment-content {
84
+	display:block;
85
+	overflow:hidden;
86
+}
87
+
88
+.comment, .comment-revision {
89
+	padding:10px;
90
+	border-top: 1px solid #999;
91
+}
92
+
93
+.comment-revision-icon {
94
+	color:#777;
95
+	margin-right: 10px;
96
+}
97
+
98
+.title-text {
99
+	display: inline-block;
3 100
 }