content.py 46KB

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