diff --git a/.travis.yml b/.travis.yml index 602e547..9f0c1c3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,13 @@ python: - "3.5" - "3.6" +addons: + apt: + packages: + - libreoffice + - imagemagick + - libmagickwand-dev + - ghostscript services: - docker - redis-server diff --git a/README.md b/README.md index b223a58..1df42b1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ on Debian Stretch (9) with sudo: sudo apt install git sudo apt install python3 python3-venv python3-dev python3-pip sudo apt install redis-server + sudo apt install zlib1g-dev libjpeg-dev + +for better preview support: + + sudo apt install libreoffice # most office documents file and text format + sudo apt install inkscape # for .svg files. ### Get the source ### diff --git a/development.ini.sample b/development.ini.sample index 539924f..1ac5ebf 100644 --- a/development.ini.sample +++ b/development.ini.sample @@ -175,6 +175,17 @@ wsgidav.config_path = %(here)s/wsgidav.conf ## Do not set http:// prefix. # wsgidav.client.base_url = 127.0.0.1: +### Preview +## You can parametrized allowed jpg preview dimension list, if not set, default +## is 256x256. First {width}x{length} items is default preview dimensions. +## all items should be separated by ',' and you should be really careful to do +## set anything else than '{int}x{int}' item and ', ' separator +# preview.jpg.allowed_dims = 256x256,1000x1000 +## Preview dimensions can be set as restricted, if set as restricted, access +## endpoint to to get any other preview dimensions than allowed_dims will +## return error +# preview.jpg.restricted_dims = True + ### # wsgi server configuration ### diff --git a/setup.py b/setup.py index 4430756..6656ee7 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ 'filedepot', 'babel', 'python-slugify', + 'preview-generator', # mail-notifier 'mako', 'lxml', @@ -48,7 +49,8 @@ 'pytest-cov', 'pep8', 'mypy', - 'requests' + 'requests', + 'Pillow' ] mysql_require = [ diff --git a/tracim/__init__.py b/tracim/__init__.py index cba1358..ec782bb 100644 --- a/tracim/__init__.py +++ b/tracim/__init__.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- + + try: # Python 3.5+ from http import HTTPStatus except ImportError: @@ -27,6 +29,7 @@ from tracim.views.core_api.user_controller import UserController from tracim.views.core_api.workspace_controller import WorkspaceController from tracim.views.contents_api.comment_controller import CommentController +from tracim.views.contents_api.file_controller import FileController from tracim.views.errors import ErrorSchema from tracim.exceptions import NotAuthenticated from tracim.exceptions import InvalidId @@ -109,6 +112,7 @@ def web(global_config, **local_settings): comment_controller = CommentController() html_document_controller = HTMLDocumentController() thread_controller = ThreadController() + file_controller = FileController() configurator.include(session_controller.bind, route_prefix=BASE_API_V2) configurator.include(system_controller.bind, route_prefix=BASE_API_V2) configurator.include(user_controller.bind, route_prefix=BASE_API_V2) @@ -116,6 +120,7 @@ def web(global_config, **local_settings): configurator.include(comment_controller.bind, route_prefix=BASE_API_V2) configurator.include(html_document_controller.bind, route_prefix=BASE_API_V2) # nopep8 configurator.include(thread_controller.bind, route_prefix=BASE_API_V2) + configurator.include(file_controller.bind, route_prefix=BASE_API_V2) hapic.add_documentation_view( '/api/v2/doc', diff --git a/tracim/config.py b/tracim/config.py index 7c88bb1..9070f81 100644 --- a/tracim/config.py +++ b/tracim/config.py @@ -411,6 +411,26 @@ def __init__(self, settings): # self.RADICALE_CLIENT_BASE_URL_HOST, # self.RADICALE_CLIENT_BASE_URL_PREFIX, # ) + self.PREVIEW_JPG_RESTRICTED_DIMS = asbool(settings.get( + 'preview.jpg.restricted_dims', False + )) + preview_jpg_allowed_dims_str = settings.get('preview.jpg.allowed_dims', '') # nopep8 + allowed_dims = [] + if preview_jpg_allowed_dims_str: + for sizes in preview_jpg_allowed_dims_str.split(','): + parts = sizes.split('x') + assert len(parts) == 2 + width, height = parts + assert width.isdecimal() + assert height.isdecimal() + size = PreviewDim(int(width), int(height)) + allowed_dims.append(size) + + if not allowed_dims: + size = PreviewDim(256, 256) + allowed_dims.append(size) + + self.PREVIEW_JPG_ALLOWED_DIMS = allowed_dims def configure_filedepot(self): depot_storage_name = self.DEPOT_STORAGE_NAME @@ -427,3 +447,16 @@ class CST(object): TREEVIEW_FOLDERS = 'folders' TREEVIEW_ALL = 'all' + + +class PreviewDim(object): + + def __init__(self, width: int, height: int) -> None: + self.width = width + self.height = height + + def __repr__(self): + return "".format( + width=self.width, + height=self.height, + ) diff --git a/tracim/exceptions.py b/tracim/exceptions.py index 61b9f49..b33ec0f 100644 --- a/tracim/exceptions.py +++ b/tracim/exceptions.py @@ -165,3 +165,15 @@ class EmptyCommentContentNotAllowed(EmptyValueNotAllowed): class ParentNotFound(NotFound): pass + + +class RevisionDoesNotMatchThisContent(TracimException): + pass + + +class PageOfPreviewNotFound(NotFound): + pass + + +class PreviewDimNotAllowed(TracimException): + pass diff --git a/tracim/lib/core/content.py b/tracim/lib/core/content.py index 6c23cb7..1870d23 100644 --- a/tracim/lib/core/content.py +++ b/tracim/lib/core/content.py @@ -7,6 +7,7 @@ from operator import itemgetter import transaction +from preview_generator.manager import PreviewManager from sqlalchemy import func from sqlalchemy.orm import Query from depot.manager import DepotManager @@ -25,6 +26,9 @@ from tracim.lib.utils.utils import cmp_to_key from tracim.lib.core.notifications import NotifierFactory from tracim.exceptions import SameValueError +from tracim.exceptions import PageOfPreviewNotFound +from tracim.exceptions import PreviewDimNotAllowed +from tracim.exceptions import RevisionDoesNotMatchThisContent from tracim.exceptions import EmptyCommentContentNotAllowed from tracim.exceptions import EmptyLabelNotAllowed from tracim.exceptions import ContentNotFound @@ -42,7 +46,8 @@ from tracim.models.data import UserRoleInWorkspace from tracim.models.data import Workspace from tracim.lib.utils.translation import fake_translator as _ -from tracim.models.context_models import RevisionInContext +from tracim.models.context_models import RevisionInContext, \ + PreviewAllowedDim from tracim.models.context_models import ContentInContext __author__ = 'damien' @@ -133,6 +138,7 @@ def __init__( self._show_all_type_of_contents_in_treeview = all_content_in_treeview self._force_show_all_types = force_show_all_types self._disable_user_workspaces_filter = disable_user_workspaces_filter + self.preview_manager = PreviewManager(self._config.PREVIEW_CACHE_DIR, create_folder=True) # nopep8 @contextmanager def show( @@ -499,16 +505,24 @@ def get_one(self, content_id: int, content_type: str, workspace: Workspace=None, raise ContentNotFound('Content "{}" not found in database'.format(content_id)) from exc # nopep8 return content - def get_one_revision(self, revision_id: int = None) -> ContentRevisionRO: + def get_one_revision(self, revision_id: int = None, content: Content= None) -> ContentRevisionRO: # nopep8 """ This method allow us to get directly any revision with its id :param revision_id: The content's revision's id that we want to return + :param content: The content related to the revision, if None do not + check if revision is related to this content. :return: An item Content linked with the correct revision """ assert revision_id is not None# DYN_REMOVE revision = self._session.query(ContentRevisionRO).filter(ContentRevisionRO.revision_id == revision_id).one() - + if content and revision.content_id != content.content_id: + raise RevisionDoesNotMatchThisContent( + 'revision {revision_id} is not a revision of content {content_id}'.format( # nopep8 + revision_id=revision.revision_id, + content_id=content.content_id, + ) + ) return revision # INFO - A.P - 2017-07-03 - python file object getter @@ -724,6 +738,100 @@ def filter_query_for_content_label_as_path( ), )) + def get_pdf_preview_path( + self, + content_id: int, + revision_id: int, + page: int + ) -> str: + """ + Get pdf preview of revision of content + :param content_id: id of content + :param revision_id: id of content revision + :param page: page number of the preview, useful for multipage content + :return: preview_path as string + """ + file_path = self.get_one_revision_filepath(revision_id) + if page >= self.preview_manager.get_page_nb(file_path): + raise PageOfPreviewNotFound( + 'page {page} of content {content_id} does not exist'.format( + page=page, + content_id=content_id + ), + ) + jpg_preview_path = self.preview_manager.get_pdf_preview( + file_path, + page=page + ) + return jpg_preview_path + + def get_full_pdf_preview_path(self, revision_id: int) -> str: + """ + Get full(multiple page) pdf preview of revision of content + :param revision_id: id of revision + :return: path of the full pdf preview of this revision + """ + file_path = self.get_one_revision_filepath(revision_id) + pdf_preview_path = self.preview_manager.get_pdf_preview(file_path) + return pdf_preview_path + + def get_jpg_preview_allowed_dim(self) -> PreviewAllowedDim: + return PreviewAllowedDim( + self._config.PREVIEW_JPG_RESTRICTED_DIMS, + self._config.PREVIEW_JPG_ALLOWED_DIMS, + ) + + def get_jpg_preview_path( + self, + content_id: int, + revision_id: int, + page: int, + width: int = None, + height: int = None, + ) -> str: + """ + Get jpg preview of revision of content + :param content_id: id of content + :param revision_id: id of content revision + :param page: page number of the preview, useful for multipage content + :param width: width in pixel + :param height: height in pixel + :return: preview_path as string + """ + file_path = self.get_one_revision_filepath(revision_id) + if page >= self.preview_manager.get_page_nb(file_path): + raise Exception( + 'page {page} of revision {revision_id} of content {content_id} does not exist'.format( # nopep8 + page=page, + revision_id=revision_id, + content_id=content_id, + ), + ) + if not width and not height: + width = self._config.PREVIEW_JPG_ALLOWED_DIMS[0].width + height = self._config.PREVIEW_JPG_ALLOWED_DIMS[0].height + + allowed_dim = False + for preview_dim in self._config.PREVIEW_JPG_ALLOWED_DIMS: + if width == preview_dim.width and height == preview_dim.height: + allowed_dim = True + break + + if not allowed_dim and self._config.PREVIEW_JPG_RESTRICTED_DIMS: + raise PreviewDimNotAllowed( + 'Size {width}x{height} is not allowed for jpeg preview'.format( + width=width, + height=height, + ) + ) + jpg_preview_path = self.preview_manager.get_jpeg_preview( + file_path, + page=page, + width=width, + height=height, + ) + return jpg_preview_path + def get_all(self, parent_id: int=None, content_type: str=ContentType.Any, workspace: Workspace=None) -> typing.List[Content]: assert parent_id is None or isinstance(parent_id, int) # DYN_REMOVE assert content_type is not None# DYN_REMOVE diff --git a/tracim/models/context_models.py b/tracim/models/context_models.py index 204fcfe..1094a98 100644 --- a/tracim/models/context_models.py +++ b/tracim/models/context_models.py @@ -5,6 +5,7 @@ from slugify import slugify from sqlalchemy.orm import Session from tracim import CFG +from tracim.config import PreviewDim from tracim.models import User from tracim.models.auth import Profile from tracim.models.data import Content @@ -15,6 +16,17 @@ from tracim.models.contents import ContentTypeLegacy as ContentType +class PreviewAllowedDim(object): + + def __init__( + self, + restricted:bool, + dimensions: typing.List[PreviewDim] + ) -> None: + self.restricted = restricted + self.dimensions = dimensions + + class MoveParams(object): """ Json body params for move action model @@ -43,6 +55,39 @@ def __init__(self, workspace_id: int, content_id: int) -> None: self.workspace_id = workspace_id +class WorkspaceAndContentRevisionPath(object): + """ + Paths params with workspace id and content_id model + """ + def __init__(self, workspace_id: int, content_id: int, revision_id) -> None: + self.content_id = content_id + self.revision_id = revision_id + self.workspace_id = workspace_id + + +class ContentPreviewSizedPath(object): + """ + Paths params with workspace id and content_id, width, heigth + """ + def __init__(self, workspace_id: int, content_id: int, width: int, height: int) -> None: # nopep8 + self.content_id = content_id + self.workspace_id = workspace_id + self.width = width + self.height = height + + +class RevisionPreviewSizedPath(object): + """ + Paths params with workspace id and content_id, revision_id width, heigth + """ + def __init__(self, workspace_id: int, content_id: int, revision_id: int, width: int, height: int) -> None: # nopep8 + self.content_id = content_id + self.revision_id = revision_id + self.workspace_id = workspace_id + self.width = width + self.height = height + + class CommentPath(object): """ Paths params with workspace id and content_id and comment_id model @@ -58,6 +103,17 @@ def __init__( self.comment_id = comment_id +class PageQuery(object): + """ + Page query model + """ + def __init__( + self, + page: int = 0 + ): + self.page = page + + class ContentFilter(object): """ Content filter model diff --git a/tracim/tests/__init__.py b/tracim/tests/__init__.py index 8540e8d..8c04ee9 100644 --- a/tracim/tests/__init__.py +++ b/tracim/tests/__init__.py @@ -25,7 +25,8 @@ from tracim.extensions import hapic from tracim import web from webtest import TestApp - +from io import BytesIO +from PIL import Image def eq_(a, b, msg=None): # TODO - G.M - 05-04-2018 - Remove this when all old nose code is removed @@ -54,6 +55,15 @@ def set_html_document_slug_to_legacy(session_factory) -> None: assert content_query.count() > 0 +def create_test_image(): + file = BytesIO() + image = Image.new('RGBA', size=(1000, 1000), color=(0, 0, 0)) + image.save(file, 'png') + file.name = 'test_image.png' + file.seek(0) + return file + + class FunctionalTest(unittest.TestCase): fixtures = [BaseFixture] @@ -68,6 +78,7 @@ def setUp(self): 'depot_storage_dir': '/tmp/test/depot', 'depot_storage_name': 'test', 'preview_cache_dir': '/tmp/test/preview_cache', + 'preview.jpg.restricted_dims': True, } hapic.reset_context() diff --git a/tracim/tests/functional/test_contents.py b/tracim/tests/functional/test_contents.py index 79d9a6d..4ea8a0a 100644 --- a/tracim/tests/functional/test_contents.py +++ b/tracim/tests/functional/test_contents.py @@ -1,5 +1,18 @@ # -*- coding: utf-8 -*- -from tracim.tests import FunctionalTest +import io + +import pytest +import transaction +from PIL import Image +from depot.io.utils import FileIntent + +from tracim import models +from tracim.lib.core.content import ContentApi +from tracim.lib.core.workspace import WorkspaceApi +from tracim.models.data import ContentType +from tracim.models import get_tm_session +from tracim.models.revision_protection import new_revision +from tracim.tests import FunctionalTest, create_test_image from tracim.tests import set_html_document_slug_to_legacy from tracim.fixtures.content import Content as ContentFixtures from tracim.fixtures.users_and_groups import Base as BaseFixture @@ -429,6 +442,1224 @@ def test_api__set_html_document_status__err_400__wrong_status(self) -> None: ) +class TestFiles(FunctionalTest): + """ + Tests for /api/v2/workspaces/{workspace_id}/files/{content_id} + endpoint + """ + + fixtures = [BaseFixture, ContentFixtures] + + def test_api__get_file__ok_200__nominal_case(self) -> None: + """ + Get one file of a content + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=False, + do_notify=False, + ) + test_file.file_extension = '.txt' + test_file.depot_file = FileIntent( + b'Test file', + 'Test_file.txt', + 'text/plain', + ) + content_api.update_content(test_file, 'Test_file', '

description

') # nopep8 + dbsession.flush() + transaction.commit() + + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}'.format(test_file.content_id), + status=200 + ) + content = res.json_body + assert content['content_type'] == 'file' + assert content['content_id'] == test_file.content_id + assert content['is_archived'] is False + assert content['is_deleted'] is False + assert content['label'] == 'Test_file' + assert content['parent_id'] == 1 + assert content['show_in_ui'] is True + assert content['slug'] == 'test-file' + assert content['status'] == 'open' + assert content['workspace_id'] == 1 + assert content['current_revision_id'] + # TODO - G.M - 2018-06-173 - check date format + assert content['created'] + assert content['author'] + assert content['author']['user_id'] == 1 + assert content['author']['avatar_url'] is None + assert content['author']['public_name'] == 'Global manager' + # TODO - G.M - 2018-06-173 - check date format + assert content['modified'] + assert content['last_modifier'] == content['author'] + assert content['raw_content'] == '

description

' # nopep8 + + def test_api__get_files__err_400__wrong_content_type(self) -> None: + """ + Get one file of a content content + """ + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.get( + '/api/v2/workspaces/2/files/6', + status=400 + ) + + def test_api__get_file__err_400__content_does_not_exist(self) -> None: # nopep8 + """ + Get one file (content 170 does not exist in db + """ + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.get( + '/api/v2/workspaces/1/files/170', + status=400 + ) + + def test_api__get_file__err_400__content_not_in_workspace(self) -> None: # nopep8 + """ + Get one file (content 9 is in workspace 2) + """ + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.get( + '/api/v2/workspaces/1/files/9', + status=400 + ) + + def test_api__get_file__err_400__workspace_does_not_exist(self) -> None: # nopep8 + """ + Get one file (Workspace 40 does not exist) + """ + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.get( + '/api/v2/workspaces/40/files/9', + status=400 + ) + + def test_api__get_file__err_400__workspace_id_is_not_int(self) -> None: # nopep8 + """ + Get one file, workspace id is not int + """ + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.get( + '/api/v2/workspaces/coucou/files/9', + status=400 + ) + + def test_api__get_file__err_400__content_id_is_not_int(self) -> None: # nopep8 + """ + Get one file, content_id is not int + """ + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.get( + '/api/v2/workspaces/2/files/coucou', + status=400 + ) + + def test_api__update_file_info_err_400__empty_label(self) -> None: # nopep8 + """ + Update(put) one file + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=False, + do_notify=False, + ) + test_file.file_extension = '.txt' + test_file.depot_file = FileIntent( + b'Test file', + 'Test_file.txt', + 'text/plain', + ) + content_api.update_content(test_file, 'Test_file', '

description

') # nopep8 + dbsession.flush() + transaction.commit() + + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + params = { + 'label': '', + 'raw_content': '

Le nouveau contenu

', + } + res = self.testapp.put_json( + '/api/v2/workspaces/1/files/{}'.format(test_file.content_id), + params=params, + status=400 + ) + + def test_api__update_file_info__ok_200__nominal_case(self) -> None: + """ + Update(put) one file + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=False, + do_notify=False, + ) + test_file.file_extension = '.txt' + test_file.depot_file = FileIntent( + b'Test file', + 'Test_file.txt', + 'text/plain', + ) + content_api.update_content(test_file, 'Test_file', '

description

') # nopep8 + dbsession.flush() + transaction.commit() + + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + params = { + 'label': 'My New label', + 'raw_content': '

Le nouveau contenu

', + } + res = self.testapp.put_json( + '/api/v2/workspaces/1/files/{}'.format(test_file.content_id), + params=params, + status=200 + ) + content = res.json_body + assert content['content_type'] == 'file' + assert content['content_id'] == test_file.content_id + assert content['is_archived'] is False + assert content['is_deleted'] is False + assert content['label'] == 'My New label' + assert content['parent_id'] == 1 + assert content['show_in_ui'] is True + assert content['slug'] == 'my-new-label' + assert content['status'] == 'open' + assert content['workspace_id'] == 1 + assert content['current_revision_id'] + # TODO - G.M - 2018-06-173 - check date format + assert content['created'] + assert content['author'] + assert content['author']['user_id'] == 1 + assert content['author']['avatar_url'] is None + assert content['author']['public_name'] == 'Global manager' + # TODO - G.M - 2018-06-173 - check date format + assert content['modified'] + assert content['last_modifier'] == content['author'] + assert content['raw_content'] == '

Le nouveau contenu

' + + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}'.format(test_file.content_id), + status=200 + ) + content = res.json_body + assert content['content_type'] == 'file' + assert content['content_id'] == test_file.content_id + assert content['is_archived'] is False + assert content['is_deleted'] is False + assert content['label'] == 'My New label' + assert content['parent_id'] == 1 + assert content['show_in_ui'] is True + assert content['slug'] == 'my-new-label' + assert content['status'] == 'open' + assert content['workspace_id'] == 1 + assert content['current_revision_id'] + # TODO - G.M - 2018-06-173 - check date format + assert content['created'] + assert content['author'] + assert content['author']['user_id'] == 1 + assert content['author']['avatar_url'] is None + assert content['author']['public_name'] == 'Global manager' + # TODO - G.M - 2018-06-173 - check date format + assert content['modified'] + assert content['last_modifier'] == content['author'] + assert content['raw_content'] == '

Le nouveau contenu

' + + def test_api__get_file_revisions__ok_200__nominal_case( + self + ) -> None: + """ + Get file revisions + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=False, + do_notify=False, + ) + test_file.file_extension = '.txt' + test_file.depot_file = FileIntent( + b'Test file', + 'Test_file.txt', + 'text/plain', + ) + content_api.update_content(test_file, 'Test_file', '

description

') # nopep8 + dbsession.flush() + transaction.commit() + + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}/revisions'.format(test_file.content_id), + status=200 + ) + revisions = res.json_body + assert len(revisions) == 1 + revision = revisions[0] + assert revision['content_type'] == 'file' + assert revision['content_id'] == test_file.content_id + assert revision['is_archived'] is False + assert revision['is_deleted'] is False + assert revision['label'] == 'Test_file' + assert revision['parent_id'] == 1 + assert revision['show_in_ui'] is True + assert revision['slug'] == 'test-file' + assert revision['status'] == 'open' + assert revision['workspace_id'] == 1 + assert revision['revision_id'] + assert revision['sub_content_types'] + # TODO - G.M - 2018-06-173 - Test with real comments + assert revision['comment_ids'] == [] + # TODO - G.M - 2018-06-173 - check date format + assert revision['created'] + assert revision['author'] + assert revision['author']['user_id'] == 1 + assert revision['author']['avatar_url'] is None + assert revision['author']['public_name'] == 'Global manager' + + def test_api__set_file_status__ok_200__nominal_case(self) -> None: + """ + set file status + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=False, + do_notify=False, + ) + test_file.file_extension = '.txt' + test_file.depot_file = FileIntent( + b'Test file', + 'Test_file.txt', + 'text/plain', + ) + content_api.update_content(test_file, 'Test_file', '

description

') # nopep8 + dbsession.flush() + transaction.commit() + + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + params = { + 'status': 'closed-deprecated', + } + + # before + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}'.format(test_file.content_id), + status=200 + ) + content = res.json_body + assert content['content_type'] == 'file' + assert content['content_id'] == test_file.content_id + assert content['status'] == 'open' + + # set status + res = self.testapp.put_json( + '/api/v2/workspaces/1/files/{}/status'.format(test_file.content_id), + params=params, + status=204 + ) + + # after + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}'.format(test_file.content_id), + status=200 + ) + content = res.json_body + assert content['content_type'] == 'file' + assert content['content_id'] == test_file.content_id + assert content['status'] == 'closed-deprecated' + + def test_api__set_file_status__err_400__wrong_status(self) -> None: + """ + set file status + """ + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + params = { + 'status': 'unexistant-status', + } + res = self.testapp.put_json( + '/api/v2/workspaces/2/files/6/status', + params=params, + status=400 + ) + + def test_api__get_file_raw__ok_200__nominal_case(self) -> None: + """ + Get one file of a content + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=False, + do_notify=False, + ) + test_file.file_extension = '.txt' + test_file.depot_file = FileIntent( + b'Test file', + 'Test_file.txt', + 'text/plain', + ) + content_api.update_content(test_file, 'Test_file', '

description

') # nopep8 + dbsession.flush() + transaction.commit() + content_id = int(test_file.content_id) + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}/raw'.format(content_id), + status=200 + ) + assert res.body == b'Test file' + assert res.content_type == 'text/plain' + assert res.content_length == len(b'Test file') + + def test_api__set_file_raw__ok_200__nominal_case(self) -> None: + """ + Set one file of a content + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=True, + do_notify=False, + ) + dbsession.flush() + transaction.commit() + content_id = int(test_file.content_id) + image = create_test_image() + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.put( + '/api/v2/workspaces/1/files/{}/raw'.format(content_id), + upload_files=[ + ('files',image.name, image.getvalue()) + ], + status=204, + ) + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}/raw'.format(content_id), + status=200 + ) + assert res.body == image.getvalue() + assert res.content_type == 'image/png' + assert res.content_length == len(image.getvalue()) + + def test_api__get_allowed_size_dim__ok__nominal_case(self) -> None: + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=False, + do_notify=False, + ) + test_file.file_extension = '.txt' + test_file.depot_file = FileIntent( + b'Test file', + 'Test_file.txt', + 'text/plain', + ) + dbsession.flush() + transaction.commit() + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + content_id = int(test_file.content_id) + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}/preview/jpg/allowed_dims'.format(content_id), + status=200, + ) + res = res.json_body + assert res['restricted'] == True + assert len(res['dimensions']) == 1 + dim = res['dimensions'][0] + assert dim['width'] == 256 + assert dim['height'] == 256 + + def test_api__get_jpeg_preview__ok__200__nominal_case(self) -> None: + """ + Set one file of a content + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=False, + do_notify=False, + ) + test_file.file_extension = '.txt' + test_file.depot_file = FileIntent( + b'Test file', + 'Test_file.txt', + 'text/plain', + ) + dbsession.flush() + transaction.commit() + content_id = int(test_file.content_id) + image = create_test_image() + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.put( + '/api/v2/workspaces/1/files/{}/raw'.format(content_id), + upload_files=[ + ('files', image.name, image.getvalue()) + ], + status=204, + ) + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}/preview/jpg'.format(content_id), + status=200 + ) + assert res.body != image.getvalue() + assert res.content_type == 'image/jpeg' + + def test_api__get_sized_jpeg_preview__ok__200__nominal_case(self) -> None: + """ + get 256x256 preview of a txt file + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=True, + do_notify=False, + ) + dbsession.flush() + transaction.commit() + content_id = int(test_file.content_id) + image = create_test_image() + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.put( + '/api/v2/workspaces/1/files/{}/raw'.format(content_id), + upload_files=[ + ('files',image.name, image.getvalue()) + ], + status=204, + ) + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}/preview/jpg/256x256'.format(content_id), # nopep8 + status=200 + ) + assert res.body != image.getvalue() + assert res.content_type == 'image/jpeg' + new_image = Image.open(io.BytesIO(res.body)) + assert 256, 256 == new_image.size + + def test_api__get_sized_jpeg_preview__err__400__SizeNotAllowed(self) -> None: + """ + get 256x256 preview of a txt file + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=True, + do_notify=False, + ) + dbsession.flush() + transaction.commit() + content_id = int(test_file.content_id) + image = create_test_image() + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.put( + '/api/v2/workspaces/1/files/{}/raw'.format(content_id), + upload_files=[ + ('files',image.name, image.getvalue()) + ], + status=204, + ) + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}/preview/jpg/512x512'.format(content_id), # nopep8 + status=400 + ) + + def test_api__get_sized_jpeg_revision_preview__ok__200__nominal_case(self) -> None: + """ + get 256x256 revision preview of a txt file + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=False, + do_notify=False, + ) + test_file.file_extension = '.txt' + test_file.depot_file = FileIntent( + b'Test file', + 'Test_file.txt', + 'text/plain', + ) + dbsession.flush() + transaction.commit() + content_id = int(test_file.content_id) + revision_id = int(test_file.revision_id) + image = create_test_image() + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.put( + '/api/v2/workspaces/1/files/{}/raw'.format(content_id), + upload_files=[ + ('files', image.name, image.getvalue()) + ], + status=204, + ) + res = self.testapp.get( + '/api/v2/workspaces/1/files/{content_id}/revisions/{revision_id}/raw'.format( + content_id=content_id, + revision_id=revision_id, + ), + status=200 + ) + assert res.content_type == 'text/plain' + res = self.testapp.get( + '/api/v2/workspaces/1/files/{content_id}/revisions/{revision_id}/preview/jpg/256x256'.format( + content_id=content_id, + revision_id=revision_id, + ), + status=200 + ) + assert res.body != image.getvalue() + assert res.content_type == 'image/jpeg' + new_image = Image.open(io.BytesIO(res.body)) + assert 256, 256 == new_image.size + + def test_api__get_full_pdf_preview__ok__200__nominal_case(self) -> None: + """ + get full pdf preview of a txt file + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=True, + do_notify=False, + ) + with new_revision( + session=dbsession, + tm=transaction.manager, + content=test_file, + ): + test_file.file_extension = '.txt' + test_file.depot_file = FileIntent( + b'Test file', + 'Test_file.txt', + 'text/plain', + ) + content_api.update_content(test_file, 'Test_file', '

description

') + dbsession.flush() + transaction.commit() + content_id = int(test_file.content_id) + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.put( + '/api/v2/workspaces/1/files/{}/raw'.format(content_id), + upload_files=[ + ('files', test_file.file_name, test_file.depot_file.file.read()) + ], + status=204, + ) + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}/preview/pdf/full'.format(content_id), # nopep8 + status=200 + ) + assert res.content_type == 'application/pdf' + + def test_api__get_full_pdf_preview__err__400__png_UnavailablePreviewType(self) -> None: + """ + get full pdf preview of a png image -> error UnavailablePreviewType + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=True, + do_notify=False, + ) + dbsession.flush() + transaction.commit() + content_id = int(test_file.content_id) + image = create_test_image() + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.put( + '/api/v2/workspaces/1/files/{}/raw'.format(content_id), + upload_files=[ + ('files',image.name, image.getvalue()) + ], + status=204, + ) + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}/preview/pdf/full'.format(content_id), # nopep8 + status=400 + ) + + def test_api__get_pdf_preview__ok__200__nominal_case(self) -> None: + """ + get full pdf preview of a txt file + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=True, + do_notify=False, + ) + with new_revision( + session=dbsession, + tm=transaction.manager, + content=test_file, + ): + test_file.file_extension = '.txt' + test_file.depot_file = FileIntent( + b'Test file', + 'Test_file.txt', + 'text/plain', + ) + content_api.update_content(test_file, 'Test_file', '

description

') + dbsession.flush() + transaction.commit() + content_id = int(test_file.content_id) + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.put( + '/api/v2/workspaces/1/files/{}/raw'.format(content_id), + upload_files=[ + ('files', test_file.file_name, test_file.depot_file.file.read()) + ], + status=204, + ) + params = {'page': 0} + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}/preview/pdf'.format(content_id), + status=200, + params=params, + ) + assert res.content_type == 'application/pdf' + + def test_api__get_pdf_preview__ok__err__400_page_of_preview_not_found(self) -> None: + """ + get full pdf preview of a txt file + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=True, + do_notify=False, + ) + with new_revision( + session=dbsession, + tm=transaction.manager, + content=test_file, + ): + test_file.file_extension = '.txt' + test_file.depot_file = FileIntent( + b'Test file', + 'Test_file.txt', + 'text/plain', + ) + content_api.update_content(test_file, 'Test_file', '

description

') + dbsession.flush() + transaction.commit() + content_id = int(test_file.content_id) + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.put( + '/api/v2/workspaces/1/files/{}/raw'.format(content_id), + upload_files=[ + ('files', test_file.file_name, test_file.depot_file.file.read()) + ], + status=204, + ) + params = {'page': 1} + res = self.testapp.get( + '/api/v2/workspaces/1/files/{}/preview/pdf'.format(content_id), + status=400, + params=params, + ) + + def test_api__get_pdf_revision_preview__ok__200__nominal_case(self) -> None: + """ + get pdf revision preview of content + """ + dbsession = get_tm_session(self.session_factory, transaction.manager) + admin = dbsession.query(models.User) \ + .filter(models.User.email == 'admin@admin.admin') \ + .one() + workspace_api = WorkspaceApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + content_api = ContentApi( + current_user=admin, + session=dbsession, + config=self.app_config + ) + business_workspace = workspace_api.get_one(1) + tool_folder = content_api.get_one(1, content_type=ContentType.Any) + test_file = content_api.create( + content_type=ContentType.File, + workspace=business_workspace, + parent=tool_folder, + label='Test file', + do_save=False, + do_notify=False, + ) + test_file.file_extension = '.txt' + test_file.depot_file = FileIntent( + b'Test file', + 'Test_file.txt', + 'text/plain', + ) + dbsession.flush() + transaction.commit() + content_id = int(test_file.content_id) + revision_id = int(test_file.revision_id) + image = create_test_image() + self.testapp.authorization = ( + 'Basic', + ( + 'admin@admin.admin', + 'admin@admin.admin' + ) + ) + res = self.testapp.put( + '/api/v2/workspaces/1/files/{}/raw'.format(content_id), + upload_files=[ + ('files', image.name, image.getvalue()) + ], + status=204, + ) + res = self.testapp.get( + '/api/v2/workspaces/1/files/{content_id}/revisions/{revision_id}/raw'.format( + content_id=content_id, + revision_id=revision_id, + ), + status=200 + ) + assert res.content_type == 'text/plain' + params = {'page': 0} + res = self.testapp.get( + '/api/v2/workspaces/1/files/{content_id}/revisions/{revision_id}/preview/pdf'.format( + content_id=content_id, + revision_id=revision_id, + ), + status=200 + ) + assert res.content_type == 'application/pdf' + + class TestThreads(FunctionalTest): """ Tests for /api/v2/workspaces/{workspace_id}/threads/{content_id} diff --git a/tracim/views/contents_api/file_controller.py b/tracim/views/contents_api/file_controller.py new file mode 100644 index 0000000..a7647a3 --- /dev/null +++ b/tracim/views/contents_api/file_controller.py @@ -0,0 +1,534 @@ +# coding=utf-8 +import typing + +import transaction +from depot.manager import DepotManager +from preview_generator.exception import UnavailablePreviewType +from pyramid.config import Configurator +from pyramid.response import FileResponse, FileIter + +try: # Python 3.5+ + from http import HTTPStatus +except ImportError: + from http import client as HTTPStatus + +from tracim import TracimRequest +from tracim.extensions import hapic +from tracim.lib.core.content import ContentApi +from tracim.views.controllers import Controller +from tracim.views.core_api.schemas import FileContentSchema +from tracim.views.core_api.schemas import AllowedJpgPreviewDimSchema +from tracim.views.core_api.schemas import ContentPreviewSizedPathSchema +from tracim.views.core_api.schemas import RevisionPreviewSizedPathSchema +from tracim.views.core_api.schemas import PageQuerySchema +from tracim.views.core_api.schemas import WorkspaceAndContentRevisionIdPathSchema # nopep8 +from tracim.views.core_api.schemas import FileRevisionSchema +from tracim.views.core_api.schemas import SetContentStatusSchema +from tracim.views.core_api.schemas import FileContentModifySchema +from tracim.views.core_api.schemas import WorkspaceAndContentIdPathSchema +from tracim.views.core_api.schemas import NoContentSchema +from tracim.lib.utils.authorization import require_content_types +from tracim.lib.utils.authorization import require_workspace_role +from tracim.models.data import UserRoleInWorkspace +from tracim.models.context_models import ContentInContext +from tracim.models.context_models import RevisionInContext +from tracim.models.contents import ContentTypeLegacy as ContentType +from tracim.models.contents import file_type +from tracim.models.revision_protection import new_revision +from tracim.exceptions import EmptyLabelNotAllowed +from tracim.exceptions import PageOfPreviewNotFound +from tracim.exceptions import PreviewDimNotAllowed + +FILE_ENDPOINTS_TAG = 'Files' + + +class FileController(Controller): + + # File data + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR) + @require_content_types([file_type]) + @hapic.input_path(WorkspaceAndContentIdPathSchema()) + #@hapic.input_files() + @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT) # nopep8 + def upload_file(self, context, request: TracimRequest, hapic_data=None): + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + content = api.get_one( + hapic_data.path.content_id, + content_type=ContentType.Any + ) + file = request.POST['files'] + with new_revision( + session=request.dbsession, + tm=transaction.manager, + content=content + ): + api.update_file_data( + content, + new_filename=file.filename, + new_mimetype=file.type, + new_content=file.file, + ) + + return + + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @require_workspace_role(UserRoleInWorkspace.READER) + @require_content_types([file_type]) + @hapic.input_path(WorkspaceAndContentIdPathSchema()) + @hapic.output_file([]) + def download_file(self, context, request: TracimRequest, hapic_data=None): + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + content = api.get_one( + hapic_data.path.content_id, + content_type=ContentType.Any + ) + file = DepotManager.get().get(content.depot_file) + response = request.response + response.content_type = file.content_type + response.app_iter = FileIter(file) + return response + + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @require_workspace_role(UserRoleInWorkspace.READER) + @require_content_types([file_type]) + @hapic.input_path(WorkspaceAndContentRevisionIdPathSchema()) + @hapic.output_file([]) + def download_revisions_file(self, context, request: TracimRequest, hapic_data=None): # nopep8 + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + content = api.get_one( + hapic_data.path.content_id, + content_type=ContentType.Any + ) + revision = api.get_one_revision( + revision_id=hapic_data.path.revision_id, + content=content + ) + file = DepotManager.get().get(revision.depot_file) + response = request.response + response.content_type = file.content_type + response.app_iter = FileIter(file) + return response + + # preview + # pdf + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @require_workspace_role(UserRoleInWorkspace.READER) + @require_content_types([file_type]) + @hapic.handle_exception(UnavailablePreviewType, HTTPStatus.BAD_REQUEST) + @hapic.handle_exception(PageOfPreviewNotFound, HTTPStatus.BAD_REQUEST) + @hapic.input_query(PageQuerySchema()) + @hapic.input_path(WorkspaceAndContentIdPathSchema()) + @hapic.output_file([]) + def preview_pdf(self, context, request: TracimRequest, hapic_data=None): + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + content = api.get_one( + hapic_data.path.content_id, + content_type=ContentType.Any + ) + pdf_preview_path = api.get_pdf_preview_path( + content.content_id, + content.revision_id, + page=hapic_data.query.page + ) + return FileResponse(pdf_preview_path) + + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @require_workspace_role(UserRoleInWorkspace.READER) + @require_content_types([file_type]) + @hapic.handle_exception(UnavailablePreviewType, HTTPStatus.BAD_REQUEST) + @hapic.input_path(WorkspaceAndContentIdPathSchema()) + @hapic.output_file([]) + def preview_pdf_full(self, context, request: TracimRequest, hapic_data=None): + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + content = api.get_one( + hapic_data.path.content_id, + content_type=ContentType.Any + ) + pdf_preview_path = api.get_full_pdf_preview_path(content.revision_id) + return FileResponse(pdf_preview_path) + + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @require_workspace_role(UserRoleInWorkspace.READER) + @require_content_types([file_type]) + @hapic.handle_exception(UnavailablePreviewType, HTTPStatus.BAD_REQUEST) + @hapic.input_path(WorkspaceAndContentRevisionIdPathSchema()) + @hapic.input_query(PageQuerySchema()) + @hapic.output_file([]) + def preview_pdf_revision(self, context, request: TracimRequest, hapic_data=None): + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + content = api.get_one( + hapic_data.path.content_id, + content_type=ContentType.Any + ) + revision = api.get_one_revision( + revision_id=hapic_data.path.revision_id, + content=content + ) + pdf_preview_path = api.get_pdf_preview_path( + revision.content_id, + revision.revision_id, + page=hapic_data.query.page + ) + return FileResponse(pdf_preview_path) + + # jpg + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @require_workspace_role(UserRoleInWorkspace.READER) + @require_content_types([file_type]) + @hapic.handle_exception(PageOfPreviewNotFound, HTTPStatus.BAD_REQUEST) + @hapic.input_path(WorkspaceAndContentIdPathSchema()) + @hapic.input_query(PageQuerySchema()) + @hapic.output_file([]) + def preview_jpg(self, context, request: TracimRequest, hapic_data=None): + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + content = api.get_one( + hapic_data.path.content_id, + content_type=ContentType.Any + ) + allowed_dim = api.get_jpg_preview_allowed_dim() + jpg_preview_path = api.get_jpg_preview_path( + content_id=content.content_id, + revision_id=content.revision_id, + page=hapic_data.query.page, + width=allowed_dim.dimensions[0].width, + height=allowed_dim.dimensions[0].height, + ) + return FileResponse(jpg_preview_path) + + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @require_workspace_role(UserRoleInWorkspace.READER) + @require_content_types([file_type]) + @hapic.handle_exception(PageOfPreviewNotFound, HTTPStatus.BAD_REQUEST) + @hapic.handle_exception(PreviewDimNotAllowed, HTTPStatus.BAD_REQUEST) + @hapic.input_query(PageQuerySchema()) + @hapic.input_path(ContentPreviewSizedPathSchema()) + @hapic.output_file([]) + def sized_preview_jpg(self, context, request: TracimRequest, hapic_data=None): + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + content = api.get_one( + hapic_data.path.content_id, + content_type=ContentType.Any + ) + jpg_preview_path = api.get_jpg_preview_path( + content_id=content.content_id, + revision_id=content.revision_id, + page=hapic_data.query.page, + height=hapic_data.path.height, + width=hapic_data.path.width, + ) + return FileResponse(jpg_preview_path) + + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @require_workspace_role(UserRoleInWorkspace.READER) + @require_content_types([file_type]) + @hapic.handle_exception(PageOfPreviewNotFound, HTTPStatus.BAD_REQUEST) + @hapic.handle_exception(PreviewDimNotAllowed, HTTPStatus.BAD_REQUEST) + @hapic.input_path(RevisionPreviewSizedPathSchema()) + @hapic.input_query(PageQuerySchema()) + @hapic.output_file([]) + def sized_preview_jpg_revision(self, context, request: TracimRequest, hapic_data=None): + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + content = api.get_one( + hapic_data.path.content_id, + content_type=ContentType.Any + ) + revision = api.get_one_revision( + revision_id=hapic_data.path.revision_id, + content=content + ) + jpg_preview_path = api.get_jpg_preview_path( + content_id=content.content_id, + revision_id=revision.revision_id, + page=hapic_data.query.page, + height=hapic_data.path.height, + width=hapic_data.path.width, + ) + return FileResponse(jpg_preview_path) + + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @require_workspace_role(UserRoleInWorkspace.READER) + @require_content_types([file_type]) + @hapic.input_path(WorkspaceAndContentIdPathSchema()) + @hapic.output_body(AllowedJpgPreviewDimSchema()) + def allowed_dim_preview_jpg(self, context, request: TracimRequest, hapic_data=None): + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + return api.get_jpg_preview_allowed_dim() + + # File infos + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @require_workspace_role(UserRoleInWorkspace.READER) + @require_content_types([file_type]) + @hapic.input_path(WorkspaceAndContentIdPathSchema()) + @hapic.output_body(FileContentSchema()) + def get_file_infos(self, context, request: TracimRequest, hapic_data=None) -> ContentInContext: # nopep8 + """ + Get thread content + """ + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + content = api.get_one( + hapic_data.path.content_id, + content_type=ContentType.Any + ) + return api.get_content_in_context(content) + + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @hapic.handle_exception(EmptyLabelNotAllowed, HTTPStatus.BAD_REQUEST) + @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR) + @require_content_types([file_type]) + @hapic.input_path(WorkspaceAndContentIdPathSchema()) + @hapic.input_body(FileContentModifySchema()) + @hapic.output_body(FileContentSchema()) + def update_file_info(self, context, request: TracimRequest, hapic_data=None) -> ContentInContext: # nopep8 + """ + update thread + """ + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + content = api.get_one( + hapic_data.path.content_id, + content_type=ContentType.Any + ) + with new_revision( + session=request.dbsession, + tm=transaction.manager, + content=content + ): + api.update_content( + item=content, + new_label=hapic_data.body.label, + new_content=hapic_data.body.raw_content, + + ) + api.save(content) + return api.get_content_in_context(content) + + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @require_workspace_role(UserRoleInWorkspace.READER) + @require_content_types([file_type]) + @hapic.input_path(WorkspaceAndContentIdPathSchema()) + @hapic.output_body(FileRevisionSchema(many=True)) + def get_file_revisions( + self, + context, + request: TracimRequest, + hapic_data=None + ) -> typing.List[RevisionInContext]: + """ + get file revisions + """ + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + content = api.get_one( + hapic_data.path.content_id, + content_type=ContentType.Any + ) + revisions = content.revisions + return [ + api.get_revision_in_context(revision) + for revision in revisions + ] + + @hapic.with_api_doc(tags=[FILE_ENDPOINTS_TAG]) + @hapic.handle_exception(EmptyLabelNotAllowed, HTTPStatus.BAD_REQUEST) + @require_workspace_role(UserRoleInWorkspace.CONTRIBUTOR) + @require_content_types([file_type]) + @hapic.input_path(WorkspaceAndContentIdPathSchema()) + @hapic.input_body(SetContentStatusSchema()) + @hapic.output_body(NoContentSchema(), default_http_code=HTTPStatus.NO_CONTENT) # nopep8 + def set_file_status(self, context, request: TracimRequest, hapic_data=None) -> None: # nopep8 + """ + set file status + """ + app_config = request.registry.settings['CFG'] + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + content = api.get_one( + hapic_data.path.content_id, + content_type=ContentType.Any + ) + with new_revision( + session=request.dbsession, + tm=transaction.manager, + content=content + ): + api.set_status( + content, + hapic_data.body.status, + ) + api.save(content) + return + + def bind(self, configurator: Configurator) -> None: + + # file info # + # Get file info + configurator.add_route( + 'file_info', + '/workspaces/{workspace_id}/files/{content_id}', + request_method='GET' + ) + configurator.add_view(self.get_file_infos, route_name='file_info') # nopep8 + # update file + configurator.add_route( + 'update_file_info', + '/workspaces/{workspace_id}/files/{content_id}', + request_method='PUT' + ) # nopep8 + configurator.add_view(self.update_file_info, route_name='update_file_info') # nopep8 + + # raw file # + # upload raw file + configurator.add_route( + 'upload_file', + '/workspaces/{workspace_id}/files/{content_id}/raw', # nopep8 + request_method='PUT' + ) + configurator.add_view(self.upload_file, route_name='upload_file') # nopep8 + # download raw file + configurator.add_route( + 'download_file', + '/workspaces/{workspace_id}/files/{content_id}/raw', # nopep8 + request_method='GET' + ) + configurator.add_view(self.download_file, route_name='download_file') # nopep8 + # download raw file of revision + configurator.add_route( + 'download_revision', + '/workspaces/{workspace_id}/files/{content_id}/revisions/{revision_id}/raw', # nopep8 + request_method='GET' + ) + configurator.add_view(self.download_revisions_file, route_name='download_revision') # nopep8 + + # previews # + # get preview pdf full + configurator.add_route( + 'preview_pdf_full', + '/workspaces/{workspace_id}/files/{content_id}/preview/pdf/full', # nopep8 + request_method='GET' + ) + configurator.add_view(self.preview_pdf_full, route_name='preview_pdf_full') # nopep8 + # get preview pdf + configurator.add_route( + 'preview_pdf', + '/workspaces/{workspace_id}/files/{content_id}/preview/pdf', # nopep8 + request_method='GET' + ) + configurator.add_view(self.preview_pdf, route_name='preview_pdf') # nopep8 + # get preview jpg allowed dims + configurator.add_route( + 'allowed_dim_preview_jpg', + '/workspaces/{workspace_id}/files/{content_id}/preview/jpg/allowed_dims', # nopep8 + request_method='GET' + ) + configurator.add_view(self.allowed_dim_preview_jpg, route_name='allowed_dim_preview_jpg') # nopep8 + # get preview jpg + configurator.add_route( + 'preview_jpg', + '/workspaces/{workspace_id}/files/{content_id}/preview/jpg', # nopep8 + request_method='GET' + ) + configurator.add_view(self.preview_jpg, route_name='preview_jpg') # nopep8 + # get preview jpg with size + configurator.add_route( + 'sized_preview_jpg', + '/workspaces/{workspace_id}/files/{content_id}/preview/jpg/{width}x{height}', # nopep8 + request_method='GET' + ) + configurator.add_view(self.sized_preview_jpg, route_name='sized_preview_jpg') # nopep8 + # get jpg preview for revision + configurator.add_route( + 'sized_preview_jpg_revision', + '/workspaces/{workspace_id}/files/{content_id}/revisions/{revision_id}/preview/jpg/{width}x{height}', # nopep8 + request_method='GET' + ) + configurator.add_view(self.sized_preview_jpg_revision, route_name='sized_preview_jpg_revision') # nopep8 + # get jpg preview for revision + configurator.add_route( + 'preview_pdf_revision', + '/workspaces/{workspace_id}/files/{content_id}/revisions/{revision_id}/preview/pdf', # nopep8 + request_method='GET' + ) + configurator.add_view(self.preview_pdf_revision, route_name='preview_pdf_revision') # nopep8 + # others # + # get file revisions + configurator.add_route( + 'file_revisions', + '/workspaces/{workspace_id}/files/{content_id}/revisions', # nopep8 + request_method='GET' + ) + configurator.add_view(self.get_file_revisions, route_name='file_revisions') # nopep8 + + # get file status + configurator.add_route( + 'set_file_status', + '/workspaces/{workspace_id}/files/{content_id}/status', # nopep8 + request_method='PUT' + ) + configurator.add_view(self.set_file_status, route_name='set_file_status') # nopep8 diff --git a/tracim/views/core_api/schemas.py b/tracim/views/core_api/schemas.py index 677fc65..fdafb88 100644 --- a/tracim/views/core_api/schemas.py +++ b/tracim/views/core_api/schemas.py @@ -11,6 +11,10 @@ from tracim.models.contents import ContentTypeLegacy as ContentType from tracim.models.contents import ContentStatusLegacy as ContentStatus from tracim.models.context_models import ContentCreation +from tracim.models.context_models import ContentPreviewSizedPath +from tracim.models.context_models import RevisionPreviewSizedPath +from tracim.models.context_models import PageQuery +from tracim.models.context_models import WorkspaceAndContentRevisionPath from tracim.models.context_models import CommentCreation from tracim.models.context_models import TextBasedContentUpdate from tracim.models.context_models import SetContentStatus @@ -106,6 +110,10 @@ class ContentIdPathSchema(marshmallow.Schema): ) +class RevisionIdPathSchema(marshmallow.Schema): + revision_id = marshmallow.fields.Int(example=6, required=True) + + class WorkspaceAndContentIdPathSchema( WorkspaceIdPathSchema, ContentIdPathSchema @@ -115,6 +123,52 @@ def make_path_object(self, data): return WorkspaceAndContentPath(**data) +class WidthAndHeightPathSchema(marshmallow.Schema): + width = marshmallow.fields.Int(example=256) + height = marshmallow.fields.Int(example=256) + + +class AllowedJpgPreviewSizesSchema(marshmallow.Schema): + width = marshmallow.fields.Int(example=256) + height = marshmallow.fields.Int(example=256) + + +class AllowedJpgPreviewDimSchema(marshmallow.Schema): + restricted = marshmallow.fields.Bool() + dimensions = marshmallow.fields.Nested( + AllowedJpgPreviewSizesSchema, + many=True + ) + + +class WorkspaceAndContentRevisionIdPathSchema( + WorkspaceIdPathSchema, + ContentIdPathSchema, + RevisionIdPathSchema, +): + @post_load + def make_path_object(self, data): + return WorkspaceAndContentRevisionPath(**data) + + +class ContentPreviewSizedPathSchema( + WorkspaceAndContentIdPathSchema, + WidthAndHeightPathSchema +): + @post_load + def make_path_object(self, data): + return ContentPreviewSizedPath(**data) + + +class RevisionPreviewSizedPathSchema( + WorkspaceAndContentRevisionIdPathSchema, + WidthAndHeightPathSchema +): + @post_load + def make_path_object(self, data): + return RevisionPreviewSizedPath(**data) + + class CommentsPathSchema(WorkspaceAndContentIdPathSchema): comment_id = marshmallow.fields.Int( example=6, @@ -127,6 +181,19 @@ def make_path_object(self, data): return CommentPath(**data) +class PageQuerySchema(marshmallow.Schema): + page = marshmallow.fields.Int( + example=2, + default=0, + description='allow to show a specific page of a pdf file', + validate=Range(min=0, error="Value must be positive or 0"), + ) + + @post_load + def make_page_query(self, data): + return PageQuery(**data) + + class FilterContentQuerySchema(marshmallow.Schema): parent_id = marshmallow.fields.Int( example=2, @@ -453,10 +520,19 @@ class TextBasedDataAbstractSchema(marshmallow.Schema): ) +class FileInfoAbstractSchema(marshmallow.Schema): + raw_content = marshmallow.fields.String( + description='raw text or html description of the file' + ) + + class TextBasedContentSchema(ContentSchema, TextBasedDataAbstractSchema): pass +class FileContentSchema(ContentSchema, FileInfoAbstractSchema): + pass + ##### # Revision ##### @@ -484,6 +560,10 @@ class TextBasedRevisionSchema(RevisionSchema, TextBasedDataAbstractSchema): pass +class FileRevisionSchema(RevisionSchema, FileInfoAbstractSchema): + pass + + class CommentSchema(marshmallow.Schema): content_id = marshmallow.fields.Int( example=6, @@ -528,6 +608,10 @@ def text_based_content_update(self, data): return TextBasedContentUpdate(**data) +class FileContentModifySchema(TextBasedContentModifySchema): + pass + + class SetContentStatusSchema(marshmallow.Schema): status = marshmallow.fields.Str( example='closed-deprecated',