content.py 48KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279
  1. # -*- coding: utf-8 -*-
  2. from tracim.config.app_cfg import CFG
  3. __author__ = 'damien'
  4. import sys
  5. import traceback
  6. from cgi import FieldStorage
  7. from depot.manager import DepotManager
  8. from preview_generator.exception import PreviewGeneratorException
  9. from preview_generator.manager import PreviewManager
  10. from sqlalchemy.orm.exc import NoResultFound
  11. import tg
  12. from tg import abort
  13. from tg import tmpl_context
  14. from tg import require
  15. from tg import predicates
  16. from tg.i18n import ugettext as _
  17. from tg.predicates import not_anonymous
  18. from typing import List
  19. from tracim.controllers import TIMRestController
  20. from tracim.controllers import StandardController
  21. from tracim.controllers import TIMRestPathContextSetup
  22. from tracim.controllers import TIMRestControllerWithBreadcrumb
  23. from tracim.controllers import TIMWorkspaceContentRestController
  24. from tracim.lib import CST
  25. from tracim.lib.base import BaseController
  26. from tracim.lib.base import logger
  27. from tracim.lib.integrity import render_invalid_integrity_chosen_path
  28. from tracim.lib.utils import SameValueError
  29. from tracim.lib.utils import get_valid_header_file_name
  30. from tracim.lib.utils import str_as_bool
  31. from tracim.lib.content import ContentApi
  32. from tracim.lib.helpers import convert_id_into_instances
  33. from tracim.lib.predicates import current_user_is_reader
  34. from tracim.lib.predicates import current_user_is_contributor
  35. from tracim.lib.predicates import current_user_is_content_manager
  36. from tracim.lib.predicates import require_current_user_is_owner
  37. from tracim.model.serializers import Context
  38. from tracim.model.serializers import CTX
  39. from tracim.model.serializers import DictLikeClass
  40. from tracim.model.data import ActionDescription
  41. from tracim.model import new_revision
  42. from tracim.model import DBSession
  43. from tracim.model.data import Content
  44. from tracim.model.data import ContentType
  45. from tracim.model.data import UserRoleInWorkspace
  46. from tracim.model.data import Workspace
  47. class UserWorkspaceFolderThreadCommentRestController(TIMRestController):
  48. @property
  49. def _item_type(self):
  50. return ContentType.Comment
  51. @property
  52. def _item_type_label(self):
  53. return _('Comment')
  54. def _before(self, *args, **kw):
  55. TIMRestPathContextSetup.current_user()
  56. TIMRestPathContextSetup.current_workspace()
  57. TIMRestPathContextSetup.current_folder()
  58. TIMRestPathContextSetup.current_thread()
  59. @tg.expose()
  60. @tg.require(current_user_is_contributor())
  61. def post(self, content: str = ''):
  62. # TODO - SECURE THIS
  63. workspace = tmpl_context.workspace
  64. thread = tmpl_context.thread
  65. api = ContentApi(tmpl_context.current_user)
  66. comment = api.create_comment(workspace, thread, content, True)
  67. next_str = '/workspaces/{}/folders/{}/threads/{}'
  68. next_url = tg.url(next_str).format(tmpl_context.workspace_id,
  69. tmpl_context.folder_id,
  70. tmpl_context.thread_id)
  71. tg.flash(_('Comment added'), CST.STATUS_OK)
  72. tg.redirect(next_url)
  73. @tg.expose()
  74. @tg.require(not_anonymous())
  75. def put_delete(self, item_id):
  76. require_current_user_is_owner(int(item_id))
  77. # TODO - CHECK RIGHTS
  78. item_id = int(item_id)
  79. content_api = ContentApi(tmpl_context.current_user)
  80. item = content_api.get_one(item_id,
  81. self._item_type,
  82. tmpl_context.workspace)
  83. next_or_back = '/workspaces/{}/folders/{}/threads/{}'
  84. try:
  85. next_url = tg.url(next_or_back).format(tmpl_context.workspace_id,
  86. tmpl_context.folder_id,
  87. tmpl_context.thread_id)
  88. undo_str = '{}/comments/{}/put_delete_undo'
  89. undo_url = tg.url(undo_str).format(next_url,
  90. item_id)
  91. msg_str = ('{} deleted. '
  92. '<a class="alert-link" href="{}">Cancel action</a>')
  93. msg = _(msg_str).format(self._item_type_label,
  94. undo_url)
  95. with new_revision(item):
  96. content_api.delete(item)
  97. content_api.save(item, ActionDescription.DELETION)
  98. tg.flash(msg, CST.STATUS_OK, no_escape=True)
  99. tg.redirect(next_url)
  100. except ValueError as e:
  101. back_url = tg.url(next_or_back).format(tmpl_context.workspace_id,
  102. tmpl_context.folder_id,
  103. tmpl_context.thread_id)
  104. msg = _('{} not deleted: {}').format(self._item_type_label, str(e))
  105. tg.flash(msg, CST.STATUS_ERROR)
  106. tg.redirect(back_url)
  107. @tg.expose()
  108. @tg.require(not_anonymous())
  109. def put_delete_undo(self, item_id):
  110. require_current_user_is_owner(int(item_id))
  111. item_id = int(item_id)
  112. # Here we do not filter deleted items
  113. content_api = ContentApi(tmpl_context.current_user, True, True)
  114. item = content_api.get_one(item_id,
  115. self._item_type,
  116. tmpl_context.workspace)
  117. next_or_back = '/workspaces/{}/folders/{}/threads/{}'
  118. try:
  119. next_url = tg.url(next_or_back).format(tmpl_context.workspace_id,
  120. tmpl_context.folder_id,
  121. tmpl_context.thread_id)
  122. msg = _('{} undeleted.').format(self._item_type_label)
  123. with new_revision(item):
  124. content_api.undelete(item)
  125. content_api.save(item, ActionDescription.UNDELETION)
  126. tg.flash(msg, CST.STATUS_OK)
  127. tg.redirect(next_url)
  128. except ValueError as e:
  129. logger.debug(self, 'Exception: {}'.format(e.__str__))
  130. back_url = tg.url(next_or_back).format(tmpl_context.workspace_id,
  131. tmpl_context.folder_id,
  132. tmpl_context.thread_id)
  133. msg = _('{} not un-deleted: {}').format(self._item_type_label,
  134. str(e))
  135. tg.flash(msg, CST.STATUS_ERROR)
  136. tg.redirect(back_url)
  137. class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
  138. """
  139. manage a path like this: /workspaces/1/folders/XXX/files/4
  140. """
  141. TEMPLATE_NEW = 'mako:tracim.templates.file.new'
  142. TEMPLATE_EDIT = 'mako:tracim.templates.file.edit'
  143. @property
  144. def _std_url(self):
  145. return tg.url('/workspaces/{}/folders/{}/files/{}')
  146. @property
  147. def _parent_url(self):
  148. return tg.url('/workspaces/{}/folders/{}')
  149. @property
  150. def _err_url(self):
  151. return tg.url('/workspaces/{}/folders/{}/files/{}')
  152. @property
  153. def _item_type(self):
  154. return ContentType.File
  155. @property
  156. def _item_type_label(self):
  157. return _('File')
  158. @property
  159. def _get_one_context(self) -> str:
  160. return CTX.FILE
  161. @property
  162. def _get_all_context(self) -> str:
  163. return CTX.FILES
  164. @tg.require(current_user_is_reader())
  165. @tg.expose('tracim.templates.file.getone')
  166. def get_one(self, file_id, revision_id=None):
  167. file_id = int(file_id)
  168. cache_path = CFG.get_instance().PREVIEW_CACHE_DIR
  169. preview_manager = PreviewManager(cache_path, create_folder=True)
  170. user = tmpl_context.current_user
  171. workspace = tmpl_context.workspace
  172. current_user_content = Context(CTX.CURRENT_USER,
  173. current_user=user).toDict(user)
  174. current_user_content.roles.sort(key=lambda role: role.workspace.name)
  175. content_api = ContentApi(user,
  176. show_archived=True,
  177. show_deleted=True)
  178. if revision_id:
  179. file = content_api.get_one_from_revision(file_id,
  180. self._item_type,
  181. workspace,
  182. revision_id)
  183. else:
  184. file = content_api.get_one(file_id,
  185. self._item_type,
  186. workspace)
  187. revision_id = file.revision_id
  188. file_path = content_api.get_one_revision_filepath(revision_id)
  189. nb_page = 0
  190. enable_pdf_buttons = False # type: bool
  191. preview_urls = []
  192. try:
  193. nb_page = preview_manager.get_page_nb(file_path=file_path)
  194. for page in range(int(nb_page)):
  195. url_str = '/previews/{}/pages/{}?revision_id={}'
  196. url = url_str.format(file_id,
  197. page,
  198. revision_id)
  199. preview_urls.append(url)
  200. enable_pdf_buttons = \
  201. preview_manager.has_pdf_preview(file_path=file_path)
  202. except PreviewGeneratorException as e:
  203. # INFO - A.P - Silently intercepts preview exception
  204. # As preview generation isn't mandatory, just register it
  205. logger.debug(
  206. self,
  207. 'Preview Generator Exception: {}'.format(e.__str__)
  208. )
  209. except Exception as e:
  210. # INFO - D.A - 2017-08-11 - Make Tracim robust to pg exceptions
  211. # Preview generator may potentially raise any type of exception
  212. # so we prevent user interface crashes by catching all exceptions
  213. logger.error(
  214. self,
  215. 'Preview Generator Generic Exception: {}'.format(e.__str__)
  216. )
  217. pdf_available = 'true' if enable_pdf_buttons else 'false' # type: str
  218. fake_api_breadcrumb = self.get_breadcrumb(file_id)
  219. fake_api_content = DictLikeClass(
  220. breadcrumb=fake_api_breadcrumb,
  221. current_user=current_user_content
  222. )
  223. fake_api = Context(CTX.FOLDER, current_user=user)\
  224. .toDict(fake_api_content)
  225. dictified_file = Context(self._get_one_context,
  226. current_user=user).toDict(file, 'file')
  227. result = DictLikeClass(result=dictified_file,
  228. fake_api=fake_api,
  229. nb_page=nb_page,
  230. url=preview_urls,
  231. pdf_available=pdf_available)
  232. return result
  233. @tg.require(current_user_is_reader())
  234. @tg.expose()
  235. def download(self, file_id, revision_id=None):
  236. file_id = int(file_id)
  237. revision_id = int(revision_id) if revision_id != 'latest' else None
  238. user = tmpl_context.current_user
  239. workspace = tmpl_context.workspace
  240. content_api = ContentApi(user)
  241. revision_to_send = None
  242. if revision_id:
  243. item = content_api.get_one_from_revision(file_id,
  244. self._item_type,
  245. workspace,
  246. revision_id)
  247. else:
  248. item = content_api.get_one(file_id,
  249. self._item_type,
  250. workspace)
  251. revision_to_send = None
  252. if item.revision_to_serialize <= 0:
  253. for revision in item.revisions:
  254. if not revision_to_send:
  255. revision_to_send = revision
  256. if revision.revision_id > revision_to_send.revision_id:
  257. revision_to_send = revision
  258. else:
  259. for revision in item.revisions:
  260. if revision.revision_id == item.revision_to_serialize:
  261. revision_to_send = revision
  262. break
  263. content_type = 'application/x-download'
  264. if revision_to_send.file_mimetype:
  265. content_type = str(revision_to_send.file_mimetype)
  266. tg.response.headers['Content-type'] = \
  267. str(revision_to_send.file_mimetype)
  268. tg.response.headers['Content-Type'] = content_type
  269. file_name = get_valid_header_file_name(revision_to_send.file_name)
  270. tg.response.headers['Content-Disposition'] = \
  271. str('attachment; filename="{}"'.format(file_name))
  272. return DepotManager.get().get(revision_to_send.depot_file)
  273. def get_all_fake(self,
  274. context_workspace: Workspace,
  275. context_folder: Content):
  276. """
  277. fake methods are used in other controllers in order to simulate a
  278. client/server api. the "client" controller method will include the
  279. result into its own fake_api object which will be available in the
  280. templates
  281. :param context_workspace: the workspace which would be taken from
  282. tmpl_context if we were in the normal
  283. behavior
  284. :return:
  285. """
  286. workspace = context_workspace
  287. content_api = ContentApi(tmpl_context.current_user)
  288. files = content_api.get_all(context_folder.content_id,
  289. ContentType.File,
  290. workspace)
  291. dictified_files = Context(CTX.FILES).toDict(files)
  292. return DictLikeClass(result=dictified_files)
  293. @tg.require(current_user_is_contributor())
  294. @tg.expose()
  295. def post(self, label='', file_data=None):
  296. # TODO - SECURE THIS
  297. workspace = tmpl_context.workspace
  298. folder = tmpl_context.folder
  299. api = ContentApi(tmpl_context.current_user)
  300. with DBSession.no_autoflush:
  301. file = api.create(ContentType.File, workspace, folder, label)
  302. api.update_file_data(file,
  303. file_data.filename,
  304. file_data.type,
  305. file_data.file.read())
  306. # Display error page to user if chosen label is in conflict
  307. if not self._path_validation.validate_new_content(file):
  308. DBSession.rollback()
  309. return render_invalid_integrity_chosen_path(
  310. file.get_label_as_file(),
  311. )
  312. api.save(file, ActionDescription.CREATION)
  313. tg.flash(_('File created'), CST.STATUS_OK)
  314. redirect = '/workspaces/{}/folders/{}/files/{}'
  315. tg.redirect(tg.url(redirect).format(tmpl_context.workspace_id,
  316. tmpl_context.folder_id,
  317. file.content_id))
  318. @tg.require(current_user_is_contributor())
  319. @tg.expose()
  320. def put(self, item_id, file_data=None, comment=None, label=None):
  321. # TODO - SECURE THIS
  322. workspace = tmpl_context.workspace
  323. try:
  324. api = ContentApi(tmpl_context.current_user)
  325. item = api.get_one(int(item_id), self._item_type, workspace)
  326. with new_revision(item):
  327. if label:
  328. # This case is the default "file title and description
  329. # update" In this case the file itself is not revisionned
  330. # Update description and label
  331. updated_item = api.update_content(
  332. item, label if label else item.label,
  333. comment if comment else ''
  334. )
  335. # Display error page to user if chosen label is in conflict
  336. if not self._path_validation.validate_new_content(
  337. updated_item,
  338. ):
  339. return render_invalid_integrity_chosen_path(
  340. updated_item.get_label_as_file(),
  341. )
  342. api.save(updated_item, ActionDescription.EDITION)
  343. else:
  344. # So, now we may have a comment and/or a file revision
  345. comment_item = None
  346. file_revision = None
  347. # INFO - G.M - 20/03/2018 - Add new comment
  348. if comment:
  349. comment_item = api.create_comment(
  350. workspace,
  351. item,
  352. comment,
  353. do_save=False
  354. )
  355. # INFO - G.M - 20/03/2018 - Add new file-revision
  356. if isinstance(file_data, FieldStorage):
  357. api.update_file_data(item,
  358. file_data.filename,
  359. file_data.type,
  360. file_data.file.read())
  361. # Display error page to user if chosen label is in
  362. # conflict
  363. if not self._path_validation.validate_new_content(
  364. item,
  365. ):
  366. return render_invalid_integrity_chosen_path(
  367. item.get_label_as_file(),
  368. )
  369. file_revision = True
  370. # INFO - G.M - 20/03/2018 - Save revision/comment
  371. if comment_item and file_revision:
  372. api.save(
  373. comment_item,
  374. ActionDescription.COMMENT,
  375. do_notify= False
  376. )
  377. api.save(item, ActionDescription.REVISION)
  378. elif file_revision:
  379. api.save(item, ActionDescription.REVISION)
  380. elif comment_item:
  381. api.save(comment_item, ActionDescription.COMMENT)
  382. msg = _('{} updated').format(self._item_type_label)
  383. tg.flash(msg, CST.STATUS_OK)
  384. tg.redirect(self._std_url.format(tmpl_context.workspace_id,
  385. tmpl_context.folder_id,
  386. item.content_id))
  387. except SameValueError:
  388. not_updated = '{} not updated: the content did not change'
  389. msg = _(not_updated).format(self._item_type_label)
  390. tg.flash(msg, CST.STATUS_WARNING)
  391. tg.redirect(self._err_url.format(tmpl_context.workspace_id,
  392. tmpl_context.folder_id,
  393. item_id))
  394. except ValueError as e:
  395. error = '{} not updated - error: {}'
  396. msg = _(error).format(self._item_type_label,
  397. str(e))
  398. tg.flash(msg, CST.STATUS_ERROR)
  399. tg.redirect(self._err_url.format(tmpl_context.workspace_id,
  400. tmpl_context.folder_id,
  401. item_id))
  402. class UserWorkspaceFolderPageRestController(TIMWorkspaceContentRestController):
  403. """
  404. manage a path like this: /workspaces/1/folders/XXX/pages/4
  405. """
  406. TEMPLATE_NEW = 'mako:tracim.templates.page.new'
  407. TEMPLATE_EDIT = 'mako:tracim.templates.page.edit'
  408. @property
  409. def _std_url(self):
  410. return tg.url('/workspaces/{}/folders/{}/pages/{}')
  411. @property
  412. def _err_url(self):
  413. return tg.url('/workspaces/{}/folders/{}/pages/{}')
  414. @property
  415. def _parent_url(self):
  416. return tg.url('/workspaces/{}/folders/{}')
  417. @property
  418. def _item_type(self):
  419. return ContentType.Page
  420. @property
  421. def _item_type_label(self):
  422. return _('Page')
  423. @property
  424. def _get_one_context(self) -> str:
  425. return CTX.PAGE
  426. @property
  427. def _get_all_context(self) -> str:
  428. return CTX.PAGES
  429. @tg.require(current_user_is_reader())
  430. @tg.expose('tracim.templates.page.getone')
  431. def get_one(self, page_id, revision_id=None):
  432. page_id = int(page_id)
  433. user = tmpl_context.current_user
  434. workspace = tmpl_context.workspace
  435. current_user_content = Context(CTX.CURRENT_USER).toDict(user)
  436. current_user_content.roles.sort(key=lambda role: role.workspace.name)
  437. content_api = ContentApi(
  438. user,
  439. show_deleted=True,
  440. show_archived=True,
  441. )
  442. if revision_id:
  443. page = content_api.get_one_from_revision(page_id,
  444. ContentType.Page,
  445. workspace,
  446. revision_id)
  447. else:
  448. page = content_api.get_one(page_id,
  449. ContentType.Page,
  450. workspace)
  451. fake_api_breadcrumb = self.get_breadcrumb(page_id)
  452. fake_api_content = DictLikeClass(breadcrumb=fake_api_breadcrumb,
  453. current_user=current_user_content)
  454. fake_api = Context(CTX.FOLDER).toDict(fake_api_content)
  455. dictified_page = Context(CTX.PAGE).toDict(page, 'page')
  456. return DictLikeClass(result=dictified_page,
  457. fake_api=fake_api)
  458. def get_all_fake(self,
  459. context_workspace: Workspace,
  460. context_folder: Content):
  461. """
  462. fake methods are used in other controllers in order to simulate a
  463. client/server api. the "client" controller method will include the
  464. result into its own fake_api object which will be available in the
  465. templates
  466. :param context_workspace: the workspace which would be taken from
  467. tmpl_context if we were in the normal
  468. behavior
  469. :return:
  470. """
  471. workspace = context_workspace
  472. content_api = ContentApi(tmpl_context.current_user)
  473. pages = content_api.get_all(context_folder.content_id,
  474. ContentType.Page,
  475. workspace)
  476. dictified_pages = Context(CTX.PAGES).toDict(pages)
  477. return DictLikeClass(result=dictified_pages)
  478. @tg.require(current_user_is_contributor())
  479. @tg.expose()
  480. def post(self, label='', content=''):
  481. workspace = tmpl_context.workspace
  482. api = ContentApi(tmpl_context.current_user)
  483. with DBSession.no_autoflush:
  484. page = api.create(ContentType.Page,
  485. workspace,
  486. tmpl_context.folder,
  487. label)
  488. page.description = content
  489. if not self._path_validation.validate_new_content(page):
  490. DBSession.rollback()
  491. return render_invalid_integrity_chosen_path(
  492. page.get_label(),
  493. )
  494. api.save(page, ActionDescription.CREATION, do_notify=True)
  495. tg.flash(_('Page created'), CST.STATUS_OK)
  496. redirect = '/workspaces/{}/folders/{}/pages/{}'
  497. tg.redirect(tg.url(redirect).format(tmpl_context.workspace_id,
  498. tmpl_context.folder_id,
  499. page.content_id))
  500. @tg.require(current_user_is_contributor())
  501. @tg.expose()
  502. def put(self, item_id, label='', content=''):
  503. # INFO - D.A. This method is a raw copy of
  504. # TODO - SECURE THIS
  505. workspace = tmpl_context.workspace
  506. try:
  507. api = ContentApi(tmpl_context.current_user)
  508. item = api.get_one(int(item_id), self._item_type, workspace)
  509. with new_revision(item):
  510. api.update_content(item, label, content)
  511. if not self._path_validation.validate_new_content(item):
  512. return render_invalid_integrity_chosen_path(
  513. item.get_label(),
  514. )
  515. api.save(item, ActionDescription.REVISION)
  516. msg = _('{} updated').format(self._item_type_label)
  517. tg.flash(msg, CST.STATUS_OK)
  518. tg.redirect(self._std_url.format(tmpl_context.workspace_id,
  519. tmpl_context.folder_id,
  520. item.content_id))
  521. except SameValueError:
  522. not_updated = '{} not updated: the content did not change'
  523. msg = _(not_updated).format(self._item_type_label)
  524. tg.flash(msg, CST.STATUS_WARNING)
  525. tg.redirect(self._err_url.format(tmpl_context.workspace_id,
  526. tmpl_context.folder_id,
  527. item_id))
  528. except ValueError as e:
  529. not_updated = '{} not updated - error: {}'
  530. msg = _(not_updated).format(self._item_type_label, str(e))
  531. tg.flash(msg, CST.STATUS_ERROR)
  532. tg.redirect(self._err_url.format(tmpl_context.workspace_id,
  533. tmpl_context.folder_id,
  534. item_id))
  535. class UserWorkspaceFolderThreadRestController(TIMWorkspaceContentRestController):
  536. """
  537. manage a path like this: /workspaces/1/folders/XXX/pages/4
  538. """
  539. TEMPLATE_NEW = 'mako:tracim.templates.thread.new'
  540. TEMPLATE_EDIT = 'mako:tracim.templates.thread.edit'
  541. comments = UserWorkspaceFolderThreadCommentRestController()
  542. def _before(self, *args, **kw):
  543. TIMRestPathContextSetup.current_user()
  544. TIMRestPathContextSetup.current_workspace()
  545. TIMRestPathContextSetup.current_folder()
  546. @property
  547. def _std_url(self):
  548. return tg.url('/workspaces/{}/folders/{}/threads/{}')
  549. @property
  550. def _err_url(self):
  551. return self._std_url
  552. @property
  553. def _parent_url(self):
  554. return tg.url('/workspaces/{}/folders/{}')
  555. @property
  556. def _item_type(self):
  557. return ContentType.Thread
  558. @property
  559. def _item_type_label(self):
  560. return _('Thread')
  561. @property
  562. def _get_one_context(self) -> str:
  563. return CTX.THREAD
  564. @property
  565. def _get_all_context(self) -> str:
  566. return CTX.THREADS
  567. @tg.require(current_user_is_contributor())
  568. @tg.expose()
  569. def post(self, label='', content='', parent_id=None):
  570. """
  571. Creates a new thread. Actually, on POST, the content will be included
  572. in a user comment instead of being the thread description
  573. :param label:
  574. :param content:
  575. :return:
  576. """
  577. # TODO - SECURE THIS
  578. workspace = tmpl_context.workspace
  579. api = ContentApi(tmpl_context.current_user)
  580. with DBSession.no_autoflush:
  581. thread = api.create(ContentType.Thread,
  582. workspace,
  583. tmpl_context.folder,
  584. label)
  585. if not self._path_validation.validate_new_content(thread):
  586. DBSession.rollback()
  587. return render_invalid_integrity_chosen_path(
  588. thread.get_label(),
  589. )
  590. # FIXME - DO NOT DUPLICATE FIRST MESSAGE
  591. # thread.description = content
  592. api.save(thread, ActionDescription.CREATION, do_notify=False)
  593. comment = api.create(ContentType.Comment, workspace, thread, label)
  594. comment.label = ''
  595. comment.description = content
  596. api.save(comment, ActionDescription.COMMENT, do_notify=False)
  597. api.do_notify(thread)
  598. tg.flash(_('Thread created'), CST.STATUS_OK)
  599. tg.redirect(self._std_url.format(tmpl_context.workspace_id,
  600. tmpl_context.folder_id,
  601. thread.content_id))
  602. @tg.require(current_user_is_reader())
  603. @tg.expose('tracim.templates.thread.getone')
  604. def get_one(self, thread_id, **kwargs):
  605. """
  606. :param thread_id: content_id of Thread
  607. :param inverted: fill with True equivalent to invert order of comments
  608. NOTE: This parameter is in kwargs because prevent URL
  609. changes.
  610. """
  611. inverted = kwargs.get('inverted')
  612. thread_id = int(thread_id)
  613. user = tmpl_context.current_user
  614. workspace = tmpl_context.workspace
  615. current_user_content = Context(CTX.CURRENT_USER).toDict(user)
  616. current_user_content.roles.sort(key=lambda role: role.workspace.name)
  617. content_api = ContentApi(
  618. user,
  619. show_deleted=True,
  620. show_archived=True,
  621. )
  622. thread = content_api.get_one(thread_id, ContentType.Thread, workspace)
  623. fake_api_breadcrumb = self.get_breadcrumb(thread_id)
  624. fake_api_content = DictLikeClass(breadcrumb=fake_api_breadcrumb,
  625. current_user=current_user_content)
  626. fake_api = Context(CTX.FOLDER).toDict(fake_api_content)
  627. dictified_thread = Context(CTX.THREAD).toDict(thread, 'thread')
  628. if inverted:
  629. dictified_thread.thread.history = \
  630. reversed(dictified_thread.thread.history)
  631. return DictLikeClass(
  632. result=dictified_thread,
  633. fake_api=fake_api,
  634. inverted=inverted,
  635. )
  636. class ItemLocationController(TIMWorkspaceContentRestController,
  637. BaseController):
  638. @tg.require(current_user_is_content_manager())
  639. @tg.expose()
  640. def get_one(self, item_id):
  641. item_id = int(item_id)
  642. user = tmpl_context.current_user
  643. workspace = tmpl_context.workspace
  644. item = ContentApi(user).get_one(item_id, ContentType.Any, workspace)
  645. raise NotImplementedError
  646. return item
  647. @tg.require(current_user_is_content_manager())
  648. @tg.expose('tracim.templates.folder.move')
  649. def edit(self, item_id):
  650. """
  651. Show the edit form (do not really edit the data)
  652. :param item_id:
  653. :return:
  654. """
  655. current_user_content = \
  656. Context(CTX.CURRENT_USER).toDict(tmpl_context.current_user)
  657. fake_api = \
  658. Context(CTX.FOLDER) \
  659. .toDict(DictLikeClass(current_user=current_user_content))
  660. item_id = int(item_id)
  661. user = tmpl_context.current_user
  662. workspace = tmpl_context.workspace
  663. content_api = ContentApi(user)
  664. item = content_api.get_one(item_id, ContentType.Any, workspace)
  665. dictified_item = Context(CTX.DEFAULT).toDict(item, 'item')
  666. return DictLikeClass(result=dictified_item, fake_api=fake_api)
  667. @tg.require(current_user_is_content_manager())
  668. @tg.expose()
  669. def put(self, item_id, folder_id='0'):
  670. """
  671. :param item_id:
  672. :param folder_id: id of the folder, in a style like
  673. 'workspace_14__content_1586'
  674. :return:
  675. """
  676. # TODO - SECURE THIS
  677. workspace = tmpl_context.workspace
  678. item_id = int(item_id)
  679. new_workspace, new_parent = convert_id_into_instances(folder_id)
  680. if new_workspace != workspace:
  681. # check that user is at least
  682. # - content manager in current workspace
  683. # - content manager in new workspace
  684. user = tmpl_context.current_user
  685. if user.get_role(workspace) < UserRoleInWorkspace.CONTENT_MANAGER:
  686. tg.flash(_('You are not allowed '
  687. 'to move this folder'), CST.STATUS_ERROR)
  688. tg.redirect(self.parent_controller.url(item_id))
  689. if user.get_role(new_workspace) < UserRoleInWorkspace.CONTENT_MANAGER:
  690. tg.flash(_('You are not allowed to move '
  691. 'this folder to this workspace'), CST.STATUS_ERROR)
  692. tg.redirect(self.parent_controller.url(item_id))
  693. api = ContentApi(tmpl_context.current_user)
  694. item = api.get_one(item_id, ContentType.Any, workspace)
  695. with new_revision(item):
  696. api.move_recursively(item, new_parent, new_workspace)
  697. next_url = tg.url('/workspaces/{}/folders/{}'.format(
  698. new_workspace.workspace_id, item_id))
  699. if new_parent:
  700. tg.flash(_('Item moved to {} (workspace {})').format(
  701. new_parent.label,
  702. new_workspace.label), CST.STATUS_OK)
  703. else:
  704. tg.flash(_('Item moved to workspace {}').format(
  705. new_workspace.label))
  706. tg.redirect(next_url)
  707. else:
  708. # Default move inside same workspace
  709. api = ContentApi(tmpl_context.current_user)
  710. item = api.get_one(item_id, ContentType.Any, workspace)
  711. with new_revision(item):
  712. api.move(item, new_parent)
  713. next_url = self.parent_controller.url(item_id)
  714. if new_parent:
  715. tg.flash(_('Item moved to {}').format(new_parent.label),
  716. CST.STATUS_OK)
  717. else:
  718. tg.flash(_('Item moved to workspace root'))
  719. tg.redirect(next_url)
  720. class UserWorkspaceFolderRestController(TIMRestControllerWithBreadcrumb):
  721. TEMPLATE_NEW = 'mako:tracim.templates.folder.new'
  722. location = ItemLocationController()
  723. files = UserWorkspaceFolderFileRestController()
  724. pages = UserWorkspaceFolderPageRestController()
  725. threads = UserWorkspaceFolderThreadRestController()
  726. def _before(self, *args, **kw):
  727. TIMRestPathContextSetup.current_user()
  728. try:
  729. TIMRestPathContextSetup.current_workspace()
  730. except NoResultFound:
  731. abort(404)
  732. @tg.require(current_user_is_content_manager())
  733. @tg.expose('tracim.templates.folder.edit')
  734. def edit(self, folder_id):
  735. """
  736. Show the edit form (do not really edit the data)
  737. :param item_id:
  738. :return:
  739. """
  740. folder_id = int(folder_id)
  741. user = tmpl_context.current_user
  742. workspace = tmpl_context.workspace
  743. content_api = ContentApi(user)
  744. folder = content_api.get_one(folder_id, ContentType.Folder, workspace)
  745. dictified_folder = Context(CTX.FOLDER).toDict(folder, 'folder')
  746. return DictLikeClass(result=dictified_folder)
  747. @tg.require(current_user_is_reader())
  748. @tg.expose('tracim.templates.folder.getone')
  749. def get_one(self, folder_id, **kwargs):
  750. """
  751. :param folder_id: Displayed folder id
  752. :param kwargs:
  753. * show_deleted: bool: Display deleted contents or hide them if False
  754. * show_archived: bool: Display archived contents or hide them
  755. if False
  756. """
  757. show_deleted = str_as_bool(kwargs.get('show_deleted', ''))
  758. show_archived = str_as_bool(kwargs.get('show_archived', ''))
  759. folder_id = int(folder_id)
  760. user = tmpl_context.current_user
  761. workspace = tmpl_context.workspace
  762. current_user_content = Context(CTX.CURRENT_USER,
  763. current_user=user).toDict(user)
  764. current_user_content.roles.sort(key=lambda role: role.workspace.name)
  765. content_api = ContentApi(
  766. user,
  767. show_deleted=show_deleted,
  768. show_archived=show_archived,
  769. )
  770. with content_api.show(show_deleted=True, show_archived=True):
  771. folder = content_api.get_one(
  772. folder_id,
  773. ContentType.Folder,
  774. workspace,
  775. )
  776. fake_api_breadcrumb = self.get_breadcrumb(folder_id)
  777. fake_api_subfolders = self.get_all_fake(workspace,
  778. folder.content_id).result
  779. fake_api_pages = self.pages.get_all_fake(workspace, folder).result
  780. fake_api_files = self.files.get_all_fake(workspace, folder).result
  781. fake_api_threads = self.threads.get_all_fake(workspace, folder).result
  782. fake_api_content = DictLikeClass(
  783. current_user=current_user_content,
  784. breadcrumb=fake_api_breadcrumb,
  785. current_folder_subfolders=fake_api_subfolders,
  786. current_folder_pages=fake_api_pages,
  787. current_folder_files=fake_api_files,
  788. current_folder_threads=fake_api_threads,
  789. )
  790. fake_api = Context(CTX.FOLDER).toDict(fake_api_content)
  791. sub_items = content_api.get_children(
  792. parent_id=folder.content_id,
  793. content_types=[
  794. ContentType.Folder,
  795. ContentType.File,
  796. ContentType.Page,
  797. ContentType.Thread,
  798. ],
  799. )
  800. fake_api.sub_items = Context(CTX.FOLDER_CONTENT_LIST).toDict(sub_items)
  801. fake_api.content_types = Context(CTX.DEFAULT).toDict(
  802. content_api.get_all_types()
  803. )
  804. dictified_folder = Context(CTX.FOLDER).toDict(folder, 'folder')
  805. return DictLikeClass(
  806. result=dictified_folder,
  807. fake_api=fake_api,
  808. show_deleted=show_deleted,
  809. show_archived=show_archived,
  810. )
  811. def get_all_fake(self, context_workspace: Workspace, parent_id=None):
  812. """
  813. fake methods are used in other controllers in order to simulate a
  814. client/server api. the "client" controller method will include the
  815. result into its own fake_api object which will be available in the
  816. templates
  817. :param context_workspace: the workspace which would be taken from
  818. tmpl_context if we were in the normal
  819. behavior
  820. :return:
  821. """
  822. workspace = context_workspace
  823. content_api = ContentApi(tmpl_context.current_user)
  824. with content_api.show(show_deleted=True, show_archived=True):
  825. parent_folder = content_api.get_one(parent_id, ContentType.Folder)
  826. folders = content_api.get_child_folders(parent_folder, workspace)
  827. folders = Context(CTX.FOLDERS).toDict(folders)
  828. return DictLikeClass(result=folders)
  829. @tg.require(current_user_is_content_manager())
  830. @tg.expose()
  831. def post(self,
  832. label,
  833. parent_id=None,
  834. can_contain_folders=False,
  835. can_contain_threads=False,
  836. can_contain_files=False,
  837. can_contain_pages=False):
  838. # TODO - SECURE THIS
  839. workspace = tmpl_context.workspace
  840. api = ContentApi(tmpl_context.current_user)
  841. redirect_url_tmpl = '/workspaces/{}/folders/{}'
  842. redirect_url = ''
  843. try:
  844. parent = None
  845. if parent_id:
  846. parent = api.get_one(int(parent_id),
  847. ContentType.Folder,
  848. workspace)
  849. with DBSession.no_autoflush:
  850. folder = api.create(ContentType.Folder,
  851. workspace,
  852. parent,
  853. label)
  854. subcontent = dict(
  855. folder=True if can_contain_folders == 'on' else False,
  856. thread=True if can_contain_threads == 'on' else False,
  857. file=True if can_contain_files == 'on' else False,
  858. page=True if can_contain_pages == 'on' else False
  859. )
  860. api.set_allowed_content(folder, subcontent)
  861. if not self._path_validation.validate_new_content(folder):
  862. DBSession.rollback()
  863. return render_invalid_integrity_chosen_path(
  864. folder.get_label(),
  865. )
  866. api.save(folder)
  867. tg.flash(_('Folder created'), CST.STATUS_OK)
  868. redirect_url = redirect_url_tmpl.format(tmpl_context.workspace_id,
  869. folder.content_id)
  870. except Exception as e:
  871. error_msg = 'An unexpected exception has been catched. ' \
  872. 'Look at the traceback below.'
  873. logger.error(self, error_msg)
  874. traceback.print_exc()
  875. tb = sys.exc_info()[2]
  876. tg.flash(_('Folder not created: {}').format(e.with_traceback(tb)),
  877. CST.STATUS_ERROR)
  878. if parent_id:
  879. redirect_url = \
  880. redirect_url_tmpl.format(tmpl_context.workspace_id,
  881. parent_id)
  882. else:
  883. redirect_url = \
  884. '/workspaces/{}'.format(tmpl_context.workspace_id)
  885. ####
  886. #
  887. # INFO - D.A. - 2014-10-22 - Do not put redirect in a
  888. # try/except block as redirect is using exceptions!
  889. #
  890. tg.redirect(tg.url(redirect_url))
  891. @tg.require(current_user_is_content_manager())
  892. @tg.expose()
  893. def put(self,
  894. folder_id,
  895. label,
  896. can_contain_folders=False,
  897. can_contain_threads=False,
  898. can_contain_files=False,
  899. can_contain_pages=False):
  900. # TODO - SECURE THIS
  901. workspace = tmpl_context.workspace
  902. api = ContentApi(tmpl_context.current_user)
  903. next_url = ''
  904. try:
  905. folder = api.get_one(int(folder_id), ContentType.Folder, workspace)
  906. subcontent = dict(
  907. folder=True if can_contain_folders == 'on' else False,
  908. thread=True if can_contain_threads == 'on' else False,
  909. file=True if can_contain_files == 'on' else False,
  910. page=True if can_contain_pages == 'on' else False
  911. )
  912. with new_revision(folder):
  913. if label != folder.label:
  914. # TODO - D.A. - 2015-05-25
  915. # Allow to set folder description
  916. api.update_content(folder, label, folder.description)
  917. api.set_allowed_content(folder, subcontent)
  918. if not self._path_validation.validate_new_content(folder):
  919. return render_invalid_integrity_chosen_path(
  920. folder.get_label(),
  921. )
  922. api.save(folder)
  923. tg.flash(_('Folder updated'), CST.STATUS_OK)
  924. next_url = self.url(folder.content_id)
  925. except Exception as e:
  926. tg.flash(_('Folder not updated: {}').format(str(e)),
  927. CST.STATUS_ERROR)
  928. next_url = self.url(int(folder_id))
  929. tg.redirect(next_url)
  930. @property
  931. def _std_url(self):
  932. return tg.url('/workspaces/{}/folders/{}')
  933. @property
  934. def _parent_url(self):
  935. return tg.url('/workspaces/{}')
  936. @property
  937. def _item_type_label(self):
  938. return _('Folder')
  939. @property
  940. def _item_type(self):
  941. return ContentType.Folder
  942. @tg.require(current_user_is_content_manager())
  943. @tg.expose()
  944. def put_archive(self, item_id):
  945. # TODO - CHECK RIGHTS
  946. item_id = int(item_id)
  947. content_api = ContentApi(tmpl_context.current_user)
  948. item = content_api.get_one(item_id,
  949. self._item_type,
  950. tmpl_context.workspace)
  951. try:
  952. next_url = self._parent_url.format(item.workspace_id,
  953. item.parent_id)
  954. tmp_url = self._std_url.format(item.workspace_id,
  955. item.content_id)
  956. undo_url = tmp_url + '/put_archive_undo'
  957. archived_msg = '{} archived. ' \
  958. '<a class="alert-link" href="{}">Cancel action</a>'
  959. msg = _(archived_msg).format(self._item_type_label,
  960. undo_url)
  961. with new_revision(item):
  962. content_api.archive(item)
  963. content_api.save(item, ActionDescription.ARCHIVING)
  964. # TODO allow to come back
  965. tg.flash(msg, CST.STATUS_OK, no_escape=True)
  966. tg.redirect(next_url)
  967. except ValueError as e:
  968. next_url = self._std_url.format(item.workspace_id,
  969. item.parent_id,
  970. item.content_id)
  971. msg = _('{} not archived: {}').format(self._item_type_label,
  972. str(e))
  973. tg.flash(msg, CST.STATUS_ERROR)
  974. tg.redirect(next_url)
  975. @tg.require(current_user_is_content_manager())
  976. @tg.expose()
  977. def put_archive_undo(self, item_id):
  978. # TODO - CHECK RIGHTS
  979. item_id = int(item_id)
  980. # Here we do not filter deleted items
  981. content_api = ContentApi(tmpl_context.current_user, True, True)
  982. item = content_api.get_one(item_id,
  983. self._item_type,
  984. tmpl_context.workspace)
  985. try:
  986. next_url = self._std_url.format(item.workspace_id, item.content_id)
  987. msg = _('{} unarchived.').format(self._item_type_label)
  988. with new_revision(item):
  989. content_api.unarchive(item)
  990. content_api.save(item, ActionDescription.UNARCHIVING)
  991. tg.flash(msg, CST.STATUS_OK)
  992. tg.redirect(next_url)
  993. except ValueError as e:
  994. msg = _('{} not un-archived: {}').format(self._item_type_label,
  995. str(e))
  996. next_url = self._std_url.format(item.workspace_id, item.content_id)
  997. # We still use std url because the item has not been archived
  998. tg.flash(msg, CST.STATUS_ERROR)
  999. tg.redirect(next_url)
  1000. @tg.require(current_user_is_content_manager())
  1001. @tg.expose()
  1002. def put_delete(self, item_id):
  1003. # TODO - CHECK RIGHTS
  1004. item_id = int(item_id)
  1005. content_api = ContentApi(tmpl_context.current_user)
  1006. item = content_api.get_one(item_id,
  1007. self._item_type,
  1008. tmpl_context.workspace)
  1009. try:
  1010. next_url = self._parent_url.format(item.workspace_id,
  1011. item.parent_id)
  1012. tmp_url = self._std_url.format(item.workspace_id,
  1013. item.content_id)
  1014. undo_url = tmp_url + '/put_delete_undo'
  1015. deleted_msg = '{} deleted. ' \
  1016. '<a class="alert-link" href="{}">Cancel action</a>'
  1017. msg = _(deleted_msg).format(self._item_type_label,
  1018. undo_url)
  1019. with new_revision(item):
  1020. content_api.delete(item)
  1021. content_api.save(item, ActionDescription.DELETION)
  1022. tg.flash(msg, CST.STATUS_OK, no_escape=True)
  1023. tg.redirect(next_url)
  1024. except ValueError as e:
  1025. back_url = self._std_url.format(item.workspace_id, item.content_id)
  1026. msg = _('{} not deleted: {}').format(self._item_type_label, str(e))
  1027. tg.flash(msg, CST.STATUS_ERROR)
  1028. tg.redirect(back_url)
  1029. @tg.require(current_user_is_content_manager())
  1030. @tg.expose()
  1031. def put_delete_undo(self, item_id):
  1032. # TODO - CHECK RIGHTS
  1033. item_id = int(item_id)
  1034. # Here we do not filter deleted items
  1035. content_api = ContentApi(tmpl_context.current_user, True, True)
  1036. item = content_api.get_one(item_id,
  1037. self._item_type,
  1038. tmpl_context.workspace)
  1039. try:
  1040. next_url = self._std_url.format(item.workspace_id, item.content_id)
  1041. msg = _('{} undeleted.').format(self._item_type_label)
  1042. with new_revision(item):
  1043. content_api.undelete(item)
  1044. content_api.save(item, ActionDescription.UNDELETION)
  1045. tg.flash(msg, CST.STATUS_OK)
  1046. tg.redirect(next_url)
  1047. except ValueError as e:
  1048. logger.debug(self, 'Exception: {}'.format(e.__str__))
  1049. back_url = self._parent_url.format(item.workspace_id,
  1050. item.parent_id)
  1051. msg = _('{} not un-deleted: {}').format(self._item_type_label,
  1052. str(e))
  1053. tg.flash(msg, CST.STATUS_ERROR)
  1054. tg.redirect(back_url)
  1055. class ContentController(StandardController):
  1056. """
  1057. Class of controllers used for example in home to mark read the unread
  1058. contents via mark_all_read()
  1059. """
  1060. @classmethod
  1061. def current_item_id_key_in_context(cls) -> str:
  1062. return''
  1063. @tg.expose()
  1064. def index(self):
  1065. return dict()
  1066. @require(predicates.not_anonymous())
  1067. @tg.expose()
  1068. def mark_all_read(self):
  1069. '''
  1070. Mark as read all the content that hasn't been read
  1071. redirects the user to "/home"
  1072. '''
  1073. user = tg.tmpl_context.current_user
  1074. content_api = ContentApi(user)
  1075. content_api.mark_read__all()
  1076. tg.redirect("/home")