data.py 47KB

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