content.py 43KB

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