Content.get_last_action()   A
last analyzed

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