data.py 47KB

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