content.py 48KB

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