content.py 52KB

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