content.py 45KB

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