data.py 51KB

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