data.py 51KB

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