data.py 47KB

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