| Total Complexity | 237 |
| Total Lines | 1514 |
| Duplicated Lines | 1.59 % |
| 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 | @property |
||
| 647 | def file_name(self): |
||
| 648 | return '{0}{1}'.format( |
||
| 649 | self.label, |
||
| 650 | self.file_extension, |
||
| 651 | ) |
||
| 652 | |||
| 653 | @classmethod |
||
| 654 | def new_from(cls, revision: 'ContentRevisionRO') -> 'ContentRevisionRO': |
||
| 655 | """ |
||
| 656 | |||
| 657 | Return new instance of ContentRevisionRO where properties are copied from revision parameter. |
||
| 658 | Look at ContentRevisionRO._cloned_columns to see what columns are copieds. |
||
| 659 | |||
| 660 | :param revision: revision to copy |
||
| 661 | :type revision: ContentRevisionRO |
||
| 662 | :return: new revision from revision parameter |
||
| 663 | :rtype: ContentRevisionRO |
||
| 664 | """ |
||
| 665 | new_rev = cls() |
||
| 666 | |||
| 667 | for column_name in cls._cloned_columns: |
||
| 668 | column_value = getattr(revision, column_name) |
||
| 669 | setattr(new_rev, column_name, column_value) |
||
| 670 | |||
| 671 | new_rev.updated = datetime.utcnow() |
||
| 672 | if revision.depot_file: |
||
| 673 | new_rev.depot_file = FileIntent( |
||
| 674 | revision.depot_file.file.read(), |
||
| 675 | revision.file_name, |
||
| 676 | revision.file_mimetype, |
||
| 677 | ) |
||
| 678 | |||
| 679 | return new_rev |
||
| 680 | |||
| 681 | @classmethod |
||
| 682 | def copy( |
||
| 683 | cls, |
||
| 684 | revision: 'ContentRevisionRO', |
||
| 685 | parent: 'Content' |
||
| 686 | ) -> 'ContentRevisionRO': |
||
| 687 | |||
| 688 | copy_rev = cls() |
||
| 689 | import copy |
||
| 690 | copy_columns = cls._cloned_columns |
||
| 691 | for column_name in copy_columns: |
||
| 692 | # INFO - G-M - 15-03-2018 - set correct parent |
||
| 693 | if column_name == 'parent_id': |
||
| 694 | column_value = copy.copy(parent.id) |
||
| 695 | elif column_name == 'parent': |
||
| 696 | column_value = copy.copy(parent) |
||
| 697 | else: |
||
| 698 | column_value = copy.copy(getattr(revision, column_name)) |
||
| 699 | setattr(copy_rev, column_name, column_value) |
||
| 700 | |||
| 701 | # copy attached_file |
||
| 702 | if revision.depot_file: |
||
| 703 | copy_rev.depot_file = FileIntent( |
||
| 704 | revision.depot_file.file.read(), |
||
| 705 | revision.file_name, |
||
| 706 | revision.file_mimetype, |
||
| 707 | ) |
||
| 708 | return copy_rev |
||
| 709 | |||
| 710 | def __setattr__(self, key: str, value: 'mixed'): |
||
| 711 | """ |
||
| 712 | ContentRevisionUpdateError is raised if tried to update column and revision own identity |
||
| 713 | :param key: attribute name |
||
| 714 | :param value: attribute value |
||
| 715 | :return: |
||
| 716 | """ |
||
| 717 | if key in ('_sa_instance_state', ): # Prevent infinite loop from SQLAlchemy code and altered set |
||
| 718 | return super().__setattr__(key, value) |
||
| 719 | |||
| 720 | # FIXME - G.M - 28-03-2018 - Cycling Import |
||
| 721 | from tracim_backend.models.revision_protection import RevisionsIntegrity |
||
| 722 | if inspect(self).has_identity \ |
||
| 723 | and key in self._cloned_columns \ |
||
| 724 | and not RevisionsIntegrity.is_updatable(self): |
||
| 725 | raise ContentRevisionUpdateError( |
||
| 726 | "Can't modify revision. To work on new revision use tracim.model.new_revision " + |
||
| 727 | "context manager.") |
||
| 728 | |||
| 729 | super().__setattr__(key, value) |
||
| 730 | |||
| 731 | @property |
||
| 732 | def is_active(self) -> bool: |
||
| 733 | return not self.is_deleted and not self.is_archived |
||
| 734 | |||
| 735 | @property |
||
| 736 | def is_readonly(self) -> bool: |
||
| 737 | return False |
||
| 738 | |||
| 739 | def get_status(self) -> ContentStatus: |
||
| 740 | try: |
||
| 741 | return content_status_list.get_one_by_slug(self.status) |
||
| 742 | except ContentStatusNotExist as exc: |
||
| 743 | return content_status_list.get_default_status() |
||
| 744 | |||
| 745 | def get_label(self) -> str: |
||
| 746 | return self.label or self.file_name or '' |
||
| 747 | |||
| 748 | def get_last_action(self) -> ActionDescription: |
||
| 749 | return ActionDescription(self.revision_type) |
||
| 750 | |||
| 751 | def has_new_information_for(self, user: User) -> bool: |
||
| 752 | """ |
||
| 753 | :param user: the _session current user |
||
| 754 | :return: bool, True if there is new information for given user else False |
||
| 755 | False if the user is None |
||
| 756 | """ |
||
| 757 | if not user: |
||
| 758 | return False |
||
| 759 | |||
| 760 | if user not in self.read_by.keys(): |
||
| 761 | return True |
||
| 762 | |||
| 763 | return False |
||
| 764 | |||
| 765 | def get_label_as_file(self): |
||
| 766 | file_extension = self.file_extension or '' |
||
| 767 | |||
| 768 | if self.type == content_type_list.Thread.slug: |
||
| 769 | file_extension = '.html' |
||
| 770 | elif self.type == content_type_list.Page.slug: |
||
| 771 | file_extension = '.html' |
||
| 772 | |||
| 773 | return '{0}{1}'.format( |
||
| 774 | self.label, |
||
| 775 | file_extension, |
||
| 776 | ) |
||
| 777 | |||
| 778 | # TODO - G.M - 2018-06-177 - [author] Owner should be renamed "author" |
||
| 779 | Index('idx__content_revisions__owner_id', ContentRevisionRO.owner_id) |
||
| 780 | Index('idx__content_revisions__parent_id', ContentRevisionRO.parent_id) |
||
| 781 | |||
| 782 | |||
| 783 | class Content(DeclarativeBase): |
||
| 784 | """ |
||
| 785 | Content is used as a virtual representation of ContentRevisionRO. |
||
| 786 | content.PROPERTY (except for content.id, content.revisions, content.children_revisions) will return |
||
| 787 | value of most recent revision of content. |
||
| 788 | |||
| 789 | # UPDATE A CONTENT |
||
| 790 | |||
| 791 | To update an existing Content, you must use tracim.model.new_revision context manager: |
||
| 792 | content = my_sontent_getter_method() |
||
| 793 | with new_revision(content): |
||
| 794 | content.description = 'foo bar baz' |
||
| 795 | DBSession.flush() |
||
| 796 | |||
| 797 | # QUERY CONTENTS |
||
| 798 | |||
| 799 | To query contents you will need to join your content query with ContentRevisionRO. Join |
||
| 800 | condition is available at tracim.lib.content.ContentApi#_get_revision_join: |
||
| 801 | |||
| 802 | content = DBSession.query(Content).join(ContentRevisionRO, ContentApi._get_revision_join()) |
||
| 803 | .filter(Content.label == 'foo') |
||
| 804 | .one() |
||
| 805 | |||
| 806 | ContentApi provide also prepared Content at tracim.lib.content.ContentApi#get_canonical_query: |
||
| 807 | |||
| 808 | content = ContentApi.get_canonical_query() |
||
| 809 | .filter(Content.label == 'foo') |
||
| 810 | .one() |
||
| 811 | """ |
||
| 812 | |||
| 813 | __tablename__ = 'content' |
||
| 814 | |||
| 815 | revision_to_serialize = -0 # This flag allow to serialize a given revision if required by the user |
||
| 816 | |||
| 817 | id = Column(Integer, primary_key=True) |
||
| 818 | # TODO - A.P - 2017-09-05 - revisions default sorting |
||
| 819 | # The only sorting that makes sens is ordering by "updated" field. But: |
||
| 820 | # - its content will soon replace the one of "created", |
||
| 821 | # - this "updated" field will then be dropped. |
||
| 822 | # So for now, we order by "revision_id" explicitly, but remember to switch |
||
| 823 | # to "created" once "updated" removed. |
||
| 824 | # https://github.com/tracim/tracim/issues/336 |
||
| 825 | revisions = relationship("ContentRevisionRO", |
||
| 826 | foreign_keys=[ContentRevisionRO.content_id], |
||
| 827 | back_populates="node", |
||
| 828 | order_by="ContentRevisionRO.revision_id") |
||
| 829 | children_revisions = relationship("ContentRevisionRO", |
||
| 830 | foreign_keys=[ContentRevisionRO.parent_id], |
||
| 831 | back_populates="parent") |
||
| 832 | |||
| 833 | @hybrid_property |
||
| 834 | def content_id(self) -> int: |
||
| 835 | return self.revision.content_id |
||
| 836 | |||
| 837 | @content_id.setter |
||
| 838 | def content_id(self, value: int) -> None: |
||
| 839 | self.revision.content_id = value |
||
| 840 | |||
| 841 | @content_id.expression |
||
| 842 | def content_id(cls) -> InstrumentedAttribute: |
||
| 843 | return ContentRevisionRO.content_id |
||
| 844 | |||
| 845 | @hybrid_property |
||
| 846 | def revision_id(self) -> int: |
||
| 847 | return self.revision.revision_id |
||
| 848 | |||
| 849 | @revision_id.setter |
||
| 850 | def revision_id(self, value: int): |
||
| 851 | self.revision.revision_id = value |
||
| 852 | |||
| 853 | @revision_id.expression |
||
| 854 | def revision_id(cls) -> InstrumentedAttribute: |
||
| 855 | return ContentRevisionRO.revision_id |
||
| 856 | |||
| 857 | # TODO - G.M - 2018-06-177 - [author] Owner should be renamed "author" |
||
| 858 | # and should be author of first revision. |
||
| 859 | @hybrid_property |
||
| 860 | def owner_id(self) -> int: |
||
| 861 | return self.revision.owner_id |
||
| 862 | |||
| 863 | @owner_id.setter |
||
| 864 | def owner_id(self, value: int) -> None: |
||
| 865 | self.revision.owner_id = value |
||
| 866 | |||
| 867 | @owner_id.expression |
||
| 868 | def owner_id(cls) -> InstrumentedAttribute: |
||
| 869 | return ContentRevisionRO.owner_id |
||
| 870 | |||
| 871 | @hybrid_property |
||
| 872 | def label(self) -> str: |
||
| 873 | return self.revision.label |
||
| 874 | |||
| 875 | @label.setter |
||
| 876 | def label(self, value: str) -> None: |
||
| 877 | self.revision.label = value |
||
| 878 | |||
| 879 | @label.expression |
||
| 880 | def label(cls) -> InstrumentedAttribute: |
||
| 881 | return ContentRevisionRO.label |
||
| 882 | |||
| 883 | @hybrid_property |
||
| 884 | def description(self) -> str: |
||
| 885 | return self.revision.description |
||
| 886 | |||
| 887 | @description.setter |
||
| 888 | def description(self, value: str) -> None: |
||
| 889 | self.revision.description = value |
||
| 890 | |||
| 891 | @description.expression |
||
| 892 | def description(cls) -> InstrumentedAttribute: |
||
| 893 | return ContentRevisionRO.description |
||
| 894 | |||
| 895 | @hybrid_property |
||
| 896 | def file_name(self) -> str: |
||
| 897 | return '{0}{1}'.format( |
||
| 898 | self.revision.label, |
||
| 899 | self.revision.file_extension, |
||
| 900 | ) |
||
| 901 | |||
| 902 | @file_name.setter |
||
| 903 | def file_name(self, value: str) -> None: |
||
| 904 | file_name, file_extension = os.path.splitext(value) |
||
| 905 | self.revision.label = file_name |
||
| 906 | self.revision.file_extension = file_extension |
||
| 907 | |||
| 908 | @file_name.expression |
||
| 909 | def file_name(cls) -> InstrumentedAttribute: |
||
| 910 | return ContentRevisionRO.file_name + ContentRevisionRO.file_extension |
||
| 911 | |||
| 912 | @hybrid_property |
||
| 913 | def file_extension(self) -> str: |
||
| 914 | return self.revision.file_extension |
||
| 915 | |||
| 916 | @file_extension.setter |
||
| 917 | def file_extension(self, value: str) -> None: |
||
| 918 | self.revision.file_extension = value |
||
| 919 | |||
| 920 | @file_extension.expression |
||
| 921 | def file_extension(cls) -> InstrumentedAttribute: |
||
| 922 | return ContentRevisionRO.file_extension |
||
| 923 | |||
| 924 | @hybrid_property |
||
| 925 | def file_mimetype(self) -> str: |
||
| 926 | return self.revision.file_mimetype |
||
| 927 | |||
| 928 | @file_mimetype.setter |
||
| 929 | def file_mimetype(self, value: str) -> None: |
||
| 930 | self.revision.file_mimetype = value |
||
| 931 | |||
| 932 | @file_mimetype.expression |
||
| 933 | def file_mimetype(cls) -> InstrumentedAttribute: |
||
| 934 | return ContentRevisionRO.file_mimetype |
||
| 935 | |||
| 936 | @hybrid_property |
||
| 937 | def _properties(self) -> str: |
||
| 938 | return self.revision.properties |
||
| 939 | |||
| 940 | @_properties.setter |
||
| 941 | def _properties(self, value: str) -> None: |
||
| 942 | self.revision.properties = value |
||
| 943 | |||
| 944 | @_properties.expression |
||
| 945 | def _properties(cls) -> InstrumentedAttribute: |
||
| 946 | return ContentRevisionRO.properties |
||
| 947 | |||
| 948 | @hybrid_property |
||
| 949 | def type(self) -> str: |
||
| 950 | return self.revision.type |
||
| 951 | |||
| 952 | @type.setter |
||
| 953 | def type(self, value: str) -> None: |
||
| 954 | self.revision.type = value |
||
| 955 | |||
| 956 | @type.expression |
||
| 957 | def type(cls) -> InstrumentedAttribute: |
||
| 958 | return ContentRevisionRO.type |
||
| 959 | |||
| 960 | @hybrid_property |
||
| 961 | def status(self) -> str: |
||
| 962 | return self.revision.status |
||
| 963 | |||
| 964 | @status.setter |
||
| 965 | def status(self, value: str) -> None: |
||
| 966 | self.revision.status = value |
||
| 967 | |||
| 968 | @status.expression |
||
| 969 | def status(cls) -> InstrumentedAttribute: |
||
| 970 | return ContentRevisionRO.status |
||
| 971 | |||
| 972 | @hybrid_property |
||
| 973 | def created(self) -> datetime: |
||
| 974 | return self.revision.created |
||
| 975 | |||
| 976 | @created.setter |
||
| 977 | def created(self, value: datetime) -> None: |
||
| 978 | self.revision.created = value |
||
| 979 | |||
| 980 | @created.expression |
||
| 981 | def created(cls) -> InstrumentedAttribute: |
||
| 982 | return ContentRevisionRO.created |
||
| 983 | |||
| 984 | @hybrid_property |
||
| 985 | def updated(self) -> datetime: |
||
| 986 | return self.revision.updated |
||
| 987 | |||
| 988 | @updated.setter |
||
| 989 | def updated(self, value: datetime) -> None: |
||
| 990 | self.revision.updated = value |
||
| 991 | |||
| 992 | @updated.expression |
||
| 993 | def updated(cls) -> InstrumentedAttribute: |
||
| 994 | return ContentRevisionRO.updated |
||
| 995 | |||
| 996 | @hybrid_property |
||
| 997 | def is_deleted(self) -> bool: |
||
| 998 | return self.revision.is_deleted |
||
| 999 | |||
| 1000 | @is_deleted.setter |
||
| 1001 | def is_deleted(self, value: bool) -> None: |
||
| 1002 | self.revision.is_deleted = value |
||
| 1003 | |||
| 1004 | @is_deleted.expression |
||
| 1005 | def is_deleted(cls) -> InstrumentedAttribute: |
||
| 1006 | return ContentRevisionRO.is_deleted |
||
| 1007 | |||
| 1008 | @hybrid_property |
||
| 1009 | def is_archived(self) -> bool: |
||
| 1010 | return self.revision.is_archived |
||
| 1011 | |||
| 1012 | @is_archived.setter |
||
| 1013 | def is_archived(self, value: bool) -> None: |
||
| 1014 | self.revision.is_archived = value |
||
| 1015 | |||
| 1016 | @is_archived.expression |
||
| 1017 | def is_archived(cls) -> InstrumentedAttribute: |
||
| 1018 | return ContentRevisionRO.is_archived |
||
| 1019 | |||
| 1020 | @hybrid_property |
||
| 1021 | def is_temporary(self) -> bool: |
||
| 1022 | return self.revision.is_temporary |
||
| 1023 | |||
| 1024 | @is_temporary.setter |
||
| 1025 | def is_temporary(self, value: bool) -> None: |
||
| 1026 | self.revision.is_temporary = value |
||
| 1027 | |||
| 1028 | @is_temporary.expression |
||
| 1029 | def is_temporary(cls) -> InstrumentedAttribute: |
||
| 1030 | return ContentRevisionRO.is_temporary |
||
| 1031 | |||
| 1032 | @hybrid_property |
||
| 1033 | def revision_type(self) -> str: |
||
| 1034 | return self.revision.revision_type |
||
| 1035 | |||
| 1036 | @revision_type.setter |
||
| 1037 | def revision_type(self, value: str) -> None: |
||
| 1038 | self.revision.revision_type = value |
||
| 1039 | |||
| 1040 | @revision_type.expression |
||
| 1041 | def revision_type(cls) -> InstrumentedAttribute: |
||
| 1042 | return ContentRevisionRO.revision_type |
||
| 1043 | |||
| 1044 | @hybrid_property |
||
| 1045 | def workspace_id(self) -> int: |
||
| 1046 | return self.revision.workspace_id |
||
| 1047 | |||
| 1048 | @workspace_id.setter |
||
| 1049 | def workspace_id(self, value: int) -> None: |
||
| 1050 | self.revision.workspace_id = value |
||
| 1051 | |||
| 1052 | @workspace_id.expression |
||
| 1053 | def workspace_id(cls) -> InstrumentedAttribute: |
||
| 1054 | return ContentRevisionRO.workspace_id |
||
| 1055 | |||
| 1056 | @hybrid_property |
||
| 1057 | def workspace(self) -> Workspace: |
||
| 1058 | return self.revision.workspace |
||
| 1059 | |||
| 1060 | @workspace.setter |
||
| 1061 | def workspace(self, value: Workspace) -> None: |
||
| 1062 | self.revision.workspace = value |
||
| 1063 | |||
| 1064 | @workspace.expression |
||
| 1065 | def workspace(cls) -> InstrumentedAttribute: |
||
| 1066 | return ContentRevisionRO.workspace |
||
| 1067 | |||
| 1068 | @hybrid_property |
||
| 1069 | def parent_id(self) -> int: |
||
| 1070 | return self.revision.parent_id |
||
| 1071 | |||
| 1072 | @parent_id.setter |
||
| 1073 | def parent_id(self, value: int) -> None: |
||
| 1074 | self.revision.parent_id = value |
||
| 1075 | |||
| 1076 | @parent_id.expression |
||
| 1077 | def parent_id(cls) -> InstrumentedAttribute: |
||
| 1078 | return ContentRevisionRO.parent_id |
||
| 1079 | |||
| 1080 | @hybrid_property |
||
| 1081 | def parent(self) -> 'Content': |
||
| 1082 | return self.revision.parent |
||
| 1083 | |||
| 1084 | @parent.setter |
||
| 1085 | def parent(self, value: 'Content') -> None: |
||
| 1086 | self.revision.parent = value |
||
| 1087 | |||
| 1088 | @parent.expression |
||
| 1089 | def parent(cls) -> InstrumentedAttribute: |
||
| 1090 | return ContentRevisionRO.parent |
||
| 1091 | |||
| 1092 | @hybrid_property |
||
| 1093 | def node(self) -> 'Content': |
||
| 1094 | return self.revision.node |
||
| 1095 | |||
| 1096 | @node.setter |
||
| 1097 | def node(self, value: 'Content') -> None: |
||
| 1098 | self.revision.node = value |
||
| 1099 | |||
| 1100 | @node.expression |
||
| 1101 | def node(cls) -> InstrumentedAttribute: |
||
| 1102 | return ContentRevisionRO.node |
||
| 1103 | |||
| 1104 | # TODO - G.M - 2018-06-177 - [author] Owner should be renamed "author" |
||
| 1105 | # and should be author of first revision. |
||
| 1106 | @hybrid_property |
||
| 1107 | def owner(self) -> User: |
||
| 1108 | return self.revision.owner |
||
| 1109 | |||
| 1110 | @owner.setter |
||
| 1111 | def owner(self, value: User) -> None: |
||
| 1112 | self.revision.owner = value |
||
| 1113 | |||
| 1114 | @owner.expression |
||
| 1115 | def owner(cls) -> InstrumentedAttribute: |
||
| 1116 | return ContentRevisionRO.owner |
||
| 1117 | |||
| 1118 | @hybrid_property |
||
| 1119 | def children(self) -> ['Content']: |
||
| 1120 | """ |
||
| 1121 | :return: list of children Content |
||
| 1122 | :rtype Content |
||
| 1123 | """ |
||
| 1124 | # Return a list of unique revisions parent content |
||
| 1125 | return list(set([revision.node for revision in self.children_revisions])) |
||
| 1126 | |||
| 1127 | @property |
||
| 1128 | def revision(self) -> ContentRevisionRO: |
||
| 1129 | return self.get_current_revision() |
||
| 1130 | |||
| 1131 | @property |
||
| 1132 | def first_revision(self) -> ContentRevisionRO: |
||
| 1133 | return self.revisions[0] # FIXME |
||
| 1134 | |||
| 1135 | @property |
||
| 1136 | def last_revision(self) -> ContentRevisionRO: |
||
| 1137 | return self.revisions[-1] |
||
| 1138 | |||
| 1139 | @property |
||
| 1140 | def is_readonly(self) -> bool: |
||
| 1141 | return self.revision.is_readonly |
||
| 1142 | |||
| 1143 | @property |
||
| 1144 | def is_active(self) -> bool: |
||
| 1145 | return self.revision.is_active |
||
| 1146 | |||
| 1147 | @property |
||
| 1148 | def depot_file(self) -> UploadedFile: |
||
| 1149 | return self.revision.depot_file |
||
| 1150 | |||
| 1151 | @depot_file.setter |
||
| 1152 | def depot_file(self, value): |
||
| 1153 | self.revision.depot_file = value |
||
| 1154 | |||
| 1155 | def get_current_revision(self) -> ContentRevisionRO: |
||
| 1156 | if not self.revisions: |
||
| 1157 | return self.new_revision() |
||
| 1158 | |||
| 1159 | # If last revisions revision don't have revision_id, return it we just add it. |
||
| 1160 | if self.revisions[-1].revision_id is None: |
||
| 1161 | return self.revisions[-1] |
||
| 1162 | |||
| 1163 | # Revisions should be ordred by revision_id but we ensure that here |
||
| 1164 | revisions = sorted(self.revisions, key=lambda revision: revision.revision_id) |
||
| 1165 | return revisions[-1] |
||
| 1166 | |||
| 1167 | def new_revision(self) -> ContentRevisionRO: |
||
| 1168 | """ |
||
| 1169 | Return and assign to this content a new revision. |
||
| 1170 | If it's a new content, revision is totally new. |
||
| 1171 | If this content already own revision, revision is build from last revision. |
||
| 1172 | :return: |
||
| 1173 | """ |
||
| 1174 | if not self.revisions: |
||
| 1175 | self.revisions.append(ContentRevisionRO()) |
||
| 1176 | return self.revisions[0] |
||
| 1177 | |||
| 1178 | new_rev = ContentRevisionRO.new_from(self.get_current_revision()) |
||
| 1179 | self.revisions.append(new_rev) |
||
| 1180 | return new_rev |
||
| 1181 | |||
| 1182 | def get_valid_children(self, content_types: list=None) -> ['Content']: |
||
| 1183 | for child in self.children: |
||
| 1184 | if not child.is_deleted and not child.is_archived: |
||
| 1185 | if not content_types or child.type in content_types: |
||
| 1186 | yield child.node |
||
| 1187 | |||
| 1188 | @hybrid_property |
||
| 1189 | def properties(self) -> dict: |
||
| 1190 | """ return a structure decoded from json content of _properties """ |
||
| 1191 | |||
| 1192 | if not self._properties: |
||
| 1193 | properties = {} |
||
| 1194 | else: |
||
| 1195 | properties = json.loads(self._properties) |
||
| 1196 | if content_type_list.get_one_by_slug(self.type) != content_type_list.Event: |
||
| 1197 | if not 'allowed_content' in properties: |
||
| 1198 | properties['allowed_content'] = content_type_list.default_allowed_content_properties(self.type) # nopep8 |
||
| 1199 | return properties |
||
| 1200 | |||
| 1201 | @properties.setter |
||
| 1202 | def properties(self, properties_struct: dict) -> None: |
||
| 1203 | """ encode a given structure into json and store it in _properties attribute""" |
||
| 1204 | self._properties = json.dumps(properties_struct) |
||
| 1205 | ContentChecker.check_properties(self) |
||
| 1206 | |||
| 1207 | def created_as_delta(self, delta_from_datetime:datetime=None): |
||
| 1208 | if not delta_from_datetime: |
||
| 1209 | delta_from_datetime = datetime.utcnow() |
||
| 1210 | |||
| 1211 | return format_timedelta(delta_from_datetime - self.created, |
||
| 1212 | locale=get_locale()) |
||
| 1213 | |||
| 1214 | def datetime_as_delta(self, datetime_object, |
||
| 1215 | delta_from_datetime:datetime=None): |
||
| 1216 | if not delta_from_datetime: |
||
| 1217 | delta_from_datetime = datetime.utcnow() |
||
| 1218 | return format_timedelta(delta_from_datetime - datetime_object, |
||
| 1219 | locale=get_locale()) |
||
| 1220 | |||
| 1221 | def get_child_nb(self, content_type: str, content_status = ''): |
||
| 1222 | child_nb = 0 |
||
| 1223 | for child in self.get_valid_children(): |
||
| 1224 | if child.type == content_type or content_type.slug == content_type_list.Any_SLUG: |
||
| 1225 | if not content_status: |
||
| 1226 | child_nb = child_nb+1 |
||
| 1227 | elif content_status==child.status: |
||
| 1228 | child_nb = child_nb+1 |
||
| 1229 | return child_nb |
||
| 1230 | |||
| 1231 | def get_label(self): |
||
| 1232 | return self.label or self.file_name or '' |
||
| 1233 | |||
| 1234 | def get_label_as_file(self) -> str: |
||
| 1235 | """ |
||
| 1236 | :return: Return content label in file representation context |
||
| 1237 | """ |
||
| 1238 | return self.revision.get_label_as_file() |
||
| 1239 | |||
| 1240 | def get_status(self) -> ContentStatus: |
||
| 1241 | return self.revision.get_status() |
||
| 1242 | |||
| 1243 | def get_last_action(self) -> ActionDescription: |
||
| 1244 | return ActionDescription(self.revision_type) |
||
| 1245 | |||
| 1246 | def get_simple_last_activity_date(self) -> datetime_root.datetime: |
||
| 1247 | """ |
||
| 1248 | Get last activity_date, comments_included. Do not search recursively |
||
| 1249 | in revision or children. |
||
| 1250 | :return: |
||
| 1251 | """ |
||
| 1252 | last_revision_date = self.updated |
||
| 1253 | for comment in self.get_comments(): |
||
| 1254 | if comment.updated > last_revision_date: |
||
| 1255 | last_revision_date = comment.updated |
||
| 1256 | return last_revision_date |
||
| 1257 | |||
| 1258 | def get_last_activity_date(self) -> datetime_root.datetime: |
||
| 1259 | """ |
||
| 1260 | Get last activity date with complete recursive search |
||
| 1261 | :return: |
||
| 1262 | """ |
||
| 1263 | last_revision_date = self.updated |
||
| 1264 | for revision in self.revisions: |
||
| 1265 | if revision.updated > last_revision_date: |
||
| 1266 | last_revision_date = revision.updated |
||
| 1267 | |||
| 1268 | for child in self.children: |
||
| 1269 | if child.updated > last_revision_date: |
||
| 1270 | last_revision_date = child.updated |
||
| 1271 | return last_revision_date |
||
| 1272 | |||
| 1273 | def has_new_information_for(self, user: User) -> bool: |
||
| 1274 | """ |
||
| 1275 | :param user: the _session current user |
||
| 1276 | :return: bool, True if there is new information for given user else False |
||
| 1277 | False if the user is None |
||
| 1278 | """ |
||
| 1279 | revision = self.get_current_revision() |
||
| 1280 | |||
| 1281 | if not user: |
||
| 1282 | return False |
||
| 1283 | |||
| 1284 | if user not in revision.read_by.keys(): |
||
| 1285 | # The user did not read this item, so yes! |
||
| 1286 | return True |
||
| 1287 | |||
| 1288 | for child in self.get_valid_children(): |
||
| 1289 | if child.has_new_information_for(user): |
||
| 1290 | return True |
||
| 1291 | |||
| 1292 | return False |
||
| 1293 | |||
| 1294 | def get_comments(self): |
||
| 1295 | children = [] |
||
| 1296 | for child in self.children: |
||
| 1297 | if content_type_list.Comment.slug == child.type and not child.is_deleted and not child.is_archived: |
||
| 1298 | children.append(child.node) |
||
| 1299 | return children |
||
| 1300 | |||
| 1301 | def get_last_comment_from(self, user: User) -> 'Content': |
||
| 1302 | # TODO - Make this more efficient |
||
| 1303 | last_comment_updated = None |
||
| 1304 | last_comment = None |
||
| 1305 | for comment in self.get_comments(): |
||
| 1306 | if user.user_id == comment.owner.user_id: |
||
| 1307 | if not last_comment or last_comment_updated<comment.updated: |
||
| 1308 | # take only the latest comment ! |
||
| 1309 | last_comment = comment |
||
| 1310 | last_comment_updated = comment.updated |
||
| 1311 | |||
| 1312 | return last_comment |
||
| 1313 | |||
| 1314 | def get_previous_revision(self) -> 'ContentRevisionRO': |
||
| 1315 | rev_ids = [revision.revision_id for revision in self.revisions] |
||
| 1316 | rev_ids.sort() |
||
| 1317 | |||
| 1318 | if len(rev_ids)>=2: |
||
| 1319 | revision_rev_id = rev_ids[-2] |
||
| 1320 | |||
| 1321 | for revision in self.revisions: |
||
| 1322 | if revision.revision_id == revision_rev_id: |
||
| 1323 | return revision |
||
| 1324 | |||
| 1325 | return None |
||
| 1326 | |||
| 1327 | def description_as_raw_text(self): |
||
| 1328 | # 'html.parser' fixes a hanging bug |
||
| 1329 | # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django |
||
| 1330 | return BeautifulSoup(self.description, 'html.parser').text |
||
| 1331 | |||
| 1332 | def get_allowed_content_types(self): |
||
| 1333 | types = [] |
||
| 1334 | try: |
||
| 1335 | allowed_types = self.properties['allowed_content'] |
||
| 1336 | for type_label, is_allowed in allowed_types.items(): |
||
| 1337 | if is_allowed: |
||
| 1338 | types.append( |
||
| 1339 | content_type_list.get_one_by_slug(type_label) |
||
| 1340 | ) |
||
| 1341 | # TODO BS 2018-08-13: This try/except is not correct: except exception |
||
| 1342 | # if we know what to except. |
||
| 1343 | except Exception as e: |
||
| 1344 | print(e.__str__()) |
||
| 1345 | print('----- /*\ *****') |
||
| 1346 | raise ValueError('Not allowed content property') |
||
| 1347 | |||
| 1348 | return types |
||
| 1349 | |||
| 1350 | def get_history(self, drop_empty_revision=False) -> '[VirtualEvent]': |
||
| 1351 | events = [] |
||
| 1352 | for comment in self.get_comments(): |
||
| 1353 | events.append(VirtualEvent.create_from_content(comment)) |
||
| 1354 | |||
| 1355 | revisions = sorted(self.revisions, key=lambda rev: rev.revision_id) |
||
| 1356 | for revision in revisions: |
||
| 1357 | # INFO - G.M - 09-03-2018 - Do not show file revision with empty |
||
| 1358 | # file to have a more clear view of revision. |
||
| 1359 | # Some webdav client create empty file before uploading, we must |
||
| 1360 | # have possibility to not show the related revision |
||
| 1361 | if drop_empty_revision: |
||
| 1362 | if revision.depot_file and revision.depot_file.file.content_length == 0: # nopep8 |
||
| 1363 | # INFO - G.M - 12-03-2018 -Always show the last and |
||
| 1364 | # first revision. |
||
| 1365 | if revision != revisions[-1] and revision != revisions[0]: |
||
| 1366 | continue |
||
| 1367 | |||
| 1368 | events.append(VirtualEvent.create_from_content_revision(revision)) |
||
| 1369 | |||
| 1370 | sorted_events = sorted(events, |
||
| 1371 | key=lambda event: event.created, reverse=True) |
||
| 1372 | return sorted_events |
||
| 1373 | |||
| 1374 | @classmethod |
||
| 1375 | def format_path(cls, url_template: str, content: 'Content') -> str: |
||
| 1376 | wid = content.workspace.workspace_id |
||
| 1377 | fid = content.parent_id # May be None if no parent |
||
| 1378 | ctype = content.type |
||
| 1379 | cid = content.content_id |
||
| 1380 | return url_template.format(wid=wid, fid=fid, ctype=ctype, cid=cid) |
||
| 1381 | |||
| 1382 | def copy(self, parent): |
||
| 1383 | cpy_content = Content() |
||
| 1384 | for rev in self.revisions: |
||
| 1385 | cpy_rev = ContentRevisionRO.copy(rev, parent) |
||
| 1386 | cpy_content.revisions.append(cpy_rev) |
||
| 1387 | return cpy_content |
||
| 1388 | |||
| 1389 | |||
| 1390 | class RevisionReadStatus(DeclarativeBase): |
||
| 1391 | |||
| 1392 | __tablename__ = 'revision_read_status' |
||
| 1393 | |||
| 1394 | revision_id = Column(Integer, ForeignKey('content_revisions.revision_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True) |
||
| 1395 | user_id = Column(Integer, ForeignKey('users.user_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True) |
||
| 1396 | # Default value datetime.utcnow, see: http://stackoverflow.com/a/13370382/801924 (or http://pastebin.com/VLyWktUn) |
||
| 1397 | view_datetime = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow) |
||
| 1398 | |||
| 1399 | content_revision = relationship( |
||
| 1400 | 'ContentRevisionRO', |
||
| 1401 | backref=backref( |
||
| 1402 | 'revision_read_statuses', |
||
| 1403 | collection_class=attribute_mapped_collection('user'), |
||
| 1404 | cascade='all, delete-orphan' |
||
| 1405 | )) |
||
| 1406 | |||
| 1407 | user = relationship('User', backref=backref( |
||
| 1408 | 'revision_readers', |
||
| 1409 | collection_class=attribute_mapped_collection('view_datetime'), |
||
| 1410 | cascade='all, delete-orphan' |
||
| 1411 | )) |
||
| 1412 | |||
| 1413 | |||
| 1414 | class NodeTreeItem(object): |
||
| 1415 | """ |
||
| 1416 | This class implements a model that allow to simply represents |
||
| 1417 | the left-panel menu items. This model is used by dbapi but |
||
| 1418 | is not directly related to sqlalchemy and database |
||
| 1419 | """ |
||
| 1420 | def __init__( |
||
| 1421 | self, |
||
| 1422 | node: Content, |
||
| 1423 | children: typing.List['NodeTreeItem'], |
||
| 1424 | is_selected: bool = False, |
||
| 1425 | ): |
||
| 1426 | self.node = node |
||
| 1427 | self.children = children |
||
| 1428 | self.is_selected = is_selected |
||
| 1429 | |||
| 1430 | |||
| 1431 | class VirtualEvent(object): |
||
| 1432 | @classmethod |
||
| 1433 | def create_from(cls, object): |
||
| 1434 | if Content == object.__class__: |
||
| 1435 | return cls.create_from_content(object) |
||
| 1436 | elif ContentRevisionRO == object.__class__: |
||
| 1437 | return cls.create_from_content_revision(object) |
||
| 1438 | |||
| 1439 | @classmethod |
||
| 1440 | def create_from_content(cls, content: Content): |
||
| 1441 | |||
| 1442 | label = content.get_label() |
||
| 1443 | if content.type == content_type_list.Comment.slug: |
||
| 1444 | # TODO - G.M - 10-04-2018 - [Cleanup] Remove label param |
||
| 1445 | # from this object ? |
||
| 1446 | # TODO - G.M - 2018-08-20 - [I18n] fix trad of this |
||
| 1447 | label = '<strong>{}</strong> wrote:'.format(content.owner.get_display_name()) |
||
| 1448 | |||
| 1449 | return VirtualEvent(id=content.content_id, |
||
| 1450 | created=content.created, |
||
| 1451 | owner=content.owner, |
||
| 1452 | type=ActionDescription(content.revision_type), |
||
| 1453 | label=label, |
||
| 1454 | content=content.description, |
||
| 1455 | ref_object=content) |
||
| 1456 | |||
| 1457 | @classmethod |
||
| 1458 | def create_from_content_revision(cls, revision: ContentRevisionRO): |
||
| 1459 | action_description = ActionDescription(revision.revision_type) |
||
| 1460 | |||
| 1461 | return VirtualEvent(id=revision.revision_id, |
||
| 1462 | created=revision.updated, |
||
| 1463 | owner=revision.owner, |
||
| 1464 | type=action_description, |
||
| 1465 | label=action_description.label, |
||
| 1466 | content='', |
||
| 1467 | ref_object=revision) |
||
| 1468 | |||
| 1469 | def __init__(self, id, created, owner, type, label, content, ref_object): |
||
| 1470 | self.id = id |
||
| 1471 | self.created = created |
||
| 1472 | self.owner = owner |
||
| 1473 | self.type = type |
||
| 1474 | self.label = label |
||
| 1475 | self.content = content |
||
| 1476 | self.ref_object = ref_object |
||
| 1477 | |||
| 1478 | assert hasattr(type, 'id') |
||
| 1479 | # TODO - G.M - 10-04-2018 - [Cleanup] Drop this |
||
| 1480 | # assert hasattr(type, 'css') |
||
| 1481 | # assert hasattr(type, 'fa_icon') |
||
| 1482 | # assert hasattr(type, 'label') |
||
| 1483 | |||
| 1484 | def created_as_delta(self, delta_from_datetime:datetime=None): |
||
| 1485 | if not delta_from_datetime: |
||
| 1486 | delta_from_datetime = datetime.utcnow() |
||
| 1487 | return format_timedelta(delta_from_datetime - self.created, |
||
| 1488 | locale=get_locale()) |
||
| 1489 | |||
| 1490 | View Code Duplication | def create_readable_date(self, delta_from_datetime:datetime=None): |
|
|
|
|||
| 1491 | aff = '' |
||
| 1492 | |||
| 1493 | if not delta_from_datetime: |
||
| 1494 | delta_from_datetime = datetime.utcnow() |
||
| 1495 | |||
| 1496 | delta = delta_from_datetime - self.created |
||
| 1497 | |||
| 1498 | if delta.days > 0: |
||
| 1499 | if delta.days >= 365: |
||
| 1500 | aff = '%d year%s ago' % (delta.days/365, 's' if delta.days/365>=2 else '') |
||
| 1501 | elif delta.days >= 30: |
||
| 1502 | aff = '%d month%s ago' % (delta.days/30, 's' if delta.days/30>=2 else '') |
||
| 1503 | else: |
||
| 1504 | aff = '%d day%s ago' % (delta.days, 's' if delta.days>=2 else '') |
||
| 1505 | else: |
||
| 1506 | if delta.seconds < 60: |
||
| 1507 | aff = '%d second%s ago' % (delta.seconds, 's' if delta.seconds>1 else '') |
||
| 1508 | elif delta.seconds/60 < 60: |
||
| 1509 | aff = '%d minute%s ago' % (delta.seconds/60, 's' if delta.seconds/60>=2 else '') |
||
| 1510 | else: |
||
| 1511 | aff = '%d hour%s ago' % (delta.seconds/3600, 's' if delta.seconds/3600>=2 else '') |
||
| 1512 | |||
| 1513 | return aff |
||
| 1514 |