Total Complexity | 236 |
Total Lines | 1518 |
Duplicated Lines | 1.58 % |
Changes | 0 |
Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.
Common duplication problems, and corresponding solutions are:
Complex classes like backend.tracim_backend.models.data often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
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): |
|
|
|||
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 |