data.py 47KB

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