data.py 49KB

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