Passed
Pull Request — develop (#31)
by Bastien
01:43
created

Content.status()   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
        self.revision.label = file_name
891
        self.revision.file_extension = file_extension
892
893
    @file_name.expression
894
    def file_name(cls) -> InstrumentedAttribute:
895
        return ContentRevisionRO.file_name + ContentRevisionRO.file_extension
896
897
    @hybrid_property
898
    def file_extension(self) -> str:
899
        return self.revision.file_extension
900
901
    @file_extension.setter
902
    def file_extension(self, value: str) -> None:
903
        self.revision.file_extension = value
904
905
    @file_extension.expression
906
    def file_extension(cls) -> InstrumentedAttribute:
907
        return ContentRevisionRO.file_extension
908
909
    @hybrid_property
910
    def file_mimetype(self) -> str:
911
        return self.revision.file_mimetype
912
913
    @file_mimetype.setter
914
    def file_mimetype(self, value: str) -> None:
915
        self.revision.file_mimetype = value
916
917
    @file_mimetype.expression
918
    def file_mimetype(cls) -> InstrumentedAttribute:
919
        return ContentRevisionRO.file_mimetype
920
921
    @hybrid_property
922
    def _properties(self) -> str:
923
        return self.revision.properties
924
925
    @_properties.setter
926
    def _properties(self, value: str) -> None:
927
        self.revision.properties = value
928
929
    @_properties.expression
930
    def _properties(cls) -> InstrumentedAttribute:
931
        return ContentRevisionRO.properties
932
933
    @hybrid_property
934
    def type(self) -> str:
935
        return self.revision.type
936
937
    @type.setter
938
    def type(self, value: str) -> None:
939
        self.revision.type = value
940
941
    @type.expression
942
    def type(cls) -> InstrumentedAttribute:
943
        return ContentRevisionRO.type
944
945
    @hybrid_property
946
    def status(self) -> str:
947
        return self.revision.status
948
949
    @status.setter
950
    def status(self, value: str) -> None:
951
        self.revision.status = value
952
953
    @status.expression
954
    def status(cls) -> InstrumentedAttribute:
955
        return ContentRevisionRO.status
956
957
    @hybrid_property
958
    def created(self) -> datetime:
959
        return self.revision.created
960
961
    @created.setter
962
    def created(self, value: datetime) -> None:
963
        self.revision.created = value
964
965
    @created.expression
966
    def created(cls) -> InstrumentedAttribute:
967
        return ContentRevisionRO.created
968
969
    @hybrid_property
970
    def updated(self) -> datetime:
971
        return self.revision.updated
972
973
    @updated.setter
974
    def updated(self, value: datetime) -> None:
975
        self.revision.updated = value
976
977
    @updated.expression
978
    def updated(cls) -> InstrumentedAttribute:
979
        return ContentRevisionRO.updated
980
981
    @hybrid_property
982
    def is_deleted(self) -> bool:
983
        return self.revision.is_deleted
984
985
    @is_deleted.setter
986
    def is_deleted(self, value: bool) -> None:
987
        self.revision.is_deleted = value
988
989
    @is_deleted.expression
990
    def is_deleted(cls) -> InstrumentedAttribute:
991
        return ContentRevisionRO.is_deleted
992
993
    @hybrid_property
994
    def is_archived(self) -> bool:
995
        return self.revision.is_archived
996
997
    @is_archived.setter
998
    def is_archived(self, value: bool) -> None:
999
        self.revision.is_archived = value
1000
1001
    @is_archived.expression
1002
    def is_archived(cls) -> InstrumentedAttribute:
1003
        return ContentRevisionRO.is_archived
1004
1005
    @hybrid_property
1006
    def is_temporary(self) -> bool:
1007
        return self.revision.is_temporary
1008
1009
    @is_temporary.setter
1010
    def is_temporary(self, value: bool) -> None:
1011
        self.revision.is_temporary = value
1012
1013
    @is_temporary.expression
1014
    def is_temporary(cls) -> InstrumentedAttribute:
1015
        return ContentRevisionRO.is_temporary
1016
1017
    @hybrid_property
1018
    def revision_type(self) -> str:
1019
        return self.revision.revision_type
1020
1021
    @revision_type.setter
1022
    def revision_type(self, value: str) -> None:
1023
        self.revision.revision_type = value
1024
1025
    @revision_type.expression
1026
    def revision_type(cls) -> InstrumentedAttribute:
1027
        return ContentRevisionRO.revision_type
1028
1029
    @hybrid_property
1030
    def workspace_id(self) -> int:
1031
        return self.revision.workspace_id
1032
1033
    @workspace_id.setter
1034
    def workspace_id(self, value: int) -> None:
1035
        self.revision.workspace_id = value
1036
1037
    @workspace_id.expression
1038
    def workspace_id(cls) -> InstrumentedAttribute:
1039
        return ContentRevisionRO.workspace_id
1040
1041
    @hybrid_property
1042
    def workspace(self) -> Workspace:
1043
        return self.revision.workspace
1044
1045
    @workspace.setter
1046
    def workspace(self, value: Workspace) -> None:
1047
        self.revision.workspace = value
1048
1049
    @workspace.expression
1050
    def workspace(cls) -> InstrumentedAttribute:
1051
        return ContentRevisionRO.workspace
1052
1053
    @hybrid_property
1054
    def parent_id(self) -> int:
1055
        return self.revision.parent_id
1056
1057
    @parent_id.setter
1058
    def parent_id(self, value: int) -> None:
1059
        self.revision.parent_id = value
1060
1061
    @parent_id.expression
1062
    def parent_id(cls) -> InstrumentedAttribute:
1063
        return ContentRevisionRO.parent_id
1064
1065
    @hybrid_property
1066
    def parent(self) -> 'Content':
1067
        return self.revision.parent
1068
1069
    @parent.setter
1070
    def parent(self, value: 'Content') -> None:
1071
        self.revision.parent = value
1072
1073
    @parent.expression
1074
    def parent(cls) -> InstrumentedAttribute:
1075
        return ContentRevisionRO.parent
1076
1077
    @hybrid_property
1078
    def node(self) -> 'Content':
1079
        return self.revision.node
1080
1081
    @node.setter
1082
    def node(self, value: 'Content') -> None:
1083
        self.revision.node = value
1084
1085
    @node.expression
1086
    def node(cls) -> InstrumentedAttribute:
1087
        return ContentRevisionRO.node
1088
1089
    # TODO - G.M - 2018-06-177 - [author] Owner should be renamed "author"
1090
    # and should be author of first revision.
1091
    @hybrid_property
1092
    def owner(self) -> User:
1093
        return self.revision.owner
1094
1095
    @owner.setter
1096
    def owner(self, value: User) -> None:
1097
        self.revision.owner = value
1098
1099
    @owner.expression
1100
    def owner(cls) -> InstrumentedAttribute:
1101
        return ContentRevisionRO.owner
1102
1103
    @hybrid_property
1104
    def children(self) -> ['Content']:
1105
        """
1106
        :return: list of children Content
1107
        :rtype Content
1108
        """
1109
        # Return a list of unique revisions parent content
1110
        return list(set([revision.node for revision in self.children_revisions]))
1111
1112
    @property
1113
    def revision(self) -> ContentRevisionRO:
1114
        return self.get_current_revision()
1115
1116
    @property
1117
    def first_revision(self) -> ContentRevisionRO:
1118
        return self.revisions[0]  # FIXME
1119
1120
    @property
1121
    def last_revision(self) -> ContentRevisionRO:
1122
        return self.revisions[-1]
1123
1124
    @property
1125
    def is_editable(self) -> bool:
1126
        return not self.is_archived and not self.is_deleted
1127
1128
    @property
1129
    def is_active(self) -> bool:
1130
        return self.is_editable
1131
1132
    @property
1133
    def depot_file(self) -> UploadedFile:
1134
        return self.revision.depot_file
1135
1136
    @depot_file.setter
1137
    def depot_file(self, value):
1138
        self.revision.depot_file = value
1139
1140
    def get_current_revision(self) -> ContentRevisionRO:
1141
        if not self.revisions:
1142
            return self.new_revision()
1143
1144
        # If last revisions revision don't have revision_id, return it we just add it.
1145
        if self.revisions[-1].revision_id is None:
1146
            return self.revisions[-1]
1147
1148
        # Revisions should be ordred by revision_id but we ensure that here
1149
        revisions = sorted(self.revisions, key=lambda revision: revision.revision_id)
1150
        return revisions[-1]
1151
1152
    def new_revision(self) -> ContentRevisionRO:
1153
        """
1154
        Return and assign to this content a new revision.
1155
        If it's a new content, revision is totally new.
1156
        If this content already own revision, revision is build from last revision.
1157
        :return:
1158
        """
1159
        if not self.revisions:
1160
            self.revisions.append(ContentRevisionRO())
1161
            return self.revisions[0]
1162
1163
        new_rev = ContentRevisionRO.new_from(self.get_current_revision())
1164
        self.revisions.append(new_rev)
1165
        return new_rev
1166
1167
    def get_valid_children(self, content_types: list=None) -> ['Content']:
1168
        for child in self.children:
1169
            if not child.is_deleted and not child.is_archived:
1170
                if not content_types or child.type in content_types:
1171
                    yield child.node
1172
1173
    @hybrid_property
1174
    def properties(self) -> dict:
1175
        """ return a structure decoded from json content of _properties """
1176
1177
        if not self._properties:
1178
            properties = {}
1179
        else:
1180
            properties = json.loads(self._properties)
1181
        if CONTENT_TYPES.get_one_by_slug(self.type) != CONTENT_TYPES.Event:
1182
            if not 'allowed_content' in properties:
1183
                properties['allowed_content'] = CONTENT_TYPES.default_allowed_content_properties(self.type)  # nopep8
1184
        return properties
1185
1186
    @properties.setter
1187
    def properties(self, properties_struct: dict) -> None:
1188
        """ encode a given structure into json and store it in _properties attribute"""
1189
        self._properties = json.dumps(properties_struct)
1190
        ContentChecker.check_properties(self)
1191
1192
    def created_as_delta(self, delta_from_datetime:datetime=None):
1193
        if not delta_from_datetime:
1194
            delta_from_datetime = datetime.utcnow()
1195
1196
        return format_timedelta(delta_from_datetime - self.created,
1197
                                locale=get_locale())
1198
1199
    def datetime_as_delta(self, datetime_object,
1200
                          delta_from_datetime:datetime=None):
1201
        if not delta_from_datetime:
1202
            delta_from_datetime = datetime.utcnow()
1203
        return format_timedelta(delta_from_datetime - datetime_object,
1204
                                locale=get_locale())
1205
1206
    def get_child_nb(self, content_type: str, content_status = ''):
1207
        child_nb = 0
1208
        for child in self.get_valid_children():
1209
            if child.type == content_type or content_type.slug == CONTENT_TYPES.Any_SLUG:
1210
                if not content_status:
1211
                    child_nb = child_nb+1
1212
                elif content_status==child.status:
1213
                    child_nb = child_nb+1
1214
        return child_nb
1215
1216
    def get_label(self):
1217
        return self.label or self.file_name or ''
1218
1219
    def get_label_as_file(self) -> str:
1220
        """
1221
        :return: Return content label in file representation context
1222
        """
1223
        return self.revision.get_label_as_file()
1224
1225
    def get_status(self) -> ContentStatus:
1226
        return CONTENT_STATUS.get_one_by_slug(
1227
            self.status,
1228
        )
1229
1230
    def get_last_action(self) -> ActionDescription:
1231
        return ActionDescription(self.revision_type)
1232
1233
    def get_simple_last_activity_date(self) -> datetime_root.datetime:
1234
        """
1235
        Get last activity_date, comments_included. Do not search recursively
1236
        in revision or children.
1237
        :return:
1238
        """
1239
        last_revision_date = self.updated
1240
        for comment in self.get_comments():
1241
            if comment.updated > last_revision_date:
1242
                last_revision_date = comment.updated
1243
        return last_revision_date
1244
1245
    def get_last_activity_date(self) -> datetime_root.datetime:
1246
        """
1247
        Get last activity date with complete recursive search
1248
        :return:
1249
        """
1250
        last_revision_date = self.updated
1251
        for revision in self.revisions:
1252
            if revision.updated > last_revision_date:
1253
                last_revision_date = revision.updated
1254
1255
        for child in self.children:
1256
            if child.updated > last_revision_date:
1257
                last_revision_date = child.updated
1258
        return last_revision_date
1259
1260
    def has_new_information_for(self, user: User) -> bool:
1261
        """
1262
        :param user: the _session current user
1263
        :return: bool, True if there is new information for given user else False
1264
                       False if the user is None
1265
        """
1266
        revision = self.get_current_revision()
1267
1268
        if not user:
1269
            return False
1270
1271
        if user not in revision.read_by.keys():
1272
            # The user did not read this item, so yes!
1273
            return True
1274
1275
        for child in self.get_valid_children():
1276
            if child.has_new_information_for(user):
1277
                return True
1278
1279
        return False
1280
1281
    def get_comments(self):
1282
        children = []
1283
        for child in self.children:
1284
            if CONTENT_TYPES.Comment.slug == child.type and not child.is_deleted and not child.is_archived:
1285
                children.append(child.node)
1286
        return children
1287
1288
    def get_last_comment_from(self, user: User) -> 'Content':
1289
        # TODO - Make this more efficient
1290
        last_comment_updated = None
1291
        last_comment = None
1292
        for comment in self.get_comments():
1293
            if user.user_id == comment.owner.user_id:
1294
                if not last_comment or last_comment_updated<comment.updated:
1295
                    # take only the latest comment !
1296
                    last_comment = comment
1297
                    last_comment_updated = comment.updated
1298
1299
        return last_comment
1300
1301
    def get_previous_revision(self) -> 'ContentRevisionRO':
1302
        rev_ids = [revision.revision_id for revision in self.revisions]
1303
        rev_ids.sort()
1304
1305
        if len(rev_ids)>=2:
1306
            revision_rev_id = rev_ids[-2]
1307
1308
            for revision in self.revisions:
1309
                if revision.revision_id == revision_rev_id:
1310
                    return revision
1311
1312
        return None
1313
1314
    def description_as_raw_text(self):
1315
        # 'html.parser' fixes a hanging bug
1316
        # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
1317
        return BeautifulSoup(self.description, 'html.parser').text
1318
1319
    def get_allowed_content_types(self):
1320
        types = []
1321
        try:
1322
            allowed_types = self.properties['allowed_content']
1323
            for type_label, is_allowed in allowed_types.items():
1324
                if is_allowed:
1325
                   types.append(
1326
                        CONTENT_TYPES.get_one_by_slug(type_label)
1327
                   )
1328
        # TODO BS 2018-08-13: This try/except is not correct: except exception
1329
        # if we know what to except.
1330
        except Exception as e:
1331
            print(e.__str__())
1332
            print('----- /*\ *****')
1333
            raise ValueError('Not allowed content property')
1334
1335
        return types
1336
1337
    def get_history(self, drop_empty_revision=False) -> '[VirtualEvent]':
1338
        events = []
1339
        for comment in self.get_comments():
1340
            events.append(VirtualEvent.create_from_content(comment))
1341
1342
        revisions = sorted(self.revisions, key=lambda rev: rev.revision_id)
1343
        for revision in revisions:
1344
            # INFO - G.M - 09-03-2018 - Do not show file revision with empty
1345
            # file to have a more clear view of revision.
1346
            # Some webdav client create empty file before uploading, we must
1347
            # have possibility to not show the related revision
1348
            if drop_empty_revision:
1349
                if revision.depot_file and revision.depot_file.file.content_length == 0:  # nopep8
1350
                    # INFO - G.M - 12-03-2018 -Always show the last and
1351
                    # first revision.
1352
                    if revision != revisions[-1] and revision != revisions[0]:
1353
                        continue
1354
1355
            events.append(VirtualEvent.create_from_content_revision(revision))
1356
1357
        sorted_events = sorted(events,
1358
                               key=lambda event: event.created, reverse=True)
1359
        return sorted_events
1360
1361
    @classmethod
1362
    def format_path(cls, url_template: str, content: 'Content') -> str:
1363
        wid = content.workspace.workspace_id
1364
        fid = content.parent_id  # May be None if no parent
1365
        ctype = content.type
1366
        cid = content.content_id
1367
        return url_template.format(wid=wid, fid=fid, ctype=ctype, cid=cid)
1368
1369
    def copy(self, parent):
1370
        cpy_content = Content()
1371
        for rev in self.revisions:
1372
            cpy_rev = ContentRevisionRO.copy(rev, parent)
1373
            cpy_content.revisions.append(cpy_rev)
1374
        return cpy_content
1375
1376
1377
class RevisionReadStatus(DeclarativeBase):
1378
1379
    __tablename__ = 'revision_read_status'
1380
1381
    revision_id = Column(Integer, ForeignKey('content_revisions.revision_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
1382
    user_id = Column(Integer, ForeignKey('users.user_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
1383
    #  Default value datetime.utcnow, see: http://stackoverflow.com/a/13370382/801924 (or http://pastebin.com/VLyWktUn)
1384
    view_datetime = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
1385
1386
    content_revision = relationship(
1387
        'ContentRevisionRO',
1388
        backref=backref(
1389
            'revision_read_statuses',
1390
            collection_class=attribute_mapped_collection('user'),
1391
            cascade='all, delete-orphan'
1392
        ))
1393
1394
    user = relationship('User', backref=backref(
1395
        'revision_readers',
1396
        collection_class=attribute_mapped_collection('view_datetime'),
1397
        cascade='all, delete-orphan'
1398
    ))
1399
1400
1401
class NodeTreeItem(object):
1402
    """
1403
        This class implements a model that allow to simply represents
1404
        the left-panel menu items. This model is used by dbapi but
1405
        is not directly related to sqlalchemy and database
1406
    """
1407
    def __init__(
1408
        self,
1409
        node: Content,
1410
        children: typing.List['NodeTreeItem'],
1411
        is_selected: bool = False,
1412
    ):
1413
        self.node = node
1414
        self.children = children
1415
        self.is_selected = is_selected
1416
1417
1418
class VirtualEvent(object):
1419
    @classmethod
1420
    def create_from(cls, object):
1421
        if Content == object.__class__:
1422
            return cls.create_from_content(object)
1423
        elif ContentRevisionRO == object.__class__:
1424
            return cls.create_from_content_revision(object)
1425
1426
    @classmethod
1427
    def create_from_content(cls, content: Content):
1428
1429
        label = content.get_label()
1430
        if content.type == CONTENT_TYPES.Comment.slug:
1431
            # TODO - G.M  - 10-04-2018 - [Cleanup] Remove label param
1432
            # from this object ?
1433
            # TODO - G.M - 2018-08-20 - [I18n] fix trad of this
1434
            label = '<strong>{}</strong> wrote:'.format(content.owner.get_display_name())
1435
1436
        return VirtualEvent(id=content.content_id,
1437
                            created=content.created,
1438
                            owner=content.owner,
1439
                            type=ActionDescription(content.revision_type),
1440
                            label=label,
1441
                            content=content.description,
1442
                            ref_object=content)
1443
1444
    @classmethod
1445
    def create_from_content_revision(cls, revision: ContentRevisionRO):
1446
        action_description = ActionDescription(revision.revision_type)
1447
1448
        return VirtualEvent(id=revision.revision_id,
1449
                            created=revision.updated,
1450
                            owner=revision.owner,
1451
                            type=action_description,
1452
                            label=action_description.label,
1453
                            content='',
1454
                            ref_object=revision)
1455
1456
    def __init__(self, id, created, owner, type, label, content, ref_object):
1457
        self.id = id
1458
        self.created = created
1459
        self.owner = owner
1460
        self.type = type
1461
        self.label = label
1462
        self.content = content
1463
        self.ref_object = ref_object
1464
1465
        assert hasattr(type, 'id')
1466
        # TODO - G.M - 10-04-2018 - [Cleanup] Drop this
1467
        # assert hasattr(type, 'css')
1468
        # assert hasattr(type, 'fa_icon')
1469
        # assert hasattr(type, 'label')
1470
1471
    def created_as_delta(self, delta_from_datetime:datetime=None):
1472
        if not delta_from_datetime:
1473
            delta_from_datetime = datetime.utcnow()
1474
        return format_timedelta(delta_from_datetime - self.created,
1475
                                locale=get_locale())
1476
1477 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...
1478
        aff = ''
1479
1480
        if not delta_from_datetime:
1481
            delta_from_datetime = datetime.utcnow()
1482
1483
        delta = delta_from_datetime - self.created
1484
1485
        if delta.days > 0:
1486
            if delta.days >= 365:
1487
                aff = '%d year%s ago' % (delta.days/365, 's' if delta.days/365>=2 else '')
1488
            elif delta.days >= 30:
1489
                aff = '%d month%s ago' % (delta.days/30, 's' if delta.days/30>=2 else '')
1490
            else:
1491
                aff = '%d day%s ago' % (delta.days, 's' if delta.days>=2 else '')
1492
        else:
1493
            if delta.seconds < 60:
1494
                aff = '%d second%s ago' % (delta.seconds, 's' if delta.seconds>1 else '')
1495
            elif delta.seconds/60 < 60:
1496
                aff = '%d minute%s ago' % (delta.seconds/60, 's' if delta.seconds/60>=2 else '')
1497
            else:
1498
                aff = '%d hour%s ago' % (delta.seconds/3600, 's' if delta.seconds/3600>=2 else '')
1499
1500
        return aff
1501