Passed
Pull Request — develop (#87)
by inkhey
01:43
created

Content.is_editable()   A

Complexity

Conditions 1

Size

Total Lines 3
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
nop 1
1
# -*- coding: utf-8 -*-
2
import datetime as datetime_root
3
import json
4
import os
5
import typing
6
from datetime import datetime
7
8
from babel.dates import format_timedelta
9
from bs4 import BeautifulSoup
10
from depot.fields.sqlalchemy import UploadedFileField
11
from depot.fields.upload import UploadedFile
12
from depot.io.utils import FileIntent
13
from sqlalchemy import Column
14
from sqlalchemy import ForeignKey
15
from sqlalchemy import Index
16
from sqlalchemy import Sequence
17
from sqlalchemy import inspect
18
from sqlalchemy.ext.associationproxy import association_proxy
19
from sqlalchemy.ext.hybrid import hybrid_property
20
from sqlalchemy.orm import backref
21
from sqlalchemy.orm import relationship
22
from sqlalchemy.orm.attributes import InstrumentedAttribute
23
from sqlalchemy.orm.collections import attribute_mapped_collection
24
from sqlalchemy.types import Boolean
25
from sqlalchemy.types import DateTime
26
from sqlalchemy.types import Integer
27
from sqlalchemy.types import Text
28
from sqlalchemy.types import Unicode
29
30
from tracim_backend.app_models.contents import ContentStatus
31
from tracim_backend.app_models.contents import content_status_list
32
from tracim_backend.app_models.contents import content_type_list
33
from tracim_backend.exceptions import ContentRevisionUpdateError
34
from tracim_backend.exceptions import ContentStatusNotExist
35
from tracim_backend.lib.utils.translation import Translator
36
from tracim_backend.lib.utils.translation import get_locale
37
from tracim_backend.models.auth import User
38
from tracim_backend.models.meta import DeclarativeBase
39
from tracim_backend.models.roles import WorkspaceRoles
40
41
42
class Workspace(DeclarativeBase):
43
44
    __tablename__ = 'workspaces'
45
46
    workspace_id = Column(Integer, Sequence('seq__workspaces__workspace_id'), autoincrement=True, primary_key=True)
47
48
    label = Column(Unicode(1024), unique=False, nullable=False, default='')
49
    description = Column(Text(), unique=False, nullable=False, default='')
50
    calendar_enabled = Column(Boolean, unique=False, nullable=False, default=False)
51
52
    #  Default value datetime.utcnow,
53
    # see: http://stackoverflow.com/a/13370382/801924 (or http://pastebin.com/VLyWktUn)
54
    created = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
55
    #  Default value datetime.utcnow,
56
    # see: http://stackoverflow.com/a/13370382/801924 (or http://pastebin.com/VLyWktUn)
57
    updated = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
58
59
    is_deleted = Column(Boolean, unique=False, nullable=False, default=False)
60
61
    revisions = relationship("ContentRevisionRO")
62
63
    @hybrid_property
64
    def contents(self) -> ['Content']:
65
        # Return a list of unique revisions parent content
66
        contents = []
67
        for revision in self.revisions:
68
            # TODO BS 20161209: This ``revision.node.workspace`` make a lot
69
            # of SQL queries !
70
            if revision.node.workspace == self and revision.node not in contents:
71
                contents.append(revision.node)
72
73
        return contents
74
75
    # TODO - G-M - 27-03-2018 - [Calendar] Replace this in context model object
76
    # @property
77
    # def calendar_url(self) -> str:
78
    #     # TODO - 20160531 - Bastien: Cyclic import if import in top of file
79
    #     from tracim.lib.calendar import CalendarManager
80
    #     calendar_manager = CalendarManager(None)
81
    #
82
    #     return calendar_manager.get_workspace_calendar_url(self.workspace_id)
83
84
    def get_user_role(self, user: User) -> int:
85
        for role in user.roles:
86
            if role.workspace.workspace_id==self.workspace_id:
87
                return role.role
88
        return UserRoleInWorkspace.NOT_APPLICABLE
89
90
    def get_label(self):
91
        """ this method is for interoperability with Content class"""
92
        return self.label
93
94
    def get_allowed_content_types(self):
95
        # @see Content.get_allowed_content_types()
96
        return content_type_list.endpoint_allowed_types_slug()
97
98
    def get_valid_children(
99
            self,
100
            content_types: list=None,
101
            show_deleted: bool=False,
102
            show_archived: bool=False,
103
    ):
104
        for child in self.contents:
105
            # we search only direct children
106
            if not child.parent \
107
                    and (show_deleted or not child.is_deleted) \
108
                    and (show_archived or not child.is_archived):
109
                if not content_types or child.type in content_types:
110
                    yield child
111
112
113
class UserRoleInWorkspace(DeclarativeBase):
114
115
    __tablename__ = 'user_workspace'
116
117
    user_id = Column(Integer, ForeignKey('users.user_id'), nullable=False, default=None, primary_key=True)
118
    workspace_id = Column(Integer, ForeignKey('workspaces.workspace_id'), nullable=False, default=None, primary_key=True)
119
    role = Column(Integer, nullable=False, default=0, primary_key=False)
120
    do_notify = Column(Boolean, unique=False, nullable=False, default=False)
121
122
    workspace = relationship('Workspace', remote_side=[Workspace.workspace_id], backref='roles', lazy='joined')
123
    user = relationship('User', remote_side=[User.user_id], backref='roles')
124
125
    NOT_APPLICABLE = WorkspaceRoles.NOT_APPLICABLE.level
126
    READER = WorkspaceRoles.READER.level
127
    CONTRIBUTOR = WorkspaceRoles.CONTRIBUTOR.level
128
    CONTENT_MANAGER = WorkspaceRoles.CONTENT_MANAGER.level
129
    WORKSPACE_MANAGER = WorkspaceRoles.WORKSPACE_MANAGER.level
130
131
    # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
132
    # SLUG = {
133
    #     NOT_APPLICABLE: 'not-applicable',
134
    #     READER: 'reader',
135
    #     CONTRIBUTOR: 'contributor',
136
    #     CONTENT_MANAGER: 'content-manager',
137
    #     WORKSPACE_MANAGER: 'workspace-manager',
138
    # }
139
140
    # LABEL = dict()
141
    # LABEL[0] = l_('N/A')
142
    # LABEL[1] = l_('Reader')
143
    # LABEL[2] = l_('Contributor')
144
    # LABEL[4] = l_('Content Manager')
145
    # LABEL[8] = l_('Workspace Manager')
146
    # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
147
    #
148
    # STYLE = dict()
149
    # STYLE[0] = ''
150
    # STYLE[1] = 'color: #1fdb11;'
151
    # STYLE[2] = 'color: #759ac5;'
152
    # STYLE[4] = 'color: #ea983d;'
153
    # STYLE[8] = 'color: #F00;'
154
    #
155
    # ICON = dict()
156
    # ICON[0] = ''
157
    # ICON[1] = 'fa-eye'
158
    # ICON[2] = 'fa-pencil'
159
    # ICON[4] = 'fa-graduation-cap'
160
    # ICON[8] = 'fa-legal'
161
    #
162
    #
163
    # @property
164
    # def fa_icon(self):
165
    #     return UserRoleInWorkspace.ICON[self.role]
166
    #
167
    # @property
168
    # def style(self):
169
    #     return UserRoleInWorkspace.STYLE[self.role]
170
    #
171
172
    def role_object(self):
173
        return WorkspaceRoles.get_role_from_level(level=self.role)
174
175
    def role_as_label(self):
176
        return self.role_object().label
177
178
    @classmethod
179
    def get_all_role_values(cls) -> typing.List[int]:
180
        """
181
        Return all valid role value
182
        """
183
        return [role.level for role in WorkspaceRoles.get_all_valid_role()]
184
185
    @classmethod
186
    def get_all_role_slug(cls) -> typing.List[str]:
187
        """
188
        Return all valid role slug
189
        """
190
        # INFO - G.M - 25-05-2018 - Be carefull, as long as this method
191
        # and get_all_role_values are both used for API, this method should
192
        # return item in the same order as get_all_role_values
193
        return [role.slug for role in WorkspaceRoles.get_all_valid_role()]
194
195
# TODO - G.M - 10-04-2018 - [Cleanup] Drop this
196
# class RoleType(object):
197
#     def __init__(self, role_id):
198
#         self.role_type_id = role_id
199
        # self.fa_icon = UserRoleInWorkspace.ICON[role_id]
200
        # self.role_label = UserRoleInWorkspace.LABEL[role_id]
201
        # self.css_style = UserRoleInWorkspace.STYLE[role_id]
202
203
204
# TODO - G.M - 09-04-2018 [Cleanup] It this items really needed ?
205
# class LinkItem(object):
206
#     def __init__(self, href, label):
207
#         self.href = href
208
#        self.label = label
209
210
211
class ActionDescription(object):
212
    """
213
    Allowed status are:
214
    - open
215
    - closed-validated
216
    - closed-invalidated
217
    - closed-deprecated
218
    """
219
220
    COPY = 'copy'
221
    ARCHIVING = 'archiving'
222
    COMMENT = 'content-comment'
223
    CREATION = 'creation'
224
    DELETION = 'deletion'
225
    EDITION = 'edition' # Default action if unknow
226
    REVISION = 'revision'
227
    STATUS_UPDATE = 'status-update'
228
    UNARCHIVING = 'unarchiving'
229
    UNDELETION = 'undeletion'
230
    MOVE = 'move'
231
232
    # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
233
    _ICONS = {
234
        'archiving': 'archive',
235
        'content-comment': 'comment-o',
236
        'creation': 'magic',
237
        'deletion': 'trash',
238
        'edition': 'edit',
239
        'revision': 'history',
240
        'status-update': 'random',
241
        'unarchiving': 'file-archive-o',
242
        'undeletion': 'trash-o',
243
        'move': 'arrows',
244
        'copy': 'files-o',
245
    }
246
    #
247
    # _LABELS = {
248
    #     'archiving': l_('archive'),
249
    #     'content-comment': l_('Item commented'),
250
    #     'creation': l_('Item created'),
251
    #     'deletion': l_('Item deleted'),
252
    #     'edition': l_('item modified'),
253
    #     'revision': l_('New revision'),
254
    #     'status-update': l_('New status'),
255
    #     'unarchiving': l_('Item unarchived'),
256
    #     'undeletion': l_('Item undeleted'),
257
    #     'move': l_('Item moved'),
258
    #     'copy': l_('Item copied'),
259
    # }
260
261
    def __init__(self, id):
262
        assert id in ActionDescription.allowed_values()
263
        self.id = id
264
        # FIXME - G.M - 17-04-2018 - Label and fa_icon needed for webdav
265
        #  design template,
266
        # find a way to not rely on this.
267
        self.label = self.id
268
        self.fa_icon = ActionDescription._ICONS[id]
269
        #self.icon = self.fa_icon
270
        # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
271
        # self.label = ActionDescription._LABELS[id]
272
273
        # self.css = ''
274
275
    @classmethod
276
    def allowed_values(cls):
277
        return [cls.ARCHIVING,
278
                cls.COMMENT,
279
                cls.CREATION,
280
                cls.DELETION,
281
                cls.EDITION,
282
                cls.REVISION,
283
                cls.STATUS_UPDATE,
284
                cls.UNARCHIVING,
285
                cls.UNDELETION,
286
                cls.MOVE,
287
                cls.COPY,
288
                ]
289
290
291
# TODO - G.M - 30-05-2018 - Drop this old code when whe are sure nothing
292
# is lost .
293
294
295
# class ContentStatus(object):
296
#     """
297
#     Allowed status are:
298
#     - open
299
#     - closed-validated
300
#     - closed-invalidated
301
#     - closed-deprecated
302
#     """
303
#
304
#     OPEN = 'open'
305
#     CLOSED_VALIDATED = 'closed-validated'
306
#     CLOSED_UNVALIDATED = 'closed-unvalidated'
307
#     CLOSED_DEPRECATED = 'closed-deprecated'
308
#
309
#     # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
310
#     # _LABELS = {'open': l_('work in progress'),
311
#     #            'closed-validated': l_('closed — validated'),
312
#     #            'closed-unvalidated': l_('closed — cancelled'),
313
#     #            'closed-deprecated': l_('deprecated')}
314
#     #
315
#     # _LABELS_THREAD = {'open': l_('subject in progress'),
316
#     #                   'closed-validated': l_('subject closed — resolved'),
317
#     #                   'closed-unvalidated': l_('subject closed — cancelled'),
318
#     #                   'closed-deprecated': l_('deprecated')}
319
#     #
320
#     # _LABELS_FILE = {'open': l_('work in progress'),
321
#     #                 'closed-validated': l_('closed — validated'),
322
#     #                 'closed-unvalidated': l_('closed — cancelled'),
323
#     #                 'closed-deprecated': l_('deprecated')}
324
#     #
325
#     # _ICONS = {
326
#     #     'open': 'fa fa-square-o',
327
#     #     'closed-validated': 'fa fa-check-square-o',
328
#     #     'closed-unvalidated': 'fa fa-close',
329
#     #     'closed-deprecated': 'fa fa-warning',
330
#     # }
331
#     #
332
#     # _CSS = {
333
#     #     'open': 'tracim-status-open',
334
#     #     'closed-validated': 'tracim-status-closed-validated',
335
#     #     'closed-unvalidated': 'tracim-status-closed-unvalidated',
336
#     #     'closed-deprecated': 'tracim-status-closed-deprecated',
337
#     # }
338
#
339
#     def __init__(self,
340
#                  id,
341
#                  # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
342
#                  # type=''
343
#     ):
344
#         self.id = id
345
#         # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
346
#         # self.icon = ContentStatus._ICONS[id]
347
#         # self.css = ContentStatus._CSS[id]
348
#         #
349
#         # if type==content_type_list.Thread.slug:
350
#         #     self.label = ContentStatus._LABELS_THREAD[id]
351
#         # elif type==content_type_list.File.slug:
352
#         #     self.label = ContentStatus._LABELS_FILE[id]
353
#         # else:
354
#         #     self.label = ContentStatus._LABELS[id]
355
#
356
#
357
#     @classmethod
358
#     def all(cls, type='') -> ['ContentStatus']:
359
#         # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
360
#         # all = []
361
#         # all.append(ContentStatus('open', type))
362
#         # all.append(ContentStatus('closed-validated', type))
363
#         # all.append(ContentStatus('closed-unvalidated', type))
364
#         # all.append(ContentStatus('closed-deprecated', type))
365
#         # return all
366
#         status_list = list()
367
#         for elem in cls.allowed_values():
368
#             status_list.append(ContentStatus(elem))
369
#         return status_list
370
#
371
#     @classmethod
372
#     def allowed_values(cls):
373
#         # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
374
#         # return ContentStatus._LABELS.keys()
375
#         return [
376
#             ContentStatus.OPEN,
377
#             ContentStatus.CLOSED_UNVALIDATED,
378
#             ContentStatus.CLOSED_VALIDATED,
379
#             ContentStatus.CLOSED_DEPRECATED
380
#         ]
381
382
383
# class ContentType(object):
384
#     Any = 'any'
385
#
386
#     Folder = 'folder'
387
#     File = 'file'
388
#     Comment = 'comment'
389
#     Thread = 'thread'
390
#     Page = 'page'
391
#     Event = 'event'
392
#
393
#     # TODO - G.M - 10-04-2018 - [Cleanup] Do we really need this ?
394
#     # _STRING_LIST_SEPARATOR = ','
395
#
396
#     # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
397
#     # _ICONS = {  # Deprecated
398
#     #     'dashboard': 'fa-home',
399
#     #     'workspace': 'fa-bank',
400
#     #     'folder': 'fa fa-folder-open-o',
401
#     #     'file': 'fa fa-paperclip',
402
#     #     'page': 'fa fa-file-text-o',
403
#     #     'thread': 'fa fa-comments-o',
404
#     #     'comment': 'fa fa-comment-o',
405
#     #     'event': 'fa fa-calendar-o',
406
#     # }
407
#     #
408
#     # _CSS_ICONS = {
409
#     #     'dashboard': 'fa fa-home',
410
#     #     'workspace': 'fa fa-bank',
411
#     #     'folder': 'fa fa-folder-open-o',
412
#     #     'file': 'fa fa-paperclip',
413
#     #     'page': 'fa fa-file-text-o',
414
#     #     'thread': 'fa fa-comments-o',
415
#     #     'comment': 'fa fa-comment-o',
416
#     #     'event': 'fa fa-calendar-o',
417
#     # }
418
#     #
419
#     # _CSS_COLORS = {
420
#     #     'dashboard': 't-dashboard-color',
421
#     #     'workspace': 't-less-visible',
422
#     #     'folder': 't-folder-color',
423
#     #     'file': 't-file-color',
424
#     #     'page': 't-page-color',
425
#     #     'thread': 't-thread-color',
426
#     #     'comment': 't-thread-color',
427
#     #     'event': 't-event-color',
428
#     # }
429
#
430
#     _ORDER_WEIGHT = {
431
#         'folder': 0,
432
#         'page': 1,
433
#         'thread': 2,
434
#         'file': 3,
435
#         'comment': 4,
436
#         'event': 5,
437
#     }
438
#
439
#     # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
440
#     # _LABEL = {
441
#     #     'dashboard': '',
442
#     #     'workspace': l_('workspace'),
443
#     #     'folder': l_('folder'),
444
#     #     'file': l_('file'),
445
#     #     'page': l_('page'),
446
#     #     'thread': l_('thread'),
447
#     #     'comment': l_('comment'),
448
#     #     'event': l_('event'),
449
#     # }
450
#     #
451
#     # _DELETE_LABEL = {
452
#     #     'dashboard': '',
453
#     #     'workspace': l_('Delete this workspace'),
454
#     #     'folder': l_('Delete this folder'),
455
#     #     'file': l_('Delete this file'),
456
#     #     'page': l_('Delete this page'),
457
#     #     'thread': l_('Delete this thread'),
458
#     #     'comment': l_('Delete this comment'),
459
#     #     'event': l_('Delete this event'),
460
#     # }
461
#     #
462
#     # @classmethod
463
#     # def get_icon(cls, type: str):
464
#     #     assert(type in ContentType._ICONS) # DYN_REMOVE
465
#     #     return ContentType._ICONS[type]
466
#
467
#     @classmethod
468
#     def all(cls):
469
#         return cls.allowed_types()
470
#
471
#     @classmethod
472
#     def allowed_types(cls):
473
#         return [cls.Folder, cls.File, cls.Comment, cls.Thread, cls.Page,
474
#                 cls.Event]
475
#
476
#     @classmethod
477
#     def allowed_types_for_folding(cls):
478
#         # This method is used for showing only "main"
479
#         # types in the left-side treeview
480
#         return [cls.Folder, cls.File, cls.Thread, cls.Page]
481
#
482
#     # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
483
#     # @classmethod
484
#     # def allowed_types_from_str(cls, allowed_types_as_string: str):
485
#     #     allowed_types = []
486
#     #     # HACK - THIS
487
#     #     for item in allowed_types_as_string.split(ContentType._STRING_LIST_SEPARATOR):
488
#     #         if item and item in ContentType.allowed_types_for_folding():
489
#     #             allowed_types.append(item)
490
#     #     return allowed_types
491
#     #
492
#     # @classmethod
493
#     # def fill_url(cls, content: 'Content'):
494
#     #     # TODO - DYNDATATYPE - D.A. - 2014-12-02
495
#     #     # Make this code dynamic loading data types
496
#     #
497
#     #     if content.type==content_type_list.Folder.slug:
498
#     #         return '/workspaces/{}/folders/{}'.format(content.workspace_id, content.content_id)
499
#     #     elif content.type==content_type_list.File.slug:
500
#     #         return '/workspaces/{}/folders/{}/files/{}'.format(content.workspace_id, content.parent_id, content.content_id)
501
#     #     elif content.type==content_type_list.Thread.slug:
502
#     #         return '/workspaces/{}/folders/{}/threads/{}'.format(content.workspace_id, content.parent_id, content.content_id)
503
#     #     elif content.type==content_type_list.Page.slug:
504
#     #         return '/workspaces/{}/folders/{}/pages/{}'.format(content.workspace_id, content.parent_id, content.content_id)
505
#     #
506
#     # @classmethod
507
#     # def fill_url_for_workspace(cls, workspace: Workspace):
508
#     #     # TODO - DYNDATATYPE - D.A. - 2014-12-02
509
#     #     # Make this code dynamic loading data types
510
#     #     return '/workspaces/{}'.format(workspace.workspace_id)
511
#
512
#     @classmethod
513
#     def sorted(cls, types: ['ContentType']) -> ['ContentType']:
514
#         return sorted(types, key=lambda content_type: content_type.priority)
515
#
516
#     @property
517
#     def type(self):
518
#         return self.id
519
#
520
#     def __init__(self, type):
521
#         self.id = type
522
#         # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
523
#         # self.icon = ContentType._CSS_ICONS[type]
524
#         # self.color = ContentType._CSS_COLORS[type]  # deprecated
525
#         # self.css = ContentType._CSS_COLORS[type]
526
#         # self.label = ContentType._LABEL[type]
527
#         self.priority = ContentType._ORDER_WEIGHT[type]
528
#
529
#     def toDict(self):
530
#         return dict(id=self.type,
531
#                     type=self.type,
532
#                     # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
533
#                     # icon=self.icon,
534
#                     # color=self.color,
535
#                     # label=self.label,
536
#                     priority=self.priority)
537
538
class ContentChecker(object):
539
540
    @classmethod
541
    def check_properties(cls, item):
542
        properties = item.properties
543
        if item.type == content_type_list.Event.slug:
544
            if 'name' not in properties.keys():
545
                return False
546
            if 'raw' not in properties.keys():
547
                return False
548
            if 'start' not in properties.keys():
549
                return False
550
            if 'end' not in properties.keys():
551
                return False
552
            return True
553
        else:
554
            if 'allowed_content' in properties.keys():
555
                for content_slug, value in properties['allowed_content'].items():  # nopep8
556
                    if not isinstance(value, bool):
557
                        return False
558
                    if not content_slug in content_type_list.endpoint_allowed_types_slug():  # nopep8
559
                        return False
560
            if 'origin' in properties.keys():
561
                pass
562
            return True
563
564
565
class ContentRevisionRO(DeclarativeBase):
566
    """
567
    Revision of Content. It's immutable, update or delete an existing ContentRevisionRO will throw
568
    ContentRevisionUpdateError errors.
569
    """
570
571
    __tablename__ = 'content_revisions'
572
573
    revision_id = Column(Integer, primary_key=True)
574
    content_id = Column(Integer, ForeignKey('content.id'), nullable=False)
575
    # TODO - G.M - 2018-06-177 - [author] Owner should be renamed "author"
576
    owner_id = Column(Integer, ForeignKey('users.user_id'), nullable=True)
577
578
    label = Column(Unicode(1024), unique=False, nullable=False)
579
    description = Column(Text(), unique=False, nullable=False, default='')
580
    file_extension = Column(
581
        Unicode(255),
582
        unique=False,
583
        nullable=False,
584
        server_default='',
585
    )
586
    file_mimetype = Column(Unicode(255),  unique=False, nullable=False, default='')
587
    # INFO - A.P - 2017-07-03 - Depot Doc
588
    # http://depot.readthedocs.io/en/latest/#attaching-files-to-models
589
    # http://depot.readthedocs.io/en/latest/api.html#module-depot.fields
590
    depot_file = Column(UploadedFileField, unique=False, nullable=True)
591
    properties = Column('properties', Text(), unique=False, nullable=False, default='')
592
593
    type = Column(Unicode(32), unique=False, nullable=False)
594
    status = Column(Unicode(32), unique=False, nullable=False, default=str(content_status_list.get_default_status().slug))
595
    created = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
596
    updated = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
597
    is_deleted = Column(Boolean, unique=False, nullable=False, default=False)
598
    is_archived = Column(Boolean, unique=False, nullable=False, default=False)
599
    is_temporary = Column(Boolean, unique=False, nullable=False, default=False)
600
    revision_type = Column(Unicode(32), unique=False, nullable=False, default='')
601
602
    workspace_id = Column(Integer, ForeignKey('workspaces.workspace_id'), unique=False, nullable=True)
603
    workspace = relationship('Workspace', remote_side=[Workspace.workspace_id])
604
605
    parent_id = Column(Integer, ForeignKey('content.id'), nullable=True, default=None)
606
    parent = relationship("Content", foreign_keys=[parent_id], back_populates="children_revisions")
607
608
    node = relationship("Content", foreign_keys=[content_id], back_populates="revisions")
609
    # TODO - G.M - 2018-06-177 - [author] Owner should be renamed "author"
610
    owner = relationship('User', remote_side=[User.user_id])
611
612
    """ List of column copied when make a new revision from another """
613
    _cloned_columns = (
614
        'content_id',
615
        'created',
616
        'description',
617
        'file_mimetype',
618
        'file_extension',
619
        'is_archived',
620
        'is_deleted',
621
        'label',
622
        'owner',
623
        'owner_id',
624
        'parent',
625
        'parent_id',
626
        'properties',
627
        'revision_type',
628
        'status',
629
        'type',
630
        'updated',
631
        'workspace',
632
        'workspace_id',
633
        'is_temporary',
634
    )
635
636
    # Read by must be used like this:
637
    # read_datetime = revision.ready_by[<User instance>]
638
    # if user did not read the content, then a key error is raised
639
    read_by = association_proxy(
640
        'revision_read_statuses',  # name of the attribute
641
        'view_datetime',  # attribute the value is taken from
642
        creator=lambda k, v: \
643
            RevisionReadStatus(user=k, view_datetime=v)
644
    )
645
646
    @property
647
    def file_name(self):
648
        return '{0}{1}'.format(
649
            self.label,
650
            self.file_extension,
651
        )
652
653
    @classmethod
654
    def new_from(cls, revision: 'ContentRevisionRO') -> 'ContentRevisionRO':
655
        """
656
657
        Return new instance of ContentRevisionRO where properties are copied from revision parameter.
658
        Look at ContentRevisionRO._cloned_columns to see what columns are copieds.
659
660
        :param revision: revision to copy
661
        :type revision: ContentRevisionRO
662
        :return: new revision from revision parameter
663
        :rtype: ContentRevisionRO
664
        """
665
        new_rev = cls()
666
667
        for column_name in cls._cloned_columns:
668
            column_value = getattr(revision, column_name)
669
            setattr(new_rev, column_name, column_value)
670
671
        new_rev.updated = datetime.utcnow()
672
        if revision.depot_file:
673
            new_rev.depot_file = FileIntent(
674
                revision.depot_file.file.read(),
675
                revision.file_name,
676
                revision.file_mimetype,
677
            )
678
679
        return new_rev
680
681
    @classmethod
682
    def copy(
683
            cls,
684
            revision: 'ContentRevisionRO',
685
            parent: 'Content'
686
    ) -> 'ContentRevisionRO':
687
688
        copy_rev = cls()
689
        import copy
690
        copy_columns = cls._cloned_columns
691
        for column_name in copy_columns:
692
            # INFO - G-M - 15-03-2018 - set correct parent
693
            if column_name == 'parent_id':
694
                column_value = copy.copy(parent.id)
695
            elif column_name == 'parent':
696
                column_value = copy.copy(parent)
697
            else:
698
                column_value = copy.copy(getattr(revision, column_name))
699
            setattr(copy_rev, column_name, column_value)
700
701
        # copy attached_file
702
        if revision.depot_file:
703
            copy_rev.depot_file = FileIntent(
704
                revision.depot_file.file.read(),
705
                revision.file_name,
706
                revision.file_mimetype,
707
            )
708
        return copy_rev
709
710
    def __setattr__(self, key: str, value: 'mixed'):
711
        """
712
        ContentRevisionUpdateError is raised if tried to update column and revision own identity
713
        :param key: attribute name
714
        :param value: attribute value
715
        :return:
716
        """
717
        if key in ('_sa_instance_state', ):  # Prevent infinite loop from SQLAlchemy code and altered set
718
            return super().__setattr__(key, value)
719
720
        # FIXME - G.M - 28-03-2018 - Cycling Import
721
        from tracim_backend.models.revision_protection import RevisionsIntegrity
722
        if inspect(self).has_identity \
723
                and key in self._cloned_columns \
724
                and not RevisionsIntegrity.is_updatable(self):
725
                raise ContentRevisionUpdateError(
726
                    "Can't modify revision. To work on new revision use tracim.model.new_revision " +
727
                    "context manager.")
728
729
        super().__setattr__(key, value)
730
731
    @property
732
    def is_active(self) -> bool:
733
        return not self.is_deleted and not self.is_archived
734
735
    @property
736
    def is_readonly(self) -> bool:
737
        return False
738
739
    def get_status(self) -> ContentStatus:
740
        try:
741
            return content_status_list.get_one_by_slug(self.status)
742
        except ContentStatusNotExist as exc:
743
            return content_status_list.get_default_status()
744
745
    def get_label(self) -> str:
746
        return self.label or self.file_name or ''
747
748
    def get_last_action(self) -> ActionDescription:
749
        return ActionDescription(self.revision_type)
750
751
    def has_new_information_for(self, user: User) -> bool:
752
        """
753
        :param user: the _session current user
754
        :return: bool, True if there is new information for given user else False
755
                       False if the user is None
756
        """
757
        if not user:
758
            return False
759
760
        if user not in self.read_by.keys():
761
            return True
762
763
        return False
764
765
    def get_label_as_file(self):
766
        file_extension = self.file_extension or ''
767
768
        if self.type == content_type_list.Thread.slug:
769
            file_extension = '.html'
770
        elif self.type == content_type_list.Page.slug:
771
            file_extension = '.html'
772
773
        return '{0}{1}'.format(
774
            self.label,
775
            file_extension,
776
        )
777
778
# TODO - G.M - 2018-06-177 - [author] Owner should be renamed "author"
779
Index('idx__content_revisions__owner_id', ContentRevisionRO.owner_id)
780
Index('idx__content_revisions__parent_id', ContentRevisionRO.parent_id)
781
782
783
class Content(DeclarativeBase):
784
    """
785
    Content is used as a virtual representation of ContentRevisionRO.
786
    content.PROPERTY (except for content.id, content.revisions, content.children_revisions) will return
787
    value of most recent revision of content.
788
789
    # UPDATE A CONTENT
790
791
    To update an existing Content, you must use tracim.model.new_revision context manager:
792
    content = my_sontent_getter_method()
793
    with new_revision(content):
794
        content.description = 'foo bar baz'
795
    DBSession.flush()
796
797
    # QUERY CONTENTS
798
799
    To query contents you will need to join your content query with ContentRevisionRO. Join
800
    condition is available at tracim.lib.content.ContentApi#_get_revision_join:
801
802
    content = DBSession.query(Content).join(ContentRevisionRO, ContentApi._get_revision_join())
803
                  .filter(Content.label == 'foo')
804
                  .one()
805
806
    ContentApi provide also prepared Content at tracim.lib.content.ContentApi#get_canonical_query:
807
808
    content = ContentApi.get_canonical_query()
809
              .filter(Content.label == 'foo')
810
              .one()
811
    """
812
813
    __tablename__ = 'content'
814
815
    revision_to_serialize = -0  # This flag allow to serialize a given revision if required by the user
816
817
    id = Column(Integer, primary_key=True)
818
    # TODO - A.P - 2017-09-05 - revisions default sorting
819
    # The only sorting that makes sens is ordering by "updated" field. But:
820
    # - its content will soon replace the one of "created",
821
    # - this "updated" field will then be dropped.
822
    # So for now, we order by "revision_id" explicitly, but remember to switch
823
    # to "created" once "updated" removed.
824
    # https://github.com/tracim/tracim/issues/336
825
    revisions = relationship("ContentRevisionRO",
826
                             foreign_keys=[ContentRevisionRO.content_id],
827
                             back_populates="node",
828
                             order_by="ContentRevisionRO.revision_id")
829
    children_revisions = relationship("ContentRevisionRO",
830
                                      foreign_keys=[ContentRevisionRO.parent_id],
831
                                      back_populates="parent")
832
833
    @hybrid_property
834
    def content_id(self) -> int:
835
        return self.revision.content_id
836
837
    @content_id.setter
838
    def content_id(self, value: int) -> None:
839
        self.revision.content_id = value
840
841
    @content_id.expression
842
    def content_id(cls) -> InstrumentedAttribute:
843
        return ContentRevisionRO.content_id
844
845
    @hybrid_property
846
    def revision_id(self) -> int:
847
        return self.revision.revision_id
848
849
    @revision_id.setter
850
    def revision_id(self, value: int):
851
        self.revision.revision_id = value
852
853
    @revision_id.expression
854
    def revision_id(cls) -> InstrumentedAttribute:
855
        return ContentRevisionRO.revision_id
856
857
    # TODO - G.M - 2018-06-177 - [author] Owner should be renamed "author"
858
    # and should be author of first revision.
859
    @hybrid_property
860
    def owner_id(self) -> int:
861
        return self.revision.owner_id
862
863
    @owner_id.setter
864
    def owner_id(self, value: int) -> None:
865
        self.revision.owner_id = value
866
867
    @owner_id.expression
868
    def owner_id(cls) -> InstrumentedAttribute:
869
        return ContentRevisionRO.owner_id
870
871
    @hybrid_property
872
    def label(self) -> str:
873
        return self.revision.label
874
875
    @label.setter
876
    def label(self, value: str) -> None:
877
        self.revision.label = value
878
879
    @label.expression
880
    def label(cls) -> InstrumentedAttribute:
881
        return ContentRevisionRO.label
882
883
    @hybrid_property
884
    def description(self) -> str:
885
        return self.revision.description
886
887
    @description.setter
888
    def description(self, value: str) -> None:
889
        self.revision.description = value
890
891
    @description.expression
892
    def description(cls) -> InstrumentedAttribute:
893
        return ContentRevisionRO.description
894
895
    @hybrid_property
896
    def file_name(self) -> str:
897
        return '{0}{1}'.format(
898
            self.revision.label,
899
            self.revision.file_extension,
900
        )
901
902
    @file_name.setter
903
    def file_name(self, value: str) -> None:
904
        file_name, file_extension = os.path.splitext(value)
905
        self.revision.label = file_name
906
        self.revision.file_extension = file_extension
907
908
    @file_name.expression
909
    def file_name(cls) -> InstrumentedAttribute:
910
        return ContentRevisionRO.file_name + ContentRevisionRO.file_extension
911
912
    @hybrid_property
913
    def file_extension(self) -> str:
914
        return self.revision.file_extension
915
916
    @file_extension.setter
917
    def file_extension(self, value: str) -> None:
918
        self.revision.file_extension = value
919
920
    @file_extension.expression
921
    def file_extension(cls) -> InstrumentedAttribute:
922
        return ContentRevisionRO.file_extension
923
924
    @hybrid_property
925
    def file_mimetype(self) -> str:
926
        return self.revision.file_mimetype
927
928
    @file_mimetype.setter
929
    def file_mimetype(self, value: str) -> None:
930
        self.revision.file_mimetype = value
931
932
    @file_mimetype.expression
933
    def file_mimetype(cls) -> InstrumentedAttribute:
934
        return ContentRevisionRO.file_mimetype
935
936
    @hybrid_property
937
    def _properties(self) -> str:
938
        return self.revision.properties
939
940
    @_properties.setter
941
    def _properties(self, value: str) -> None:
942
        self.revision.properties = value
943
944
    @_properties.expression
945
    def _properties(cls) -> InstrumentedAttribute:
946
        return ContentRevisionRO.properties
947
948
    @hybrid_property
949
    def type(self) -> str:
950
        return self.revision.type
951
952
    @type.setter
953
    def type(self, value: str) -> None:
954
        self.revision.type = value
955
956
    @type.expression
957
    def type(cls) -> InstrumentedAttribute:
958
        return ContentRevisionRO.type
959
960
    @hybrid_property
961
    def status(self) -> str:
962
        return self.revision.status
963
964
    @status.setter
965
    def status(self, value: str) -> None:
966
        self.revision.status = value
967
968
    @status.expression
969
    def status(cls) -> InstrumentedAttribute:
970
        return ContentRevisionRO.status
971
972
    @hybrid_property
973
    def created(self) -> datetime:
974
        return self.revision.created
975
976
    @created.setter
977
    def created(self, value: datetime) -> None:
978
        self.revision.created = value
979
980
    @created.expression
981
    def created(cls) -> InstrumentedAttribute:
982
        return ContentRevisionRO.created
983
984
    @hybrid_property
985
    def updated(self) -> datetime:
986
        return self.revision.updated
987
988
    @updated.setter
989
    def updated(self, value: datetime) -> None:
990
        self.revision.updated = value
991
992
    @updated.expression
993
    def updated(cls) -> InstrumentedAttribute:
994
        return ContentRevisionRO.updated
995
996
    @hybrid_property
997
    def is_deleted(self) -> bool:
998
        return self.revision.is_deleted
999
1000
    @is_deleted.setter
1001
    def is_deleted(self, value: bool) -> None:
1002
        self.revision.is_deleted = value
1003
1004
    @is_deleted.expression
1005
    def is_deleted(cls) -> InstrumentedAttribute:
1006
        return ContentRevisionRO.is_deleted
1007
1008
    @hybrid_property
1009
    def is_archived(self) -> bool:
1010
        return self.revision.is_archived
1011
1012
    @is_archived.setter
1013
    def is_archived(self, value: bool) -> None:
1014
        self.revision.is_archived = value
1015
1016
    @is_archived.expression
1017
    def is_archived(cls) -> InstrumentedAttribute:
1018
        return ContentRevisionRO.is_archived
1019
1020
    @hybrid_property
1021
    def is_temporary(self) -> bool:
1022
        return self.revision.is_temporary
1023
1024
    @is_temporary.setter
1025
    def is_temporary(self, value: bool) -> None:
1026
        self.revision.is_temporary = value
1027
1028
    @is_temporary.expression
1029
    def is_temporary(cls) -> InstrumentedAttribute:
1030
        return ContentRevisionRO.is_temporary
1031
1032
    @hybrid_property
1033
    def revision_type(self) -> str:
1034
        return self.revision.revision_type
1035
1036
    @revision_type.setter
1037
    def revision_type(self, value: str) -> None:
1038
        self.revision.revision_type = value
1039
1040
    @revision_type.expression
1041
    def revision_type(cls) -> InstrumentedAttribute:
1042
        return ContentRevisionRO.revision_type
1043
1044
    @hybrid_property
1045
    def workspace_id(self) -> int:
1046
        return self.revision.workspace_id
1047
1048
    @workspace_id.setter
1049
    def workspace_id(self, value: int) -> None:
1050
        self.revision.workspace_id = value
1051
1052
    @workspace_id.expression
1053
    def workspace_id(cls) -> InstrumentedAttribute:
1054
        return ContentRevisionRO.workspace_id
1055
1056
    @hybrid_property
1057
    def workspace(self) -> Workspace:
1058
        return self.revision.workspace
1059
1060
    @workspace.setter
1061
    def workspace(self, value: Workspace) -> None:
1062
        self.revision.workspace = value
1063
1064
    @workspace.expression
1065
    def workspace(cls) -> InstrumentedAttribute:
1066
        return ContentRevisionRO.workspace
1067
1068
    @hybrid_property
1069
    def parent_id(self) -> int:
1070
        return self.revision.parent_id
1071
1072
    @parent_id.setter
1073
    def parent_id(self, value: int) -> None:
1074
        self.revision.parent_id = value
1075
1076
    @parent_id.expression
1077
    def parent_id(cls) -> InstrumentedAttribute:
1078
        return ContentRevisionRO.parent_id
1079
1080
    @hybrid_property
1081
    def parent(self) -> 'Content':
1082
        return self.revision.parent
1083
1084
    @parent.setter
1085
    def parent(self, value: 'Content') -> None:
1086
        self.revision.parent = value
1087
1088
    @parent.expression
1089
    def parent(cls) -> InstrumentedAttribute:
1090
        return ContentRevisionRO.parent
1091
1092
    @hybrid_property
1093
    def node(self) -> 'Content':
1094
        return self.revision.node
1095
1096
    @node.setter
1097
    def node(self, value: 'Content') -> None:
1098
        self.revision.node = value
1099
1100
    @node.expression
1101
    def node(cls) -> InstrumentedAttribute:
1102
        return ContentRevisionRO.node
1103
1104
    # TODO - G.M - 2018-06-177 - [author] Owner should be renamed "author"
1105
    # and should be author of first revision.
1106
    @hybrid_property
1107
    def owner(self) -> User:
1108
        return self.revision.owner
1109
1110
    @owner.setter
1111
    def owner(self, value: User) -> None:
1112
        self.revision.owner = value
1113
1114
    @owner.expression
1115
    def owner(cls) -> InstrumentedAttribute:
1116
        return ContentRevisionRO.owner
1117
1118
    @hybrid_property
1119
    def children(self) -> ['Content']:
1120
        """
1121
        :return: list of children Content
1122
        :rtype Content
1123
        """
1124
        # Return a list of unique revisions parent content
1125
        return list(set([revision.node for revision in self.children_revisions]))
1126
1127
    @property
1128
    def revision(self) -> ContentRevisionRO:
1129
        return self.get_current_revision()
1130
1131
    @property
1132
    def first_revision(self) -> ContentRevisionRO:
1133
        return self.revisions[0]  # FIXME
1134
1135
    @property
1136
    def last_revision(self) -> ContentRevisionRO:
1137
        return self.revisions[-1]
1138
1139
    @property
1140
    def is_readonly(self) -> bool:
1141
        return self.revision.is_readonly
1142
1143
    @property
1144
    def is_active(self) -> bool:
1145
        return self.revision.is_active
1146
1147
    @property
1148
    def depot_file(self) -> UploadedFile:
1149
        return self.revision.depot_file
1150
1151
    @depot_file.setter
1152
    def depot_file(self, value):
1153
        self.revision.depot_file = value
1154
1155
    def get_current_revision(self) -> ContentRevisionRO:
1156
        if not self.revisions:
1157
            return self.new_revision()
1158
1159
        # If last revisions revision don't have revision_id, return it we just add it.
1160
        if self.revisions[-1].revision_id is None:
1161
            return self.revisions[-1]
1162
1163
        # Revisions should be ordred by revision_id but we ensure that here
1164
        revisions = sorted(self.revisions, key=lambda revision: revision.revision_id)
1165
        return revisions[-1]
1166
1167
    def new_revision(self) -> ContentRevisionRO:
1168
        """
1169
        Return and assign to this content a new revision.
1170
        If it's a new content, revision is totally new.
1171
        If this content already own revision, revision is build from last revision.
1172
        :return:
1173
        """
1174
        if not self.revisions:
1175
            self.revisions.append(ContentRevisionRO())
1176
            return self.revisions[0]
1177
1178
        new_rev = ContentRevisionRO.new_from(self.get_current_revision())
1179
        self.revisions.append(new_rev)
1180
        return new_rev
1181
1182
    def get_valid_children(self, content_types: list=None) -> ['Content']:
1183
        for child in self.children:
1184
            if not child.is_deleted and not child.is_archived:
1185
                if not content_types or child.type in content_types:
1186
                    yield child.node
1187
1188
    @hybrid_property
1189
    def properties(self) -> dict:
1190
        """ return a structure decoded from json content of _properties """
1191
1192
        if not self._properties:
1193
            properties = {}
1194
        else:
1195
            properties = json.loads(self._properties)
1196
        if content_type_list.get_one_by_slug(self.type) != content_type_list.Event:
1197
            if not 'allowed_content' in properties:
1198
                properties['allowed_content'] = content_type_list.default_allowed_content_properties(self.type)  # nopep8
1199
        return properties
1200
1201
    @properties.setter
1202
    def properties(self, properties_struct: dict) -> None:
1203
        """ encode a given structure into json and store it in _properties attribute"""
1204
        self._properties = json.dumps(properties_struct)
1205
        ContentChecker.check_properties(self)
1206
1207
    def created_as_delta(self, delta_from_datetime:datetime=None):
1208
        if not delta_from_datetime:
1209
            delta_from_datetime = datetime.utcnow()
1210
1211
        return format_timedelta(delta_from_datetime - self.created,
1212
                                locale=get_locale())
1213
1214
    def datetime_as_delta(self, datetime_object,
1215
                          delta_from_datetime:datetime=None):
1216
        if not delta_from_datetime:
1217
            delta_from_datetime = datetime.utcnow()
1218
        return format_timedelta(delta_from_datetime - datetime_object,
1219
                                locale=get_locale())
1220
1221
    def get_child_nb(self, content_type: str, content_status = ''):
1222
        child_nb = 0
1223
        for child in self.get_valid_children():
1224
            if child.type == content_type or content_type.slug == content_type_list.Any_SLUG:
1225
                if not content_status:
1226
                    child_nb = child_nb+1
1227
                elif content_status==child.status:
1228
                    child_nb = child_nb+1
1229
        return child_nb
1230
1231
    def get_label(self):
1232
        return self.label or self.file_name or ''
1233
1234
    def get_label_as_file(self) -> str:
1235
        """
1236
        :return: Return content label in file representation context
1237
        """
1238
        return self.revision.get_label_as_file()
1239
1240
    def get_status(self) -> ContentStatus:
1241
        return self.revision.get_status()
1242
1243
    def get_last_action(self) -> ActionDescription:
1244
        return ActionDescription(self.revision_type)
1245
1246
    def get_simple_last_activity_date(self) -> datetime_root.datetime:
1247
        """
1248
        Get last activity_date, comments_included. Do not search recursively
1249
        in revision or children.
1250
        :return:
1251
        """
1252
        last_revision_date = self.updated
1253
        for comment in self.get_comments():
1254
            if comment.updated > last_revision_date:
1255
                last_revision_date = comment.updated
1256
        return last_revision_date
1257
1258
    def get_last_activity_date(self) -> datetime_root.datetime:
1259
        """
1260
        Get last activity date with complete recursive search
1261
        :return:
1262
        """
1263
        last_revision_date = self.updated
1264
        for revision in self.revisions:
1265
            if revision.updated > last_revision_date:
1266
                last_revision_date = revision.updated
1267
1268
        for child in self.children:
1269
            if child.updated > last_revision_date:
1270
                last_revision_date = child.updated
1271
        return last_revision_date
1272
1273
    def has_new_information_for(self, user: User) -> bool:
1274
        """
1275
        :param user: the _session current user
1276
        :return: bool, True if there is new information for given user else False
1277
                       False if the user is None
1278
        """
1279
        revision = self.get_current_revision()
1280
1281
        if not user:
1282
            return False
1283
1284
        if user not in revision.read_by.keys():
1285
            # The user did not read this item, so yes!
1286
            return True
1287
1288
        for child in self.get_valid_children():
1289
            if child.has_new_information_for(user):
1290
                return True
1291
1292
        return False
1293
1294
    def get_comments(self):
1295
        children = []
1296
        for child in self.children:
1297
            if content_type_list.Comment.slug == child.type and not child.is_deleted and not child.is_archived:
1298
                children.append(child.node)
1299
        return children
1300
1301
    def get_last_comment_from(self, user: User) -> 'Content':
1302
        # TODO - Make this more efficient
1303
        last_comment_updated = None
1304
        last_comment = None
1305
        for comment in self.get_comments():
1306
            if user.user_id == comment.owner.user_id:
1307
                if not last_comment or last_comment_updated<comment.updated:
1308
                    # take only the latest comment !
1309
                    last_comment = comment
1310
                    last_comment_updated = comment.updated
1311
1312
        return last_comment
1313
1314
    def get_previous_revision(self) -> 'ContentRevisionRO':
1315
        rev_ids = [revision.revision_id for revision in self.revisions]
1316
        rev_ids.sort()
1317
1318
        if len(rev_ids)>=2:
1319
            revision_rev_id = rev_ids[-2]
1320
1321
            for revision in self.revisions:
1322
                if revision.revision_id == revision_rev_id:
1323
                    return revision
1324
1325
        return None
1326
1327
    def description_as_raw_text(self):
1328
        # 'html.parser' fixes a hanging bug
1329
        # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
1330
        return BeautifulSoup(self.description, 'html.parser').text
1331
1332
    def get_allowed_content_types(self):
1333
        types = []
1334
        try:
1335
            allowed_types = self.properties['allowed_content']
1336
            for type_label, is_allowed in allowed_types.items():
1337
                if is_allowed:
1338
                   types.append(
1339
                        content_type_list.get_one_by_slug(type_label)
1340
                   )
1341
        # TODO BS 2018-08-13: This try/except is not correct: except exception
1342
        # if we know what to except.
1343
        except Exception as e:
1344
            print(e.__str__())
1345
            print('----- /*\ *****')
1346
            raise ValueError('Not allowed content property')
1347
1348
        return types
1349
1350
    def get_history(self, drop_empty_revision=False) -> '[VirtualEvent]':
1351
        events = []
1352
        for comment in self.get_comments():
1353
            events.append(VirtualEvent.create_from_content(comment))
1354
1355
        revisions = sorted(self.revisions, key=lambda rev: rev.revision_id)
1356
        for revision in revisions:
1357
            # INFO - G.M - 09-03-2018 - Do not show file revision with empty
1358
            # file to have a more clear view of revision.
1359
            # Some webdav client create empty file before uploading, we must
1360
            # have possibility to not show the related revision
1361
            if drop_empty_revision:
1362
                if revision.depot_file and revision.depot_file.file.content_length == 0:  # nopep8
1363
                    # INFO - G.M - 12-03-2018 -Always show the last and
1364
                    # first revision.
1365
                    if revision != revisions[-1] and revision != revisions[0]:
1366
                        continue
1367
1368
            events.append(VirtualEvent.create_from_content_revision(revision))
1369
1370
        sorted_events = sorted(events,
1371
                               key=lambda event: event.created, reverse=True)
1372
        return sorted_events
1373
1374
    @classmethod
1375
    def format_path(cls, url_template: str, content: 'Content') -> str:
1376
        wid = content.workspace.workspace_id
1377
        fid = content.parent_id  # May be None if no parent
1378
        ctype = content.type
1379
        cid = content.content_id
1380
        return url_template.format(wid=wid, fid=fid, ctype=ctype, cid=cid)
1381
1382
    def copy(self, parent):
1383
        cpy_content = Content()
1384
        for rev in self.revisions:
1385
            cpy_rev = ContentRevisionRO.copy(rev, parent)
1386
            cpy_content.revisions.append(cpy_rev)
1387
        return cpy_content
1388
1389
1390
class RevisionReadStatus(DeclarativeBase):
1391
1392
    __tablename__ = 'revision_read_status'
1393
1394
    revision_id = Column(Integer, ForeignKey('content_revisions.revision_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
1395
    user_id = Column(Integer, ForeignKey('users.user_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
1396
    #  Default value datetime.utcnow, see: http://stackoverflow.com/a/13370382/801924 (or http://pastebin.com/VLyWktUn)
1397
    view_datetime = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
1398
1399
    content_revision = relationship(
1400
        'ContentRevisionRO',
1401
        backref=backref(
1402
            'revision_read_statuses',
1403
            collection_class=attribute_mapped_collection('user'),
1404
            cascade='all, delete-orphan'
1405
        ))
1406
1407
    user = relationship('User', backref=backref(
1408
        'revision_readers',
1409
        collection_class=attribute_mapped_collection('view_datetime'),
1410
        cascade='all, delete-orphan'
1411
    ))
1412
1413
1414
class NodeTreeItem(object):
1415
    """
1416
        This class implements a model that allow to simply represents
1417
        the left-panel menu items. This model is used by dbapi but
1418
        is not directly related to sqlalchemy and database
1419
    """
1420
    def __init__(
1421
        self,
1422
        node: Content,
1423
        children: typing.List['NodeTreeItem'],
1424
        is_selected: bool = False,
1425
    ):
1426
        self.node = node
1427
        self.children = children
1428
        self.is_selected = is_selected
1429
1430
1431
class VirtualEvent(object):
1432
    @classmethod
1433
    def create_from(cls, object):
1434
        if Content == object.__class__:
1435
            return cls.create_from_content(object)
1436
        elif ContentRevisionRO == object.__class__:
1437
            return cls.create_from_content_revision(object)
1438
1439
    @classmethod
1440
    def create_from_content(cls, content: Content):
1441
1442
        label = content.get_label()
1443
        if content.type == content_type_list.Comment.slug:
1444
            # TODO - G.M  - 10-04-2018 - [Cleanup] Remove label param
1445
            # from this object ?
1446
            # TODO - G.M - 2018-08-20 - [I18n] fix trad of this
1447
            label = '<strong>{}</strong> wrote:'.format(content.owner.get_display_name())
1448
1449
        return VirtualEvent(id=content.content_id,
1450
                            created=content.created,
1451
                            owner=content.owner,
1452
                            type=ActionDescription(content.revision_type),
1453
                            label=label,
1454
                            content=content.description,
1455
                            ref_object=content)
1456
1457
    @classmethod
1458
    def create_from_content_revision(cls, revision: ContentRevisionRO):
1459
        action_description = ActionDescription(revision.revision_type)
1460
1461
        return VirtualEvent(id=revision.revision_id,
1462
                            created=revision.updated,
1463
                            owner=revision.owner,
1464
                            type=action_description,
1465
                            label=action_description.label,
1466
                            content='',
1467
                            ref_object=revision)
1468
1469
    def __init__(self, id, created, owner, type, label, content, ref_object):
1470
        self.id = id
1471
        self.created = created
1472
        self.owner = owner
1473
        self.type = type
1474
        self.label = label
1475
        self.content = content
1476
        self.ref_object = ref_object
1477
1478
        assert hasattr(type, 'id')
1479
        # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
1480
        # assert hasattr(type, 'css')
1481
        # assert hasattr(type, 'fa_icon')
1482
        # assert hasattr(type, 'label')
1483
1484
    def created_as_delta(self, delta_from_datetime:datetime=None):
1485
        if not delta_from_datetime:
1486
            delta_from_datetime = datetime.utcnow()
1487
        return format_timedelta(delta_from_datetime - self.created,
1488
                                locale=get_locale())
1489
1490 View Code Duplication
    def create_readable_date(self, delta_from_datetime:datetime=None):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
1491
        aff = ''
1492
1493
        if not delta_from_datetime:
1494
            delta_from_datetime = datetime.utcnow()
1495
1496
        delta = delta_from_datetime - self.created
1497
1498
        if delta.days > 0:
1499
            if delta.days >= 365:
1500
                aff = '%d year%s ago' % (delta.days/365, 's' if delta.days/365>=2 else '')
1501
            elif delta.days >= 30:
1502
                aff = '%d month%s ago' % (delta.days/30, 's' if delta.days/30>=2 else '')
1503
            else:
1504
                aff = '%d day%s ago' % (delta.days, 's' if delta.days>=2 else '')
1505
        else:
1506
            if delta.seconds < 60:
1507
                aff = '%d second%s ago' % (delta.seconds, 's' if delta.seconds>1 else '')
1508
            elif delta.seconds/60 < 60:
1509
                aff = '%d minute%s ago' % (delta.seconds/60, 's' if delta.seconds/60>=2 else '')
1510
            else:
1511
                aff = '%d hour%s ago' % (delta.seconds/3600, 's' if delta.seconds/3600>=2 else '')
1512
1513
        return aff
1514