data.py 50KB

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