data.py 47KB

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