content.py 60KB

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