content.py 50KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358
  1. # -*- coding: utf-8 -*-
  2. from contextlib import contextmanager
  3. import os
  4. import datetime
  5. import re
  6. import typing
  7. from operator import itemgetter
  8. import transaction
  9. from sqlalchemy import func
  10. from sqlalchemy.orm import Query
  11. from depot.manager import DepotManager
  12. from depot.io.utils import FileIntent
  13. import sqlalchemy
  14. from sqlalchemy.orm import aliased
  15. from sqlalchemy.orm import joinedload
  16. from sqlalchemy.orm.attributes import get_history
  17. from sqlalchemy.orm.exc import NoResultFound
  18. from sqlalchemy.orm.session import Session
  19. from sqlalchemy import desc
  20. from sqlalchemy import distinct
  21. from sqlalchemy import or_
  22. from sqlalchemy.sql.elements import and_
  23. from tracim.lib.utils.utils import cmp_to_key
  24. from tracim.lib.core.notifications import NotifierFactory
  25. from tracim.exceptions import SameValueError
  26. from tracim.exceptions import EmptyCommentContentNotAllowed
  27. from tracim.exceptions import EmptyLabelNotAllowed
  28. from tracim.exceptions import ContentNotFound
  29. from tracim.exceptions import WorkspacesDoNotMatch
  30. from tracim.lib.utils.utils import current_date_for_filename
  31. from tracim.models.revision_protection import new_revision
  32. from tracim.models.auth import User
  33. from tracim.models.data import ActionDescription
  34. from tracim.models.data import ContentStatus
  35. from tracim.models.data import ContentRevisionRO
  36. from tracim.models.data import Content
  37. from tracim.models.data import ContentType
  38. from tracim.models.data import NodeTreeItem
  39. from tracim.models.data import RevisionReadStatus
  40. from tracim.models.data import UserRoleInWorkspace
  41. from tracim.models.data import Workspace
  42. from tracim.lib.utils.translation import fake_translator as _
  43. from tracim.models.context_models import RevisionInContext
  44. from tracim.models.context_models import ContentInContext
  45. __author__ = 'damien'
  46. def compare_content_for_sorting_by_type_and_name(
  47. content1: Content,
  48. content2: Content
  49. ) -> int:
  50. """
  51. :param content1:
  52. :param content2:
  53. :return: 1 if content1 > content2
  54. -1 if content1 < content2
  55. 0 if content1 = content2
  56. """
  57. if content1.type == content2.type:
  58. if content1.get_label().lower()>content2.get_label().lower():
  59. return 1
  60. elif content1.get_label().lower()<content2.get_label().lower():
  61. return -1
  62. return 0
  63. else:
  64. # TODO - D.A. - 2014-12-02 - Manage Content Types Dynamically
  65. content_type_order = [
  66. ContentType.Folder,
  67. ContentType.Page,
  68. ContentType.Thread,
  69. ContentType.File,
  70. ]
  71. content_1_type_index = content_type_order.index(content1.type)
  72. content_2_type_index = content_type_order.index(content2.type)
  73. result = content_1_type_index - content_2_type_index
  74. if result < 0:
  75. return -1
  76. elif result > 0:
  77. return 1
  78. else:
  79. return 0
  80. def compare_tree_items_for_sorting_by_type_and_name(
  81. item1: NodeTreeItem,
  82. item2: NodeTreeItem
  83. ) -> int:
  84. return compare_content_for_sorting_by_type_and_name(item1.node, item2.node)
  85. class ContentApi(object):
  86. SEARCH_SEPARATORS = ',| '
  87. SEARCH_DEFAULT_RESULT_NB = 50
  88. DISPLAYABLE_CONTENTS = (
  89. ContentType.Folder,
  90. ContentType.File,
  91. ContentType.Comment,
  92. ContentType.Thread,
  93. ContentType.Page,
  94. ContentType.PageLegacy,
  95. ContentType.MarkdownPage,
  96. )
  97. def __init__(
  98. self,
  99. session: Session,
  100. current_user: typing.Optional[User],
  101. config,
  102. show_archived: bool = False,
  103. show_deleted: bool = False,
  104. show_temporary: bool = False,
  105. show_active: bool = True,
  106. all_content_in_treeview: bool = True,
  107. force_show_all_types: bool = False,
  108. disable_user_workspaces_filter: bool = False,
  109. ) -> None:
  110. self._session = session
  111. self._user = current_user
  112. self._config = config
  113. self._user_id = current_user.user_id if current_user else None
  114. self._show_archived = show_archived
  115. self._show_deleted = show_deleted
  116. self._show_temporary = show_temporary
  117. self._show_active = show_active
  118. self._show_all_type_of_contents_in_treeview = all_content_in_treeview
  119. self._force_show_all_types = force_show_all_types
  120. self._disable_user_workspaces_filter = disable_user_workspaces_filter
  121. @contextmanager
  122. def show(
  123. self,
  124. show_archived: bool=False,
  125. show_deleted: bool=False,
  126. show_temporary: bool=False,
  127. ) -> typing.Generator['ContentApi', None, None]:
  128. """
  129. Use this method as context manager to update show_archived,
  130. show_deleted and show_temporary properties during context.
  131. :param show_archived: show archived contents
  132. :param show_deleted: show deleted contents
  133. :param show_temporary: show temporary contents
  134. """
  135. previous_show_archived = self._show_archived
  136. previous_show_deleted = self._show_deleted
  137. previous_show_temporary = self._show_temporary
  138. try:
  139. self._show_archived = show_archived
  140. self._show_deleted = show_deleted
  141. self._show_temporary = show_temporary
  142. yield self
  143. finally:
  144. self._show_archived = previous_show_archived
  145. self._show_deleted = previous_show_deleted
  146. self._show_temporary = previous_show_temporary
  147. def get_content_in_context(self, content: Content) -> ContentInContext:
  148. return ContentInContext(content, self._session, self._config, self._user) # nopep8
  149. def get_revision_in_context(self, revision: ContentRevisionRO) -> RevisionInContext: # nopep8
  150. # TODO - G.M - 2018-06-173 - create revision in context object
  151. return RevisionInContext(revision, self._session, self._config)
  152. def _get_revision_join(self) -> sqlalchemy.sql.elements.BooleanClauseList:
  153. """
  154. Return the Content/ContentRevision query join condition
  155. :return: Content/ContentRevision query join condition
  156. """
  157. return and_(Content.id == ContentRevisionRO.content_id,
  158. ContentRevisionRO.revision_id == self._session.query(
  159. ContentRevisionRO.revision_id)
  160. .filter(ContentRevisionRO.content_id == Content.id)
  161. .order_by(ContentRevisionRO.revision_id.desc())
  162. .limit(1)
  163. .correlate(Content))
  164. def get_canonical_query(self) -> Query:
  165. """
  166. Return the Content/ContentRevision base query who join these table on the last revision.
  167. :return: Content/ContentRevision Query
  168. """
  169. return self._session.query(Content)\
  170. .join(ContentRevisionRO, self._get_revision_join())
  171. @classmethod
  172. def sort_tree_items(
  173. cls,
  174. content_list: typing.List[NodeTreeItem],
  175. )-> typing.List[NodeTreeItem]:
  176. news = []
  177. for item in content_list:
  178. news.append(item)
  179. content_list.sort(key=cmp_to_key(
  180. compare_tree_items_for_sorting_by_type_and_name,
  181. ))
  182. return content_list
  183. @classmethod
  184. def sort_content(
  185. cls,
  186. content_list: typing.List[Content],
  187. ) -> typing.List[Content]:
  188. content_list.sort(key=cmp_to_key(compare_content_for_sorting_by_type_and_name))
  189. return content_list
  190. def __real_base_query(
  191. self,
  192. workspace: Workspace = None,
  193. ) -> Query:
  194. result = self.get_canonical_query()
  195. # Exclude non displayable types
  196. if not self._force_show_all_types:
  197. result = result.filter(Content.type.in_(self.DISPLAYABLE_CONTENTS))
  198. if workspace:
  199. result = result.filter(Content.workspace_id == workspace.workspace_id)
  200. # Security layer: if user provided, filter
  201. # with user workspaces privileges
  202. if self._user and not self._disable_user_workspaces_filter:
  203. user = self._session.query(User).get(self._user_id)
  204. # Filter according to user workspaces
  205. workspace_ids = [r.workspace_id for r in user.roles \
  206. if r.role>=UserRoleInWorkspace.READER]
  207. result = result.filter(or_(
  208. Content.workspace_id.in_(workspace_ids),
  209. # And allow access to non workspace document when he is owner
  210. and_(
  211. Content.workspace_id == None,
  212. Content.owner_id == self._user_id,
  213. )
  214. ))
  215. return result
  216. def _base_query(self, workspace: Workspace=None) -> Query:
  217. result = self.__real_base_query(workspace)
  218. if not self._show_active:
  219. result = result.filter(or_(
  220. Content.is_deleted==True,
  221. Content.is_archived==True,
  222. ))
  223. if not self._show_deleted:
  224. result = result.filter(Content.is_deleted==False)
  225. if not self._show_archived:
  226. result = result.filter(Content.is_archived==False)
  227. if not self._show_temporary:
  228. result = result.filter(Content.is_temporary==False)
  229. return result
  230. def __revisions_real_base_query(
  231. self,
  232. workspace: Workspace=None,
  233. ) -> Query:
  234. result = self._session.query(ContentRevisionRO)
  235. # Exclude non displayable types
  236. if not self._force_show_all_types:
  237. result = result.filter(Content.type.in_(self.DISPLAYABLE_CONTENTS))
  238. if workspace:
  239. result = result.filter(ContentRevisionRO.workspace_id==workspace.workspace_id)
  240. if self._user:
  241. user = self._session.query(User).get(self._user_id)
  242. # Filter according to user workspaces
  243. workspace_ids = [r.workspace_id for r in user.roles \
  244. if r.role>=UserRoleInWorkspace.READER]
  245. result = result.filter(ContentRevisionRO.workspace_id.in_(workspace_ids))
  246. return result
  247. def _revisions_base_query(
  248. self,
  249. workspace: Workspace=None,
  250. ) -> Query:
  251. result = self.__revisions_real_base_query(workspace)
  252. if not self._show_deleted:
  253. result = result.filter(ContentRevisionRO.is_deleted==False)
  254. if not self._show_archived:
  255. result = result.filter(ContentRevisionRO.is_archived==False)
  256. if not self._show_temporary:
  257. result = result.filter(Content.is_temporary==False)
  258. return result
  259. def _hard_filtered_base_query(
  260. self,
  261. workspace: Workspace=None,
  262. ) -> Query:
  263. """
  264. If set to True, then filterign on is_deleted and is_archived will also
  265. filter parent properties. This is required for search() function which
  266. also search in comments (for example) which may be 'not deleted' while
  267. the associated content is deleted
  268. :param hard_filtering:
  269. :return:
  270. """
  271. result = self.__real_base_query(workspace)
  272. if not self._show_deleted:
  273. parent = aliased(Content)
  274. result = result.join(parent, Content.parent).\
  275. filter(Content.is_deleted==False).\
  276. filter(parent.is_deleted==False)
  277. if not self._show_archived:
  278. parent = aliased(Content)
  279. result = result.join(parent, Content.parent).\
  280. filter(Content.is_archived==False).\
  281. filter(parent.is_archived==False)
  282. if not self._show_temporary:
  283. parent = aliased(Content)
  284. result = result.join(parent, Content.parent). \
  285. filter(Content.is_temporary == False). \
  286. filter(parent.is_temporary == False)
  287. return result
  288. def get_base_query(
  289. self,
  290. workspace: Workspace,
  291. ) -> Query:
  292. return self._base_query(workspace)
  293. # TODO - G.M - 2018-07-17 - [Cleanup] Drop this method if unneeded
  294. # def get_child_folders(self, parent: Content=None, workspace: Workspace=None, filter_by_allowed_content_types: list=[], removed_item_ids: list=[], allowed_node_types=None) -> typing.List[Content]:
  295. # """
  296. # This method returns child items (folders or items) for left bar treeview.
  297. #
  298. # :param parent:
  299. # :param workspace:
  300. # :param filter_by_allowed_content_types:
  301. # :param removed_item_ids:
  302. # :param allowed_node_types: This parameter allow to hide folders for which the given type of content is not allowed.
  303. # For example, if you want to move a Page from a folder to another, you should show only folders that accept pages
  304. # :return:
  305. # """
  306. # filter_by_allowed_content_types = filter_by_allowed_content_types or [] # FDV
  307. # removed_item_ids = removed_item_ids or [] # FDV
  308. #
  309. # if not allowed_node_types:
  310. # allowed_node_types = [ContentType.Folder]
  311. # elif allowed_node_types==ContentType.Any:
  312. # allowed_node_types = ContentType.all()
  313. #
  314. # parent_id = parent.content_id if parent else None
  315. # folders = self._base_query(workspace).\
  316. # filter(Content.parent_id==parent_id).\
  317. # filter(Content.type.in_(allowed_node_types)).\
  318. # filter(Content.content_id.notin_(removed_item_ids)).\
  319. # all()
  320. #
  321. # if not filter_by_allowed_content_types or \
  322. # len(filter_by_allowed_content_types)<=0:
  323. # # Standard case for the left treeview: we want to show all contents
  324. # # in the left treeview... so we still filter because for example
  325. # # comments must not appear in the treeview
  326. # return [folder for folder in folders \
  327. # if folder.type in ContentType.allowed_types_for_folding()]
  328. #
  329. # # Now this is a case of Folders only (used for moving content)
  330. # # When moving a content, you must get only folders that allow to be filled
  331. # # with the type of content you want to move
  332. # result = []
  333. # for folder in folders:
  334. # for allowed_content_type in filter_by_allowed_content_types:
  335. #
  336. # is_folder = folder.type == ContentType.Folder
  337. # content_type__allowed = folder.properties['allowed_content'][allowed_content_type] == True
  338. #
  339. # if is_folder and content_type__allowed:
  340. # result.append(folder)
  341. # break
  342. #
  343. # return result
  344. def create(self, content_type: str, workspace: Workspace, parent: Content=None, label: str ='', filename: str = '', do_save=False, is_temporary: bool=False, do_notify=True) -> Content:
  345. # TODO - G.M - 2018-07-16 - raise Exception instead of assert
  346. assert content_type in ContentType.allowed_types()
  347. assert not (label and filename)
  348. if content_type == ContentType.Folder and not label:
  349. label = self.generate_folder_label(workspace, parent)
  350. content = Content()
  351. if filename:
  352. # INFO - G.M - 2018-07-04 - File_name setting automatically
  353. # set label and file_extension
  354. content.file_name = label
  355. elif label:
  356. content.label = label
  357. else:
  358. if content_type == ContentType.Comment:
  359. # INFO - G.M - 2018-07-16 - Default label for comments is
  360. # empty string.
  361. content.label = ''
  362. else:
  363. raise EmptyLabelNotAllowed('Content of this type should have a valid label') # nopep8
  364. content.owner = self._user
  365. content.parent = parent
  366. content.workspace = workspace
  367. content.type = content_type
  368. content.is_temporary = is_temporary
  369. content.revision_type = ActionDescription.CREATION
  370. if content.type in (
  371. ContentType.Page,
  372. ContentType.Thread,
  373. ):
  374. content.file_extension = '.html'
  375. if do_save:
  376. self._session.add(content)
  377. self.save(content, ActionDescription.CREATION, do_notify=do_notify)
  378. return content
  379. def create_comment(self, workspace: Workspace=None, parent: Content=None, content:str ='', do_save=False) -> Content:
  380. assert parent and parent.type != ContentType.Folder
  381. if not content:
  382. raise EmptyCommentContentNotAllowed()
  383. item = Content()
  384. item.owner = self._user
  385. item.parent = parent
  386. if not workspace:
  387. workspace = item.parent.workspace
  388. item.workspace = workspace
  389. item.type = ContentType.Comment
  390. item.description = content
  391. item.label = ''
  392. item.revision_type = ActionDescription.COMMENT
  393. if do_save:
  394. self.save(item, ActionDescription.COMMENT)
  395. return item
  396. def get_one_from_revision(self, content_id: int, content_type: str, workspace: Workspace=None, revision_id=None) -> Content:
  397. """
  398. This method is a hack to convert a node revision item into a node
  399. :param content_id:
  400. :param content_type:
  401. :param workspace:
  402. :param revision_id:
  403. :return:
  404. """
  405. content = self.get_one(content_id, content_type, workspace)
  406. revision = self._session.query(ContentRevisionRO).filter(ContentRevisionRO.revision_id==revision_id).one()
  407. if revision.content_id==content.content_id:
  408. content.revision_to_serialize = revision.revision_id
  409. else:
  410. raise ValueError('Revision not found for given content')
  411. return content
  412. def get_one(self, content_id: int, content_type: str, workspace: Workspace=None, parent: Content=None) -> Content:
  413. if not content_id:
  414. return None
  415. base_request = self._base_query(workspace).filter(Content.content_id==content_id)
  416. if content_type!=ContentType.Any:
  417. base_request = base_request.filter(Content.type==content_type)
  418. if parent:
  419. base_request = base_request.filter(Content.parent_id==parent.content_id) # nopep8
  420. try:
  421. content = base_request.one()
  422. except NoResultFound as exc:
  423. # TODO - G.M - 2018-07-16 - Add better support for all different
  424. # error case who can happened here
  425. # like content doesn't exist, wrong parent, wrong content_type, wrong workspace,
  426. # wrong access to this workspace, wrong base filter according
  427. # to content_status.
  428. raise ContentNotFound('Content "{}" not found in database'.format(content_id)) from exc # nopep8
  429. return content
  430. def get_one_revision(self, revision_id: int = None) -> ContentRevisionRO:
  431. """
  432. This method allow us to get directly any revision with its id
  433. :param revision_id: The content's revision's id that we want to return
  434. :return: An item Content linked with the correct revision
  435. """
  436. assert revision_id is not None# DYN_REMOVE
  437. revision = self._session.query(ContentRevisionRO).filter(ContentRevisionRO.revision_id == revision_id).one()
  438. return revision
  439. # INFO - A.P - 2017-07-03 - python file object getter
  440. # in case of we cook a version of preview manager that allows a pythonic
  441. # access to files
  442. # def get_one_revision_file(self, revision_id: int = None):
  443. # """
  444. # This function allows us to directly get a Python file object from its
  445. # revision identifier.
  446. # :param revision_id: The revision id of the file we want to return
  447. # :return: The corresponding Python file object
  448. # """
  449. # revision = self.get_one_revision(revision_id)
  450. # return DepotManager.get().get(revision.depot_file)
  451. def get_one_revision_filepath(self, revision_id: int = None) -> str:
  452. """
  453. This method allows us to directly get a file path from its revision
  454. identifier.
  455. :param revision_id: The revision id of the filepath we want to return
  456. :return: The corresponding filepath
  457. """
  458. revision = self.get_one_revision(revision_id)
  459. depot = DepotManager.get()
  460. depot_stored_file = depot.get(revision.depot_file) # type: StoredFile
  461. depot_file_path = depot_stored_file._file_path # type: str
  462. return depot_file_path
  463. def get_one_by_label_and_parent(
  464. self,
  465. content_label: str,
  466. content_parent: Content=None,
  467. ) -> Content:
  468. """
  469. This method let us request the database to obtain a Content with its name and parent
  470. :param content_label: Either the content's label or the content's filename if the label is None
  471. :param content_parent: The parent's content
  472. :param workspace: The workspace's content
  473. :return The corresponding Content
  474. """
  475. workspace = content_parent.workspace if content_parent else None
  476. query = self._base_query(workspace)
  477. parent_id = content_parent.content_id if content_parent else None
  478. query = query.filter(Content.parent_id == parent_id)
  479. file_name, file_extension = os.path.splitext(content_label)
  480. return query.filter(
  481. or_(
  482. and_(
  483. Content.type == ContentType.File,
  484. Content.label == file_name,
  485. Content.file_extension == file_extension,
  486. ),
  487. and_(
  488. Content.type == ContentType.Thread,
  489. Content.label == file_name,
  490. ),
  491. and_(
  492. Content.type == ContentType.Page,
  493. Content.label == file_name,
  494. ),
  495. and_(
  496. Content.type == ContentType.Folder,
  497. Content.label == content_label,
  498. ),
  499. )
  500. ).one()
  501. def get_one_by_label_and_parent_labels(
  502. self,
  503. content_label: str,
  504. workspace: Workspace,
  505. content_parent_labels: [str]=None,
  506. ):
  507. """
  508. Return content with it's label, workspace and parents labels (optional)
  509. :param content_label: label of content (label or file_name)
  510. :param workspace: workspace containing all of this
  511. :param content_parent_labels: Ordered list of labels representing path
  512. of folder (without workspace label).
  513. E.g.: ['foo', 'bar'] for complete path /Workspace1/foo/bar folder
  514. :return: Found Content
  515. """
  516. query = self._base_query(workspace)
  517. parent_folder = None
  518. # Grab content parent folder if parent path given
  519. if content_parent_labels:
  520. parent_folder = self.get_folder_with_workspace_path_labels(
  521. content_parent_labels,
  522. workspace,
  523. )
  524. # Build query for found content by label
  525. content_query = self.filter_query_for_content_label_as_path(
  526. query=query,
  527. content_label_as_file=content_label,
  528. )
  529. # Modify query to apply parent folder filter if any
  530. if parent_folder:
  531. content_query = content_query.filter(
  532. Content.parent_id == parent_folder.content_id,
  533. )
  534. else:
  535. content_query = content_query.filter(
  536. Content.parent_id == None,
  537. )
  538. # Filter with workspace
  539. content_query = content_query.filter(
  540. Content.workspace_id == workspace.workspace_id,
  541. )
  542. # Return the content
  543. return content_query\
  544. .order_by(
  545. Content.revision_id.desc(),
  546. )\
  547. .one()
  548. def get_folder_with_workspace_path_labels(
  549. self,
  550. path_labels: [str],
  551. workspace: Workspace,
  552. ) -> Content:
  553. """
  554. Return a Content folder for given relative path.
  555. TODO BS 20161124: Not safe if web interface allow folder duplicate names
  556. :param path_labels: List of labels representing path of folder
  557. (without workspace label).
  558. E.g.: ['foo', 'bar'] for complete path /Workspace1/foo/bar folder
  559. :param workspace: workspace of folders
  560. :return: Content folder
  561. """
  562. query = self._base_query(workspace)
  563. folder = None
  564. for label in path_labels:
  565. # Filter query on label
  566. folder_query = query \
  567. .filter(
  568. Content.type == ContentType.Folder,
  569. Content.label == label,
  570. Content.workspace_id == workspace.workspace_id,
  571. )
  572. # Search into parent folder (if already deep)
  573. if folder:
  574. folder_query = folder_query\
  575. .filter(
  576. Content.parent_id == folder.content_id,
  577. )
  578. else:
  579. folder_query = folder_query \
  580. .filter(Content.parent_id == None)
  581. # Get thirst corresponding folder
  582. folder = folder_query \
  583. .order_by(Content.revision_id.desc()) \
  584. .one()
  585. return folder
  586. def filter_query_for_content_label_as_path(
  587. self,
  588. query: Query,
  589. content_label_as_file: str,
  590. is_case_sensitive: bool = False,
  591. ) -> Query:
  592. """
  593. Apply normalised filters to found Content corresponding as given label.
  594. :param query: query to modify
  595. :param content_label_as_file: label in this
  596. FILE version, use Content.get_label_as_file().
  597. :param is_case_sensitive: Take care about case or not
  598. :return: modified query
  599. """
  600. file_name, file_extension = os.path.splitext(content_label_as_file)
  601. label_filter = Content.label == content_label_as_file
  602. file_name_filter = Content.label == file_name
  603. file_extension_filter = Content.file_extension == file_extension
  604. if not is_case_sensitive:
  605. label_filter = func.lower(Content.label) == \
  606. func.lower(content_label_as_file)
  607. file_name_filter = func.lower(Content.label) == \
  608. func.lower(file_name)
  609. file_extension_filter = func.lower(Content.file_extension) == \
  610. func.lower(file_extension)
  611. return query.filter(or_(
  612. and_(
  613. Content.type == ContentType.File,
  614. file_name_filter,
  615. file_extension_filter,
  616. ),
  617. and_(
  618. Content.type == ContentType.Thread,
  619. file_name_filter,
  620. file_extension_filter,
  621. ),
  622. and_(
  623. Content.type == ContentType.Page,
  624. file_name_filter,
  625. file_extension_filter,
  626. ),
  627. and_(
  628. Content.type == ContentType.Folder,
  629. label_filter,
  630. ),
  631. ))
  632. def _get_all_query(
  633. self,
  634. parent_id: int = None,
  635. content_type: str = ContentType.Any,
  636. workspace: Workspace = None
  637. ) -> Query:
  638. """
  639. Extended filter for better "get all data" query
  640. :param parent_id: filter by parent_id
  641. :param content_type: filter by content_type slug
  642. :param workspace: filter by workspace
  643. :return:
  644. """
  645. assert parent_id is None or isinstance(parent_id, int)
  646. assert content_type is not None
  647. resultset = self._base_query(workspace)
  648. if content_type!=ContentType.Any:
  649. # INFO - G.M - 2018-07-05 - convert with
  650. # content type object to support legacy slug
  651. content_type_object = ContentType(content_type)
  652. resultset = resultset.filter(Content.type.in_(content_type_object.alias()))
  653. if parent_id:
  654. resultset = resultset.filter(Content.parent_id==parent_id)
  655. if parent_id == 0 or parent_id is False:
  656. resultset = resultset.filter(Content.parent_id == None)
  657. return resultset
  658. def get_all(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> typing.List[Content]:
  659. return self._get_all_query(parent_id, content_type, workspace).all()
  660. # TODO - G.M - 2018-07-17 - [Cleanup] Drop this method if unneeded
  661. # def get_children(self, parent_id: int, content_types: list, workspace: Workspace=None) -> typing.List[Content]:
  662. # """
  663. # Return parent_id childs of given content_types
  664. # :param parent_id: parent id
  665. # :param content_types: list of types
  666. # :param workspace: workspace filter
  667. # :return: list of content
  668. # """
  669. # resultset = self._base_query(workspace)
  670. # resultset = resultset.filter(Content.type.in_(content_types))
  671. #
  672. # if parent_id:
  673. # resultset = resultset.filter(Content.parent_id==parent_id)
  674. # if parent_id is False:
  675. # resultset = resultset.filter(Content.parent_id == None)
  676. #
  677. # return resultset.all()
  678. # TODO - G.M - 2018-07-17 - [Cleanup] Drop this method if unneeded
  679. # TODO find an other name to filter on is_deleted / is_archived
  680. def get_all_with_filter(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> typing.List[Content]:
  681. assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
  682. assert content_type is not None# DYN_REMOVE
  683. assert isinstance(content_type, str) # DYN_REMOVE
  684. resultset = self._base_query(workspace)
  685. if content_type != ContentType.Any:
  686. resultset = resultset.filter(Content.type==content_type)
  687. resultset = resultset.filter(Content.is_deleted == self._show_deleted)
  688. resultset = resultset.filter(Content.is_archived == self._show_archived)
  689. resultset = resultset.filter(Content.is_temporary == self._show_temporary)
  690. resultset = resultset.filter(Content.parent_id==parent_id)
  691. return resultset.all()
  692. # TODO - G.M - 2018-07-17 - [Cleanup] Drop this method if unneeded
  693. # def get_all_without_exception(self, content_type: str, workspace: Workspace=None) -> typing.List[Content]:
  694. # assert content_type is not None# DYN_REMOVE
  695. #
  696. # resultset = self._base_query(workspace)
  697. #
  698. # if content_type != ContentType.Any:
  699. # resultset = resultset.filter(Content.type==content_type)
  700. #
  701. # return resultset.all()
  702. def get_last_active(
  703. self,
  704. workspace: Workspace=None,
  705. limit: typing.Optional[int]=None,
  706. before_datetime: typing.Optional[datetime.datetime]= None,
  707. content_ids: typing.Optional[typing.List[int]] = None,
  708. ) -> typing.List[Content]:
  709. """
  710. get contents list sorted by last update
  711. (last modification of content itself or one of this comment)
  712. :param workspace: Workspace to check
  713. :param limit: maximum number of elements to return
  714. :param before_datetime: date from where we check older content.
  715. :param content_ids: restrict selection to some content ids and
  716. related Comments
  717. :return: list of content
  718. """
  719. resultset = self._get_all_query(
  720. workspace=workspace,
  721. )
  722. if content_ids:
  723. resultset.filter(
  724. or_(
  725. Content.content_id.in_(content_ids),
  726. and_(
  727. Content.parent_id.in_(content_ids),
  728. Content.type == ContentType.Comment
  729. )
  730. )
  731. )
  732. resultset = resultset.order_by(desc(Content.updated))
  733. active_contents = []
  734. too_recent_content = []
  735. for content in resultset:
  736. related_active_content = None
  737. if ContentType.Comment == content.type:
  738. related_active_content = content.parent
  739. else:
  740. related_active_content = content
  741. if not before_datetime:
  742. before_datetime = datetime.datetime.now()
  743. # INFO - D.A. - 2015-05-20
  744. # We do not want to show only one item if the last 10 items are
  745. # comments about one thread for example
  746. if related_active_content not in active_contents and related_active_content not in too_recent_content: # nopep8
  747. # we verify that content is old enough
  748. if content.updated < before_datetime:
  749. active_contents.append(related_active_content)
  750. else:
  751. too_recent_content.append(related_active_content)
  752. if limit and len(active_contents) >= limit:
  753. break
  754. return active_contents
  755. # TODO - G.M - 2018-07-19 - Find a way to update this method to something
  756. # usable and efficient for tracim v2 to get content with read/unread status
  757. # instead of relying on has_new_information_for()
  758. # def get_last_unread(self, parent_id: typing.Optional[int], content_type: str,
  759. # workspace: Workspace=None, limit=10) -> typing.List[Content]:
  760. # assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE
  761. # assert content_type is not None# DYN_REMOVE
  762. # assert isinstance(content_type, str) # DYN_REMOVE
  763. #
  764. # read_revision_ids = self._session.query(RevisionReadStatus.revision_id) \
  765. # .filter(RevisionReadStatus.user_id==self._user_id)
  766. #
  767. # not_read_revisions = self._revisions_base_query(workspace) \
  768. # .filter(~ContentRevisionRO.revision_id.in_(read_revision_ids)) \
  769. # .filter(ContentRevisionRO.workspace_id == Workspace.workspace_id) \
  770. # .filter(Workspace.is_deleted.is_(False)) \
  771. # .subquery()
  772. #
  773. # not_read_content_ids_query = self._session.query(
  774. # distinct(not_read_revisions.c.content_id)
  775. # )
  776. # not_read_content_ids = list(map(
  777. # itemgetter(0),
  778. # not_read_content_ids_query,
  779. # ))
  780. #
  781. # not_read_contents = self._base_query(workspace) \
  782. # .filter(Content.content_id.in_(not_read_content_ids)) \
  783. # .order_by(desc(Content.updated))
  784. #
  785. # if content_type != ContentType.Any:
  786. # not_read_contents = not_read_contents.filter(
  787. # Content.type==content_type)
  788. # else:
  789. # not_read_contents = not_read_contents.filter(
  790. # Content.type!=ContentType.Folder)
  791. #
  792. # if parent_id:
  793. # not_read_contents = not_read_contents.filter(
  794. # Content.parent_id==parent_id)
  795. #
  796. # result = []
  797. # for item in not_read_contents:
  798. # new_item = None
  799. # if ContentType.Comment == item.type:
  800. # new_item = item.parent
  801. # else:
  802. # new_item = item
  803. #
  804. # # INFO - D.A. - 2015-05-20
  805. # # We do not want to show only one item if the last 10 items are
  806. # # comments about one thread for example
  807. # if new_item not in result:
  808. # result.append(new_item)
  809. #
  810. # if len(result) >= limit:
  811. # break
  812. #
  813. # return result
  814. def set_allowed_content(self, folder: Content, allowed_content_dict:dict):
  815. """
  816. :param folder: the given folder instance
  817. :param allowed_content_dict: must be something like this:
  818. dict(
  819. folder = True
  820. thread = True,
  821. file = False,
  822. page = True
  823. )
  824. :return:
  825. """
  826. properties = dict(allowed_content = allowed_content_dict)
  827. folder.properties = properties
  828. def set_status(self, content: Content, new_status: str):
  829. if new_status in ContentStatus.allowed_values():
  830. content.status = new_status
  831. content.revision_type = ActionDescription.STATUS_UPDATE
  832. else:
  833. raise ValueError('The given value {} is not allowed'.format(new_status))
  834. def move(self,
  835. item: Content,
  836. new_parent: Content,
  837. must_stay_in_same_workspace: bool=True,
  838. new_workspace: Workspace=None,
  839. ):
  840. if must_stay_in_same_workspace:
  841. if new_parent and new_parent.workspace_id != item.workspace_id:
  842. raise ValueError('the item should stay in the same workspace')
  843. item.parent = new_parent
  844. if new_workspace:
  845. item.workspace = new_workspace
  846. if new_parent and \
  847. new_parent.workspace_id != new_workspace.workspace_id:
  848. raise WorkspacesDoNotMatch(
  849. 'new parent workspace and new workspace should be the same.'
  850. )
  851. else:
  852. if new_parent:
  853. item.workspace = new_parent.workspace
  854. item.revision_type = ActionDescription.MOVE
  855. def copy(
  856. self,
  857. item: Content,
  858. new_parent: Content=None,
  859. new_label: str=None,
  860. do_save: bool=True,
  861. do_notify: bool=True,
  862. ) -> Content:
  863. """
  864. Copy nearly all content, revision included. Children not included, see
  865. "copy_children" for this.
  866. :param item: Item to copy
  867. :param new_parent: new parent of the new copied item
  868. :param new_label: new label of the new copied item
  869. :param do_notify: notify copy or not
  870. :return: Newly copied item
  871. """
  872. if (not new_parent and not new_label) or (new_parent == item.parent and new_label == item.label): # nopep8
  873. # TODO - G.M - 08-03-2018 - Use something else than value error
  874. raise ValueError("You can't copy file into itself")
  875. if new_parent:
  876. workspace = new_parent.workspace
  877. parent = new_parent
  878. else:
  879. workspace = item.workspace
  880. parent = item.parent
  881. label = new_label or item.label
  882. content = item.copy(parent)
  883. # INFO - GM - 15-03-2018 - add "copy" revision
  884. with new_revision(
  885. session=self._session,
  886. tm=transaction.manager,
  887. content=content,
  888. force_create_new_revision=True
  889. ) as rev:
  890. rev.parent = parent
  891. rev.workspace = workspace
  892. rev.label = label
  893. rev.revision_type = ActionDescription.COPY
  894. rev.properties['origin'] = {
  895. 'content': item.id,
  896. 'revision': item.last_revision.revision_id,
  897. }
  898. if do_save:
  899. self.save(content, ActionDescription.COPY, do_notify=do_notify)
  900. return content
  901. def copy_children(self, origin_content: Content, new_content: Content):
  902. for child in origin_content.children:
  903. self.copy(child, new_content)
  904. def move_recursively(self, item: Content,
  905. new_parent: Content, new_workspace: Workspace):
  906. self.move(item, new_parent, False, new_workspace)
  907. self.save(item, do_notify=False)
  908. for child in item.children:
  909. with new_revision(child):
  910. self.move_recursively(child, item, new_workspace)
  911. return
  912. def update_content(self, item: Content, new_label: str, new_content: str=None) -> Content:
  913. if item.label==new_label and item.description==new_content:
  914. # TODO - G.M - 20-03-2018 - Fix internatization for webdav access.
  915. # Internatization disabled in libcontent for now.
  916. raise SameValueError('The content did not changed')
  917. if not new_label:
  918. raise EmptyLabelNotAllowed()
  919. item.owner = self._user
  920. item.label = new_label
  921. item.description = new_content if new_content else item.description # TODO: convert urls into links
  922. item.revision_type = ActionDescription.EDITION
  923. return item
  924. def update_file_data(self, item: Content, new_filename: str, new_mimetype: str, new_content: bytes) -> Content:
  925. if new_mimetype == item.file_mimetype and \
  926. new_content == item.depot_file.file.read():
  927. raise SameValueError('The content did not changed')
  928. item.owner = self._user
  929. item.file_name = new_filename
  930. item.file_mimetype = new_mimetype
  931. item.depot_file = FileIntent(
  932. new_content,
  933. new_filename,
  934. new_mimetype,
  935. )
  936. item.revision_type = ActionDescription.REVISION
  937. return item
  938. def archive(self, content: Content):
  939. content.owner = self._user
  940. content.is_archived = True
  941. # TODO - G.M - 12-03-2018 - Inspect possible label conflict problem
  942. # INFO - G.M - 12-03-2018 - Set label name to avoid trouble when
  943. # un-archiving file.
  944. content.label = '{label}-{action}-{date}'.format(
  945. label=content.label,
  946. action='archived',
  947. date=current_date_for_filename()
  948. )
  949. content.revision_type = ActionDescription.ARCHIVING
  950. def unarchive(self, content: Content):
  951. content.owner = self._user
  952. content.is_archived = False
  953. content.revision_type = ActionDescription.UNARCHIVING
  954. def delete(self, content: Content):
  955. content.owner = self._user
  956. content.is_deleted = True
  957. # TODO - G.M - 12-03-2018 - Inspect possible label conflict problem
  958. # INFO - G.M - 12-03-2018 - Set label name to avoid trouble when
  959. # un-deleting file.
  960. content.label = '{label}-{action}-{date}'.format(
  961. label=content.label,
  962. action='deleted',
  963. date=current_date_for_filename()
  964. )
  965. content.revision_type = ActionDescription.DELETION
  966. def undelete(self, content: Content):
  967. content.owner = self._user
  968. content.is_deleted = False
  969. content.revision_type = ActionDescription.UNDELETION
  970. def mark_read__all(self,
  971. read_datetime: datetime=None,
  972. do_flush: bool=True,
  973. recursive: bool=True
  974. ):
  975. return self.mark_read__workspace(None, read_datetime, do_flush, recursive) # nopep8
  976. def mark_read__workspace(self,
  977. workspace : Workspace,
  978. read_datetime: datetime=None,
  979. do_flush: bool=True,
  980. recursive: bool=True
  981. ):
  982. itemset = self.get_last_active(workspace)
  983. for item in itemset:
  984. if item.type != ContentType.Folder and item.has_new_information_for(self._user):
  985. self.mark_read(item, read_datetime, do_flush, recursive)
  986. def mark_read(self, content: Content,
  987. read_datetime: datetime=None,
  988. do_flush: bool=True, recursive: bool=True) -> Content:
  989. assert self._user
  990. assert content
  991. # The algorithm is:
  992. # 1. define the read datetime
  993. # 2. update all revisions related to current Content
  994. # 3. do the same for all child revisions
  995. # (ie parent_id is content_id of current content)
  996. if not read_datetime:
  997. read_datetime = datetime.datetime.now()
  998. viewed_revisions = self._session.query(ContentRevisionRO) \
  999. .filter(ContentRevisionRO.content_id==content.content_id).all()
  1000. for revision in viewed_revisions:
  1001. revision.read_by[self._user] = read_datetime
  1002. if recursive:
  1003. # mark read :
  1004. # - all children
  1005. # - parent stuff (if you mark a comment as read,
  1006. # then you have seen the parent)
  1007. # - parent comments
  1008. for child in content.get_valid_children():
  1009. self.mark_read(child, read_datetime=read_datetime,
  1010. do_flush=False)
  1011. if ContentType.Comment == content.type:
  1012. self.mark_read(content.parent, read_datetime=read_datetime,
  1013. do_flush=False, recursive=False)
  1014. for comment in content.parent.get_comments():
  1015. if comment != content:
  1016. self.mark_read(comment, read_datetime=read_datetime,
  1017. do_flush=False, recursive=False)
  1018. if do_flush:
  1019. self.flush()
  1020. return content
  1021. def mark_unread(self, content: Content, do_flush=True) -> Content:
  1022. assert self._user
  1023. assert content
  1024. revisions = self._session.query(ContentRevisionRO) \
  1025. .filter(ContentRevisionRO.content_id==content.content_id).all()
  1026. for revision in revisions:
  1027. try:
  1028. del revision.read_by[self._user]
  1029. except KeyError:
  1030. pass
  1031. for child in content.get_valid_children():
  1032. self.mark_unread(child, do_flush=False)
  1033. if do_flush:
  1034. self.flush()
  1035. return content
  1036. def flush(self):
  1037. self._session.flush()
  1038. def save(self, content: Content, action_description: str=None, do_flush=True, do_notify=True):
  1039. """
  1040. Save an object, flush the session and set the revision_type property
  1041. :param content:
  1042. :param action_description:
  1043. :return:
  1044. """
  1045. assert action_description is None or action_description in ActionDescription.allowed_values()
  1046. if not action_description:
  1047. # See if the last action has been modified
  1048. if content.revision_type==None or len(get_history(content.revision, 'revision_type'))<=0:
  1049. # The action has not been modified, so we set it to default edition
  1050. action_description = ActionDescription.EDITION
  1051. if action_description:
  1052. content.revision_type = action_description
  1053. if do_flush:
  1054. # INFO - 2015-09-03 - D.A.
  1055. # There are 2 flush because of the use
  1056. # of triggers for content creation
  1057. #
  1058. # (when creating a content, actually this is an insert of a new
  1059. # revision in content_revisions ; so the mark_read operation need
  1060. # to get full real data from database before to be prepared.
  1061. self._session.add(content)
  1062. self._session.flush()
  1063. # TODO - 2015-09-03 - D.A. - Do not use triggers
  1064. # We should create a new ContentRevisionRO object instead of Content
  1065. # This would help managing view/not viewed status
  1066. self.mark_read(content, do_flush=True)
  1067. if do_notify:
  1068. self.do_notify(content)
  1069. def do_notify(self, content: Content):
  1070. """
  1071. Allow to force notification for a given content. By default, it is
  1072. called during the .save() operation
  1073. :param content:
  1074. :return:
  1075. """
  1076. NotifierFactory.create(
  1077. config=self._config,
  1078. current_user=self._user,
  1079. session=self._session,
  1080. ).notify_content_update(content)
  1081. def get_keywords(self, search_string, search_string_separators=None) -> [str]:
  1082. """
  1083. :param search_string: a list of coma-separated keywords
  1084. :return: a list of str (each keyword = 1 entry
  1085. """
  1086. search_string_separators = search_string_separators or ContentApi.SEARCH_SEPARATORS
  1087. keywords = []
  1088. if search_string:
  1089. keywords = [keyword.strip() for keyword in re.split(search_string_separators, search_string)]
  1090. return keywords
  1091. def search(self, keywords: [str]) -> Query:
  1092. """
  1093. :return: a sorted list of Content items
  1094. """
  1095. if len(keywords)<=0:
  1096. return None
  1097. filter_group_label = list(Content.label.ilike('%{}%'.format(keyword)) for keyword in keywords)
  1098. filter_group_desc = list(Content.description.ilike('%{}%'.format(keyword)) for keyword in keywords)
  1099. title_keyworded_items = self._hard_filtered_base_query().\
  1100. filter(or_(*(filter_group_label+filter_group_desc))).\
  1101. options(joinedload('children_revisions')).\
  1102. options(joinedload('parent'))
  1103. return title_keyworded_items
  1104. def get_all_types(self) -> typing.List[ContentType]:
  1105. labels = ContentType.all()
  1106. content_types = []
  1107. for label in labels:
  1108. content_types.append(ContentType(label))
  1109. return ContentType.sorted(content_types)
  1110. def exclude_unavailable(
  1111. self,
  1112. contents: typing.List[Content],
  1113. ) -> typing.List[Content]:
  1114. """
  1115. Update and return list with content under archived/deleted removed.
  1116. :param contents: List of contents to parse
  1117. """
  1118. for content in contents[:]:
  1119. if self.content_under_deleted(content) or self.content_under_archived(content):
  1120. contents.remove(content)
  1121. return contents
  1122. def content_under_deleted(self, content: Content) -> bool:
  1123. if content.parent:
  1124. if content.parent.is_deleted:
  1125. return True
  1126. if content.parent.parent:
  1127. return self.content_under_deleted(content.parent)
  1128. return False
  1129. def content_under_archived(self, content: Content) -> bool:
  1130. if content.parent:
  1131. if content.parent.is_archived:
  1132. return True
  1133. if content.parent.parent:
  1134. return self.content_under_archived(content.parent)
  1135. return False
  1136. def find_one_by_unique_property(
  1137. self,
  1138. property_name: str,
  1139. property_value: str,
  1140. workspace: Workspace=None,
  1141. ) -> Content:
  1142. """
  1143. Return Content who contains given property.
  1144. Raise sqlalchemy.orm.exc.MultipleResultsFound if more than one Content
  1145. contains this property value.
  1146. :param property_name: Name of property
  1147. :param property_value: Value of property
  1148. :param workspace: Workspace who contains Content
  1149. :return: Found Content
  1150. """
  1151. # TODO - 20160602 - Bastien: Should be JSON type query
  1152. # see https://www.compose.io/articles/using-json-extensions-in-\
  1153. # postgresql-from-python-2/
  1154. query = self._base_query(workspace=workspace).filter(
  1155. Content._properties.like(
  1156. '%"{property_name}": "{property_value}"%'.format(
  1157. property_name=property_name,
  1158. property_value=property_value,
  1159. )
  1160. )
  1161. )
  1162. return query.one()
  1163. def generate_folder_label(
  1164. self,
  1165. workspace: Workspace,
  1166. parent: Content=None,
  1167. ) -> str:
  1168. """
  1169. Generate a folder label
  1170. :param workspace: Future folder workspace
  1171. :param parent: Parent of foture folder (can be None)
  1172. :return: Generated folder name
  1173. """
  1174. query = self._base_query(workspace=workspace)\
  1175. .filter(Content.label.ilike('{0}%'.format(
  1176. _('New folder'),
  1177. )))
  1178. if parent:
  1179. query = query.filter(Content.parent == parent)
  1180. return _('New folder {0}').format(
  1181. query.count() + 1,
  1182. )