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