diff --git a/.bumpversion.toml b/.bumpversion.toml
new file mode 100755
index 000000000..f5f14423c
--- /dev/null
+++ b/.bumpversion.toml
@@ -0,0 +1,35 @@
+[tool.bumpversion]
+current_version = "3.4.4+pbs.1"
+commit = false
+tag = false
+parse = """(?x)
+ (?P0|[1-9]\\d*)\\.
+ (?P0|[1-9]\\d*)\\.
+ (?P0|[1-9]\\d*)
+ \\+pbs\\.
+ (?P0|[1-9]\\d*)
+ (?:
+ \\.(?Pdev)
+ \\.g[0-9a-f]{7,40}
+ \\.\\d{8}
+ )?
+"""
+
+serialize = [
+ "{major}.{minor}.{patch}+pbs.{pbs}.{prekind}.g{$GITHUB_SHA}.{now:%Y%m%d}",
+ "{major}.{minor}.{patch}+pbs.{pbs}"
+]
+
+
+[tool.bumpversion.parts.prekind]
+values = [
+ "",
+ "dev"
+]
+optional_value = ""
+
+
+[[tool.bumpversion.files]]
+filename = "filer/__init__.py"
+search = "{current_version}"
+replace = "{new_version}"
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 000000000..d5481b3df
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,5 @@
+# The bento-dev-team is responsible for the whole repo
+# * - all files
+# pbs - org
+# bento-dev-team - team
+* @pbs/bento-dev-team
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 000000000..34a6a23a4
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,44 @@
+name: Tests
+
+on:
+ workflow_call:
+ pull_request:
+ branches: [ "**" ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.12", "3.13"]
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install pytest pytest-django "Django>=5.1,<5.2"
+ pip install -e .
+
+ - name: Run tests
+ run: |
+ pytest \
+ --junitxml=pytest-results.xml \
+ --ds=filer.test_settings \
+ --pyargs \
+ filer.tests
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: test-results-py${{ matrix.python-version }}
+ path: pytest-results.xml
+
diff --git a/.gitignore b/.gitignore
index 157d67be8..4e08979de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,10 @@
*.pyc
+*.bak
*.pyo
.installed.cfg
bin
develop-eggs
-dist
+/dist
downloads
eggs
parts
@@ -12,6 +13,8 @@ tmp
*.egg-info
*.egg
.DS_Store
+.sass-cache
+node_modules
.project
.pydevproject
.settings
@@ -27,3 +30,8 @@ pep8.txt
htmlcov/
share/
.idea
+*.iml
+.tox
+*.pyo
+full.log
+pytest-results.xml
\ No newline at end of file
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
new file mode 100644
index 000000000..6fbd0813b
--- /dev/null
+++ b/CHANGELOG.rst
@@ -0,0 +1,438 @@
+CHANGELOG
+=========
+
+Revision 6af1333 (15.05.2018, 12:39 UTC)
+----------------------------------------
+
+No new issues.
+
+* Misc commits
+
+ * BEN-434: Add second trucate filename test.
+ * BEN-434: Add filename truncate test.
+ * BEN-434: Use filename with extesion instead of title.
+
+Revision e86cfa2 (08.08.2017, 15:01 UTC)
+----------------------------------------
+
+* LUN-3798
+
+ * Implement review findings
+ * Add unit-tests and bump version
+ * No 500 error at uploading long filename
+
+No other commits.
+
+Revision 108e8d0 (20.06.2017, 07:59 UTC)
+----------------------------------------
+
+* LUN-3517
+
+ * Put error messages in a dictionary
+ * removed commented code
+ * update unit-tests for clipboardadmin after changes
+ * do not show popup when same file uploaded
+ * fix detection of duplicate files
+
+No other commits.
+
+Revision f62676c (14.02.2017, 12:16 UTC)
+----------------------------------------
+
+* LUN-3465
+
+ * Allow positional parameters.
+
+* LUN-3545
+
+ * Add exlude name hashing rules for rootfolders.
+
+No other commits.
+
+Revision df5e50f (18.10.2016, 07:43 UTC)
+----------------------------------------
+
+* LUN-3289
+
+ * Rework permission checking to work with multiple managers.
+
+No other commits.
+
+Revision b5b4b34 (10.10.2016, 08:26 UTC)
+----------------------------------------
+
+No new issues.
+
+* Misc commits
+
+ * Handle unicode in filemodels.
+
+Revision 23058b6 (29.09.2016, 12:07 UTC)
+----------------------------------------
+
+* LUN-3217
+
+ * Add option to filter files by type.
+
+* LUN-3221
+
+ * Set specific exceptions for sha1 generation.
+ * Review fixes.
+ * Add content hash to filenames so CDN urls are automatically invalidated.
+
+No other commits.
+
+Revision b0041d1 (06.09.2016, 08:55 UTC)
+----------------------------------------
+
+* LUN-3211
+
+ * add filer popup callback support for iframes
+
+No other commits.
+
+Revision 7095511 (15.06.2016, 14:37 UTC)
+----------------------------------------
+
+* LUN-2784
+
+ * Be graceful about base_fields lookup.
+ * Remove FolderAdmin related field actions
+
+No other commits.
+
+Revision e0c4870 (06.05.2016, 15:22 UTC)
+----------------------------------------
+
+* LUN-2622
+
+ * LUN-2960: Apply styling from LUN-2622 to trash change list view.
+
+* LUN-2960
+
+ * Apply styling from LUN-2622 to trash change list view.
+
+No other commits.
+
+Revision 9084219 (13.04.2016, 10:56 UTC)
+----------------------------------------
+
+* LUN-2961
+
+ * js-hint.
+ * Fix property name.
+
+No other commits.
+
+Revision 8611055 (01.04.2016, 07:22 UTC)
+----------------------------------------
+
+* LUN-2853
+
+ * PEP8
+ * Remove deprecated request.REQUEST.
+ * Redirect files/folder '_save' changes to parent directory.
+ * Always redirect popup POST change requests to parent view.
+
+* LUN-2940
+
+ * Add unique constraint for user-clipboard.
+ * Whitespace.
+
+* Misc commits
+
+ * Fix test.
+
+Revision 8776bb3 (03.02.2016, 07:35 UTC)
+----------------------------------------
+
+* LUN-2622
+
+ * Fix indenting.
+ * Fix search pop-up header margins.
+ * Fix filer search pop-up for minimum resolution.
+
+No other commits.
+
+Revision 3f84a76 (14.01.2016, 14:46 UTC)
+----------------------------------------
+
+* LUN-2689
+
+ * Handle case when request body is missing.
+
+* Misc commits
+
+ * master_pbs Pin django-mptt to last working version.
+
+Revision 8cd8cf3 (18.11.2015, 08:15 UTC)
+----------------------------------------
+
+* LUN-2744
+
+ * Review: Refactored code that should be dead. Kept because not sure of intention.
+ * Review: identation & remove global variables.
+ * Review: Handle invalid urls.
+ * Add marker css class to show if a image is selected or not.
+ * Use full image size when widget is customized.
+ * Fix bug with file link not changing.
+ * Reworked customizable file widget to have a separate template.
+ * Refactored templates to separate custom image widget preview.
+ * custom preview- buttons updated
+ * Use widget customization for default case.
+ * Add option in file widget to enlarge preview and customize labels.
+
+* Misc commits
+
+ * fixed misspelling from js file
+
+Revision 47a0d53 (28.10.2015, 12:04 UTC)
+----------------------------------------
+
+* LUN-2647
+
+ * history button made yellow
+
+No other commits.
+
+Revision 5352d52 (13.10.2015, 13:15 UTC)
+----------------------------------------
+
+* LUN-2643
+
+ * Prevent access to image/file changelist views.
+ * Remove useless stuff.
+ * Revert fix as view will not be accessible.
+ * . Remove the option to add files/images from their changelist/change admin view.
+ * Move styling fix so it will be used in both image and file changelists.
+ * Remove the "Add image" link from the admin/filer/image changelist view.
+ * Handle case when view is reached without an object.
+
+* Misc commits
+
+ * Update server_backends.py. Replace deprecated method.
+
+Revision 49fdf9b (01.10.2015, 12:23 UTC)
+----------------------------------------
+
+No new issues.
+
+* Misc commits
+
+ * Add migration 0002.
+ * Fixed related lookup popup icons
+
+Revision df8010a (24.09.2015, 11:12 UTC)
+----------------------------------------
+
+No new issues.
+
+* Misc commits
+
+ * Django 1.8: fixed popup opening for add folder
+ * Django 1.8: updated extra context for custom admin view
+ * Django 1.8 upgrade: removed some django1.9 deprecation warnings
+ * Django 1.8 upgrade: updated test settings & fixed file/folder model related fields
+
+Revision 6cbcd3b (12.09.2015, 11:23 UTC)
+----------------------------------------
+
+* LUN-2620
+
+ * resize file/folder plugin popup
+
+No other commits.
+
+Revision eef2065 (04.09.2015, 09:05 UTC)
+----------------------------------------
+
+* LUN-2569
+
+ * 6.Revisit the layout of the filer upload pop-up window
+
+* LUN-2580
+
+ * fixes on sidebar
+
+* LUN-2596
+
+ * left align fieldsets
+
+* LUN-2603
+
+ * save button should appear when creating new folders on Filer
+
+No other commits.
+
+Revision 017a043 (28.08.2015, 08:51 UTC)
+----------------------------------------
+
+* LUN-2309
+
+ * collapsible fieldset style fix
+ * changed restricted link color changed
+ * add error messages wrapper only if they exist
+ * remove submit buttons padding around wrapper
+ * submit buttons updates
+ * updated manifest.in and .gitignore
+ * removed .sass-cache files
+ * filer updates for small resolutions and bug fixes
+ * updates after django upgrade
+ * remove deprecated function get_ordered_objects()
+ * Filer updates on forms
+ * Filer forms updates
+ * re-structure of change forms
+ * default boostrap updates
+ * Ace resources added to plugin
+ * updates on edit, delete pages
+ * Filer refactoring
+
+* Misc commits
+
+ * Add .iml files to gitignore.
+ * Restore check for permission before rendering save buttons.
+ * copy-folder form updates
+
+Revision 0aca38c (03.08.2015, 09:19 UTC)
+----------------------------------------
+
+* LUN-1434
+
+ * -celery-task Added tests for trash management command.
+ * -celery-task Added celery task for take_out_filer_trash command.
+
+* LUN-2124
+
+ * Small optimization since this error in improbable.
+ * Added tests for restriction changes.
+ * Updated tests to expect warning messages instead of permission denied.
+ * Added warning messages for some possible incorrect usage cases.
+
+* LUN-2156
+
+ * Fixed widgets name clash.
+ * Refactor imports
+ * Adding new line
+ * Do not show Clear checkbox on Filer asset details form
+
+* Misc commits
+
+ * added filer status command to check all files on storage
+
+Revision 9c535d2 (24.07.2015, 14:46 UTC)
+----------------------------------------
+
+No new issues.
+
+* Misc commits
+
+ * Django 1.7 upgrade: Folder widget should be visible.
+
+Revision 3a18983 (17.07.2015, 13:47 UTC)
+----------------------------------------
+
+No new issues.
+
+* Misc commits
+
+ * tox: Don't allow django 1.8 prereleases
+ * Django 1.7 upgrade: fixed dummy model for admin index page
+ * django 1.7 upgrade: fixed trash feature & deprecation warnings
+ * Django 1.7 upgrade; regenerated migrations
+ * Django 1.6 upgrade; fixed sites allowed on move action
+ * Django 1.6 upgrade changes
+
+Revision 9bdd109 (08.04.2015, 08:55 UTC)
+----------------------------------------
+
+No new issues.
+
+* Misc commits
+
+ * django-mptt 0.7.1 was released recently, it doesn't work out of the box
+
+Revision 553cd36 (11.03.2015, 10:41 UTC)
+----------------------------------------
+
+No new issues.
+
+* Misc commits
+
+ * Fix success message
+
+Revision b594c8f (03.03.2015, 12:20 UTC)
+----------------------------------------
+
+* LUN-1426
+
+ * fixed tests for folder destination filtering
+ * added destination cacndidates tree view for move action
+
+* LUN-1587
+
+ * displayed error mesages for zip extract process
+ * files with image extension but without valid image data will be ignored upon extraction.
+
+* Misc commits
+
+ * deleted pytest leftovers
+ * added destination field to copy action template
+
+Revision db6f7e5 (06.02.2015, 12:23 UTC)
+----------------------------------------
+
+No new issues.
+
+* Misc commits
+
+ * convert both str & unicode to unicode
+
+Revision d7f700c (05.11.2014, 16:58 UTC)
+----------------------------------------
+
+* LUN-1934
+
+ * fixed circular import reproducible when DEBUG is False * this happend while running management command from other apps that depend on filer.
+
+No other commits.
+
+Revision 77bf2d1 (21.10.2014, 11:16 UTC)
+----------------------------------------
+
+No new issues.
+
+* Misc commits
+
+ * Switch the import order to avoid a circular dependcy in case filer.models is imported before filer.fields.image
+
+Revision 2606d5f (30.09.2014, 13:35 UTC)
+----------------------------------------
+
+No new issues.
+
+* Misc commits
+
+ * Avoid upgrade to easy-thumbnails 2.x.x versions since would break the tests
+
+Revision a58cd5e (06.08.2014, 07:56 UTC)
+----------------------------------------
+
+* LUN-1762
+
+ * append popup params to files thumbnails
+
+No other commits.
+
+Revision ad5508f (13.06.2014, 12:26 UTC)
+----------------------------------------
+
+No new issues.
+
+* Misc commits
+
+ * Set correct destination for test results in tox.ini
+
+Revision cdfe111 (17.04.2014, 12:31 UTC)
+----------------------------------------
+
+Changelog history starts here.
diff --git a/Dockerfile.test b/Dockerfile.test
new file mode 100644
index 000000000..0f2554268
--- /dev/null
+++ b/Dockerfile.test
@@ -0,0 +1,16 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+# Install system deps needed for building Python packages
+RUN apt-get update && apt-get install -y --no-install-recommends gcc && \
+ rm -rf /var/lib/apt/lists/*
+
+# Copy the full source and install
+COPY . .
+RUN pip install --no-cache-dir pytest pytest-django filetype pytz "Django>=5.1,<5.2" && \
+ pip install --no-cache-dir -e .
+
+
+CMD ["pytest", "--ds=filer.test_settings", "filer/tests/"]
+
diff --git a/MANIFEST.in b/MANIFEST.in
index 02878d577..699091990 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -6,3 +6,7 @@ include HISTORY
recursive-include filer/templates *
recursive-include filer/static *
recursive-include filer/locale *
+recursive-exclude filer/static/libs/bootstrap/.sass-cache *
+exclude filer/static/libs/bootstrap/js/bootstrap.js
+recursive-exclude filer/static/libs/bootstrap/sass *
+exclude filer/static/libs/bootstrap/config.rb
diff --git a/Makefile b/Makefile
new file mode 100644
index 000000000..7202313c9
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,70 @@
+.PHONY: help
+help: ## This help.
+ @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n\nTargets:\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-10s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
+.DEFAULT_GOAL := help
+
+.PHONY: bump-pbs bump-prekind build publish-nexus publish-nexus-dry-run
+
+IMAGE_NAME := django-filer-test
+CONTAINER_NAME := django-filer-test-run
+SHELL:=/bin/bash
+
+test-build: ## Build the test Docker image
+ docker build -f Dockerfile.test -t $(IMAGE_NAME) .
+
+test: ## Run unit tests in a Docker container
+ @if [ -z "$$(docker images -q $(IMAGE_NAME) 2>/dev/null)" ]; then \
+ echo "Image not found, building..."; \
+ $(MAKE) test-build; \
+ fi
+ docker run --rm --name $(CONTAINER_NAME) $(IMAGE_NAME)
+
+test-verbose: test-build ## Run tests with verbose output and stop on first failure
+ docker run --rm --name $(CONTAINER_NAME) $(IMAGE_NAME) \
+ pytest -vx --ds=filer.test_settings --pyargs filer.tests
+
+test-shell: test-build ## Open a shell in the test container (useful for debugging)
+ docker run --rm -it --name $(CONTAINER_NAME) $(IMAGE_NAME) /bin/bash
+
+test-clean: ## Remove the test Docker image
+ -docker rmi $(IMAGE_NAME)
+
+
+bump-pbs: ## Bump PBS number: 3.4.4+pbs.3 -> 3.4.4+pbs.4
+ bump-my-version bump --allow-dirty pbs
+
+bump-prekind: ## Bump prekind dev version: 3.4.4+pbs.3 -> 3.4.4+pbs.3.dev.g.YYYYMMDD. Pass sha= to override.
+ @current=$$(grep "^__version__" filer/__init__.py | sed "s/^__version__ = '//;s/'.*//"); \
+ sha="$${sha:-$$(git rev-parse --short=8 HEAD)}"; \
+ sha8="$${sha:0:8}"; \
+ if echo "$$current" | grep -q '\.dev\.'; then \
+ current_sha=$$(echo "$$current" | sed -nE "s/.*\.dev\.g([0-9a-f]{7,40})\.[0-9]{8}/\1/p"); \
+ current_sha8="$${current_sha:0:8}"; \
+ if [ "$$current_sha8" = "$$sha8" ]; then \
+ echo "Nothing to bump: '$$current' already targets commit '$$sha8'. Create a new commit first or run 'make bump-pbs'."; \
+ exit 1; \
+ fi; \
+ base=$$(echo "$$current" | sed -E "s/\.dev\.g[0-9a-f]+\.[0-9]{8}$$//"); \
+ new_version="$$base.dev.g$$sha8.$$(date +%Y%m%d)"; \
+ else \
+ base="$$current"; \
+ new_version="$$base.dev.g$$sha8.$$(date +%Y%m%d)"; \
+ fi; \
+ echo "Bumping: $$current -> $$new_version"; \
+ sed -i.bak "s/__version__ = '$$current'/__version__ = '$$new_version'/" filer/__init__.py && rm -f filer/__init__.py.bak; \
+ sed -i.bak -E "s/^current_version = \"[^\"]+\"/current_version = \"$$new_version\"/" .bumpversion.toml && rm -f .bumpversion.toml.bak
+
+
+build: ## Build distribution packages (sdist and wheel)
+ rm -rf dist/ build/ *.egg-info
+ python3 -m pip install --upgrade --break-system-packages build
+ python3 -m build
+
+publish-nexus: build ## Publish to Nexus using ~/.pypirc config
+ python3 -m pip install --upgrade --break-system-packages twine
+ python3 -m twine upload -r nexus dist/*
+
+publish-nexus-dry-run: build ## Validate packages ready for Nexus (without uploading)
+ python3 -m pip install --upgrade --break-system-packages twine
+ python3 -m twine check dist/*
+
diff --git a/README.rst b/README.rst
index 067599279..98ea4ec54 100644
--- a/README.rst
+++ b/README.rst
@@ -12,8 +12,8 @@ Wiki: https://github.com/stefanfoulis/django-filer/wiki
Dependencies
------------
-* `Django`_ >= 1.3 (with ``django.contrib.staticfiles``)
-* django-mptt >= 0.2.1
+* `Django`_ >=4.2 (with ``django.contrib.staticfiles``)
+* `django-mptt`_ >= 0.2.1
* `easy_thumbnails`_ >= 1.0-alpha-17
* `django-polymorphic`_ >= 0.2
* `PIL`_ 1.1.7 (with JPEG and ZLIB support)
diff --git a/README_PBS.md b/README_PBS.md
new file mode 100644
index 000000000..40ba51006
--- /dev/null
+++ b/README_PBS.md
@@ -0,0 +1,150 @@
+# PBS Fork – Developer Guide
+
+## Versioning Scheme
+
+This fork uses a PBS-specific versioning scheme:
+
+```
++pbs.
+```
+
+Example: `3.4.4+pbs.1`, `3.4.4+pbs.2`
+
+Development (pre-release) versions append a dev suffix:
+
+```
++pbs..dev.g.YYYYMMDD
+```
+
+Example: `3.4.4+pbs.1.dev.g1a2b3c4d.20260617`
+
+The version is stored in `filer/__init__.py` and read dynamically by `pyproject.toml`.
+
+---
+
+## Makefile Targets
+
+Run `make help` to see all available targets. Summary:
+
+| Target | Description |
+|--------|-------------|
+| `make help` | Show all available targets |
+| `make test-build` | Build the test Docker image |
+| `make test` | Run unit tests in a Docker container (builds image if missing) |
+| `make test-verbose` | Run tests with verbose output, stop on first failure |
+| `make test-shell` | Open a shell in the test container (for debugging) |
+| `make test-clean` | Remove the test Docker image |
+| `make bump-pbs` | Bump the PBS version number (e.g. `+pbs.1` → `+pbs.2`) |
+| `make bump-prekind` | Create a dev pre-release version from the current commit |
+| `make build` | Build sdist and wheel packages into `dist/` |
+| `make publish-nexus` | Build and upload packages to Nexus |
+| `make publish-nexus-dry-run` | Build and validate packages without uploading |
+
+---
+
+## How to Publish a New Version
+
+### Prerequisites
+
+- Python 3 installed
+- `bump-my-version` installed (`pip install bump-my-version`)
+- `~/.pypirc` configured with a `[nexus]` section containing your repository URL and credentials
+
+### Steps
+
+#### 1. Run tests
+
+```bash
+make test
+```
+
+Ensure all tests pass before proceeding.
+
+#### 2. Bump the PBS version
+
+```bash
+make bump-pbs
+```
+
+This increments the PBS number (e.g. `3.4.4+pbs.1` → `3.4.4+pbs.2`) in both `filer/__init__.py` and `.bumpversion.toml`.
+
+#### 3. Commit and push
+
+```bash
+git add filer/__init__.py .bumpversion.toml
+git commit -m "Bump to $(grep __version__ filer/__init__.py | sed "s/.*'//;s/'.*//")"
+git push
+```
+
+#### 4. Publish to Nexus
+
+```bash
+make publish-nexus
+```
+
+This will:
+1. Clean previous build artifacts
+2. Build source distribution and wheel (`python3 -m build`)
+3. Upload to Nexus using `twine` with the `[nexus]` section from `~/.pypirc`
+
+#### 5. (Optional) Validate without uploading
+
+```bash
+make publish-nexus-dry-run
+```
+
+Runs `twine check` on the built packages to verify they are well-formed.
+
+---
+
+## Publishing a Dev (Pre-release) Version
+
+Use this when you need to test unreleased changes without a formal PBS bump:
+
+```bash
+make bump-prekind
+```
+
+This creates a version like `3.4.4+pbs.1.dev.g1a2b3c4d.20260617` based on the current HEAD commit and date.
+
+You can override the commit SHA:
+
+```bash
+make bump-prekind sha=abc12345
+```
+
+Then publish normally:
+
+```bash
+make publish-nexus
+```
+
+> **Note:** To bump again on the same commit, you must first run `make bump-pbs` to move to the next PBS number.
+
+---
+
+## Publishing via GitHub Actions (Bento3)
+
+You can also publish a new version using the **bento3** GitHub Actions workflow:
+
+🔗 [publish_django_filer.yml](https://github.com/pbs-digital/bento3/actions/workflows/publish_django_filer.yml)
+
+Trigger the workflow manually from the Actions tab. This is the recommended approach for CI-driven releases.
+
+---
+
+## `~/.pypirc` Configuration (for local publishing)
+
+Ensure your `~/.pypirc` contains a `[nexus]` entry:
+
+```ini
+[distutils]
+index-servers =
+ nexus
+
+[nexus]
+repository = https://your-nexus-instance/repository/pypi-hosted/
+username = your-username
+password = your-password-or-token
+```
+
diff --git a/UPSTREAM_MERGE_FIXES.md b/UPSTREAM_MERGE_FIXES.md
new file mode 100644
index 000000000..237a27d2e
--- /dev/null
+++ b/UPSTREAM_MERGE_FIXES.md
@@ -0,0 +1,207 @@
+# Upstream Merge Fix Progress
+
+## Overview
+
+**Upstream source:** `https://github.com/django-cms/django-filer` (master branch, v3.4.4)
+**Fork divergence:** Forked at tag `0.9` (commit `a9364960`), ~790 PBS vs ~1,977 upstream commits, 603 files changed.
+**Target:** Django 5.1 / Python 3.12+
+
+### Test Results Timeline
+
+| Stage | Failed | Passed | Skipped |
+|-------|--------|--------|---------|
+| Pre-merge (PBS baseline) | 0 | 153 | 68 |
+| After upstream merge | 73 | ~80 | 68 |
+| After Session 1 fixes | 67 | 86 | 68 |
+| After Session 2 fixes | 59 | 94 | 68 |
+| **After all fixes (final)** | **0** | **153** | **68** |
+
+---
+
+## Fixes Applied
+
+### 1. `setup.py` — Build-time import error (GHA blocker)
+
+- **Problem:** `__import__('filer').__version__` fails because Django isn't installed in the build environment
+- **Fix:** Replaced with regex-based `get_version()` that reads `filer/__init__.py` without importing
+- **Also:** Removed deprecated `setuptools.command.test` references and `test_suite`/`tests_require`
+- **Code:**
+ ```python
+ def get_version():
+ """Read version from filer/__init__.py without importing the package."""
+ init_py = os.path.join(os.path.dirname(__file__), 'filer', '__init__.py')
+ with open(init_py) as f:
+ match = re.search(r"^__version__\s*=\s*['\"]([^'\"]+)['\"]", f.read(), re.M)
+ if not match:
+ raise RuntimeError("Cannot find __version__ in filer/__init__.py")
+ return match.group(1)
+ ```
+
+### 2. `filer/__init__.py` — PEP 440 version
+
+- **Problem:** `3.5.0.pbs.1` is invalid per PEP 440
+- **Fix:** Changed to `3.5.0+pbs.1` (local version identifier)
+
+### 3. `filer/models/abstract.py` — VILImage import
+
+- **Problem:** `from easy_thumbnails.VIL import Image` fails without `reportlab`
+- **Fix:** Wrapped in try/except, set `VILImage = None` as fallback
+- **Code:**
+ ```python
+ try:
+ from easy_thumbnails.VIL import Image as VILImage
+ except (ImportError, ModuleNotFoundError):
+ VILImage = None
+ ```
+
+### 4. `filer/admin/fileadmin.py` — `display_canonical` FieldError
+
+- **Problem:** `get_readonly_fields` returned only model field names for restricted/core files, but fieldsets contain `display_canonical` (an admin method, not a model field)
+- **Fix:** Appended `'display_canonical'` to the readonly fields list in that code path
+- **Error:** `FieldError: Unknown field(s) (display_canonical) specified for File`
+
+### 5. `filer/templates/admin/filer/submit_line.html` — Delete URL with empty pk
+
+- **Problem:** `{% url opts|admin_urlname:'delete' obj.pk %}` failed when `obj.pk` was empty
+- **Fix:** Changed to `original.pk` and added `and original.pk` guard
+
+### 6. `filer/admin/folderadmin.py` — Multiple fixes
+
+#### 6a. `destination_folders` AJAX view
+- **Problem:** `NoReverseMatch: 'filer-destination_folders' not found` — the fancytree folder picker widget needed this URL
+- **Fix:** Added AJAX view and URL pattern to `FolderAdmin.get_urls()`
+
+#### 6b. `move_file_to_clipboard` signature mismatch
+- **Problem:** Both call sites passed wrong number of arguments
+- **Fix:** Added missing `request` argument at both call sites
+
+#### 6c. `KeyError: 'name'` in folder form clean
+- **Problem:** Folder form clean method assumed `'name'` was always in `cleaned_data`
+- **Fix:** Added guard `if 'name' not in cleaned_data: return cleaned_data`
+
+#### 6d. `exclude` tuple incomplete
+- **Problem:** Upstream had `exclude = ('parent',)` but PBS needs `('parent', 'owner', 'folder_type')`
+- **Fix:** Restored full PBS exclude tuple
+
+#### 6e. `get_form()` — PBS field visibility logic
+- **Problem:** Upstream `get_form()` didn't handle PBS-specific dynamic field visibility
+- **Fix:** Restored full PBS logic:
+ - Hide `site`/`shared` for child folders and core folders
+ - Only show `shared` to superusers
+ - Pop `restricted` for add view
+ - Set `owner`/`parent` in clean method
+
+#### 6f. `_move_files_and_folders_impl` — MPTT corruption
+- **Problem:** Upstream used bulk `update()` which bypasses MPTT tree updates and model signals
+- **Fix:** Changed to individual `save()` calls to trigger MPTT tree recalculation
+
+#### 6g. `move_files_and_folders()` — PBS validation
+- **Problem:** PBS site validation checks were missing from upstream's move implementation
+- **Fix:** Restored:
+ - Root folder move prevention
+ - Site consistency checks (all moved items must belong to same site as destination)
+- **Code:**
+ ```python
+ # PBS: prevent moving root folders
+ if folders_queryset.filter(parent=None).exists():
+ messages.error(request, "To prevent potential problems, users "
+ "are not allowed to move root folders.")
+ return
+ # PBS: site consistency checks
+ sites_from_folders = \
+ set(folders_queryset.values_list('site_id', flat=True)) | \
+ set(files_queryset.exclude(folder__isnull=True).\
+ values_list('folder__site_id', flat=True))
+ if sites_from_folders and None in sites_from_folders:
+ messages.error(request, "Some of the selected files/folders "
+ "do not belong to any site.")
+ return
+ ```
+
+#### 6h. `log_deletion` deprecation
+- **Problem:** Dead `else` branches for Django < 5.1; bug where `files_queryset` was logged instead of `folders_queryset`
+- **Fix:** Removed dead branches, fixed queryset reference
+
+### 7. `filer/server/backends/` — Server backend fixes (3 files)
+
+- **Files:** `default.py`, `nginx.py`, `xsendfile.py`
+- **Problem 1:** `filer_file.mime_type` — `filer_file` is a `FieldFile` (not the `File` model), so it has no `mime_type` attribute
+- **Fix 1:** Added `mimetypes.guess_type()` fallback
+- **Problem 2:** `file_obj=filer_file.file` — `filer_file` is already the `FieldFile`, `.file` is the raw Python file object
+- **Fix 2:** Changed to `file_obj=filer_file`
+- **Error:** `AttributeError: 'MultiStorageFieldFile' has no attribute 'mime_type'`
+
+### 8. `filer/admin/views.py` — NewFolderForm missing `site` field
+
+- **Problem:** Form only had `fields = ('name',)`, ignoring PBS `site` field from POST data
+- **Fix:** Added `'site'` to form fields: `fields = ('name', 'site')`
+
+### 9. `filer/templatetags/filermedia.py` — Deleted by upstream merge
+
+- **Problem:** Upstream deleted `filermedia.py` but 8+ templates still reference `{% load filermedia %}`
+- **Fix:** Restored file with `filer_staticmedia_prefix` simple tag
+- **Error:** `TemplateSyntaxError: 'filermedia' is not a registered tag library`
+
+### 10. `filer/admin/clipboardadmin.py` — Clipboard not created on upload
+
+- **Problem:** Upstream commented out `Clipboard.objects.get_or_create(user=request.user)` in `ajax_upload`
+- **Fix:** Restored the get_or_create call so clipboard is created during upload
+- **Error:** `Clipboard.DoesNotExist`
+
+### 11. `filer/tests/admin.py` — Test compatibility fixes
+
+- **Clipboard access:** Replaced `self.superuser.filer_clipboard` with `Clipboard.objects.get(user=self.superuser)`
+- **Return values:** Removed `return folders, files` from test methods
+
+### 12. `filer/utils/cdn.py` — `modified_at` None guard
+
+- **Problem:** `get_cdn_url()` crashes with `TypeError: unsupported operand type(s) for +: 'NoneType' and 'datetime.timedelta'` when `file_obj.modified_at` is `None`
+- **Fix:** Added early return `if file_obj.modified_at is None: return url` before the timedelta arithmetic
+- **Error:** `TypeError: unsupported operand type(s) for +: 'NoneType' and 'datetime.timedelta'`
+
+---
+
+## PBS-Specific Features Preserved
+
+These features exist in the PBS fork but not in upstream django-filer:
+
+| Feature | Description |
+|---------|-------------|
+| **Trash / Soft-delete** | Files/folders are soft-deleted to trash before permanent deletion |
+| **Site-based permissions** | Folders belong to Django sites; users have site-scoped access |
+| **Folder types** | `CORE_FOLDER`, `SITE_FOLDER` — restricts operations on system folders |
+| **Restricted flag** | Files/folders can be marked restricted; cascades to children |
+| **Shared folders** | M2M relationship allowing folders shared across sites |
+| **CDN invalidation** | URL hashing and CDN cache invalidation on file changes |
+| **S3/Botocore storage** | Multi-storage backend with S3 support |
+| **Clipboard model** | Per-user clipboard for file operations |
+| **Folder-affects-URL** | Hash-based filenames tied to folder structure |
+
+---
+
+## Test Results — ✅ All Passing
+
+**Final result: 153 passed, 68 skipped, 0 failed** (matches PBS baseline)
+
+All 59 previously failing tests have been resolved. The upstream merge is fully compatible with the PBS test suite.
+
+---
+
+## Files Modified (Summary)
+
+| File | Type of Change |
+|------|---------------|
+| `setup.py` | Build fix (regex version reader) |
+| `filer/__init__.py` | PEP 440 version fix |
+| `filer/models/abstract.py` | Import guard for VILImage |
+| `filer/admin/fileadmin.py` | Readonly fields fix |
+| `filer/admin/folderadmin.py` | 8 separate fixes (AJAX view, move/copy validation, MPTT, field visibility) |
+| `filer/admin/views.py` | NewFolderForm site field |
+| `filer/admin/clipboardadmin.py` | Clipboard creation restored |
+| `filer/server/backends/default.py` | mime_type + file_obj fixes |
+| `filer/server/backends/nginx.py` | mime_type + file_obj fixes |
+| `filer/server/backends/xsendfile.py` | mime_type + file_obj fixes |
+| `filer/templatetags/filermedia.py` | Restored deleted file |
+| `filer/templates/admin/filer/submit_line.html` | Delete URL pk guard |
+| `filer/tests/admin.py` | Test compatibility fixes |
+| `filer/utils/cdn.py` | `modified_at` None guard for CDN URL |
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 000000000..a91cddad2
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,9 @@
+import os
+
+collect_ignore = [
+ os.path.join(os.path.dirname(__file__), "filer", "tests", "utils"),
+ os.path.join(os.path.dirname(__file__), "filer", "tests", "__init__.py"),
+ os.path.join(os.path.dirname(__file__), "filer", "contrib"),
+ os.path.join(os.path.dirname(__file__), "filer", "management"),
+]
+
diff --git a/docs/settings.rst b/docs/settings.rst
index 4dfd2ab30..c0f32e455 100644
--- a/docs/settings.rst
+++ b/docs/settings.rst
@@ -6,7 +6,7 @@ Settings
``FILER_ENABLE_PERMISSIONS``
----------------------------
-Activate the or not the Permission check on the files and folders before
+Activate the or not the Permission check on the files and folders before
displaying them in the admin. When set to ``False`` it gives all the authorization
to staff members based on standard Django model permissions.
@@ -28,7 +28,7 @@ The prefix for static media where filer will look for bundled javascript, css
and images.
Defaults to ``/filer/`` if ``STATIC_URL`` is defined. Otherwise
-falls back to ``/filer/``. It is the URL where the ``static/filer/``
+falls back to ``/filer/``. It is the URL where the ``static/filer/``
directory should be served.
.. _FILER_STORAGES:
@@ -122,15 +122,33 @@ Defaults to ``20``
``FILER_SUBJECT_LOCATION_IMAGE_DEBUG``
--------------------------------------
-Draws a red circle around to point in the image that was used to do the
+Draws a red circle around to point in the image that was used to do the
subject location aware image cropping.
Defaults to ``False``
-``FILER_ALLOW_REGULAR_USERS_TO_ADD_ROOT_FOLDERS``
--------------------------------------------------
+``FILER_FOLDER_AFFECTS_URL``
+----------------------------
+
+Set this to ``True`` when your ``UPLOAD_TO`` function generates a path that depends on the
+folder the file belongs to.
-Regular users are not allowed to create new folders at the root level, only
-subfolders of already existing folders, unless this setting is set to ``True``.
+When renaming a file or moving it in and out of the clipboard the file is renamed/move on the
+backend storage as well
Defaults to ``False``
+
+
+``FILER_CDN_DOMAIN`` and ``FILER_CDN_INVALIDATION_TIME``
+--------------------------------------------------------
+
+This settings and ``CDN_INVALIDATION_TIME`` are only useful if using a cdn and if when uploading a newer version of a file object the physical file gets overwritten on the storage backend.
+
+When the above condition is True:
+* make sure you're storage backend returns urls that always point to the origin (NOT to the cdn)
+* make ``FILER_CDN_DOMAIN`` point to the cdn distribution's domain
+* set ``FILER_CDN_INVALIDATION_TIME`` to the time it takes the cdn to clear it's cache
+
+When a file object gets updated to a newer version, setting the above will result in:
+* if the file's url is requested BEFORE the CDN got updated, the origin's domain will be used
+* if the file's url is requested AFTER the CDN got updated, the cdn's domain will be used
diff --git a/filer/__init__.py b/filer/__init__.py
index c0325d34b..03cc0b520 100644
--- a/filer/__init__.py
+++ b/filer/__init__.py
@@ -1,3 +1,14 @@
-#-*- coding: utf-8 -*-
-# version string following pep-0396 and pep-0386
-__version__ = '0.9' # pragma: nocover
+"""
+See PEP 386 (https://www.python.org/dev/peps/pep-0386/)
+
+Release logic:
+ 1. Increase version number (change __version__ below).
+ 2. Check that all changes have been documented in CHANGELOG.rst.
+ 3. git add filer/__init__.py CHANGELOG.rst
+ 4. git commit -m 'Bump to {new version}'
+ 5. git push
+ 6. Assure that all tests pass on CI
+ 7. Create a new release on github.
+"""
+
+__version__ = '3.4.4+pbs.1'
diff --git a/filer/admin/__init__.py b/filer/admin/__init__.py
index 32b7da334..c84fb51b9 100644
--- a/filer/admin/__init__.py
+++ b/filer/admin/__init__.py
@@ -1,15 +1,43 @@
-#-*- coding: utf-8 -*-
from django.contrib import admin
-from filer.admin.clipboardadmin import ClipboardAdmin
-from filer.admin.fileadmin import FileAdmin
-from filer.admin.folderadmin import FolderAdmin
-from filer.admin.imageadmin import ImageAdmin
-from filer.admin.permissionadmin import PermissionAdmin
-from filer.models import FolderPermission, Folder, File, Clipboard, Image
+from ..models import Clipboard, File, Folder, FolderPermission
+from ..settings import FILER_IMAGE_MODEL
+from ..utils.loader import load_model
+from .clipboardadmin import ClipboardAdmin
+from .fileadmin import FileAdmin
+from .folderadmin import FolderAdmin
+from .imageadmin import ImageAdmin
+from .permissionadmin import PermissionAdmin
+
+# PBS-specific: ThumbnailOption model may not exist in PBS migrations yet
+try:
+ from ..models import ThumbnailOption
+ from .thumbnailoptionadmin import ThumbnailOptionAdmin
+ _has_thumbnail_option = True
+except ImportError:
+ _has_thumbnail_option = False
+
+# PBS-specific: Trash admin
+from ..admin.trashadmin import Trash, TrashAdmin
+
+Image = load_model(FILER_IMAGE_MODEL)
admin.site.register(Folder, FolderAdmin)
admin.site.register(File, FileAdmin)
admin.site.register(Clipboard, ClipboardAdmin)
admin.site.register(Image, ImageAdmin)
admin.site.register(FolderPermission, PermissionAdmin)
+
+if _has_thumbnail_option:
+ admin.site.register(ThumbnailOption, ThumbnailOptionAdmin)
+
+# PBS-specific: register Trash admin
+admin.site.register([Trash], TrashAdmin)
+
+# PBS-specific: Archive (backward compat)
+try:
+ from ..models import Archive
+ from ..admin.archiveadmin import ArchiveAdmin
+ admin.site.register(Archive, ArchiveAdmin)
+except ImportError:
+ pass
diff --git a/filer/admin/archiveadmin.py b/filer/admin/archiveadmin.py
new file mode 100644
index 000000000..2c30342b9
--- /dev/null
+++ b/filer/admin/archiveadmin.py
@@ -0,0 +1,13 @@
+from filer.admin.fileadmin import FileAdmin, FileAdminChangeFrom
+from filer.models import Archive
+
+
+class ArchiveAdminForm(FileAdminChangeFrom):
+
+ class Meta:
+ model = Archive
+ exclude = ()
+
+
+class ArchiveAdmin(FileAdmin):
+ form = ArchiveAdminForm
diff --git a/filer/admin/clipboardadmin.py b/filer/admin/clipboardadmin.py
index cf403c7fd..fbcc41cf2 100644
--- a/filer/admin/clipboardadmin.py
+++ b/filer/admin/clipboardadmin.py
@@ -1,13 +1,29 @@
-#-*- coding: utf-8 -*-
+
+from django.contrib import admin, messages
+from django.core.exceptions import ValidationError
from django.forms.models import modelform_factory
-from django.contrib import admin
-from django.http import HttpResponse
-from django.utils import simplejson
+from django.http import JsonResponse
+from django.urls import path, reverse
+from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
-from filer import settings as filer_settings
-from filer.models import Clipboard, ClipboardItem
-from filer.utils.files import handle_upload, UploadException
-from filer.utils.loader import load_object
+
+from .. import settings as filer_settings
+from ..models import Clipboard, ClipboardItem, Folder
+from ..settings import FILER_THUMBNAIL_ICON_SIZE
+from ..utils.files import handle_request_files_upload, handle_upload, truncate_filename
+from ..utils.loader import load_model
+from ..validation import validate_upload
+from . import views
+
+
+NO_PERMISSIONS = _("You do not have permission to upload files.")
+NO_FOLDER_ERROR = _("Can't find folder to upload. Please refresh and try again")
+NO_PERMISSIONS_FOR_FOLDER = _(
+ "Can't use this folder, Permission Denied. Please select another folder."
+)
+
+
+Image = load_model(filer_settings.FILER_IMAGE_MODEL)
# ModelAdmins
@@ -18,85 +34,33 @@ class ClipboardItemInline(admin.TabularInline):
class ClipboardAdmin(admin.ModelAdmin):
model = Clipboard
inlines = [ClipboardItemInline]
- filter_horizontal = ('files',)
raw_id_fields = ('user',)
verbose_name = "DEBUG Clipboard"
verbose_name_plural = "DEBUG Clipboards"
+ messages = {
+ 'already-exists': "File '{}' already exists in the clipboard.",
+ }
def get_urls(self):
- from django.conf.urls.defaults import patterns, url
- urls = super(ClipboardAdmin, self).get_urls()
- from filer import views
- url_patterns = patterns('',
- url(r'^operations/paste_clipboard_to_folder/$',
- self.admin_site.admin_view(views.paste_clipboard_to_folder),
- name='filer-paste_clipboard_to_folder'),
- url(r'^operations/discard_clipboard/$',
- self.admin_site.admin_view(views.discard_clipboard),
- name='filer-discard_clipboard'),
- url(r'^operations/delete_clipboard/$',
- self.admin_site.admin_view(views.delete_clipboard),
- name='filer-delete_clipboard'),
- # upload does it's own permission stuff (because of the stupid
- # flash missing cookie stuff)
- url(r'^operations/upload/$',
- self.ajax_upload,
- name='filer-ajax_upload'),
- )
- url_patterns.extend(urls)
- return url_patterns
-
- @csrf_exempt
- def ajax_upload(self, request, folder_id=None):
- """
- receives an upload from the uploader. Receives only one file at the time.
- """
- mimetype = "application/json" if request.is_ajax() else "text/html"
- try:
- upload, filename, is_raw = handle_upload(request)
-
- # Get clipboad
- clipboard = Clipboard.objects.get_or_create(user=request.user)[0]
-
- # find the file type
- for filer_class in filer_settings.FILER_FILE_MODELS:
- FileSubClass = load_object(filer_class)
- #TODO: What if there are more than one that qualify?
- if FileSubClass.matches_file_type(filename, upload, request):
- FileForm = modelform_factory(
- model = FileSubClass,
- fields = ('original_filename', 'owner', 'file')
- )
- break
- uploadform = FileForm({'original_filename': filename,
- 'owner': request.user.pk},
- {'file': upload})
- if uploadform.is_valid():
- file_obj = uploadform.save(commit=False)
- # Enforce the FILER_IS_PUBLIC_DEFAULT
- file_obj.is_public = filer_settings.FILER_IS_PUBLIC_DEFAULT
- file_obj.save()
- clipboard_item = ClipboardItem(
- clipboard=clipboard, file=file_obj)
- clipboard_item.save()
- json_response = {
- 'thumbnail': file_obj.icons['32'],
- 'alt_text': '',
- 'label': unicode(file_obj),
- }
- return HttpResponse(simplejson.dumps(json_response),
- mimetype=mimetype)
- else:
- form_errors = '; '.join(['%s: %s' % (
- field,
- ', '.join(errors)) for field, errors in uploadform.errors.items()
- ])
- raise UploadException("AJAX request not valid: form invalid '%s'" % (form_errors,))
- except UploadException, e:
- return HttpResponse(simplejson.dumps({'error': unicode(e)}),
- mimetype=mimetype)
-
- def get_model_perms(self, request):
+ return [
+ path('operations/paste_clipboard_to_folder/',
+ self.admin_site.admin_view(views.paste_clipboard_to_folder),
+ name='filer-paste_clipboard_to_folder'),
+ path('operations/discard_clipboard/',
+ self.admin_site.admin_view(views.discard_clipboard),
+ name='filer-discard_clipboard'),
+ path('operations/delete_clipboard/',
+ self.admin_site.admin_view(views.delete_clipboard),
+ name='filer-delete_clipboard'),
+ path('operations/upload//',
+ ajax_upload,
+ name='filer-ajax_upload'),
+ path('operations/upload/no_folder/',
+ ajax_upload,
+ name='filer-ajax_upload'),
+ ] + super().get_urls()
+
+ def get_model_perms(self, *args, **kwargs):
"""
It seems this is only used for the list view. NICE :-)
"""
@@ -105,3 +69,128 @@ def get_model_perms(self, request):
'change': False,
'delete': False,
}
+
+
+@csrf_exempt
+def ajax_upload(request, folder_id=None):
+ """
+ Receives an upload from the uploader. Receives only one file at a time.
+ """
+
+ if not request.user.has_perm("filer.add_file"):
+ messages.error(request, NO_PERMISSIONS)
+ return JsonResponse({'error': NO_PERMISSIONS})
+
+ if folder_id:
+ try:
+ # Get folder
+ folder = Folder.objects.get(pk=folder_id)
+ except Folder.DoesNotExist:
+ messages.error(request, NO_FOLDER_ERROR)
+ return JsonResponse({'error': NO_FOLDER_ERROR})
+ else:
+ folder = Folder.objects.filter(pk=request.session.get('filer_last_folder_id', 0)).first()
+
+ # check permissions
+ if folder and not folder.has_add_children_permission(request):
+ messages.error(request, NO_PERMISSIONS_FOR_FOLDER)
+ return JsonResponse({'error': NO_PERMISSIONS_FOR_FOLDER})
+
+ try:
+ if len(request.FILES) == 1:
+ # don't check if request is ajax or not, just grab the file
+ upload, filename, is_raw, mime_type = handle_request_files_upload(request)
+ else:
+ # else process the request as usual
+ upload, filename, is_raw, mime_type = handle_upload(request)
+ except Exception as e:
+ return JsonResponse({'error': str(e)})
+
+ # Truncate long filenames
+ filename = truncate_filename(upload, maxlen=100)
+ upload.name = filename
+
+ # Re-detect mime_type after truncation may have added an extension
+ import mimetypes as _mimetypes
+ guessed_type = _mimetypes.guess_type(filename)[0]
+ if guessed_type and (
+ mime_type == 'application/octet-stream'
+ or not _mimetypes.guess_all_extensions(mime_type)
+ ):
+ mime_type = guessed_type
+
+
+ # Get clipboard
+ clipboard = Clipboard.objects.get_or_create(user=request.user)[0]
+
+ # Remove any stale clipboard entries with the same filename
+ # (e.g. from previous failed upload attempts) to allow re-upload
+ existing_in_clipboard = clipboard.files.filter(original_filename=filename)
+ if existing_in_clipboard.exists():
+ # Get the actual file pks before clearing the M2M
+ stale_file_pks = list(existing_in_clipboard.values_list('pk', flat=True))
+ ClipboardItem.objects.filter(clipboard=clipboard, file_id__in=stale_file_pks).delete()
+ from ..models import File as FilerFile
+ FilerFile.objects.filter(pk__in=stale_file_pks).delete()
+
+ # find the file type
+ for filer_class in filer_settings.FILER_FILE_MODELS:
+ FileSubClass = load_model(filer_class)
+ # TODO: What if there are more than one that qualify?
+ if FileSubClass.matches_file_type(filename, upload, mime_type):
+ FileForm = modelform_factory(
+ model=FileSubClass,
+ fields=('original_filename', 'owner', 'file')
+ )
+ break
+ uploadform = FileForm({'original_filename': filename, 'owner': request.user.pk},
+ {'file': upload})
+ uploadform.request = request
+ uploadform.instance.mime_type = mime_type
+ if uploadform.is_valid():
+ try:
+ validate_upload(filename, upload, request.user, mime_type)
+ file_obj = uploadform.save(commit=False)
+ # Enforce the FILER_IS_PUBLIC_DEFAULT
+ file_obj.is_public = filer_settings.FILER_IS_PUBLIC_DEFAULT
+ except ValidationError as error:
+ messages.error(request, str(error))
+ return JsonResponse({'error': str(error)})
+ file_obj.folder = folder
+ try:
+ file_obj.save()
+ except Exception as error:
+ messages.error(request, str(error))
+ return JsonResponse({'error': str(error)})
+ clipboard_item = ClipboardItem(
+ clipboard=clipboard, file=file_obj)
+ clipboard_item.save()
+
+ try:
+ thumbnail = None
+ data = {
+ 'thumbnail': thumbnail,
+ 'alt_text': '',
+ 'label': str(file_obj),
+ 'file_id': file_obj.pk,
+ }
+ # prepare preview thumbnail
+ if isinstance(file_obj, Image):
+ data['thumbnail_180'] = reverse(
+ f"admin:filer_{file_obj._meta.model_name}_fileicon",
+ args=(file_obj.pk, FILER_THUMBNAIL_ICON_SIZE),
+ )
+ data['original_image'] = file_obj.url
+ return JsonResponse(data)
+ except Exception as error:
+ messages.error(request, str(error))
+ return JsonResponse({"error": str(error)})
+ else:
+ for key, error_list in uploadform.errors.items():
+ for error in error_list:
+ messages.error(request, error)
+
+ form_errors = '; '.join(['{}'.format(
+ ', '.join(errors)) for errors in list(uploadform.errors.values())
+ ])
+ return JsonResponse({'error': str(form_errors)}, status=200)
diff --git a/filer/admin/common_admin.py b/filer/admin/common_admin.py
new file mode 100644
index 000000000..c7771bc1a
--- /dev/null
+++ b/filer/admin/common_admin.py
@@ -0,0 +1,244 @@
+#-*- coding: utf-8 -*-
+from django.contrib import admin
+from django.contrib.admin.utils import unquote
+from django.contrib.admin.options import IS_POPUP_VAR
+from django.contrib.auth import get_permission_codename
+from django.urls import reverse, resolve
+from django.http import HttpResponseRedirect
+
+from filer.models import Folder, File
+from filer.utils.cms_roles import has_admin_role, has_role_on_site
+from filer.admin.tools import has_multi_file_action_permission
+from filer.views import (popup_param, selectfolder_param, popup_status,
+ selectfolder_status, current_site_param,
+ get_param_from_request)
+
+
+class CommonModelAdmin(admin.ModelAdmin):
+ save_as = False
+
+ def _make_restricted_field_readonly(self, user, obj=None):
+ perm = 'filer.can_restrict_operations'
+ can_restrict = user.has_perm(perm, obj) or user.has_perm(perm)
+ if not can_restrict or (obj and not obj.can_change_restricted(user)):
+ if 'restricted' not in self.readonly_fields:
+ self.readonly_fields += ['restricted']
+ else:
+ if 'restricted' in self.readonly_fields:
+ self.readonly_fields.remove('restricted')
+
+ def _get_post_url(self, obj):
+ """
+ Needed to retrieve the changelist url as Folder/File can be extended
+ and admin url may change
+ """
+ # Code borrowed from django ModelAdmin to determine
+ # changelist on the fly
+ opts = obj._meta
+ module_name = opts.model_name
+ return reverse('admin:%s_%s_changelist' %
+ (opts.app_label, module_name),
+ current_app=self.admin_site.name)
+
+ def _get_parent_for_view(self, obj):
+ return obj.parent
+
+ def _redirect_to_directory_listing(
+ self, request, response, expected_urls, parent, current_obj):
+ """
+ Overrides the default to enable redirecting to the directory view after
+ change/delete of a file/folder.
+ """
+ url = response.get("Location", None)
+ if url in expected_urls or url == self._get_post_url(current_obj):
+ return self._make_redirect_to_parent(request, parent)
+ return response
+
+ def _make_redirect_to_parent(self, request, parent):
+ if parent:
+ url = reverse('admin:filer-directory_listing', kwargs={'folder_id': parent.id})
+ else:
+ url = reverse('admin:filer-directory_listing-root')
+
+ url = "%s%s%s%s" % (url, popup_param(request),
+ selectfolder_param(request, "&"),
+ current_site_param(request),)
+ return HttpResponseRedirect(url)
+
+
+ def delete_view(self, request, object_id, extra_context=None):
+ """
+ Overrides the default to enable redirecting to the directory view after
+ deletion of a image.
+
+ we need to fetch the object and find out who the parent is
+ before super, because super will delete the object and make it
+ impossible to find out the parent folder to redirect to.
+ """
+ parent_folder = None
+ try:
+ obj = self.get_queryset(request).get(pk=unquote(object_id))
+ parent_folder = self._get_parent_for_view(obj)
+ except self.model.DoesNotExist:
+ obj = None
+ expected_urls = ["../../../../", "../../"]
+
+ response = super(CommonModelAdmin, self).delete_view(
+ request=request, object_id=object_id,
+ extra_context=extra_context)
+
+ return self._redirect_to_directory_listing(
+ request, response, expected_urls, parent_folder, obj)
+
+ def has_change_permission(self, request, obj=None):
+ """
+ Implemented to allow auth backends to handle object permissions.
+ """
+ opts = self.opts
+ if opts.auto_created:
+ # The model was auto-created as intermediary for a
+ # ManyToMany-relationship, find the target model
+ for field in opts.fields:
+ if field.remote_field and field.remote_field.model != self.parent_model:
+ opts = field.remote_field.model._meta
+ break
+ codename = get_permission_codename('change', opts)
+ perm_name, user = "{}.{}".format(opts.app_label, codename), request.user
+ return user.has_perm(perm_name, obj) or user.has_perm(perm_name)
+
+ def has_delete_permission(self, request, obj=None):
+ """
+ Implemented to allow auth backends to handle object permissions.
+ """
+ if self.opts.auto_created:
+ # For link objects
+ return self.has_change_permission(request, obj)
+ codename = get_permission_codename('delete', self.opts)
+ perm_name, user = "{}.{}".format(self.opts.app_label, codename), request.user
+ return user.has_perm(perm_name, obj) or user.has_perm(perm_name)
+
+ def render_change_form(self, request, context, add=False, change=False,
+ form_url='', obj=None):
+ context.update({
+ 'current_site': get_param_from_request(request, 'current_site'),
+ 'show_delete': True,
+ 'is_popup': popup_status(request),
+ 'select_folder': selectfolder_status(request),
+ })
+ return super(CommonModelAdmin, self).render_change_form(
+ request=request, context=context, add=add,
+ change=change, form_url=form_url, obj=obj)
+
+ def response_change(self, request, obj):
+ """
+ Overrides the default to be able to forward to the directory listing
+ instead of the default change_list_view
+ """
+ parent_folder = self._get_parent_for_view(obj)
+ if IS_POPUP_VAR in request.POST:
+ # In popup we always want to see the parent after changes
+ return self._make_redirect_to_parent(request, parent_folder)
+ response = super(CommonModelAdmin, self).response_change(request, obj)
+ expected_urls = ['../', reverse('admin:index')]
+ return self._redirect_to_directory_listing(
+ request, response, expected_urls, parent_folder, obj)
+
+
+class FolderPermissionModelAdmin(CommonModelAdmin):
+
+ def has_add_permission(self, request):
+ # allow only make folder views
+ current_view = resolve(request.path_info).url_name
+ allowed_views = (
+ 'filer-directory_listing-make_root_folder',
+ 'filer-directory_listing-make_folder',
+ )
+ if current_view not in allowed_views:
+ return False
+
+ folder_id = get_param_from_request(request, 'parent_id')
+ # Also check URL kwargs for folder_id
+ if not folder_id:
+ resolved = resolve(request.path_info)
+ folder_id = resolved.kwargs.get('folder_id')
+ if not folder_id:
+ # only site admins and superusers can add root folders
+ if has_admin_role(request.user):
+ return True
+ else:
+ folder = Folder.objects.get(id=folder_id)
+ return folder.has_add_permission(request.user)
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ folder = obj
+ can_change = super(FolderPermissionModelAdmin, self).\
+ has_change_permission(request, folder)
+
+ if not folder:
+ return request.user.has_perm('filer.can_use_directory_listing')
+
+ return folder.has_change_permission(request.user)
+
+ def has_view_permission(self, request, obj=None):
+ # In Django 4.2+, has_view_permission is checked separately from
+ # has_change_permission. Delegate to has_change_permission so that
+ # folders that deny change also deny the read-only view.
+ if obj:
+ return obj.has_change_permission(request.user)
+ return request.user.has_perm('filer.can_use_directory_listing')
+
+ def has_delete_permission(self, request, obj=None):
+ folder = obj
+ can_delete = super(FolderPermissionModelAdmin, self).\
+ has_delete_permission(request, obj)
+
+ if not can_delete or not folder:
+ return can_delete
+
+ if request.method == 'POST':
+ return has_multi_file_action_permission(
+ request, File.objects.none(),
+ Folder.objects.filter(id=folder.id))
+
+ return folder.has_delete_permission(request.user)
+
+ def can_view_folder_content(self, request, folder):
+ if folder.is_readonly_for_user(request.user):
+ return True
+ # only admins can see site folders with no site owner
+ if not folder.site and has_admin_role(request.user):
+ return True
+
+ if folder.site and has_role_on_site(request.user, folder.site):
+ return True
+
+ return False
+
+
+class FilePermissionModelAdmin(CommonModelAdmin):
+
+ def _get_parent_for_view(self, obj):
+ return obj.folder
+
+ def has_change_permission(self, request, obj=None):
+ can_change = super(FilePermissionModelAdmin, self).\
+ has_change_permission(request, obj)
+ if not can_change or not obj:
+ return can_change
+ return obj.has_change_permission(request.user)
+
+ def has_view_permission(self, request, obj=None):
+ # In Django 4.2+, delegate to has_change_permission so that
+ # files that deny change also deny the read-only view.
+ if obj:
+ return obj.has_change_permission(request.user)
+ return super(FilePermissionModelAdmin, self).has_view_permission(
+ request, obj)
+
+ def has_delete_permission(self, request, obj=None):
+ can_delete = super(FilePermissionModelAdmin, self).\
+ has_delete_permission(request, obj)
+ if not can_delete or not obj:
+ return can_delete
+ return obj.has_delete_permission(request.user)
diff --git a/filer/admin/fileadmin.py b/filer/admin/fileadmin.py
index f0756d1fe..7487e2846 100644
--- a/filer/admin/fileadmin.py
+++ b/filer/admin/fileadmin.py
@@ -1,48 +1,131 @@
-#-*- coding: utf-8 -*-
+import mimetypes
+
from django import forms
-from django.contrib.admin.util import unquote
-from django.core.urlresolvers import reverse
-from django.http import HttpResponseRedirect
-from django.utils.translation import ugettext as _
-from filer import settings
-from filer.admin.permissions import PrimitivePermissionAwareModelAdmin
-from filer.models import File
-from filer.views import (popup_param, selectfolder_param, popup_status,
- selectfolder_status)
+from django.contrib.admin.templatetags.admin_urls import admin_urlname
+from django.contrib.admin.utils import unquote
+from django.contrib.staticfiles.storage import staticfiles_storage
+from django.db import models
+from django.http import Http404, HttpResponse, HttpResponseRedirect
+from django.shortcuts import get_object_or_404
+from django.urls import path, reverse
+from django.utils.safestring import mark_safe
+from django.utils.timezone import now
+from django.utils.translation import gettext as _
+
+from easy_thumbnails.engine import NoSourceGenerator
+from easy_thumbnails.exceptions import InvalidImageFormatError
+from easy_thumbnails.files import get_thumbnailer
+from easy_thumbnails.models import Thumbnail as EasyThumbnail
+from easy_thumbnails.options import ThumbnailOptions
+
+from .. import settings
+from ..models import File
+from ..settings import DEFERRED_THUMBNAIL_SIZES
+from ..utils.loader import load_model
+from .permissions import PrimitivePermissionAwareModelAdmin
+from .tools import AdminContext, admin_url_params_encoded, popup_status
+
+# PBS-specific imports
+from .common_admin import FilePermissionModelAdmin
+from ..fields.file import NonClearableFileInput
+
+try:
+ from ..models import BaseImage
+except ImportError:
+ BaseImage = None
+
+Image = load_model(settings.FILER_IMAGE_MODEL)
class FileAdminChangeFrom(forms.ModelForm):
class Meta:
model = File
+ exclude = ()
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if "file" in self.fields:
+ self.fields["file"].widget = forms.FileInput()
+ # Pre-populate the "name" field with the original_filename when it is
+ # empty so that users see the current effective filename on first edit.
+ if self.instance and self.instance.pk and "name" in self.fields:
+ if not self.instance.name and self.instance.original_filename:
+ self.initial["name"] = self.instance.original_filename
+
+ def clean(self):
+ from ..validation import validate_upload
+ cleaned_data = super().clean()
+ if "file" in self.changed_data and cleaned_data["file"]:
+ mime_type = mimetypes.guess_type(cleaned_data["file"].name)[0] or 'application/octet-stream'
+ file = cleaned_data["file"]
+ file.open("w+") # Allow for sanitizing upload
+ file.seek(0)
+ validate_upload(
+ file_name=cleaned_data["file"].name,
+ file=file.file,
+ owner=cleaned_data.get("owner"),
+ mime_type=mime_type,
+ )
+ file.open("r")
+ return self.cleaned_data
class FileAdmin(PrimitivePermissionAwareModelAdmin):
list_display = ('label',)
list_per_page = 10
search_fields = ['name', 'original_filename', 'sha1', 'description']
- raw_id_fields = ('owner',)
- readonly_fields = ('sha1',)
+ readonly_fields = ('sha1', 'display_canonical')
+ form = FileAdminChangeFrom
- # save_as hack, because without save_as it is impossible to hide the
- # save_and_add_another if save_as is False. To show only save_and_continue
- # and save in the submit row we need save_as=True and in
- # render_change_form() override add and change to False.
- save_as = True
+ formfield_overrides = {
+ models.FileField: {'widget': NonClearableFileInput},
+ }
- form = FileAdminChangeFrom
+ # PBS-specific: make fields readonly for restricted/core files
+ def get_readonly_fields(self, request, obj=None):
+ if obj and (obj.is_readonly_for_user(request.user) or
+ obj.is_restricted_for_user(request.user)):
+ return [field.name for field in obj.__class__._meta.fields] + ['display_canonical']
+ readonly = list(self.readonly_fields)
+ self._make_restricted_field_readonly(request.user, obj)
+ if not request.user.is_superuser:
+ if 'owner' not in readonly:
+ readonly.append('owner')
+ return readonly
+
+ def _make_restricted_field_readonly(self, user, obj):
+ """PBS: make restricted field readonly if user can't change restriction."""
+ if obj and hasattr(obj, 'can_change_restricted'):
+ if not obj.can_change_restricted(user):
+ if 'restricted' not in self.readonly_fields:
+ self.readonly_fields = list(self.readonly_fields) + ['restricted']
@classmethod
- def build_fieldsets(cls, extra_main_fields=(), extra_advanced_fields=(), extra_fieldsets=()):
+ def build_fieldsets(cls, extra_main_fields=(), extra_advanced_fields=(),
+ extra_fieldsets=()):
fieldsets = (
(None, {
- 'fields': ('name', 'owner', 'description',) + extra_main_fields,
+ 'fields': (
+ 'title',
+ 'owner',
+ 'description',
+ ) + extra_main_fields,
}),
(_('Advanced'), {
- 'fields': ('file', 'sha1',) + extra_advanced_fields,
+ 'fields': (
+ 'file',
+ 'name',
+ 'sha1',
+ 'display_canonical',
+ ) + extra_advanced_fields,
'classes': ('collapse',),
- }),
- ) + extra_fieldsets
+ }),
+ (_('Permissions'), {
+ 'fields': ('restricted',),
+ 'classes': ('collapse', 'wide', 'extrapretty'),
+ }),
+ ) + extra_fieldsets
if settings.FILER_ENABLE_PERMISSIONS:
fieldsets = fieldsets + (
(None, {
@@ -51,85 +134,130 @@ def build_fieldsets(cls, extra_main_fields=(), extra_advanced_fields=(), extra_f
)
return fieldsets
-
def response_change(self, request, obj):
- """
- Overrides the default to be able to forward to the directory listing
- instead of the default change_list_view
- """
- r = super(FileAdmin, self).response_change(request, obj)
- if r['Location']:
- # it was a successful save
- if (r['Location'] in ['../'] or
- r['Location'] == self._get_post_url(obj)):
- # this means it was a save: redirect to the directory view
- if obj.folder:
- url = reverse('admin:filer-directory_listing',
- kwargs={'folder_id': obj.folder.id})
- else:
- url = reverse(
- 'admin:filer-directory_listing-unfiled_images')
- url = "%s%s%s" % (url,popup_param(request),
- selectfolder_param(request,"&"))
- return HttpResponseRedirect(url)
+ if (
+ request.POST
+ and '_continue' not in request.POST
+ and '_saveasnew' not in request.POST
+ and '_addanother' not in request.POST
+ and '_edit_from_widget' not in request.POST
+ ):
+ if obj.folder:
+ url = reverse('admin:filer-directory_listing',
+ kwargs={'folder_id': obj.folder.id})
else:
- # this means it probably was a save_and_continue_editing
- pass
- return r
+ url = reverse(
+ 'admin:filer-directory_listing-unfiled_images')
+ url = "{}{}".format(
+ url,
+ admin_url_params_encoded(request),
+ )
+ return HttpResponseRedirect(url)
+
+ template_response = super().response_change(request, obj)
+ if hasattr(template_response, 'context_data'):
+ template_response.context_data["media"] = self.media
+ return template_response
def render_change_form(self, request, context, add=False, change=False,
form_url='', obj=None):
- extra_context = {'show_delete': True,
- 'is_popup': popup_status(request),
- 'select_folder': selectfolder_status(request),}
- context.update(extra_context)
- return super(FileAdmin, self).render_change_form(
- request=request, context=context, add=False, change=False,
- form_url=form_url, obj=obj)
+ # PBS: flag readonly files to suppress submit buttons in template
+ is_readonly = obj and (obj.is_readonly_for_user(request.user) or
+ obj.is_restricted_for_user(request.user))
+ context.update({
+ 'show_delete': True,
+ 'history_url': admin_urlname(self.opts, 'history'),
+ 'expand_image_url': None,
+ 'is_popup': popup_status(request),
+ 'filer_admin_context': AdminContext(request),
+ 'is_readonly_file': is_readonly,
+ })
+ if obj and obj.mime_maintype == 'image' and obj.file.exists():
+ if 'svg' in obj.mime_type:
+ context['expand_image_url'] = reverse(admin_urlname(Image._meta, 'expand'), args=(obj.pk,))
+ else:
+ context['expand_image_url'] = obj.file.url
+ return super().render_change_form(
+ request=request, context=context, add=add, change=change,
+ form_url=form_url, obj=obj)
def delete_view(self, request, object_id, extra_context=None):
- """
- Overrides the default to enable redirecting to the directory view after
- deletion of a image.
-
- we need to fetch the object and find out who the parent is
- before super, because super will delete the object and make it
- impossible to find out the parent folder to redirect to.
- """
- parent_folder = None
try:
- obj = self.queryset(request).get(pk=unquote(object_id))
+ obj = self.get_queryset(request).get(pk=unquote(object_id))
parent_folder = obj.folder
except self.model.DoesNotExist:
- obj = None
+ parent_folder = None
- r = super(FileAdmin, self).delete_view(
- request=request, object_id=object_id,
- extra_context=extra_context)
-
- url = r.get("Location", None)
- # Check against filer_file_changelist as file deletion is always made by
- # the base class
- if (url in ["../../../../", "../../"] or
- url == reverse("admin:filer_file_changelist")):
+ if request.POST:
+ super().delete_view(
+ request=request, object_id=object_id,
+ extra_context=extra_context)
if parent_folder:
url = reverse('admin:filer-directory_listing',
- kwargs={'folder_id': parent_folder.id})
+ kwargs={'folder_id': parent_folder.id})
else:
url = reverse('admin:filer-directory_listing-unfiled_images')
- url = "%s%s%s" % (url,popup_param(request),
- selectfolder_param(request,"&"))
+ url = "{}{}".format(
+ url,
+ admin_url_params_encoded(request)
+ )
return HttpResponseRedirect(url)
- return r
+
+ return super().delete_view(
+ request=request, object_id=object_id,
+ extra_context=extra_context)
+
+ def has_add_permission(self, request):
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ if not obj:
+ return False
+ return super().has_change_permission(request, obj)
+
+ def has_view_permission(self, request, obj=None):
+ if not obj:
+ return False
+ return super().has_view_permission(request, obj)
def get_model_perms(self, request):
- """
- It seems this is only used for the list view. NICE :-)
- """
return {
'add': False,
'change': False,
'delete': False,
}
-FileAdmin.fieldsets = FileAdmin.build_fieldsets()
\ No newline at end of file
+ def display_canonical(self, instance):
+ canonical = instance.canonical_url
+ if canonical:
+ return mark_safe(f'{canonical}')
+ else:
+ return '-'
+ display_canonical.allow_tags = True
+ display_canonical.short_description = _('canonical URL')
+
+ def get_urls(self):
+ return super().get_urls() + [
+ path("icon//",
+ self.admin_site.admin_view(self.icon_view),
+ name=f"filer_{self.model._meta.model_name}_fileicon")
+ ]
+
+ def icon_view(self, request, file_id: int, size: int) -> HttpResponse:
+ if size not in DEFERRED_THUMBNAIL_SIZES:
+ raise Http404
+ file = get_object_or_404(File, pk=file_id)
+ if BaseImage and not isinstance(file, BaseImage):
+ raise Http404()
+
+ try:
+ thumbnailer = get_thumbnailer(file)
+ thumbnail_options = ThumbnailOptions({'size': (size, size), "crop": True})
+ thumbnail = thumbnailer.get_thumbnail(thumbnail_options, generate=True)
+ EasyThumbnail.objects.filter(name=thumbnail.name).update(modified=now())
+ return HttpResponseRedirect(thumbnail.url)
+ except (InvalidImageFormatError, NoSourceGenerator):
+ return HttpResponseRedirect(staticfiles_storage.url('filer/icons/file-missing.svg'))
+
+
+FileAdmin.fieldsets = FileAdmin.build_fieldsets()
diff --git a/filer/admin/folderadmin.py b/filer/admin/folderadmin.py
index e94745896..482d98b44 100644
--- a/filer/admin/folderadmin.py
+++ b/filer/admin/folderadmin.py
@@ -1,46 +1,60 @@
-#-*- coding: utf-8 -*-
+import itertools
+import logging
+import os
+import re
+from collections import OrderedDict
+from urllib.parse import quote as urlquote
+from urllib.parse import unquote as urlunquote
+
+from django import VERSION as DJANGO_VERSION
from django import forms
-from django import template
-from django.contrib import admin
+from django.conf import settings as django_settings
+from django.contrib import messages
from django.contrib.admin import helpers
-from django.contrib.admin.util import quote, unquote, capfirst
-from django.template.defaultfilters import urlencode
-from filer.admin.patched.admin_utils import get_deleted_objects
-from django.core.exceptions import PermissionDenied
-from django.core.paginator import Paginator, InvalidPage, EmptyPage
-from django.core.urlresolvers import reverse
-from django.db import router
-from django.db.models import Q
-from django.http import HttpResponseRedirect, Http404, HttpResponse
-from django.shortcuts import render_to_response
-from django.template import RequestContext
-from django.utils.encoding import force_unicode
-from django.utils.html import escape
+from django.contrib.admin.utils import capfirst, quote, unquote
+from django.core.exceptions import PermissionDenied, ValidationError
+from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
+from django.db import models, router
+from django.db.models import Case, F, OuterRef, Subquery, When
+from django.db.models.functions import Coalesce, Lower
+from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
+from django.shortcuts import get_object_or_404
+from django.template.response import TemplateResponse
+from django.urls import path, reverse
+from django.utils.encoding import force_str
+from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
-from django.utils.translation import ugettext as _
-from django.utils.translation import ungettext, ugettext_lazy
-from filer import settings
-from filer.admin.forms import (CopyFilesAndFoldersForm, ResizeImagesForm,
- RenameFilesForm)
-from filer.admin.permissions import PrimitivePermissionAwareModelAdmin
-from filer.views import (popup_status, popup_param, selectfolder_status,
- selectfolder_param)
-from filer.admin.tools import (userperms_for_request,
- check_folder_edit_permissions,
- check_files_edit_permissions,
- check_files_read_permissions,
- check_folder_read_permissions)
-from filer.models import (Folder, FolderRoot, UnfiledImages, File, tools,
- ImagesWithMissingData, FolderPermission, Image)
-from filer.settings import FILER_STATICMEDIA_PREFIX, FILER_PAGINATE_BY
-from filer.utils.filer_easy_thumbnails import FilerActionThumbnailer
-from filer.thumbnail_processors import normalize_subject_location
-from django.conf import settings as django_settings
-import urllib
-import os
-import itertools
-import inspect
-
+from django.utils.translation import gettext_lazy as _
+from django.utils.translation import ngettext_lazy
+
+from easy_thumbnails.models import Thumbnail
+
+from .. import settings
+from ..cache import clear_folder_permission_cache
+from ..models import File, Folder, FolderPermission, FolderRoot, ImagesWithMissingData, UnsortedImages, tools
+from ..settings import (
+ FILER_IMAGE_MODEL, FILER_PAGINATE_BY, FILER_TABLE_ICON_SIZE, FILER_THUMBNAIL_ICON_SIZE, TABLE_LIST_TYPE,
+)
+from ..thumbnail_processors import normalize_subject_location
+from ..utils.compatibility import get_delete_permission
+from ..utils.filer_easy_thumbnails import FilerActionThumbnailer
+from ..utils.loader import load_model
+from . import views
+from .forms import CopyFilesAndFoldersForm, RenameFilesForm, ResizeImagesForm
+from .patched.admin_utils import get_deleted_objects
+from .permissions import PrimitivePermissionAwareModelAdmin
+from .common_admin import FolderPermissionModelAdmin
+from .tools import (
+ AdminContext, admin_url_params_encoded, check_files_edit_permissions, check_files_read_permissions,
+ check_folder_edit_permissions, check_folder_read_permissions, get_directory_listing_type,
+ has_multi_file_action_permission, popup_status,
+ userperms_for_request,
+)
+
+
+Image = load_model(FILER_IMAGE_MODEL)
+
+logger = logging.getLogger(__name__)
class AddFolderPopupForm(forms.ModelForm):
folder = forms.HiddenInput()
@@ -50,39 +64,143 @@ class Meta:
fields = ('name',)
-class FolderAdmin(PrimitivePermissionAwareModelAdmin):
+class FolderAdmin(FolderPermissionModelAdmin):
list_display = ('name',)
- exclude = ('parent',)
- list_per_page = 20
+ exclude = ('parent', 'owner', 'folder_type')
+ list_per_page = 100
list_filter = ('owner',)
- search_fields = ['name', 'files__name']
- raw_id_fields = ('owner',)
+ search_fields = ['name']
+ autocomplete_fields = ['owner']
save_as = True # see ImageAdmin
- actions = ['move_to_clipboard', 'files_set_public', 'files_set_private',
- 'delete_files_or_folders', 'move_files_and_folders',
- 'copy_files_and_folders', 'resize_images', 'rename_files']
+ actions = ['delete_files_or_folders', 'move_files_and_folders',
+ 'copy_files_and_folders', 'resize_images', 'rename_files',
+ 'extract_files',
+ 'enable_restriction', 'disable_restriction']
+
+ if DJANGO_VERSION >= (5, 2):
+ directory_listing_template = 'admin/filer/folder/directory_listing.html'
+ else: # Remove this when Django 5.2 is the minimum version
+ directory_listing_template = 'admin/filer/folder/legacy_listing.html'
+
+ order_by_file_fields = ['_file_size', 'original_filename', 'name', 'owner',
+ 'uploaded_at', 'modified_at']
+
+ def get_readonly_fields(self, request, obj=None):
+ self.readonly_fields = [ro_field
+ for ro_field in self.readonly_fields]
+ self._make_restricted_field_readonly(request.user, obj)
+ return super().get_readonly_fields(request, obj)
+
+ def formfield_for_foreignkey(self, db_field, request=None, **kwargs):
+ from django.contrib.sites.models import Site
+ formfield = super().formfield_for_foreignkey(
+ db_field, request, **kwargs)
+ if request and db_field.remote_field.model is Site:
+ if request.user.is_superuser:
+ formfield.queryset = Site.objects.all()
+ else:
+ from filer.utils.cms_roles import get_admin_sites_for_user
+ admin_sites = [site.id
+ for site in get_admin_sites_for_user(request.user)]
+ formfield.queryset = Site.objects.filter(id__in=admin_sites)
+ return formfield
+
+ def formfield_for_manytomany(self, db_field, request, **kwargs):
+ from django.contrib.sites.models import Site
+ formfield = super().formfield_for_manytomany(
+ db_field, request, **kwargs)
+ if request and db_field.remote_field.model is Site:
+ if request.user.is_superuser:
+ formfield.queryset = Site.objects.all()
+ else:
+ from filer.utils.cms_roles import get_admin_sites_for_user
+ admin_sites = [site.id
+ for site in get_admin_sites_for_user(request.user)]
+ formfield.queryset = Site.objects.filter(id__in=admin_sites)
+ return formfield
def get_form(self, request, obj=None, **kwargs):
"""
Returns a Form class for use in the admin add view. This is used by
add_view and change_view.
+
+ Sets the parent folder and owner for the folder that will be edited
+ in the form.
"""
- parent_id = request.REQUEST.get('parent_id', None)
- if parent_id:
- return AddFolderPopupForm
+ parent_id = request.GET.get('parent_id', None)
+ if not parent_id:
+ parent_id = request.POST.get('parent_id', None)
+
+ folder_form = super().get_form(
+ request, obj=obj, **kwargs)
+
+ if 'site' in folder_form.base_fields:
+ folder_form.base_fields['site'].widget.can_add_related = False
+ folder_form.base_fields['site'].widget.can_delete_related = False
+ folder_form.base_fields['site'].widget.can_change_related = False
+
+ if 'shared' in folder_form.base_fields:
+ folder_form.base_fields['shared'].widget.can_add_related = False
+
+ # only show shared sites field for superusers
+ if not request.user.is_superuser:
+ folder_form.base_fields.pop('shared', None)
+
+ # check if site field should be visible in the form
+ is_core_folder = False
+ if obj and obj.pk:
+ # change view
+ change_parent_id = obj.parent_id
+ is_core_folder = obj.is_core()
else:
- return super(FolderAdmin, self).get_form(
- request, obj=None, **kwargs)
+ # add view
+ change_parent_id = parent_id
+ folder_form.base_fields.pop('restricted', None)
+
+ # hide site/shared for child folders or core folders
+ pop_site_fields = change_parent_id or is_core_folder
+ if pop_site_fields:
+ folder_form.base_fields.pop('site', None)
+ folder_form.base_fields.pop('shared', None)
+
+ def folder_form_clean(form_obj):
+ cleaned_data = form_obj.cleaned_data
+ if 'name' not in cleaned_data:
+ return cleaned_data
+ # make sure owner and parent are passed to the model clean method
+ current_folder = form_obj.instance
+ if not current_folder.owner:
+ current_folder.owner = request.user
+ if parent_id:
+ current_folder.parent = Folder.objects.get(id=parent_id)
+ folders_with_same_name = self.get_queryset(request).filter(
+ parent=form_obj.instance.parent,
+ name=cleaned_data['name'])
+ if form_obj.instance.pk:
+ folders_with_same_name = folders_with_same_name.exclude(
+ pk=form_obj.instance.pk)
+ if folders_with_same_name.exists():
+ raise ValidationError(
+ 'Folder with this name already exists.')
+ return cleaned_data
+
+ folder_form.clean = folder_form_clean
+ return folder_form
def save_form(self, request, form, change):
"""
Given a ModelForm return an unsaved instance. ``change`` is True if
the object is being changed, and False if it's being added.
"""
+ if not change:
+ # New folder invalidates the folder permission cache (or it will not be visible)
+ clear_folder_permission_cache(request.user)
r = form.save(commit=False)
- parent_id = request.REQUEST.get('parent_id', None)
+ parent_id = request.GET.get('parent_id', None)
+ if not parent_id:
+ parent_id = request.POST.get('parent_id', None)
if parent_id:
- parent = Folder.objects.get(id=parent_id)
+ parent = self.get_queryset(request).get(id=parent_id)
r.parent = parent
return r
@@ -91,34 +209,53 @@ def response_change(self, request, obj):
Overrides the default to be able to forward to the directory listing
instead of the default change_list_view
"""
- r = super(FolderAdmin, self).response_change(request, obj)
- ## Code borrowed from django ModelAdmin to determine changelist on the fly
- if r['Location']:
- # it was a successful save
- if (r['Location'] in ['../'] or
- r['Location'] == self._get_post_url(obj)):
- if obj.parent:
- url = reverse('admin:filer-directory_listing',
- kwargs={'folder_id': obj.parent.id})
- else:
- url = reverse('admin:filer-directory_listing-root')
- url = "%s%s%s" % (url,popup_param(request),
- selectfolder_param(request,"&"))
- return HttpResponseRedirect(url)
+ if (
+ request.POST
+ and '_continue' not in request.POST
+ and '_saveasnew' not in request.POST
+ and '_addanother' not in request.POST
+ ):
+
+ if obj.parent:
+ url = reverse('admin:filer-directory_listing',
+ kwargs={'folder_id': obj.parent.id})
else:
- # this means it probably was a save_and_continue_editing
- pass
- return r
+ url = reverse('admin:filer-directory_listing-root')
+ url = "{}{}".format(
+ url,
+ admin_url_params_encoded(request),
+ )
+ return HttpResponseRedirect(url)
+ return super().response_change(request, obj)
def render_change_form(self, request, context, add=False, change=False,
form_url='', obj=None):
+ info = self.model._meta.app_label, self.model._meta.model_name
extra_context = {'show_delete': True,
+ 'history_url': 'admin:%s_%s_history' % info,
'is_popup': popup_status(request),
- 'select_folder': selectfolder_status(request),}
+ 'filer_admin_context': AdminContext(request)}
context.update(extra_context)
- return super(FolderAdmin, self).render_change_form(
- request=request, context=context, add=False,
- change=False, form_url=form_url, obj=obj)
+ return super().render_change_form(
+ request=request, context=context, add=add,
+ change=change, form_url=form_url, obj=obj)
+
+ def add_view(self, request, *args, **kwargs):
+ raise PermissionDenied
+
+ def make_folder(self, request, folder_id=None, *args, **kwargs):
+ response = super(FolderAdmin, self).add_view(request, *args, **kwargs)
+
+ if (request.method == 'POST' and popup_status(request) and
+ not isinstance(response, HttpResponseRedirect)):
+ if hasattr(response, 'render'):
+ response = response.render()
+ content = getattr(response, 'content', b'')
+ if response.status_code == 200 and b'errorlist' not in content:
+ return HttpResponse('')
+ return response
def delete_view(self, request, object_id, extra_context=None):
"""
@@ -128,79 +265,129 @@ def delete_view(self, request, object_id, extra_context=None):
we need to fetch the object and find out who the parent is
before super, because super will delete the object and make it
impossible to find out the parent folder to redirect to.
+
+ The delete_view breaks with polymorphic models if the cascade will
+ try delete objects that are of different polymorphic types
+ (AttributeError: 'File' object has no attribute 'file_ptr').
+ The default implementation of the delete_view is hard to override
+ without just copying the whole big thing. Since we've already done
+ the overriding work on the delete_files_or_folders admin action, we
+ can re-use that here instead.
"""
- parent_folder = None
try:
- obj = self.queryset(request).get(pk=unquote(object_id))
+ obj = self.get_queryset(request).get(pk=unquote(object_id))
parent_folder = obj.parent
except self.model.DoesNotExist:
- obj = None
-
- r = super(FolderAdmin, self).delete_view(
- request=request, object_id=object_id,
- extra_context=extra_context)
- url = r.get("Location", None)
- if url in ["../../../../", "../../"] or url == self._get_post_url(obj):
+ parent_folder = None
+
+ # PBS: block deletion of core folders
+ if obj and not self.has_delete_permission(request, obj):
+ from django.http import HttpResponseForbidden
+ return HttpResponseForbidden("Permission denied")
+
+ if request.POST:
+ self.delete_files_or_folders(
+ request,
+ files_queryset=File.objects.none(),
+ folders_queryset=self.get_queryset(request).filter(id=object_id)
+ )
if parent_folder:
url = reverse('admin:filer-directory_listing',
- kwargs={'folder_id': parent_folder.id})
+ kwargs={'folder_id': parent_folder.id})
else:
url = reverse('admin:filer-directory_listing-root')
- url = "%s%s%s" % (url,popup_param(request),
- selectfolder_param(request,"&"))
+ url = "{}{}".format(
+ url,
+ admin_url_params_encoded(request),
+ )
return HttpResponseRedirect(url)
- return r
+
+ return self.delete_files_or_folders(
+ request,
+ files_queryset=File.objects.none(),
+ folders_queryset=self.get_queryset(request).filter(id=object_id)
+ )
def icon_img(self, xs):
- return mark_safe(('') % FILER_STATICMEDIA_PREFIX)
+ return format_html('', django_settings.STATIC_ROOT)
icon_img.allow_tags = True
def get_urls(self):
- from django.conf.urls.defaults import patterns, url
- urls = super(FolderAdmin, self).get_urls()
- from filer import views
- url_patterns = patterns('',
+ return [
# we override the default list view with our own directory listing
# of the root directories
- url(r'^$', self.admin_site.admin_view(self.directory_listing),
- name='filer-directory_listing-root'),
- url(r'^(?P\d+)/list/$',
- self.admin_site.admin_view(self.directory_listing),
- name='filer-directory_listing'),
-
- url(r'^(?P\d+)/make_folder/$',
- self.admin_site.admin_view(views.make_folder),
- name='filer-directory_listing-make_folder'),
- url(r'^make_folder/$',
- self.admin_site.admin_view(views.make_folder),
- name='filer-directory_listing-make_root_folder'),
- url(r'^images_with_missing_data/$',
- self.admin_site.admin_view(self.directory_listing),
- {'viewtype': 'images_with_missing_data'},
- name='filer-directory_listing-images_with_missing_data'),
- url(r'^unfiled_images/$',
- self.admin_site.admin_view(self.directory_listing),
- {'viewtype': 'unfiled_images'},
- name='filer-directory_listing-unfiled_images'),
- )
- url_patterns.extend(urls)
- return url_patterns
+ path('',
+ self.admin_site.admin_view(self.directory_listing),
+ name='filer-directory_listing-root'),
+
+ path('last/',
+ self.admin_site.admin_view(self.directory_listing),
+ {'viewtype': 'last'},
+ name='filer-directory_listing-last'),
+
+ path('/list/',
+ self.admin_site.admin_view(self.directory_listing),
+ name='filer-directory_listing'),
+
+ path('/make_folder/',
+ self.admin_site.admin_view(self.make_folder),
+ name='filer-directory_listing-make_folder'),
+ path('make_folder/',
+ self.admin_site.admin_view(self.make_folder),
+ name='filer-directory_listing-make_root_folder'),
+
+ path('images_with_missing_data/',
+ self.admin_site.admin_view(self.directory_listing),
+ {'viewtype': 'images_with_missing_data'},
+ name='filer-directory_listing-images_with_missing_data'),
+
+ path('unfiled_images/',
+ self.admin_site.admin_view(self.directory_listing),
+ {'viewtype': 'unfiled_images'},
+ name='filer-directory_listing-unfiled_images'),
+
+ path('destination_folders/',
+ self.admin_site.admin_view(self.destination_folders),
+ name='filer-destination_folders'),
+ ] + super().get_urls()
# custom views
def directory_listing(self, request, folder_id=None, viewtype=None):
- clipboard = tools.get_user_clipboard(request.user)
+ if not request.user.has_perm("filer.can_use_directory_listing"):
+ raise PermissionDenied()
+ file_type = request.GET.get('file_type', None)
if viewtype == 'images_with_missing_data':
folder = ImagesWithMissingData()
elif viewtype == 'unfiled_images':
- folder = UnfiledImages()
- elif folder_id == None:
+ # pass user in the class invocation, so that we can get
+ # access to the current user instance in the class
+ folder = UnsortedImages(user=request.user)
+ elif viewtype == 'last':
+ last_folder_id = request.session.get('filer_last_folder_id')
+ try:
+ self.get_queryset(request).get(id=last_folder_id)
+ except self.model.DoesNotExist:
+ url = reverse('admin:filer-directory_listing-root')
+ url = f"{url}{admin_url_params_encoded(request)}"
+ else:
+ url = reverse('admin:filer-directory_listing', kwargs={'folder_id': last_folder_id})
+ url = f"{url}{admin_url_params_encoded(request)}"
+ return HttpResponseRedirect(url)
+ elif folder_id is None:
folder = FolderRoot()
else:
- try:
- folder = Folder.objects.get(id=folder_id)
- except Folder.DoesNotExist:
- raise Http404
+ folder = get_object_or_404(self.get_queryset(request), id=folder_id)
+ request.session['filer_last_folder_id'] = folder_id
+
+ list_type = get_directory_listing_type(request) or settings.FILER_FOLDER_ADMIN_DEFAULT_LIST_TYPE
+ if list_type == TABLE_LIST_TYPE:
+ # Prefetch thumbnails for table view
+ size = f"{FILER_TABLE_ICON_SIZE}x{FILER_TABLE_ICON_SIZE}"
+ size_x2 = f"{2 * FILER_TABLE_ICON_SIZE}x{2 * FILER_TABLE_ICON_SIZE}"
+ else:
+ # Prefetch thumbnails for thumbnail view
+ size = f"{FILER_THUMBNAIL_ICON_SIZE}x{FILER_THUMBNAIL_ICON_SIZE}"
+ size_x2 = f"{2 * FILER_THUMBNAIL_ICON_SIZE}x{2 * FILER_THUMBNAIL_ICON_SIZE}"
# Check actions to see if any are available on this changelist
actions = self.get_actions(request)
@@ -214,42 +401,31 @@ def directory_listing(self, request, folder_id=None, viewtype=None):
pass
# search
- def filter_folder(qs, terms=[]):
- for term in terms:
- qs = qs.filter(Q(name__icontains=term) | \
- Q(owner__username__icontains=term) | \
- Q(owner__first_name__icontains=term) | \
- Q(owner__last_name__icontains=term))
- return qs
-
- def filter_file(qs, terms=[]):
- for term in terms:
- qs = qs.filter(Q(name__icontains=term) | \
- Q(description__icontains=term) | \
- Q(original_filename__icontains=term) | \
- Q(owner__username__icontains=term) | \
- Q(owner__first_name__icontains=term) | \
- Q(owner__last_name__icontains=term))
- return qs
- q = request.GET.get('q', None)
+ q = request.GET.get('q')
if q:
- search_terms = unquote(q).split(" ")
+ search_terms = urlunquote(q).split(' ')
+ search_mode = True
else:
search_terms = []
q = ''
+ search_mode = False
+ # Limit search results to current folder.
limit_search_to_folder = request.GET.get('limit_search_to_folder',
False) in (True, 'on')
if len(search_terms) > 0:
if folder and limit_search_to_folder and not folder.is_root:
- folder_qs = folder.get_descendants()
- file_qs = File.objects.filter(
- folder__in=folder.get_descendants())
+ desc_folder_ids = folder.get_descendants_ids()
+ # Do not include current folder itself in search results.
+ folder_qs = Folder.objects.filter(pk__in=desc_folder_ids)
+ # Limit search results to files in the current folder or any
+ # nested folder.
+ file_qs = File.objects.filter(folder_id__in=desc_folder_ids + [folder.pk])
else:
- folder_qs = Folder.objects.all()
+ folder_qs = self.get_queryset(request)
file_qs = File.objects.all()
- folder_qs = filter_folder(folder_qs, search_terms)
- file_qs = filter_file(file_qs, search_terms)
+ folder_qs = self.filter_folder(folder_qs, search_terms).prefetch_related("children", "all_files")
+ file_qs = self.filter_file(file_qs, search_terms)
show_result_count = True
else:
@@ -257,66 +433,85 @@ def filter_file(qs, terms=[]):
file_qs = folder.files.all()
show_result_count = False
- folder_qs = folder_qs.order_by('name')
- file_qs = file_qs.order_by('name')
-
- folder_children = []
- folder_files = []
+ # PBS: filter by file_type if requested
+ if file_type == 'image':
+ file_qs = file_qs.instance_of(Image)
+
+ folder_qs = folder_qs.order_by('name').select_related("owner")
+ order_by = request.GET.get('order_by', None)
+ order_by_annotation = None
+ if order_by is None:
+ order_by_annotation = Lower(Coalesce(
+ Case(
+ When(name__exact='', then=None),
+ When(name__isnull=False, then='name')
+ ),
+ 'original_filename'
+ ))
+
+ order_by = order_by.split(',') if order_by else []
+ order_by = [field for field in order_by
+ if re.sub(r'^-', '', field) in self.order_by_file_fields]
+ if len(order_by) > 0:
+ file_qs = file_qs.order_by(*order_by)
+ elif order_by_annotation:
+ file_qs = file_qs.order_by(order_by_annotation)
+
+ if folder.is_root and not search_mode:
+ virtual_items = folder.virtual_folders
+ else:
+ virtual_items = []
+
+ perms = FolderPermission.objects.get_read_id_list(request.user)
+ if perms != 'All':
+ file_qs = file_qs.filter(
+ models.Q(folder__id__in=perms)
+ | models.Q(folder_id__isnull=True)
+ | models.Q(owner=request.user)
+ )
+ folder_qs = folder_qs.filter(models.Q(id__in=perms) | models.Q(owner=request.user))
+ root_exclude_kwargs = {'parent__isnull': False, 'parent__id__in': perms}
+ else:
+ root_exclude_kwargs = {'parent__isnull': False}
if folder.is_root:
- folder_children += folder.virtual_folders
- for f in folder_qs:
- f.perms = userperms_for_request(f, request)
- if hasattr(f, 'has_read_permission'):
- if f.has_read_permission(request):
- folder_children.append(f)
- else:
- pass
- else:
- folder_children.append(f)
- for f in file_qs:
- f.perms = userperms_for_request(f, request)
- if hasattr(f, 'has_read_permission'):
- if f.has_read_permission(request):
- folder_files.append(f)
- else:
- pass
- else:
- folder_files.append(f)
+ folder_qs = folder_qs.exclude(**root_exclude_kwargs)
+
+ # Annotate thumbnail status
+ thumbnail_qs = (
+ Thumbnail.objects
+ .filter(
+ source__name=OuterRef("file"),
+ modified__gte=F("source__modified"),
+ )
+ .exclude(name__contains="upscale") # TODO: Check WHY not used by directory listing
+ .order_by("-modified")
+ )
+ file_qs = file_qs.annotate(
+ thumbnail_name=Subquery(thumbnail_qs.filter(name__contains=f"__{size}_").values_list("name")[:1]),
+ thumbnailx2_name=Subquery(thumbnail_qs.filter(name__contains=f"__{size_x2}_").values_list("name")[:1])
+ ).select_related("owner")
+
try:
permissions = {
'has_edit_permission': folder.has_edit_permission(request),
'has_read_permission': folder.has_read_permission(request),
- 'has_add_children_permission': \
- folder.has_add_children_permission(request),
+ 'has_add_children_permission':
+ folder.has_add_children_permission(request),
}
- except:
+ except: # noqa
permissions = {}
- folder_files.sort()
- items = folder_children + folder_files
+
+ items = list(itertools.chain(folder_qs, file_qs))
paginator = Paginator(items, FILER_PAGINATE_BY)
- # Make sure page request is an int. If not, deliver first page.
- try:
- page = int(request.GET.get('page', '1'))
- except ValueError:
- page = 1
-
- # Are we moving to clipboard?
- if request.method == 'POST' and '_save' not in request.POST:
- for f in folder_files:
- if "move-to-clipboard-%d" % (f.id,) in request.POST:
- clipboard = tools.get_user_clipboard(request.user)
- if f.has_edit_permission(request):
- tools.move_file_to_clipboard([f], clipboard)
- return HttpResponseRedirect(request.get_full_path())
- else:
- raise PermissionDenied
selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
-
# Actions with no confirmation
- if (actions and request.method == 'POST' and
- 'index' in request.POST and '_save' not in request.POST):
+ if (
+ actions and request.method == 'POST'
+ and 'index' in request.POST
+ and '_save' not in request.POST
+ ):
if selected:
response = self.response_action(request, files_queryset=file_qs, folders_queryset=folder_qs)
if response:
@@ -327,9 +522,12 @@ def filter_file(qs, terms=[]):
self.message_user(request, msg)
# Actions with confirmation
- if (actions and request.method == 'POST' and
- helpers.ACTION_CHECKBOX_NAME in request.POST and
- 'index' not in request.POST and '_save' not in request.POST):
+ if (
+ actions and request.method == 'POST'
+ and helpers.ACTION_CHECKBOX_NAME in request.POST
+ and 'index' not in request.POST
+ and '_save' not in request.POST
+ ):
if selected:
response = self.response_action(request, files_queryset=file_qs, folders_queryset=folder_qs)
if response:
@@ -342,44 +540,106 @@ def filter_file(qs, terms=[]):
else:
action_form = None
- selection_note_all = ungettext('%(total_count)s selected',
+ selection_note_all = ngettext_lazy('%(total_count)s selected',
'All %(total_count)s selected', paginator.count)
# If page request (9999) is out of range, deliver last page of results.
try:
- paginated_items = paginator.page(page)
- except (EmptyPage, InvalidPage):
+ paginated_items = paginator.page(request.GET.get('page', 1))
+ except PageNotAnInteger:
+ paginated_items = paginator.page(1)
+ except EmptyPage:
paginated_items = paginator.page(paginator.num_pages)
- return render_to_response(
- 'admin/filer/folder/directory_listing.html',
- {
- 'folder': folder,
- 'clipboard_files': File.objects.filter(
- in_clipboards__clipboarditem__clipboard__user=request.user
- ).distinct(),
- 'paginator': paginator,
- 'paginated_items': paginated_items,
- 'permissions': permissions,
- 'permstest': userperms_for_request(folder, request),
- 'current_url': request.path,
- 'title': u'Directory listing for %s' % folder.name,
- 'search_string': ' '.join(search_terms),
- 'q': urlencode(q),
- 'show_result_count': show_result_count,
- 'limit_search_to_folder': limit_search_to_folder,
- 'is_popup': popup_status(request),
- 'select_folder': selectfolder_status(request),
- # needed in the admin/base.html template for logout links
- 'root_path': reverse('admin:index'),
- 'action_form': action_form,
- 'actions_on_top': self.actions_on_top,
- 'actions_on_bottom': self.actions_on_bottom,
- 'actions_selection_counter': self.actions_selection_counter,
- 'selection_note': _('0 of %(cnt)s selected') % {'cnt': len(paginated_items.object_list)},
- 'selection_note_all': selection_note_all % {'total_count': paginator.count},
- 'media': self.media,
- 'enable_permissions': settings.FILER_ENABLE_PERMISSIONS
- }, context_instance=RequestContext(request))
+
+ context = self.admin_site.each_context(request)
+ context.update({
+ 'folder': folder,
+ 'paginator': paginator,
+ 'paginated_items': paginated_items,
+ 'virtual_items': virtual_items,
+ 'uploader_connections': settings.FILER_UPLOADER_CONNECTIONS,
+ 'max_files': settings.FILER_UPLOADER_MAX_FILES,
+ 'max_filesize': settings.FILER_UPLOADER_MAX_FILE_SIZE,
+ 'permissions': permissions,
+ 'permstest': userperms_for_request(folder, request),
+ 'current_url': request.path,
+ 'title': _('Directory listing for %(folder_name)s') % {'folder_name': folder.name},
+ 'search_string': ' '.join(search_terms),
+ 'q': urlquote(q),
+ 'show_result_count': show_result_count,
+ 'folder_children': folder_qs,
+ 'folder_files': file_qs,
+ 'thumbnail_size': FILER_TABLE_ICON_SIZE if list_type == TABLE_LIST_TYPE else FILER_THUMBNAIL_ICON_SIZE,
+ 'limit_search_to_folder': limit_search_to_folder,
+ 'is_popup': popup_status(request),
+ 'filer_admin_context': AdminContext(request),
+ # needed in the admin/base.html template for logout links
+ 'root_path': reverse('admin:index'),
+ 'action_form': action_form,
+ 'actions_on_top': self.actions_on_top,
+ 'actions_on_bottom': self.actions_on_bottom,
+ 'actions_selection_counter': self.actions_selection_counter,
+ 'selection_note': _('0 of %(cnt)s selected') % {'cnt': len(paginated_items.object_list)},
+ 'selection_note_all': selection_note_all % {'total_count': paginator.count},
+ 'list_type': list_type,
+ 'list_type_template': settings.FILER_FOLDER_ADMIN_LIST_TYPE_SWITCHER_SETTINGS[list_type]['template'],
+ 'media': self.media,
+ 'enable_permissions': settings.FILER_ENABLE_PERMISSIONS,
+ 'can_make_folder': request.user.is_superuser or (folder.is_root and settings.FILER_ALLOW_REGULAR_USERS_TO_ADD_ROOT_FOLDERS) or permissions.get("has_add_children_permission"),
+ })
+ return TemplateResponse(request, self.directory_listing_template, context)
+
+ def filter_folder(self, qs, terms=()):
+ # Source: https://github.com/django/django/blob/1.7.1/django/contrib/admin/options.py#L939-L947 flake8: noqa
+ def construct_search(field_name):
+ if field_name.startswith('^'):
+ return "%s__istartswith" % field_name[1:]
+ elif field_name.startswith('='):
+ return "%s__iexact" % field_name[1:]
+ elif field_name.startswith('@'):
+ return "%s__search" % field_name[1:]
+ else:
+ return "%s__icontains" % field_name
+
+ for term in terms:
+ filters = models.Q()
+ for filter_ in self.search_fields:
+ filters |= models.Q(**{construct_search(filter_): term})
+ for filter_ in self.get_owner_filter_lookups():
+ filters |= models.Q(**{filter_: term})
+ qs = qs.filter(filters)
+ return qs
+
+ def filter_file(self, qs, terms=()):
+ for term in terms:
+ filters = (models.Q(name__icontains=term)
+ | models.Q(description__icontains=term)
+ | models.Q(original_filename__icontains=term))
+ for filter_ in self.get_owner_filter_lookups():
+ filters |= models.Q(**{filter_: term})
+ qs = qs.filter(filters)
+ return qs
+
+ @property
+ def owner_search_fields(self):
+ """
+ Returns all the fields that are CharFields except for password from the
+ User model. For the built-in User model, that means username,
+ first_name, last_name, and email.
+ """
+ from django.contrib.auth import get_user_model
+ User = get_user_model()
+
+ return [
+ field.name for field in User._meta.fields
+ if isinstance(field, models.CharField) and field.name != 'password'
+ ]
+
+ def get_owner_filter_lookups(self):
+ return [
+ f'owner__{field}__icontains'
+ for field in self.owner_search_fields
+ ]
def response_action(self, request, files_queryset, folders_queryset):
"""
@@ -424,7 +684,8 @@ def response_action(self, request, files_queryset, folders_queryset):
# the action explicitly on all objects.
selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
if not selected and not select_across:
- # Reminder that something needs to be selected or nothing will happen
+ # Reminder that something needs to be selected or nothing
+ # will happen
msg = _("Items must be selected in order to perform "
"actions on them. No items have been changed.")
self.message_user(request, msg)
@@ -442,7 +703,8 @@ def response_action(self, request, files_queryset, folders_queryset):
# Perform the action only on the selected objects
files_queryset = files_queryset.filter(pk__in=selected_files)
- folders_queryset = folders_queryset.filter(pk__in=selected_folders)
+ folders_queryset = folders_queryset.filter(
+ pk__in=selected_folders)
response = func(self, request, files_queryset, folders_queryset)
@@ -459,65 +721,40 @@ def response_action(self, request, files_queryset, folders_queryset):
return None
def get_actions(self, request):
- actions = super(FolderAdmin, self).get_actions(request)
+ if settings.FILER_ENABLE_PERMISSIONS:
+ actions = OrderedDict()
+ actions['files_set_public'] = self.get_action('files_set_public')
+ actions['files_set_private'] = self.get_action('files_set_private')
+ actions.update(super().get_actions(request))
+ else:
+ actions = super().get_actions(request)
+
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
- def move_to_clipboard(self, request, files_queryset, folders_queryset):
- """
- Action which moves the selected files and files in selected folders to clipboard.
- """
-
- if not self.has_change_permission(request):
- raise PermissionDenied
-
- if request.method != 'POST':
- return None
-
- clipboard = tools.get_user_clipboard(request.user)
-
- check_files_edit_permissions(request, files_queryset)
- check_folder_edit_permissions(request, folders_queryset)
- # TODO: Display a confirmation page if moving more than X files to clipboard?
-
- files_count = [0] # We define it like that so that we can modify it inside the move_files function
-
- def move_files(files):
- files_count[0] += tools.move_file_to_clipboard(files, clipboard)
-
- def move_folders(folders):
- for f in folders:
- move_files(f.files)
- move_folders(f.children.all())
-
- move_files(files_queryset)
- move_folders(folders_queryset)
-
- self.message_user(request, _("Successfully moved %(count)d files to clipboard.") % {
- "count": files_count[0],
- })
-
- return None
-
- move_to_clipboard.short_description = ugettext_lazy("Move selected files to clipboard")
-
- def files_set_public_or_private(self, request, set_public, files_queryset, folders_queryset):
+ def files_set_public_or_private(self, request, set_public, files_queryset,
+ folders_queryset):
"""
- Action which enables or disables permissions for selected files and files in selected folders to clipboard (set them private or public).
+ Action which enables or disables permissions for selected files and
+ files in selected folders (set them private or public).
"""
if not self.has_change_permission(request):
raise PermissionDenied
- if request.method != 'POST':
+ permissions_enabled = settings.FILER_ENABLE_PERMISSIONS
+
+ if request.method != 'POST' or not permissions_enabled:
return None
check_files_edit_permissions(request, files_queryset)
check_folder_edit_permissions(request, folders_queryset)
- files_count = [0] # We define it like that so that we can modify it inside the set_files function
+ # We define it like that so that we can modify it inside the
+ # set_files function
+ files_count = [0]
def set_files(files):
for f in files:
@@ -535,35 +772,39 @@ def set_folders(folders):
set_folders(folders_queryset)
if set_public:
- self.message_user(request, _("Successfully disabled permissions for %(count)d files.") % {
- "count": files_count[0],
- })
+ self.message_user(request, _("Successfully disabled permissions for %(count)d files.") % {"count": files_count[0], })
else:
- self.message_user(request, _("Successfully enabled permissions for %(count)d files.") % {
- "count": files_count[0],
- })
+ self.message_user(request, _("Successfully enabled permissions for %(count)d files.") % {"count": files_count[0], })
return None
def files_set_private(self, request, files_queryset, folders_queryset):
- return self.files_set_public_or_private(request, False, files_queryset, folders_queryset)
+ return self.files_set_public_or_private(request, False, files_queryset,
+ folders_queryset)
- files_set_private.short_description = ugettext_lazy("Enable permissions for selected files")
+ files_set_private.short_description = _("Enable permissions for selected files")
def files_set_public(self, request, files_queryset, folders_queryset):
- return self.files_set_public_or_private(request, True, files_queryset, folders_queryset)
+ return self.files_set_public_or_private(request, True, files_queryset,
+ folders_queryset)
- files_set_public.short_description = ugettext_lazy("Disable permissions for selected files")
+ files_set_public.short_description = _("Disable permissions for selected files")
+
+ def log_deletions(self, request, queryset):
+ """Log deletion for each object in the queryset."""
+ for obj in queryset:
+ self.log_deletion(request, obj, str(obj))
def delete_files_or_folders(self, request, files_queryset, folders_queryset):
"""
Action which deletes the selected files and/or folders.
This action first displays a confirmation page whichs shows all the
- deleteable files and/or folders, or, if the user has no permission on one of the related
- childs (foreignkeys), a "permission denied" message.
+ deletable files and/or folders, or, if the user has no permission on
+ one of the related childs (foreignkeys), a "permission denied" message.
- Next, it delets all selected files and/or folders and redirects back to the folder.
+ Next, it deletes all selected files and/or folders and redirects back to
+ the folder.
"""
opts = self.model._meta
app_label = opts.app_label
@@ -572,58 +813,60 @@ def delete_files_or_folders(self, request, files_queryset, folders_queryset):
if not self.has_delete_permission(request):
raise PermissionDenied
- current_folder = self._get_current_action_folder(request, files_queryset, folders_queryset)
+ # PBS: block deletion of readonly/core/restricted folders/files
+ if not has_multi_file_action_permission(request, files_queryset, folders_queryset):
+ messages.error(request, _("You do not have permission to delete "
+ "the selected files and/or folders."))
+ return None
+
+ current_folder = self._get_current_action_folder(
+ request, files_queryset, folders_queryset)
all_protected = []
- # Populate deletable_objects, a data structure of all related objects that
- # will also be deleted.
- # Hopefully this also checks for necessary permissions.
+ # Populate deletable_objects, a data structure of all related objects
+ # that will also be deleted. Hopefully this also checks for necessary
+ # permissions.
# TODO: Check if permissions are really verified
- (args, varargs, keywords, defaults) = inspect.getargspec(get_deleted_objects)
- if 'levels_to_root' in args:
- # Django 1.2
- deletable_files, perms_needed_files = get_deleted_objects(files_queryset, files_queryset.model._meta, request.user, self.admin_site, levels_to_root=2)
- deletable_folders, perms_needed_folders = get_deleted_objects(folders_queryset, folders_queryset.model._meta, request.user, self.admin_site, levels_to_root=2)
- else:
- # Django 1.3
- using = router.db_for_write(self.model)
- deletable_files, perms_needed_files, protected_files = get_deleted_objects(files_queryset, files_queryset.model._meta, request.user, self.admin_site, using)
- deletable_folders, perms_needed_folders, protected_folders = get_deleted_objects(folders_queryset, folders_queryset.model._meta, request.user, self.admin_site, using)
- all_protected.extend(protected_files)
- all_protected.extend(protected_folders)
+ using = router.db_for_write(self.model)
+ deletable_files, model_count_files, perms_needed_files, protected_files = get_deleted_objects(files_queryset, files_queryset.model._meta, request.user, self.admin_site, using)
+ deletable_folders, model_count_folder, perms_needed_folders, protected_folders = get_deleted_objects(folders_queryset, folders_queryset.model._meta, request.user, self.admin_site, using)
+ all_protected.extend(protected_files)
+ all_protected.extend(protected_folders)
all_deletable_objects = [deletable_files, deletable_folders]
all_perms_needed = perms_needed_files.union(perms_needed_folders)
- # The user has already confirmed the deletion.
- # Do the deletion and return a None to display the change list view again.
+ # The user has already confirmed the deletion. Do the deletion and
+ # return a None to display the change list view again.
if request.POST.get('post'):
if all_perms_needed:
raise PermissionDenied
n = files_queryset.count() + folders_queryset.count()
if n:
# delete all explicitly selected files
+ self.log_deletions(request, files_queryset)
+ # Still need to delete files individually (not only the database entries)
for f in files_queryset:
- self.log_deletion(request, f, force_unicode(f))
f.delete()
# delete all files in all selected folders and their children
- # This would happen automatically by ways of the delete cascade, but then the individual .delete()
- # methods won't be called and the files won't be deleted from the filesystem.
+ # This would happen automatically by ways of the delete
+ # cascade, but then the individual .delete() methods won't be
+ # called and the files won't be deleted from the filesystem.
folder_ids = set()
for folder in folders_queryset:
folder_ids.add(folder.id)
- folder_ids.update(folder.get_descendants().values_list('id', flat=True))
- for f in File.objects.filter(folder__in=folder_ids):
- self.log_deletion(request, f, force_unicode(f))
+ folder_ids.update(folder.get_descendants_ids())
+ qs = File.objects.filter(folder__in=folder_ids)
+ self.log_deletions(request, qs)
+ # Still need to delete files individually (not only the database entries)
+ for f in qs:
f.delete()
- # delete all folders
- for f in folders_queryset:
- self.log_deletion(request, f, force_unicode(f))
- f.delete()
- self.message_user(request, _("Successfully deleted %(count)d files and/or folders.") % {
- "count": n,
- })
+ # delete all folders individually to trigger soft-delete
+ self.log_deletions(request, folders_queryset)
+ for folder in folders_queryset:
+ folder.delete()
+ self.message_user(request, _("Successfully deleted %(count)d files and/or folders.") % {"count": n, })
# Return None to display the change list page again.
return None
@@ -632,7 +875,8 @@ def delete_files_or_folders(self, request, files_queryset, folders_queryset):
else:
title = _("Are you sure?")
- context = {
+ context = self.admin_site.each_context(request)
+ context.update({
"title": title,
"instance": current_folder,
"breadcrumbs_action": _("Delete files and/or folders"),
@@ -643,18 +887,20 @@ def delete_files_or_folders(self, request, files_queryset, folders_queryset):
"protected": all_protected,
"opts": opts,
'is_popup': popup_status(request),
- 'select_folder': selectfolder_status(request),
+ 'filer_admin_context': AdminContext(request),
"root_path": reverse('admin:index'),
"app_label": app_label,
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
- }
+ })
# Display the destination folder selection page
- return render_to_response([
- "admin/filer/delete_selected_files_confirmation.html"
- ], context, context_instance=template.RequestContext(request))
+ return TemplateResponse(
+ request,
+ "admin/filer/delete_selected_files_confirmation.html",
+ context
+ )
- delete_files_or_folders.short_description = ugettext_lazy("Delete selected files and/or folders")
+ delete_files_or_folders.short_description = _("Delete selected files and/or folders")
# Copied from django.contrib.admin.util
def _format_callback(self, obj, user, admin_site, perms_needed):
@@ -666,20 +912,15 @@ def _format_callback(self, obj, user, admin_site, perms_needed):
opts.app_label,
opts.object_name.lower()),
None, (quote(obj._get_pk_val()),))
- p = '%s.%s' % (opts.app_label,
- opts.get_delete_permission())
+ p = get_delete_permission(opts)
if not user.has_perm(p):
perms_needed.add(opts.verbose_name)
# Display a link to the admin page.
- return mark_safe(u'%s: %s' %
- (escape(capfirst(opts.verbose_name)),
- admin_url,
- escape(obj)))
+ return format_html('{}: {}', escape(capfirst(opts.verbose_name)), admin_url, escape(obj))
else:
# Don't display link to edit, because it either has no
# admin or is edited inline.
- return u'%s: %s' % (capfirst(opts.verbose_name),
- force_unicode(obj))
+ return f'{capfirst(opts.verbose_name)}: {force_str(obj)}'
def _check_copy_perms(self, request, files_queryset, folders_queryset):
try:
@@ -699,7 +940,8 @@ def _check_move_perms(self, request, files_queryset, folders_queryset):
return True
return False
- def _get_current_action_folder(self, request, files_queryset, folders_queryset):
+ def _get_current_action_folder(self, request, files_queryset,
+ folders_queryset):
if files_queryset:
return files_queryset[0].folder
elif folders_queryset:
@@ -731,43 +973,162 @@ def _list_all_destination_folders_recursive(self, request, folders_queryset, cur
# We do not allow copying/moving back to the folder itself
enabled = (allow_self or fo != current_folder) and fo.has_add_children_permission(request)
- yield (fo, (mark_safe((" " * level) + force_unicode(fo)), enabled))
- for c in self._list_all_destination_folders_recursive(request, folders_queryset, current_folder, fo.children.all(), allow_self, level + 1):
- yield c
+ yield (fo, (mark_safe((" " * level) + force_str(fo)), enabled))
+ yield from self._list_all_destination_folders_recursive(request, folders_queryset, current_folder, fo.children.all(), allow_self, level + 1)
def _list_all_destination_folders(self, request, folders_queryset, current_folder, allow_self):
- return list(self._list_all_destination_folders_recursive(request, folders_queryset, current_folder, FolderRoot().children, allow_self, 0))
+ root_folders = self.get_queryset(request).filter(parent__isnull=True).order_by('name')
+ return list(self._list_all_destination_folders_recursive(request, folders_queryset, current_folder, root_folders, allow_self, 0))
+
+ def destination_folders(self, request):
+ """
+ AJAX view returning JSON list of destination folders for the fancytree
+ widget used in move/copy operations.
+ PBS: Filters by site access, excludes core folders and orphaned folders.
+ """
+ import json
+ selected_folders_raw = request.GET.get('selected_folders', '[]')
+ try:
+ selected_ids = json.loads(selected_folders_raw)
+ except (json.JSONDecodeError, TypeError):
+ selected_ids = []
+ selected_qs = self.get_queryset(request).filter(pk__in=selected_ids)
+
+ current_folder_id = request.GET.get('current_folder')
+ current_folder = None
+ if current_folder_id and current_folder_id != 'null':
+ try:
+ current_folder = self.get_queryset(request).get(pk=int(current_folder_id))
+ except (Folder.DoesNotExist, ValueError):
+ pass
+
+ parent_raw = request.GET.get('parent')
+ if parent_raw and parent_raw != 'null':
+ try:
+ parent_id = int(parent_raw)
+ folders = self.get_queryset(request).filter(parent_id=parent_id).order_by('name')
+ except (ValueError, TypeError):
+ folders = self.get_queryset(request).filter(parent__isnull=True).order_by('name')
+ else:
+ folders = self.get_queryset(request).filter(parent__isnull=True).order_by('name')
+
+ result = []
+ for fo in folders:
+ if fo in selected_qs:
+ continue
+ if not fo.has_read_permission(request):
+ continue
+ # PBS: exclude core folders and orphaned folders (no site) as destinations
+ if fo.is_core():
+ continue
+ if fo.parent is None and not fo.site:
+ continue
+ is_selectable = (fo != current_folder) and fo.has_add_children_permission(request)
+ has_children = fo.children.exists()
+ result.append({
+ 'key': fo.pk,
+ 'title': fo.name,
+ 'folder': True,
+ 'lazy': has_children,
+ 'unselectable': not is_selectable,
+ })
+ return JsonResponse(result, safe=False)
def _move_files_and_folders_impl(self, files_queryset, folders_queryset, destination):
for f in files_queryset:
f.folder = destination
f.save()
- for f in folders_queryset:
- f.move_to(destination, 'last-child')
+ for f_id in folders_queryset.values_list('id', flat=True):
+ f = Folder.objects.get(id=f_id)
+ f.parent = destination
f.save()
+ def _validate_destination(self, request, destination, folders_queryset, current_folder, allow_self=False):
+ """
+ Validate that destination is a legitimate target for copy/move without
+ recursively traversing the entire folder tree. Returns True if valid.
+ """
+ if not destination.has_read_permission(request):
+ return False
+ if not destination.has_add_children_permission(request):
+ return False
+ if not allow_self and destination == current_folder:
+ return False
+ # Cannot move/copy into one of the selected folders or their descendants
+ selected_pks = set(folders_queryset.values_list('pk', flat=True))
+ if destination.pk in selected_pks:
+ return False
+ # Check that destination is not a descendant of any selected folder
+ ancestor = destination.parent
+ while ancestor is not None:
+ if ancestor.pk in selected_pks:
+ return False
+ ancestor = ancestor.parent
+ return True
+
def move_files_and_folders(self, request, files_queryset, folders_queryset):
opts = self.model._meta
app_label = opts.app_label
+ # PBS: prevent moving root folders
+ if folders_queryset.filter(parent=None).exists():
+ messages.error(request, "To prevent potential problems, users "
+ "are not allowed to move root folders. You may copy folders "
+ "and files.")
+ return
+
current_folder = self._get_current_action_folder(request, files_queryset, folders_queryset)
perms_needed = self._check_move_perms(request, files_queryset, folders_queryset)
to_move = self._list_all_to_copy_or_move(request, files_queryset, folders_queryset)
- folders = self._list_all_destination_folders(request, folders_queryset, current_folder, False)
if request.method == 'POST' and request.POST.get('post'):
if perms_needed:
raise PermissionDenied
try:
- destination = Folder.objects.get(pk=request.POST.get('destination'))
- except Folder.DoesNotExist:
+ destination = self.get_queryset(request).get(pk=request.POST.get('destination'))
+ except self.model.DoesNotExist:
raise PermissionDenied
- folders_dict = dict(folders)
- if destination not in folders_dict or not folders_dict[destination][1]:
+ if not self._validate_destination(request, destination, folders_queryset, current_folder):
raise PermissionDenied
+
+ # PBS: validate destination is not a core folder
+ if destination.is_core():
+ messages.error(request, "You cannot move files/folders into a core folder.")
+ return None
+
+ # PBS: site consistency checks
+ sites_from_folders = \
+ set(folders_queryset.values_list('site_id', flat=True)) | \
+ set(files_queryset.exclude(folder__isnull=True).\
+ values_list('folder__site_id', flat=True))
+
+ if sites_from_folders and None in sites_from_folders:
+ messages.error(request, "Some of the selected files/folders "
+ "do not belong to any site. Folders need to be assigned "
+ "to a site before you can move files/folders from it.")
+ return
+ elif not destination.site:
+ messages.error(request, "The destination folder does not "
+ "belong to any site.")
+ return
+ elif len(sites_from_folders) > 1:
+ messages.error(request, "You cannot move files/folders that "
+ "belong to several sites. Select files/folders that "
+ "belong to only one site.")
+ return
+ elif (sites_from_folders and destination.site and
+ sites_from_folders.pop() != destination.site.id):
+ messages.error(request, "Selected files/folders need to "
+ "belong to the same site as the destination folder.")
+ return
+
# We count only topmost files and folders here
n = files_queryset.count() + folders_queryset.count()
- if n:
+ conflicting_names = [folder.name for folder in self.get_queryset(request).filter(parent=destination, name__in=folders_queryset.values('name'))]
+ if conflicting_names:
+ messages.error(request, _("Folders with names %s already exist at the selected "
+ "destination") % ", ".join(conflicting_names))
+ elif n:
self._move_files_and_folders_impl(files_queryset, folders_queryset, destination)
self.message_user(request, _("Successfully moved %(count)d files and/or folders to folder '%(destination)s'.") % {
"count": n,
@@ -775,12 +1136,13 @@ def move_files_and_folders(self, request, files_queryset, folders_queryset):
})
return None
- context = {
+ context = self.admin_site.each_context(request)
+ context.update({
"title": _("Move files and/or folders"),
"instance": current_folder,
"breadcrumbs_action": _("Move files and/or folders"),
"to_move": to_move,
- "destination_folders": folders,
+ "destination_folders": [],
"files_queryset": files_queryset,
"folders_queryset": folders_queryset,
"perms_lacking": perms_needed,
@@ -788,14 +1150,12 @@ def move_files_and_folders(self, request, files_queryset, folders_queryset):
"root_path": reverse('admin:index'),
"app_label": app_label,
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
- }
+ })
# Display the destination folder selection page
- return render_to_response([
- "admin/filer/folder/choose_move_destination.html"
- ], context, context_instance=template.RequestContext(request))
+ return TemplateResponse(request, "admin/filer/folder/choose_move_destination.html", context)
- move_files_and_folders.short_description = ugettext_lazy("Move selected files and/or folders")
+ move_files_and_folders.short_description = _("Move selected files and/or folders")
def _rename_file(self, file_obj, form_data, counter, global_counter):
original_basename, original_extension = os.path.splitext(file_obj.original_filename)
@@ -805,16 +1165,16 @@ def _rename_file(self, file_obj, form_data, counter, global_counter):
current_basename = ""
current_extension = ""
file_obj.name = form_data['rename_format'] % {
- 'original_filename': file_obj.original_filename,
- 'original_basename': original_basename,
- 'original_extension': original_extension,
- 'current_filename': file_obj.name or "",
- 'current_basename': current_basename,
- 'current_extension': current_extension,
- 'current_folder': file_obj.folder.name,
- 'counter': counter + 1, # 1-based
- 'global_counter': global_counter + 1, # 1-based
- }
+ 'original_filename': file_obj.original_filename,
+ 'original_basename': original_basename,
+ 'original_extension': original_extension,
+ 'current_filename': file_obj.name or "",
+ 'current_basename': current_basename,
+ 'current_extension': current_extension,
+ 'current_folder': getattr(file_obj.folder, 'name', ''),
+ 'counter': counter + 1, # 1-based
+ 'global_counter': global_counter + 1, # 1-based
+ }
file_obj.save()
def _rename_files(self, files, form_data, global_counter):
@@ -859,7 +1219,8 @@ def rename_files(self, request, files_queryset, folders_queryset):
else:
form = RenameFilesForm()
- context = {
+ context = self.admin_site.each_context(request)
+ context.update({
"title": _("Rename files"),
"instance": current_folder,
"breadcrumbs_action": _("Rename files"),
@@ -872,14 +1233,146 @@ def rename_files(self, request, files_queryset, folders_queryset):
"root_path": reverse('admin:index'),
"app_label": app_label,
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
- }
+ })
# Display the rename format selection page
- return render_to_response([
- "admin/filer/folder/choose_rename_format.html"
- ], context, context_instance=template.RequestContext(request))
+ return TemplateResponse(request, "admin/filer/folder/choose_rename_format.html", context)
- rename_files.short_description = ugettext_lazy("Rename files")
+ rename_files.short_description = _("Rename files")
+
+ def extract_files(self, request, files_queryset, folders_queryset):
+ if request.method != 'POST':
+ return None
+
+ from ..models import Archive
+ success_format = "Successfully extracted archive {}."
+
+ # Filter by zip file extension rather than polymorphic_ctype,
+ # because zip files may have been uploaded as plain File instances
+ zip_extensions = Archive._filename_extensions # ['.zip']
+ from django.db.models import Q
+ extension_filter = Q()
+ for ext in zip_extensions:
+ extension_filter |= Q(original_filename__iendswith=ext)
+ extension_filter |= Q(file__iendswith=ext)
+ files_queryset = files_queryset.filter(extension_filter)
+
+ if not files_queryset.exists():
+ self.message_user(request, _("No archive files were selected."),
+ level=messages.WARNING)
+ return None
+
+ # cannot extract in unfiled files folder
+ if files_queryset.filter(folder__isnull=True).exists():
+ raise PermissionDenied
+
+ if not has_multi_file_action_permission(request, files_queryset,
+ Folder.objects.none()):
+ raise PermissionDenied
+
+ def _as_archive(filer_file):
+ """Convert a File instance to Archive so extract methods work."""
+ if isinstance(filer_file, Archive):
+ return filer_file
+ archive = Archive()
+ archive.__dict__.update(filer_file.__dict__)
+ archive.extract_errors = []
+ return archive
+
+ def is_valid_archive(filer_file):
+ is_valid = filer_file.is_valid()
+ if not is_valid:
+ error_format = "{} is not a valid zip file"
+ message = error_format.format(filer_file.clean_actual_name)
+ messages.error(request, _(message))
+ return is_valid
+
+ def has_collisions(filer_file):
+ collisions = filer_file.collisions()
+ if collisions:
+ error_format = "Files/Folders from {archive} with names:"
+ error_format += "{names} already exist."
+ names = ", ".join(collisions)
+ archive = filer_file.clean_actual_name
+ message = error_format.format(
+ archive=archive,
+ names=names,
+ )
+ messages.error(request, _(message))
+ return len(collisions) > 0
+
+ for f in files_queryset:
+ f = _as_archive(f)
+ if not is_valid_archive(f) or has_collisions(f):
+ continue
+ try:
+ f.extract()
+ message = success_format.format(f.actual_name)
+ self.message_user(request, _(message))
+ for err_msg in f.extract_errors:
+ messages.warning(
+ request,
+ _("%s: %s" % (f.actual_name, err_msg))
+ )
+ except Exception as e:
+ logger.exception("Exception extracting file pk=%s: %s", f.pk, e)
+ messages.error(request, _("Error extracting %s: %s" % (f.actual_name, str(e))))
+ return None
+
+ extract_files.short_description = _("Extract selected zip files")
+
+ def files_toggle_restriction(self, request, restriction,
+ files_qs, folders_qs):
+ """
+ Action which enables or disables restriction for files/folders.
+ """
+ if request.method != 'POST':
+ return None
+ # cannot restrict/unrestrict unfiled files
+ unfiled_files = files_qs.filter(folder__isnull=True)
+ if unfiled_files.exists():
+ messages.warning(request, _("Some of the selected files do not have parents: %s, "
+ "so their rights cannot be changed.") %
+ ', '.join([str(unfiled_file) for unfiled_file in unfiled_files.all()]))
+ return None
+
+ if not has_multi_file_action_permission(request, files_qs, folders_qs):
+ messages.warning(request, _("You are not allowed to modify the restrictions on "
+ "the selected files and folders."))
+ return None
+
+ count = [0]
+
+ def set_files_or_folders(filer_obj):
+ for f in filer_obj:
+ if f.restricted != restriction:
+ f.restricted = restriction
+ f.save()
+ count[0] += 1
+
+ set_files_or_folders(files_qs)
+ set_files_or_folders(folders_qs)
+ count = count[0]
+ if restriction:
+ self.message_user(request,
+ _("Successfully enabled restriction for %(count)d files "
+ "and/or folders.") % {"count": count,})
+ else:
+ self.message_user(request,
+ _("Successfully disabled restriction for %(count)d files "
+ "and/or folders.") % {"count": count,})
+
+ def enable_restriction(self, request, files_qs, folders_qs):
+ return self.files_toggle_restriction(
+ request, True, files_qs, folders_qs)
+
+ enable_restriction.short_description = _("Enable role restriction for selected files and/or folders")
+
+ def disable_restriction(self, request, files_qs, folders_qs):
+ return self.files_toggle_restriction(
+ request, False, files_qs, folders_qs)
+
+ disable_restriction.short_description = _("Disable role restriction for selected files and/or folders")
def _generate_new_filename(self, filename, suffix):
basename, extension = os.path.splitext(filename)
@@ -899,6 +1392,7 @@ def _copy_file(self, file_obj, destination, suffix, overwrite):
file_obj.id = None
file_obj.save()
file_obj.folder = destination
+ file_obj._file_data_changed_hint = False # no need to update size, sha1, etc.
file_obj.file = file_obj._copy_file(filename)
file_obj.original_filename = self._generate_new_filename(file_obj.original_filename, suffix)
file_obj.save()
@@ -912,7 +1406,7 @@ def _get_available_name(self, destination, name):
count = itertools.count(1)
original = name
while destination.contains_folder(name):
- name = "%s_%s" % (original, count.next())
+ name = f"{original}_{next(count)}"
return name
def _copy_folder(self, folder, destination, suffix, overwrite):
@@ -927,11 +1421,11 @@ def _copy_folder(self, folder, destination, suffix, overwrite):
old_folder = Folder.objects.get(pk=folder.pk)
- # Due to how inheritance works, we have to set both pk and id to None
- folder.pk = None
- folder.id = None
- folder.name = foldername
- folder.insert_at(destination, 'last-child', True) # We save folder here
+ folder, _ = Folder.objects.get_or_create(
+ name=foldername,
+ owner=old_folder.owner,
+ parent=destination,
+ )
for perm in FolderPermission.objects.filter(folder=old_folder):
perm.pk = None
@@ -956,7 +1450,6 @@ def copy_files_and_folders(self, request, files_queryset, folders_queryset):
current_folder = self._get_current_action_folder(request, files_queryset, folders_queryset)
perms_needed = self._check_copy_perms(request, files_queryset, folders_queryset)
to_copy = self._list_all_to_copy_or_move(request, files_queryset, folders_queryset)
- folders = self._list_all_destination_folders(request, folders_queryset, current_folder, True)
if request.method == 'POST' and request.POST.get('post'):
if perms_needed:
@@ -964,14 +1457,31 @@ def copy_files_and_folders(self, request, files_queryset, folders_queryset):
form = CopyFilesAndFoldersForm(request.POST)
if form.is_valid():
try:
- destination = Folder.objects.get(pk=request.POST.get('destination'))
- except Folder.DoesNotExist:
+ destination = self.get_queryset(request).get(pk=request.POST.get('destination'))
+ except self.model.DoesNotExist:
raise PermissionDenied
- folders_dict = dict(folders)
- if destination not in folders_dict or not folders_dict[destination][1]:
+ if not self._validate_destination(request, destination, folders_queryset, current_folder):
raise PermissionDenied
+
+ # PBS: validate destination is not a core folder
+ if destination.is_core():
+ messages.warning(request, _("The selected destination was not valid. "
+ "You cannot copy files/folders into a core folder."))
+ return None
+
+ # PBS: site consistency checks (same as move)
+ sites_from_folders = \
+ set(folders_queryset.values_list('site_id', flat=True)) | \
+ set(files_queryset.exclude(folder__isnull=True).\
+ values_list('folder__site_id', flat=True))
+ if sites_from_folders and destination.site and \
+ any(s != destination.site_id for s in sites_from_folders if s is not None):
+ messages.warning(request, _("The selected destination was not valid. "
+ "Selected files/folders need to belong to the same site as the destination folder."))
+ return None
+
if files_queryset.count() + folders_queryset.count():
- # We count all files and folders here (recursivelly)
+ # We count all files and folders here (recursively)
n = self._copy_files_and_folders_impl(files_queryset, folders_queryset, destination, form.cleaned_data['suffix'], False)
self.message_user(request, _("Successfully copied %(count)d files and/or folders to folder '%(destination)s'.") % {
"count": n,
@@ -984,15 +1494,19 @@ def copy_files_and_folders(self, request, files_queryset, folders_queryset):
try:
selected_destination_folder = int(request.POST.get('destination', 0))
except ValueError:
- selected_destination_folder = 0
+ if current_folder:
+ selected_destination_folder = current_folder.pk
+ else:
+ selected_destination_folder = 0
- context = {
+ context = self.admin_site.each_context(request)
+ context.update({
"title": _("Copy files and/or folders"),
"instance": current_folder,
"breadcrumbs_action": _("Copy files and/or folders"),
"to_copy": to_copy,
- "destination_folders": folders,
- "selected_destination_folder": selected_destination_folder or current_folder.pk,
+ "destination_folders": [],
+ "selected_destination_folder": selected_destination_folder,
"copy_form": form,
"files_queryset": files_queryset,
"folders_queryset": folders_queryset,
@@ -1001,14 +1515,12 @@ def copy_files_and_folders(self, request, files_queryset, folders_queryset):
"root_path": reverse('admin:index'),
"app_label": app_label,
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
- }
+ })
# Display the destination folder selection page
- return render_to_response([
- "admin/filer/folder/choose_copy_destination.html"
- ], context, context_instance=template.RequestContext(request))
+ return TemplateResponse(request, "admin/filer/folder/choose_copy_destination.html", context)
- copy_files_and_folders.short_description = ugettext_lazy("Copy selected files and/or folders")
+ copy_files_and_folders.short_description = _("Copy selected files and/or folders")
def _check_resize_perms(self, request, files_queryset, folders_queryset):
try:
@@ -1033,24 +1545,34 @@ def _list_all_to_resize(self, request, files_queryset, folders_queryset):
return to_resize
def _new_subject_location(self, original_width, original_height, new_width, new_height, x, y, crop):
- # TODO: We could probably do better
- return (round(new_width / 2), round(new_height / 2))
+ # TODO: We could probably do even better, but this method knows nothing
+ # about actual thumbnailing algorithm details.
+ # It's better to reset subject location to the central point of the new
+ # image if the image is being cropped. The originally specified subject
+ # location could be outside of the new image.
+ if crop:
+ return int(new_width / 2), int(new_height / 2)
+ else:
+ # Calculate scaling factor of the new image compared to old.
+ scale = min(new_width / original_width, new_height / original_height)
+ return int(scale * x), int(scale * y)
def _resize_image(self, image, form_data):
original_width = float(image.width)
original_height = float(image.height)
- thumbnailer = FilerActionThumbnailer(file=image.file.file, name=image.file.name, source_storage=image.file.source_storage, thumbnail_storage=image.file.source_storage)
+ thumbnailer = FilerActionThumbnailer(file=image.file, name=image.file.name, source_storage=image.file.source_storage, thumbnail_storage=image.file.source_storage)
# This should overwrite the original image
new_image = thumbnailer.get_thumbnail({
- 'size': (form_data['width'], form_data['height']),
+ 'size': tuple(int(form_data[d] or 0) for d in ('width', 'height')),
'crop': form_data['crop'],
'upscale': form_data['upscale'],
'subject_location': image.subject_location,
})
- from django.db.models.fields.files import ImageFieldFile
image.file.file = new_image.file
- image.generate_sha1()
- image.save() # Also gets new width and height
+ # Since only file data was changed, there is no way for file field to know about the change.
+ # To update size, sha1, width and height fields let's call file_data_changed callback directly.
+ image.file_data_changed()
+ image.save()
subject_location = normalize_subject_location(image.subject_location)
if subject_location:
@@ -1101,22 +1623,20 @@ def resize_images(self, request, files_queryset, folders_queryset):
form.cleaned_data['crop'] = form.cleaned_data['thumbnail_option'].crop
form.cleaned_data['upscale'] = form.cleaned_data['thumbnail_option'].upscale
if files_queryset.count() + folders_queryset.count():
- # We count all files here (recursivelly)
+ # We count all files here (recursively)
n = self._resize_images_impl(files_queryset, folders_queryset, form.cleaned_data)
- self.message_user(request, _("Successfully resized %(count)d images.") % {
- "count": n,
- })
+ self.message_user(request, _("Successfully resized %(count)d images.") % {"count": n, })
return None
else:
form = ResizeImagesForm()
- context = {
+ context = self.admin_site.each_context(request)
+ context.update({
"title": _("Resize images"),
"instance": current_folder,
"breadcrumbs_action": _("Resize images"),
"to_resize": to_resize,
"resize_form": form,
- "cmsplugin_enabled": 'cmsplugin_filer_image' in django_settings.INSTALLED_APPS,
"files_queryset": files_queryset,
"folders_queryset": folders_queryset,
"perms_lacking": perms_needed,
@@ -1124,11 +1644,9 @@ def resize_images(self, request, files_queryset, folders_queryset):
"root_path": reverse('admin:index'),
"app_label": app_label,
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
- }
+ })
# Display the resize options page
- return render_to_response([
- "admin/filer/folder/choose_images_resize_options.html"
- ], context, context_instance=template.RequestContext(request))
+ return TemplateResponse(request, "admin/filer/folder/choose_images_resize_options.html", context)
- resize_images.short_description = ugettext_lazy("Resize selected images")
+ resize_images.short_description = _("Resize selected images")
diff --git a/filer/admin/forms.py b/filer/admin/forms.py
index b49149ce5..a6c876719 100644
--- a/filer/admin/forms.py
+++ b/filer/admin/forms.py
@@ -1,40 +1,82 @@
from django import forms
-from django.db import models
from django.contrib.admin import widgets
-from filer.utils.files import get_valid_filename
-from django.utils.translation import ugettext as _
+from django.contrib.admin.helpers import AdminForm
from django.core.exceptions import ValidationError
-from django.conf import settings
+from django.db import models
+from django.utils.translation import gettext as _
+
+from ..models import ThumbnailOption
+from ..utils.files import get_valid_filename
-if 'cmsplugin_filer_image' in settings.INSTALLED_APPS:
- from cmsplugin_filer_image.models import ThumbnailOption
+class WithFieldsetMixin:
+ def get_fieldsets(self):
+ return getattr(self, "fieldsets", [
+ (None, {"fields": [field for field in self.fields]})
+ ])
+
+ def admin_form(self):
+ "Returns a class contains the Admin fieldset to show form as admin form"
+ return AdminForm(self, self.get_fieldsets(), {})
class AsPWithHelpMixin(object):
def as_p_with_help(self):
- "Returns this form rendered as HTML
s with help text formated for admin."
- return self._html_output(
- normal_row=u'
%(label)s %(field)s
%(help_text)s',
- error_row=u'%s',
- row_ender='
',
- help_text_html=u'
%s
',
- errors_on_separate_row=True)
+ "Returns this form rendered as HTML
s with help text formatted for admin."
+ from django.utils.html import conditional_escape
+ from django.utils.safestring import mark_safe
+
+ output = []
+ # Render non-field errors (e.g. from Form.clean())
+ top_errors = self.non_field_errors()
+ if top_errors:
+ output.append(str(self.error_class(top_errors, renderer=self.renderer)))
+
+ hidden_fields = []
+ for name in self.fields:
+ bf = self[name]
+ # Collect hidden fields to render at the end
+ if bf.is_hidden:
+ if bf.errors:
+ for error in bf.errors:
+ output.append(
+ '