data.py 52KB

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