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

Content.get_label()   A

Complexity

Conditions 1

Size

Total Lines 2
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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