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