diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 136b3ef29..000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: CodeCov - -on: [push, pull_request] - -jobs: - unit-tests: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - python-version: ['3.10', '3.11', '3.12'] - requirements-file: [ - django-4.2.txt, - django-5.0.txt, - django-main.txt, - ] - custom-image-model: [false, true] - os: [ - ubuntu-20.04, - ] - - steps: - - uses: actions/checkout@v1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: library prerequisites - run: sudo apt-get install python-dev libpq-dev libmagic1 gcc libxml2-dev libxslt1-dev libjpeg62 libopenjp2-7 -y - - name: Install extra dependencies - run: pip install lxml - if: matrix.python-version == '3.10' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r tests/requirements/${{ matrix.requirements-file }} - python setup.py install - - name: Enable the custom image model - run: echo "CUSTOM_IMAGE=custom_image.Image" >> $GITHUB_ENV - if: ${{ matrix.custom-image-model }} - - name: Run coverage - run: coverage run setup.py test - - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..5493875a2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,51 @@ +name: Test django-filer + +on: + push: + branches: + - finder + paths-ignore: + - '**.md' + - '**.rst' + - '/docs/**' + pull_request: + branches: + - develop + paths-ignore: + - '**.md' + - '**.rst' + - '/docs/**' + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + django-version: ["5.2.*"] + node-version: ["18.x"] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + npm install --include=dev + python -m pip install --upgrade pip + python -m pip install https://github.com/django/django/archive/refs/heads/main.zip + python -m pip install django-cte django-entangled ffmpeg-python pillow reportlab svglib + python -m pip install beautifulsoup4 coverage Faker lxml pytest pytest-django pytest-cov + - name: Build Client + run: | + npm run compilescss + npm run esbuild + - name: Test with pytest + run: | + python -m pytest -v demoapp/unittests diff --git a/client/scss/finder-admin.scss b/client/scss/finder-admin.scss index 0fedbf42b..58bb8c688 100644 --- a/client/scss/finder-admin.scss +++ b/client/scss/finder-admin.scss @@ -695,4 +695,3 @@ ul.messagelist { } @import 'node_modules/react-image-crop/src/ReactCrop.scss'; -@import 'node_modules/react-h5-audio-player/src/styles.scss'; diff --git a/demoapp/pytest.ini b/demoapp/pytest.ini new file mode 100644 index 000000000..d6e8c50eb --- /dev/null +++ b/demoapp/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +DJANGO_SETTINGS_MODULE = demoapp.settings +django_find_project = false +addopts = --tb=native diff --git a/demoapp/settings.py b/demoapp/settings.py index 7420c4688..ee887d40f 100644 --- a/demoapp/settings.py +++ b/demoapp/settings.py @@ -75,24 +75,25 @@ WSGI_APPLICATION = 'wsgi.application' -# Database -# https://docs.djangoproject.com/en/4.1/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'workdir/db.sqlite3', - }, - 'default_': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'finder', - 'USER': 'finder', - 'PASSWORD': '', - 'HOST': 'localhost', - 'PORT': 5432, - # 'CONN_MAX_AGE': 900, - }, -} +if os.getenv('USE_POSTGRES', False) in ['1', 'True', 'true']: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'finder', + 'USER': 'finder', + 'PASSWORD': '', + 'HOST': 'localhost', + 'PORT': 5432, + # 'CONN_MAX_AGE': 900, + }, + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'workdir/db.sqlite3', + }, + } DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/demoapp/unittests/__init__.py b/demoapp/unittests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/demoapp/unittests/conftest.py b/demoapp/unittests/conftest.py new file mode 100644 index 000000000..6eedf20b6 --- /dev/null +++ b/demoapp/unittests/conftest.py @@ -0,0 +1,111 @@ +import os +import pytest + +from playwright.sync_api import sync_playwright + +from django.conf import settings +from django.contrib.admin.sites import site as admin_site +from django.core.management import call_command +from django.urls import reverse + +from finder.models.folder import FolderModel +from finder.models.realm import RealmModel + +from .utils import create_random_image + +os.environ.setdefault('DJANGO_ALLOW_ASYNC_UNSAFE', 'true') + + +@pytest.fixture(autouse=True, scope='session') +def create_assets(): + os.makedirs(settings.BASE_DIR / 'workdir/assets', exist_ok=True) + for counter in range(10): + image = create_random_image() + image.save(settings.BASE_DIR / 'workdir/assets' / f'image_{counter:01d}.png') + + +@pytest.fixture(scope='session') +def django_db_setup(django_db_blocker): + database_file = settings.BASE_DIR / 'workdir/test_db.sqlite3' + settings.DATABASES['default']['NAME'] = database_file + with django_db_blocker.unblock(): + call_command('migrate', verbosity=0) + yield + os.remove(database_file) + + +@pytest.fixture +def realm(admin_client): + if realm := RealmModel.objects.first(): + return realm + response = admin_client.get(reverse('admin:finder_foldermodel_changelist')) + assert response.status_code == 302 + realm = RealmModel.objects.first() + assert realm is not None + redirected = reverse('admin:finder_inodemodel_change', kwargs={'inode_id': realm.root_folder.id}) + assert response.url == redirected + assert realm.root_folder.is_folder is True + assert realm.root_folder.is_trash is False + assert realm.root_folder.owner == response.wsgi_request.user + assert realm.root_folder.name == '__root__' + assert realm.root_folder.parent is None + assert realm.root_folder.is_root + assert realm.trash_folders.count() == 0 + return realm + + +class Connector: + def __init__(self, live_server): + print(f"\nStarting end-to-end test server at {live_server}\n") + self.live_server = live_server + + def __enter__(self): + def print_args(msg): + if msg.type in ['info', 'debug']: + return + for arg in msg.args: + print(arg.json_value()) + + self.playwright = sync_playwright().start() + self.browser = self.playwright.chromium.launch() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.browser.close() + self.playwright.stop() + + +@pytest.fixture(scope='session') +def connector(live_server): + with Connector(live_server) as connector: + yield connector + + +@pytest.fixture +def locale(): + return 'en-US' + + +@pytest.fixture +def language(): + return 'en' + + +def print_args(msg): + """ + Print messages from the browser console. + """ + for arg in msg.args: + print(arg.json_value()) + + +@pytest.fixture() +def page(connector, viewname, locale, language): + context = connector.browser.new_context(locale=locale) + context.add_cookies([{'name': 'django_language', 'value': language, 'domain': 'localhost', 'path': '/'}]) + page = context.new_page() + # page.on('console', print_args) + page.goto(connector.live_server.url + reverse(viewname)) + # django_formset = page.locator('django-formset:defined') + # django_formset.wait_for() + return page diff --git a/demoapp/unittests/test_folder_admin.py b/demoapp/unittests/test_folder_admin.py new file mode 100644 index 000000000..60ec6f08b --- /dev/null +++ b/demoapp/unittests/test_folder_admin.py @@ -0,0 +1,33 @@ +import json +import pytest + +from bs4 import BeautifulSoup + +from django.urls import reverse + + +@pytest.mark.django_db +def test_access_root_folder(realm, admin_client): + admin_url = reverse('admin:finder_inodemodel_change', kwargs={'inode_id': realm.root_folder.id}) + request = admin_client.get(admin_url) + assert request.status_code == 200 + soup = BeautifulSoup(request.content, 'html.parser') + assert soup.title.string == "Root | Change Folder | Django site admin" + script_element = soup.find(id='finder-settings') + assert script_element.name == 'script' + finder_settings = json.loads(script_element.string) + finder_settings.pop('csrf_token') + finder_settings.pop('favorite_folders') + finder_settings.pop('menu_extensions') + assert finder_settings == { + 'name': '__root__', + 'is_folder': True, + 'folder_id': str(realm.root_folder.id), + 'parent_id': None, + 'parent_url': None, + 'is_root': True, + 'is_trash': False, + 'folder_url': admin_url, + 'base_url': reverse('admin:finder_foldermodel_changelist'), + 'ancestors': [str(realm.root_folder.id)], + } diff --git a/demoapp/unittests/utils.py b/demoapp/unittests/utils.py new file mode 100644 index 000000000..7c23db973 --- /dev/null +++ b/demoapp/unittests/utils.py @@ -0,0 +1,29 @@ +import colorsys +from faker import Faker +import random +from PIL import Image, ImageDraw +from typing import NewType + + +ColorRGBA = NewType('ColorRGBA', tuple[int, int, int, int]) + + +def random_color() -> ColorRGBA: + return *(random.randint(0, 255) for _ in range(3)), 255 + + +def rotate_hue(rgb: ColorRGBA, degrees: float) -> ColorRGBA: + h, l, s = colorsys.rgb_to_hls(rgb[0], rgb[1], rgb[2]) + h = (h + degrees / 360.0) % 1.0 + l = 255.0 - l + return *map(lambda c: int(c), colorsys.hls_to_rgb(h, l, s)), rgb[3] + + +def create_random_image() -> Image: + faker = Faker() + background_color = random_color() + image = Image.new('RGB', (100, 100), color=background_color) + drawing = ImageDraw.Draw(image) + foreground_color = rotate_hue(background_color, 180) + drawing.text((5, 40), faker.text(20), fill=foreground_color) + return image diff --git a/finder/admin/folder.py b/finder/admin/folder.py index 3012315e9..cb8ae1465 100644 --- a/finder/admin/folder.py +++ b/finder/admin/folder.py @@ -23,13 +23,6 @@ class FolderAdmin(InodeAdmin): form_template = 'finder/admin/change_folder_form.html' _model_admin_cache = {} - _legends = { - 'name': _("Name"), - 'owner_name': _("Owner"), - 'details': _("Details"), - 'created_at': _("Created at"), - 'mime_type': _("Mime type"), - } @property def media(self): @@ -146,7 +139,6 @@ def get_editor_settings(self, request, inode): settings.update( base_url=reverse('admin:finder_foldermodel_changelist', current_app=self.admin_site.name), ancestors=ancestor_ids, - legends=self._legends, menu_extensions=self.get_menu_extension_settings(request), ) return settings diff --git a/finder/models/inode.py b/finder/models/inode.py index 5ef0f984f..d80ada2bd 100644 --- a/finder/models/inode.py +++ b/finder/models/inode.py @@ -89,8 +89,8 @@ class InodeManagerMixin: def filter_unified(self, **lookup): """ - Returns a unified QuerySet of all folders and files with fields from all involved models inheriting - from InodeModel. The QuerySet is filtered by the given lookup parameters. + Returns a unified QuerySet of all folders and files with fields from all involved models + inheriting from InodeModel. The QuerySet is filtered by the given lookup parameters. Entries are represented as dictionaries rather than model instances. """ from .file import FileModel diff --git a/finder/models/realm.py b/finder/models/realm.py index aa94b9cf1..a01dd7d07 100644 --- a/finder/models/realm.py +++ b/finder/models/realm.py @@ -4,6 +4,11 @@ class RealmModel(models.Model): + """ + The RealmModel is the top-level container for each tennant. This usually is associated with a + Django Admin Site. + Each RealmModel has one root folder and a trash folder per user. + """ site = models.ForeignKey( Site, on_delete=models.CASCADE, diff --git a/package.json b/package.json index d5e1982bd..73582c46f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "downshift": "^9.0.8", "esbuild": "^0.19.12", "esbuild-plugin-svgr": "^2.1.0", - "react-h5-audio-player": "^3.9.3", "react-image-crop": "^11.0.7", "react-intersection-observer": "^9.13.1", "react-player": "^2.16.0",