Completed
Pull Request — develop (#31)
by inkhey
20:37 queued 14:44
created

Content.properties()   A

Complexity

Conditions 1

Size

Total Lines 12
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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