content.py 44KB

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