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