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