├── .github └── workflows │ ├── README.md │ ├── gitversion.yml │ ├── pages.yml │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── conftest.py ├── django_pdf_actions ├── __init__.py ├── actions │ ├── __init__.py │ ├── landscape.py │ ├── portrait.py │ └── utils.py ├── admin.py ├── apps.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── setup_fonts.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_add_page_size_field.py │ ├── 0003_exportpdfsettings_rtl_support_and_more.py │ ├── 0004_add_alignment_fields.py │ └── __init__.py ├── models.py ├── tests.py └── views.py ├── docs ├── assets │ ├── logo.png │ └── logo.svg ├── custom-methods.md ├── examples.md ├── index.md ├── installation.md ├── quickstart.md ├── requirements.txt ├── settings.md └── usage.md ├── mkdocs.yml ├── module.py ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── setup.cfg ├── setup.py ├── templates └── django_pdf │ └── .gitkeep └── tests ├── .gitkeep ├── __init__.py ├── settings.py ├── test_actions.py ├── test_models.py ├── test_utils.py └── utils.py /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflows 2 | 3 | This directory contains the GitHub Actions workflows for the django-pdf-actions package. Here's what each workflow does: 4 | 5 | ## Version Management (`gitversion.yml`) 6 | Handles automatic version management for the package: 7 | - Runs on pushes to `main`, `develop`, `releases/*`, and `hotfix/*` branches 8 | - Automatically increments the patch version in `pyproject.toml` on pushes to main 9 | - Respects manually set versions (if you manually set a higher version) 10 | - Creates git tags for each new version 11 | - Skips version bump if the version was manually updated 12 | 13 | ## Documentation Pages (`pages.yml`) 14 | Handles the documentation site deployment: 15 | - Builds and deploys the documentation to GitHub Pages 16 | - Uses MkDocs for documentation generation 17 | - Runs when documentation-related files are changed 18 | - Deploys to the gh-pages branch 19 | 20 | ## Tests (`tests.yml`) 21 | Runs the test suite: 22 | - Executes on pull requests and pushes to main/develop 23 | - Runs tests across different Python versions 24 | - Checks code quality and test coverage 25 | - Ensures compatibility across Python versions 26 | 27 | ## Package Publishing (`publish.yml`) 28 | Handles package publishing to PyPI: 29 | - Triggered when a new release is created 30 | - Builds the package using Poetry 31 | - Publishes the package to PyPI 32 | - Ensures the package is properly distributed 33 | 34 | ## Workflow Dependencies 35 | Some workflows may depend on others: 36 | 1. `gitversion.yml` runs independently to manage versions 37 | 2. When a new version is tagged, it can trigger `publish.yml` 38 | 3. Documentation updates via `pages.yml` run independently 39 | 4. Tests run on PRs and pushes to ensure quality 40 | -------------------------------------------------------------------------------- /.github/workflows/gitversion.yml: -------------------------------------------------------------------------------- 1 | name: Version Management 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - develop 8 | - 'releases/**' 9 | - 'hotfix/**' 10 | pull_request: 11 | branches: 12 | - main 13 | - develop 14 | 15 | permissions: 16 | contents: write 17 | pull-requests: read 18 | 19 | jobs: 20 | version: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | token: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Set up Python 31 | uses: actions/setup-python@v4 32 | with: 33 | python-version: '3.x' 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install tomli tomli-w 39 | 40 | - name: Cache dependencies 41 | uses: actions/cache@v3 42 | with: 43 | path: ~/.cache/pip 44 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }} 45 | restore-keys: | 46 | ${{ runner.os }}-pip- 47 | 48 | - name: Install package locally 49 | run: pip install -e . # This installs from local directory instead of PyPI 50 | 51 | - name: Check and update version 52 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 53 | id: check_version 54 | run: | 55 | # Python script to handle version management 56 | VERSION=$(python -c " 57 | import tomli, tomli_w, subprocess 58 | 59 | def get_latest_tag_version(): 60 | try: 61 | result = subprocess.run(['git', 'describe', '--tags', '--abbrev=0'], 62 | capture_output=True, text=True) 63 | if result.returncode == 0: 64 | return result.stdout.strip().lstrip('v') 65 | return None 66 | except: 67 | return None 68 | 69 | def version_to_tuple(version): 70 | return tuple(map(int, version.split('.'))) 71 | 72 | # Read current version from pyproject.toml 73 | with open('pyproject.toml', 'rb') as f: 74 | data = tomli.load(f) 75 | current = data['tool']['poetry']['version'] 76 | 77 | # Get latest tag version 78 | latest_tag = get_latest_tag_version() 79 | 80 | # Compare versions if tag exists 81 | if latest_tag: 82 | current_tuple = version_to_tuple(current) 83 | tag_tuple = version_to_tuple(latest_tag) 84 | 85 | # If pyproject version is greater than latest tag, respect it 86 | if current_tuple > tag_tuple: 87 | print(current) 88 | exit(0) 89 | 90 | # Otherwise increment the patch version 91 | major, minor, patch = version_to_tuple(current) 92 | new_version = f'{major}.{minor}.{patch + 1}' 93 | 94 | # Update pyproject.toml 95 | data['tool']['poetry']['version'] = new_version 96 | with open('pyproject.toml', 'wb') as f: 97 | tomli_w.dump(data, f) 98 | 99 | print(new_version) 100 | ") 101 | echo "VERSION=$VERSION" >> $GITHUB_ENV 102 | echo "Version set to: $VERSION" 103 | 104 | - name: Commit version update 105 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 106 | run: | 107 | # Check if pyproject.toml was modified 108 | if git diff --quiet pyproject.toml; then 109 | echo "No version changes to commit" 110 | exit 0 111 | fi 112 | 113 | git config user.name "${{ github.actor }}" 114 | git config user.email "${{ github.actor }}@users.noreply.github.com" 115 | git add pyproject.toml 116 | git commit -m "chore: Bump version to ${{ env.VERSION }} [skip ci]" 117 | git push 118 | 119 | - name: Create version tag 120 | if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/releases/')) 121 | run: | 122 | VERSION=$(python -c "import tomli; print(tomli.load(open('pyproject.toml', 'rb'))['tool']['poetry']['version'])") 123 | 124 | # Check if tag already exists 125 | if ! git rev-parse "v$VERSION" >/dev/null 2>&1; then 126 | git tag -a "v$VERSION" -m "Release v$VERSION" 127 | git push origin "v$VERSION" 128 | echo "Created and pushed tag v$VERSION" 129 | else 130 | echo "Tag v$VERSION already exists" 131 | fi -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main # Trigger the workflow only on pushes to the main branch 7 | workflow_dispatch: # Allow manual triggering 8 | 9 | # Add permissions block 10 | permissions: 11 | contents: write # Required for pushing to gh-pages branch 12 | pages: write # Required for deploying GitHub pages 13 | id-token: write # Required for authentication 14 | 15 | jobs: 16 | deploy: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Repository 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: '3.11' 28 | cache: 'pip' 29 | cache-dependency-path: | 30 | docs/requirements.txt 31 | pyproject.toml 32 | 33 | - name: Cache dependencies 34 | uses: actions/cache@v4 35 | with: 36 | path: ~/.cache/pip 37 | key: ${{ runner.os }}-pip-docs-${{ hashFiles('docs/requirements.txt') }} 38 | restore-keys: | 39 | ${{ runner.os }}-pip-docs- 40 | ${{ runner.os }}-pip- 41 | 42 | - name: Install package and documentation dependencies 43 | run: | 44 | python -m pip install --upgrade pip 45 | pip install -e . # Install package locally 46 | pip install -r docs/requirements.txt # Install documentation requirements 47 | 48 | - name: Configure Git 49 | run: | 50 | git config user.name "${{ github.actor }}" 51 | git config user.email "${{ github.actor }}@users.noreply.github.com" 52 | 53 | - name: Build documentation 54 | run: mkdocs build --clean --verbose 55 | 56 | - name: Setup Pages 57 | uses: actions/configure-pages@v4 58 | 59 | - name: Upload artifact 60 | uses: actions/upload-pages-artifact@v3 61 | with: 62 | path: 'site' 63 | 64 | - name: Deploy to GitHub Pages 65 | id: deployment 66 | uses: actions/deploy-pages@v4 67 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: '3.11' 19 | cache: 'pip' 20 | cache-dependency-path: | 21 | **/requirements*.txt 22 | pyproject.toml 23 | 24 | - name: Cache dependencies 25 | uses: actions/cache@v3 26 | with: 27 | path: ~/.cache/pip 28 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }} 29 | restore-keys: | 30 | ${{ runner.os }}-pip- 31 | 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install poetry 36 | 37 | - name: Configure Poetry 38 | run: | 39 | poetry config pypi-token.pypi ${{ secrets.PYPI_API_TOKEN }} 40 | 41 | - name: Verify version matches release 42 | run: | 43 | # Get release tag version (strip the 'v' prefix) 44 | RELEASE_VERSION=${GITHUB_REF#refs/tags/v} 45 | 46 | # Update version in pyproject.toml to match release 47 | poetry version $RELEASE_VERSION 48 | 49 | echo "Building version: $RELEASE_VERSION" 50 | 51 | - name: Build and publish 52 | run: | 53 | poetry build 54 | poetry publish --no-interaction -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 15 | django-version: ['3.2', '4.0', '4.1', '4.2', '5.0'] 16 | exclude: 17 | # Django 5.0 requires Python 3.10 or higher 18 | - django-version: '5.0' 19 | python-version: '3.8' 20 | - django-version: '5.0' 21 | python-version: '3.9' 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | cache: 'pip' 31 | cache-dependency-path: | 32 | **/requirements*.txt 33 | pyproject.toml 34 | 35 | - name: Cache dependencies 36 | uses: actions/cache@v3 37 | with: 38 | path: ~/.cache/pip 39 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}-py${{ matrix.python-version }}-dj${{ matrix.django-version }} 40 | restore-keys: | 41 | ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}-py${{ matrix.python-version }}- 42 | ${{ runner.os }}-pip- 43 | 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | pip install wheel setuptools 48 | pip install -e . # Install package locally 49 | pip install Django~=${{ matrix.django-version }}.0 50 | pip install coverage pytest pytest-django pytest-cov django-model-utils>=4.3.1 51 | 52 | - name: Run Tests 53 | env: 54 | PYTHONPATH: ${{ github.workspace }} 55 | DJANGO_SETTINGS_MODULE: tests.settings 56 | run: | 57 | python -m pytest --cov=django_pdf_actions --cov-report=xml 58 | 59 | - name: Upload coverage to Codecov 60 | uses: codecov/codecov-action@v4 61 | with: 62 | file: ./coverage.xml 63 | fail_ci_if_error: true 64 | env: 65 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Django ### 2 | *.log 3 | *.pot 4 | *.pyc 5 | __pycache__/ 6 | local_settings.py 7 | db.sqlite3 8 | db.sqlite3-journal 9 | media 10 | 11 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 12 | # in your Git repository. Update and uncomment the following line accordingly. 13 | # /staticfiles/ 14 | 15 | ### Django.Python Stack ### 16 | # Byte-compiled / optimized / DLL files 17 | *.py[cod] 18 | *$py.class 19 | 20 | # C extensions 21 | *.so 22 | 23 | # Distribution / packaging 24 | .Python 25 | build/ 26 | develop-eggs/ 27 | dist/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | wheels/ 37 | share/python-wheels/ 38 | *.egg-info/ 39 | .installed.cfg 40 | *.egg 41 | MANIFEST 42 | 43 | # PyInstaller 44 | # Usually these files are written by a python script from a template 45 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 46 | *.manifest 47 | *.spec 48 | 49 | # Installer logs 50 | pip-log.txt 51 | pip-delete-this-directory.txt 52 | 53 | # Unit test / coverage reports 54 | htmlcov/ 55 | .tox/ 56 | .nox/ 57 | .coverage 58 | .coverage.* 59 | .cache 60 | nosetests.xml 61 | coverage.xml 62 | *.cover 63 | *.py,cover 64 | .hypothesis/ 65 | .pytest_cache/ 66 | cover/ 67 | 68 | # Translations 69 | *.mo 70 | 71 | # Django stuff: 72 | 73 | # Flask stuff: 74 | instance/ 75 | .webassets-cache 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # PyBuilder 84 | .pybuilder/ 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # IPython 91 | profile_default/ 92 | ipython_config.py 93 | 94 | # pyenv 95 | # For a library or package, you might want to ignore these files since the code is 96 | # intended to run in multiple environments; otherwise, check them in: 97 | # .python-version 98 | 99 | # pipenv 100 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 101 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 102 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 103 | # install all needed dependencies. 104 | #Pipfile.lock 105 | 106 | # poetry 107 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 108 | # This is especially recommended for binary packages to ensure reproducibility, and is more 109 | # commonly ignored for libraries. 110 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 111 | #poetry.lock 112 | 113 | # pdm 114 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 115 | #pdm.lock 116 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 117 | # in version control. 118 | # https://pdm.fming.dev/#use-with-ide 119 | .pdm.toml 120 | 121 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 122 | __pypackages__/ 123 | 124 | # Celery stuff 125 | celerybeat-schedule 126 | celerybeat.pid 127 | 128 | # SageMath parsed files 129 | *.sage.py 130 | 131 | # Environments 132 | .env 133 | .venv 134 | env/ 135 | venv/ 136 | ENV/ 137 | env.bak/ 138 | venv.bak/ 139 | 140 | # Spyder project settings 141 | .spyderproject 142 | .spyproject 143 | 144 | # Rope project settings 145 | .ropeproject 146 | 147 | # mkdocs documentation 148 | /site 149 | 150 | # mypy 151 | .mypy_cache/ 152 | .dmypy.json 153 | dmypy.json 154 | 155 | # Pyre type checker 156 | .pyre/ 157 | 158 | # pytype static type analyzer 159 | .pytype/ 160 | 161 | # Cython debug symbols 162 | cython_debug/ 163 | 164 | # PyCharm 165 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 166 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 167 | # and can be added to the global gitignore or merged into this file. For a more nuclear 168 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 169 | #.idea/ 170 | 171 | ### Linux ### 172 | *~ 173 | 174 | # temporary files which can be created if a process still has a handle open of a deleted file 175 | .fuse_hidden* 176 | 177 | # KDE directory preferences 178 | .directory 179 | 180 | # Linux trash folder which might appear on any partition or disk 181 | .Trash-* 182 | 183 | # .nfs files are created when an open file is removed but is still being accessed 184 | .nfs* 185 | 186 | ### macOS ### 187 | # General 188 | .DS_Store 189 | .AppleDouble 190 | .LSOverride 191 | 192 | # Icon must end with two \r 193 | Icon 194 | 195 | 196 | # Thumbnails 197 | ._* 198 | 199 | # Files that might appear in the root of a volume 200 | .DocumentRevisions-V100 201 | .fseventsd 202 | .Spotlight-V100 203 | .TemporaryItems 204 | .Trashes 205 | .VolumeIcon.icns 206 | .com.apple.timemachine.donotpresent 207 | 208 | # Directories potentially created on remote AFP share 209 | .AppleDB 210 | .AppleDesktop 211 | Network Trash Folder 212 | Temporary Items 213 | .apdisk 214 | 215 | ### macOS Patch ### 216 | # iCloud generated files 217 | *.icloud 218 | 219 | ### PyCharm+all ### 220 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 221 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 222 | 223 | # User-specific stuff 224 | .idea/**/workspace.xml 225 | .idea/**/tasks.xml 226 | .idea/**/usage.statistics.xml 227 | .idea/**/dictionaries 228 | .idea/**/shelf 229 | 230 | # AWS User-specific 231 | .idea/**/aws.xml 232 | 233 | # Generated files 234 | .idea/**/contentModel.xml 235 | 236 | # Sensitive or high-churn files 237 | .idea/**/dataSources/ 238 | .idea/**/dataSources.ids 239 | .idea/**/dataSources.local.xml 240 | .idea/**/sqlDataSources.xml 241 | .idea/**/dynamic.xml 242 | .idea/**/uiDesigner.xml 243 | .idea/**/dbnavigator.xml 244 | 245 | # Gradle 246 | .idea/**/gradle.xml 247 | .idea/**/libraries 248 | 249 | # Gradle and Maven with auto-import 250 | # When using Gradle or Maven with auto-import, you should exclude module files, 251 | # since they will be recreated, and may cause churn. Uncomment if using 252 | # auto-import. 253 | # .idea/artifacts 254 | # .idea/compiler.xml 255 | # .idea/jarRepositories.xml 256 | # .idea/modules.xml 257 | # .idea/*.iml 258 | # .idea/modules 259 | # *.iml 260 | # *.ipr 261 | 262 | # CMake 263 | cmake-build-*/ 264 | 265 | # Mongo Explorer plugin 266 | .idea/**/mongoSettings.xml 267 | 268 | # File-based project format 269 | *.iws 270 | 271 | # IntelliJ 272 | out/ 273 | 274 | # mpeltonen/sbt-idea plugin 275 | .idea_modules/ 276 | 277 | # JIRA plugin 278 | atlassian-ide-plugin.xml 279 | 280 | # Cursive Clojure plugin 281 | .idea/replstate.xml 282 | 283 | # SonarLint plugin 284 | .idea/sonarlint/ 285 | 286 | # Crashlytics plugin (for Android Studio and IntelliJ) 287 | com_crashlytics_export_strings.xml 288 | crashlytics.properties 289 | crashlytics-build.properties 290 | fabric.properties 291 | 292 | # Editor-based Rest Client 293 | .idea/httpRequests 294 | 295 | # Android studio 3.1+ serialized cache file 296 | .idea/caches/build_file_checksums.ser 297 | 298 | ### PyCharm+all Patch ### 299 | # Ignore everything but code style settings and run configurations 300 | # that are supposed to be shared within teams. 301 | 302 | .idea/* 303 | 304 | !.idea/codeStyles 305 | !.idea/runConfigurations 306 | 307 | ### Python ### 308 | # Byte-compiled / optimized / DLL files 309 | 310 | # C extensions 311 | 312 | # Distribution / packaging 313 | 314 | # PyInstaller 315 | # Usually these files are written by a python script from a template 316 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 317 | 318 | # Installer logs 319 | 320 | # Unit test / coverage reports 321 | 322 | # Translations 323 | 324 | # Django stuff: 325 | 326 | # Flask stuff: 327 | 328 | # Scrapy stuff: 329 | 330 | # Sphinx documentation 331 | 332 | # PyBuilder 333 | 334 | # Jupyter Notebook 335 | 336 | # IPython 337 | 338 | # pyenv 339 | # For a library or package, you might want to ignore these files since the code is 340 | # intended to run in multiple environments; otherwise, check them in: 341 | # .python-version 342 | 343 | # pipenv 344 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 345 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 346 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 347 | # install all needed dependencies. 348 | 349 | # poetry 350 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 351 | # This is especially recommended for binary packages to ensure reproducibility, and is more 352 | # commonly ignored for libraries. 353 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 354 | 355 | # pdm 356 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 357 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 358 | # in version control. 359 | # https://pdm.fming.dev/#use-with-ide 360 | 361 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 362 | 363 | # Celery stuff 364 | 365 | # SageMath parsed files 366 | 367 | # Environments 368 | 369 | # Spyder project settings 370 | 371 | # Rope project settings 372 | 373 | # mkdocs documentation 374 | 375 | # mypy 376 | 377 | # Pyre type checker 378 | 379 | # pytype static type analyzer 380 | 381 | # Cython debug symbols 382 | 383 | # PyCharm 384 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 385 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 386 | # and can be added to the global gitignore or merged into this file. For a more nuclear 387 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 388 | 389 | ### Python Patch ### 390 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 391 | poetry.toml 392 | 393 | # ruff 394 | .ruff_cache/ 395 | 396 | # LSP config files 397 | pyrightconfig.json 398 | 399 | ### VisualStudioCode ### 400 | .vscode/* 401 | !.vscode/settings.json 402 | !.vscode/tasks.json 403 | !.vscode/launch.json 404 | !.vscode/extensions.json 405 | !.vscode/*.code-snippets 406 | 407 | # Local History for Visual Studio Code 408 | .history/ 409 | 410 | # Built Visual Studio Code Extensions 411 | *.vsix 412 | 413 | ### VisualStudioCode Patch ### 414 | # Ignore all local history of files 415 | .history 416 | .ionide 417 | 418 | ### Windows ### 419 | # Windows thumbnail cache files 420 | Thumbs.db 421 | Thumbs.db:encryptable 422 | ehthumbs.db 423 | ehthumbs_vista.db 424 | 425 | # Dump file 426 | *.stackdump 427 | 428 | # Folder config file 429 | [Dd]esktop.ini 430 | 431 | # Recycle Bin used on file shares 432 | $RECYCLE.BIN/ 433 | 434 | # Windows Installer files 435 | *.cab 436 | *.msi 437 | *.msix 438 | *.msm 439 | *.msp 440 | 441 | # Windows shortcuts 442 | *.lnk 443 | 444 | # Project specific 445 | staticfiles/ 446 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3.11" 11 | 12 | mkdocs: 13 | configuration: mkdocs.yml 14 | 15 | python: 16 | install: 17 | - method: pip 18 | path: . 19 | - requirements: requirements/docs.txt -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to Django PDF Actions will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.1.11] - 2025-02-09 9 | 10 | ### Added 11 | - Enhanced error handling for PDF generation 12 | - Improved font loading mechanism 13 | - Better support for custom templates 14 | - Additional unit tests for edge cases 15 | 16 | ### Changed 17 | - Optimized PDF generation performance 18 | - Updated documentation structure 19 | - Improved code organization 20 | - Enhanced logging system 21 | 22 | ### Fixed 23 | - Font loading issues in certain environments 24 | - Memory usage optimization 25 | - Documentation typos and broken links 26 | 27 | ## [0.1.6] - 2025-02-08 28 | 29 | ### Added 30 | - Improved documentation with better organized features 31 | - Added GitHub Pages documentation site 32 | - Added comprehensive test suite 33 | - Added GitHub Actions workflows for testing and documentation 34 | 35 | ### Changed 36 | - Updated badges in README for better visibility 37 | - Reorganized project structure for better maintainability 38 | - Improved code documentation and docstrings 39 | 40 | ### Fixed 41 | - Fixed PyPI package metadata and badges 42 | - Fixed documentation deployment workflow 43 | 44 | ## [0.1.5] - 2025-02-08 45 | 46 | ### Added 47 | - Initial public release 48 | - Basic PDF export functionality 49 | - Portrait and landscape orientations 50 | - Customizable styling options 51 | - Unicode and RTL support 52 | - Arabic text rendering 53 | - Configurable settings through admin interface 54 | 55 | ### Features 56 | - Export capabilities for Django admin 57 | - Design customization options 58 | - International text support 59 | - Developer-friendly integration 60 | - Comprehensive documentation 61 | 62 | ## [Unreleased] 63 | 64 | ### Added 65 | - Page size selection feature in PDF export settings 66 | - Support for A4, A3, A2, and A1 paper sizes 67 | - Page size visible in admin list view and filterable 68 | - Automatic adjustment of table layout for different page sizes 69 | - Default to A4 for backward compatibility 70 | 71 | ### Planned 72 | - Additional export formats 73 | - Custom template support 74 | - Enhanced styling options 75 | - Performance optimizations 76 | - More font options 77 | - Additional language support 78 | 79 | [0.1.6]: https://github.com/ibrahimroshdy/django-pdf-actions/compare/v0.1.5...v0.1.6 80 | [0.1.5]: https://github.com/ibrahimroshdy/django-pdf-actions/releases/tag/v0.1.5 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024, Ibrahim Roshdy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include django_pdf_actions/static * 4 | recursive-include django_pdf_actions/templates * 5 | recursive-include docs * 6 | recursive-exclude * __pycache__ 7 | recursive-exclude * *.py[co] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django PDF Actions 2 | 3 |

4 | Django PDF Actions Logo 5 |

6 | 7 | [![PyPI version](https://img.shields.io/pypi/v/django-pdf-actions.svg?cache=no)](https://pypi.org/project/django-pdf-actions/) 8 | [![Python Versions](https://img.shields.io/pypi/pyversions/django-pdf-actions.svg)](https://pypi.org/project/django-pdf-actions/) 9 | [![Django Versions](https://img.shields.io/badge/django-3.2%20%7C%204.0%20%7C%204.1%20%7C%204.2%20%7C%205.0-green.svg)](https://pypi.org/project/django-pdf-actions/) 10 | [![Documentation](https://img.shields.io/badge/docs-github_pages-blue.svg)](https://ibrahimroshdy.github.io/django-pdf-actions/) 11 | [![Documentation Status](https://readthedocs.org/projects/django-pdf-actions/badge/?version=latest)](https://django-pdf-actions.readthedocs.io/en/latest/?badge=latest) 12 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 13 | [![Development Status](https://img.shields.io/badge/status-stable-green.svg)](https://pypi.org/project/django-pdf-actions/) 14 | [![GitHub last commit](https://img.shields.io/github/last-commit/ibrahimroshdy/django-pdf-actions.svg)](https://github.com/ibrahimroshdy/django-pdf-actions/commits/main) 15 | [![PyPI Downloads](https://img.shields.io/pypi/dm/django-pdf-actions.svg)](https://pypistats.org/packages/django-pdf-actions) 16 | [![Total Downloads](https://static.pepy.tech/badge/django-pdf-actions)](https://pepy.tech/project/django-pdf-actions) 17 | [![Published on Django Packages](https://img.shields.io/badge/Published%20on-Django%20Packages-0c3c26)](https://djangopackages.org/packages/p/django-pdf-actions/) 18 | 19 | A Django application that adds PDF export capabilities to your Django admin interface. Export your model data to PDF documents with customizable layouts and styling. 20 | 21 | ## Prerequisites 22 | 23 | Before installing Django PDF Export, ensure you have: 24 | - Python 3.8 or higher 25 | - Django 3.2 or higher 26 | - pip (Python package installer) 27 | 28 | ## Features 29 | 30 | ### 📊 Export Capabilities 31 | - Export any Django model data to PDF directly from the admin interface 32 | - Support for both portrait and landscape orientations 33 | - Automatic pagination with configurable items per page 34 | - Smart table layouts with automatic column width adjustment 35 | - Support for Django model fields from list_display 36 | - Batch export multiple records at once 37 | - Professional table styling with grid lines and backgrounds 38 | 39 | ### 🎨 Design & Customization 40 | Through the ExportPDFSettings model, you can configure: 41 | - Page Layout: 42 | - Items per page (1-50) 43 | - Page margins (5-50mm) 44 | - Automatic column width calculation 45 | - Smart pagination handling 46 | - Font Settings: 47 | - Custom font support (TTF files) 48 | - Configurable header and body font sizes 49 | - Default DejaVu Sans font included 50 | - Visual Settings: 51 | - Company logo integration with flexible positioning 52 | - Header background color customization 53 | - Grid line color and width control 54 | - Professional table styling 55 | - Display Options: 56 | - Toggle header visibility 57 | - Toggle logo visibility 58 | - Toggle export timestamp 59 | - Toggle page numbers 60 | - Customizable header and footer information 61 | - Alignment Options: 62 | - Customizable title alignment (left, center, right) 63 | - Customizable header alignment (left, center, right) 64 | - Customizable content alignment (left, center, right) 65 | - Automatic RTL alignment for right-to-left languages 66 | - Table Settings: 67 | - Cell spacing and padding control 68 | - Text wrapping with configurable character limits 69 | - Grid line customization 70 | - Header row styling 71 | 72 | ### 🌍 International Support 73 | - Complete Unicode compatibility for all languages 74 | - Arabic text support with automatic reshaping 75 | - Bidirectional text handling 76 | - Multi-language content support in the same document 77 | - RTL (Right-to-Left) text support 78 | - Enhanced RTL support with proper text alignment and bidirectional text handling 79 | - Configurable RTL support that can be enabled/disabled as needed 80 | - Column order reversal for proper RTL table display 81 | - Uses model verbose_name for proper localized headings 82 | - Customizable alignment options for RTL content 83 | 84 | ## Quick Start 85 | 86 | ### 1. Installation 87 | 88 | #### Using pip (Recommended) 89 | ```bash 90 | pip install django-pdf-actions 91 | ``` 92 | 93 | #### From Source 94 | If you want to install the latest development version: 95 | ```bash 96 | git clone https://github.com/ibrahimroshdy/django-pdf-actions.git 97 | cd django-pdf-actions 98 | pip install -e . 99 | ``` 100 | 101 | ### 2. Add to INSTALLED_APPS 102 | 103 | Add 'django_pdf_actions' to your INSTALLED_APPS setting: 104 | 105 | ```python 106 | INSTALLED_APPS = [ 107 | ... 108 | 'django_pdf_actions' 109 | ] 110 | ``` 111 | 112 | ### 3. Run Migrations 113 | 114 | ```bash 115 | python manage.py migrate 116 | ``` 117 | 118 | ### 4. Set up Fonts 119 | 120 | The package uses fonts from your project's `static/assets/fonts` directory. The default font is DejaVu Sans, which provides excellent Unicode support. 121 | 122 | To use custom fonts: 123 | 1. Create the fonts directory if it doesn't exist: 124 | ```bash 125 | mkdir -p static/assets/fonts 126 | ``` 127 | 2. Install the default font (DejaVu Sans): 128 | ```bash 129 | python manage.py setup_fonts 130 | ``` 131 | 3. Add custom fonts (optional): 132 | ```bash 133 | # Example: Installing Roboto font 134 | python manage.py setup_fonts --font-url "https://github.com/google/fonts/raw/main/apache/roboto/Roboto-Regular.ttf" --font-name "Roboto-Regular.ttf" 135 | 136 | # Example: Installing Cairo font for Arabic support 137 | python manage.py setup_fonts --font-url "https://github.com/google/fonts/raw/main/ofl/cairo/Cairo-Regular.ttf" --font-name "Cairo-Regular.ttf" 138 | ``` 139 | 140 | #### Font Directory Structure 141 | After setup, your project should have this structure: 142 | ``` 143 | your_project/ 144 | ├── static/ 145 | │ └── assets/ 146 | │ └── fonts/ 147 | │ ├── DejaVuSans.ttf 148 | │ ├── Roboto-Regular.ttf (optional) 149 | │ └── Cairo-Regular.ttf (optional) 150 | ``` 151 | 152 | ### 5. Verify Installation 153 | 154 | To verify the installation: 155 | 1. Start your Django development server 156 | 2. Navigate to the Django admin interface 157 | 3. Select any model with list view 158 | 4. You should see "Export to PDF (Portrait)" and "Export to PDF (Landscape)" in the actions dropdown 159 | 160 | ### 6. Add to Your Models 161 | 162 | Import and use the PDF export actions in your admin.py: 163 | 164 | ```python 165 | from django.contrib import admin 166 | from django_pdf_actions.actions import export_to_pdf_landscape, export_to_pdf_portrait 167 | from .models import YourModel 168 | 169 | @admin.register(YourModel) 170 | class YourModelAdmin(admin.ModelAdmin): 171 | list_display = ('field1', 'field2', ...) # Your fields here 172 | actions = [export_to_pdf_landscape, export_to_pdf_portrait] 173 | ``` 174 | 175 | ## Configuration 176 | 177 | ### PDF Export Settings 178 | 179 | Access the Django admin interface to configure PDF export settings: 180 | 181 | 1. Go to Admin > Django PDF > Export PDF Settings 182 | 2. Create a new configuration with your desired settings 183 | 3. Mark it as active (only one configuration can be active at a time) 184 | 185 | The active configuration will be used for all PDF exports across your admin interface. 186 | 187 | ### Available Settings 188 | 189 | | Setting | Description | Default | Range | 190 | |---------|-------------|---------|--------| 191 | | Page Size | PDF page size | A4 | A4, A3, A2, A1 | 192 | | Items Per Page | Rows per page | 10 | 1-50 | 193 | | Page Margin | Page margins | 15mm | 5-50mm | 194 | | Font Name | TTF font to use | DejaVuSans.ttf | Any installed TTF | 195 | | Header Font Size | Header text size | 10 | 6-24 | 196 | | Body Font Size | Content text size | 7 | 6-18 | 197 | | Logo | Company logo | Optional | Image file | 198 | | Header Background | Header color | #F0F0F0 | Hex color | 199 | | Grid Line Color | Table lines color | #000000 | Hex color | 200 | | Grid Line Width | Table line width | 0.25 | 0.1-2.0 | 201 | | Table Spacing | Cell padding | 1.0mm | 0.5-5.0mm | 202 | | Max Chars Per Line | Text wrapping | 45 | 20-100 | 203 | | RTL Support | Right-to-left text | Disabled | Enabled/Disabled | 204 | | Title Alignment | Title text alignment | Center | Left/Center/Right | 205 | | Header Alignment | Column headers alignment | Center | Left/Center/Right | 206 | | Content Alignment | Table content alignment | Center | Left/Center/Right | 207 | 208 | ### Page Sizes 209 | 210 | The package supports multiple standard page sizes: 211 | - **A4**: 210mm × 297mm (default) 212 | - **A3**: 297mm × 420mm 213 | - **A2**: 420mm × 594mm 214 | - **A1**: 594mm × 841mm 215 | 216 | The page size affects: 217 | - Available space for content 218 | - Number of rows per page 219 | - Table column widths 220 | - Overall document dimensions 221 | 222 | ### Technical Details 223 | 224 | - **Python Compatibility**: Python 3.8 or higher 225 | - **Django Compatibility**: Django 3.2, 4.0, 4.1, 4.2, 5.0 226 | - **Dependencies**: Automatically handled by pip 227 | - **PDF Engine**: ReportLab 228 | - **Character Encoding**: UTF-8 229 | - **Paper Size**: A4 (default) 230 | 231 | ## Development 232 | 233 | ### Setting Up Development Environment 234 | 235 | 1. Clone the repository: 236 | ```bash 237 | git clone https://github.com/ibrahimroshdy/django-pdf-actions.git 238 | cd django-pdf-actions 239 | ``` 240 | 241 | 2. Create a virtual environment: 242 | ```bash 243 | python -m venv venv 244 | source venv/bin/activate # On Windows: venv\Scripts\activate 245 | ``` 246 | 247 | 3. Install development dependencies: 248 | ```bash 249 | pip install -e ".[dev]" 250 | ``` 251 | 252 | 4. Run tests: 253 | ```bash 254 | pytest 255 | ``` 256 | 257 | ## Documentation 258 | 259 | For more detailed information, check out our documentation: 260 | - [Installation Guide](https://ibrahimroshdy.github.io/django-pdf-actions/installation/) 261 | - [Quick Start Guide](https://ibrahimroshdy.github.io/django-pdf-actions/quickstart/) 262 | - [Configuration Guide](https://ibrahimroshdy.github.io/django-pdf-actions/settings/) 263 | 264 | ## License 265 | 266 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 267 | 268 | ## Support 269 | 270 | If you are having issues, please let us know by: 271 | - Opening an issue in our [issue tracker](https://github.com/ibrahimroshdy/django-pdf-actions/issues) 272 | - Checking our [documentation](https://ibrahimroshdy.github.io/django-pdf-actions/) 273 | 274 | ### Common Issues 275 | 276 | 1. Font Installation 277 | - Ensure your fonts directory exists at `static/assets/fonts/` 278 | - Verify font files are in TTF format 279 | - Check file permissions 280 | 281 | 2. PDF Generation 282 | - Ensure your model fields are properly defined in list_display 283 | - Check that an active PDF Export Settings configuration exists 284 | - Verify logo file paths if using custom logos 285 | - Check for any errors in the Django admin console 286 | 287 | 3. RTL Text Support 288 | - For Arabic, Persian, or other RTL languages, enable the RTL Support option 289 | - Use a font that supports the language (e.g., Cairo for Arabic) 290 | - Install appropriate fonts using the `setup_fonts` command 291 | - Text alignment and directionality will automatically adjust when RTL is enabled 292 | 293 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | """Test configuration for pytest.""" 2 | 3 | import os 4 | import pytest 5 | from django.conf import settings 6 | 7 | 8 | def pytest_configure(): 9 | """Configure Django for tests.""" 10 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') 11 | try: 12 | import django 13 | django.setup() 14 | except Exception as e: 15 | print(f"Error setting up Django: {e}") 16 | raise 17 | 18 | 19 | @pytest.fixture(scope='session') 20 | def django_db_setup(django_db_setup, django_db_blocker): 21 | """Set up the test database.""" 22 | with django_db_blocker.unblock(): 23 | try: 24 | from django.core.management import call_command 25 | # Run migrations 26 | call_command('migrate', verbosity=0, interactive=False) 27 | except Exception as e: 28 | print(f"Error running migrations: {e}") 29 | raise 30 | 31 | 32 | @pytest.fixture 33 | def admin_user(django_db_setup, django_db_blocker): 34 | """Create and return a superuser for testing.""" 35 | from django.contrib.auth import get_user_model 36 | User = get_user_model() 37 | 38 | with django_db_blocker.unblock(): 39 | return User.objects.create_superuser( 40 | username='admin', 41 | email='admin@example.com', 42 | password='password' 43 | ) 44 | 45 | 46 | @pytest.fixture 47 | def admin_client(admin_user, client): 48 | """Create and return an admin client for testing.""" 49 | from django.test import Client 50 | client = Client() 51 | client.force_login(admin_user) 52 | return client 53 | 54 | 55 | @pytest.fixture 56 | def pdf_settings(django_db_setup, django_db_blocker): 57 | """Create and return PDF export settings for testing.""" 58 | from django_pdf_actions.models import ExportPDFSettings 59 | 60 | with django_db_blocker.unblock(): 61 | return ExportPDFSettings.objects.create( 62 | title='Test Settings', 63 | active=True, 64 | header_font_size=12, 65 | body_font_size=10, 66 | page_margin_mm=15, 67 | items_per_page=10, 68 | header_background_color='#F0F0F0', 69 | grid_line_color='#000000' 70 | ) -------------------------------------------------------------------------------- /django_pdf_actions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibrahimroshdy/django-pdf-actions/e15a313306b1659529e75c4ebe779fa3d0be47b9/django_pdf_actions/__init__.py -------------------------------------------------------------------------------- /django_pdf_actions/actions/__init__.py: -------------------------------------------------------------------------------- 1 | """PDF export actions""" 2 | 3 | from .landscape import export_to_pdf_landscape 4 | from .portrait import export_to_pdf_portrait 5 | 6 | __all__ = ['export_to_pdf_landscape', 'export_to_pdf_portrait'] -------------------------------------------------------------------------------- /django_pdf_actions/actions/landscape.py: -------------------------------------------------------------------------------- 1 | """Landscape PDF export action""" 2 | 3 | import io 4 | import os 5 | from datetime import datetime 6 | 7 | import arabic_reshaper 8 | from bidi.algorithm import get_display 9 | from django.http import HttpResponse 10 | from django.utils.text import capfirst 11 | from reportlab.lib import colors 12 | from reportlab.lib.units import mm 13 | from reportlab.pdfgen import canvas 14 | from reportlab.platypus import Paragraph, Table 15 | 16 | from .utils import ( 17 | get_active_settings, hex_to_rgb, setup_font, get_logo_path, 18 | create_table_style, create_header_style, calculate_column_widths, 19 | draw_model_name, draw_exported_at, draw_page_number, draw_logo, 20 | get_page_size 21 | ) 22 | 23 | 24 | def reshape_to_arabic(columns, font_name, font_size, queryset, max_chars_per_line, pdf_settings=None, modeladmin=None): 25 | """Process and reshape Arabic text if present""" 26 | # Create header style with larger font and bold for column headers 27 | header_style = create_header_style(pdf_settings, font_name, is_header=True) 28 | body_style = create_header_style(pdf_settings, font_name, is_header=False) 29 | 30 | # Get RTL setting 31 | rtl_enabled = pdf_settings and hasattr(pdf_settings, 'rtl_support') and pdf_settings.rtl_support 32 | 33 | # If RTL is enabled, reverse the columns order to display right-to-left 34 | if rtl_enabled: 35 | columns = list(reversed(columns)) 36 | 37 | # Process column headers - capitalize and format 38 | headers = [] 39 | for column in columns: 40 | # Get header text from different sources 41 | header = None 42 | 43 | # First, try to get verbose name from model field 44 | if hasattr(queryset.model, column): 45 | try: 46 | field = queryset.model._meta.get_field(column) 47 | header = capfirst(field.verbose_name) if hasattr(field, 'verbose_name') else capfirst(column) 48 | except: 49 | # Field might exist but not be a model field 50 | header = capfirst(column.replace('_', ' ')) 51 | elif modeladmin and hasattr(modeladmin, column): 52 | # Check if the method has a short_description attribute 53 | method = getattr(modeladmin, column) 54 | if hasattr(method, 'short_description'): 55 | header = str(method.short_description) 56 | else: 57 | header = capfirst(column.replace('_', ' ')) 58 | else: 59 | header = capfirst(column.replace('_', ' ')) 60 | 61 | # Apply RTL processing to headers if enabled 62 | if rtl_enabled and isinstance(header, str): 63 | header = arabic_reshaper.reshape(header) 64 | header = get_display(header) 65 | 66 | headers.append(Paragraph(str(header), header_style)) 67 | 68 | data = [headers] 69 | 70 | for obj in queryset: 71 | row = [] 72 | for column in columns: 73 | # Try to get the value from different sources 74 | value = None 75 | 76 | # First, try to get from the object directly (model field or property) 77 | if hasattr(obj, column): 78 | value = getattr(obj, column) 79 | # If that fails and we have a modeladmin, try to call the admin method 80 | elif modeladmin and hasattr(modeladmin, column): 81 | try: 82 | method = getattr(modeladmin, column) 83 | if callable(method): 84 | value = method(obj) 85 | else: 86 | value = method 87 | except: 88 | value = f"Error: {column}" 89 | else: 90 | value = f"Missing: {column}" 91 | 92 | # Convert to string 93 | value = str(value) if value is not None else "" 94 | 95 | if isinstance(value, str): 96 | # Only reshape if RTL is enabled and the string contains text 97 | if rtl_enabled: 98 | value = arabic_reshaper.reshape(value) 99 | value = get_display(value) 100 | 101 | # Handle line wrapping for long text 102 | if len(value) > max_chars_per_line: 103 | lines = [value[i:i + max_chars_per_line] for i in range(0, len(value), max_chars_per_line)] 104 | # Reverse lines for RTL text to display properly from top to bottom 105 | if rtl_enabled: 106 | lines.reverse() 107 | value = "
".join(lines) 108 | row.append(Paragraph(str(value), body_style)) 109 | data.append(row) 110 | return data 111 | 112 | 113 | def export_to_pdf_landscape(modeladmin, request, queryset): 114 | """Export data to PDF in landscape orientation""" 115 | # Get active settings 116 | pdf_settings = get_active_settings() 117 | 118 | # Get page size from settings and rotate for landscape 119 | pagesize = get_page_size(pdf_settings) 120 | pagesize = pagesize[1], pagesize[0] # Swap width and height for landscape 121 | 122 | # Create the response object with content type as PDF 123 | response = HttpResponse(content_type='application/pdf') 124 | response[ 125 | 'Content-Disposition'] = f'attachment; filename="{modeladmin.model.__name__}_export_{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}.pdf"' 126 | buffer = io.BytesIO() 127 | 128 | # Initialize canvas with landscape orientation 129 | p = canvas.Canvas(buffer, pagesize=pagesize) 130 | canvas_width, canvas_height = pagesize 131 | 132 | # Use settings or defaults - optimized for landscape 133 | ROWS_PER_PAGE = pdf_settings.items_per_page if pdf_settings else 15 # Fewer rows in landscape due to less height 134 | max_chars_per_line = pdf_settings.max_chars_per_line if pdf_settings else 60 # More chars per line in landscape 135 | page_margin = (pdf_settings.page_margin_mm if pdf_settings else 15) * mm 136 | 137 | # Setup font and colors 138 | font_name = setup_font(pdf_settings) 139 | logo_file = get_logo_path(pdf_settings) 140 | header_bg_color = hex_to_rgb(pdf_settings.header_background_color) if pdf_settings else colors.lightgrey 141 | grid_color = hex_to_rgb(pdf_settings.grid_line_color) if pdf_settings else colors.black 142 | 143 | # Create table style optimized for landscape 144 | table_style = create_table_style(pdf_settings, font_name, header_bg_color, grid_color) 145 | 146 | # Calculate available space for table 147 | table_width = canvas_width - (2 * page_margin) 148 | table_height = canvas_height - (3 * page_margin) # Leave space for header and footer 149 | 150 | # Prepare data - include all fields from list_display (both model fields and admin methods) 151 | valid_fields = list(modeladmin.list_display) 152 | data = reshape_to_arabic(valid_fields, font_name, 153 | pdf_settings.body_font_size if pdf_settings else 7, 154 | queryset, max_chars_per_line, pdf_settings, modeladmin) 155 | 156 | # Calculate column widths and pages 157 | col_widths = calculate_column_widths(data, table_width, font_name, 158 | pdf_settings.body_font_size if pdf_settings else 7) 159 | total_rows = len(data) - 1 160 | total_pages = int((total_rows + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE) 161 | 162 | # Define margins - optimized for landscape 163 | header_margin = page_margin + (10 * mm) # Space for title 164 | table_top_margin = header_margin + (8 * mm) # Start table below header 165 | footer_margin = page_margin + (5 * mm) 166 | 167 | # Draw pages 168 | for page in range(total_pages): 169 | if not pdf_settings or pdf_settings.show_header: 170 | draw_model_name(p, modeladmin, font_name, 171 | pdf_settings.header_font_size if pdf_settings else 12, 172 | canvas_width, canvas_height, header_margin) 173 | 174 | # Draw the table with adjusted positioning - centered 175 | start_row = page * ROWS_PER_PAGE 176 | end_row = min((page + 1) * ROWS_PER_PAGE, len(data)) 177 | page_data = data[0:1] + data[start_row + 1:end_row] # Include header row 178 | 179 | table = Table(page_data, colWidths=col_widths, style=table_style) 180 | table.wrapOn(p, table_width, table_height) 181 | table_x = (canvas_width - table_width) / 2 # Center the table 182 | table_y = canvas_height - table_top_margin - table._height 183 | table.drawOn(p, table_x, table_y) 184 | 185 | # Draw footer elements 186 | if not pdf_settings or pdf_settings.show_export_time: 187 | draw_exported_at(p, font_name, 188 | pdf_settings.body_font_size if pdf_settings else 7, 189 | canvas_width, footer_margin) 190 | 191 | if not pdf_settings or pdf_settings.show_page_numbers: 192 | draw_page_number(p, page, total_pages, font_name, 193 | pdf_settings.body_font_size if pdf_settings else 7, 194 | canvas_width, footer_margin) 195 | 196 | if (not pdf_settings or pdf_settings.show_logo) and os.path.exists(logo_file): 197 | draw_logo(p, logo_file, canvas_width, canvas_height) 198 | 199 | p.showPage() 200 | 201 | p.save() 202 | pdf = buffer.getvalue() 203 | buffer.close() 204 | response.write(pdf) 205 | return response 206 | -------------------------------------------------------------------------------- /django_pdf_actions/actions/portrait.py: -------------------------------------------------------------------------------- 1 | """Portrait PDF export action""" 2 | 3 | import io 4 | import os 5 | from datetime import datetime 6 | 7 | import arabic_reshaper 8 | from bidi.algorithm import get_display 9 | from django.http import HttpResponse 10 | from django.utils.text import capfirst 11 | from reportlab.lib import colors 12 | from reportlab.lib.units import mm 13 | from reportlab.pdfgen import canvas 14 | from reportlab.platypus import Paragraph, Table 15 | 16 | from .utils import ( 17 | get_active_settings, hex_to_rgb, setup_font, get_logo_path, 18 | create_table_style, create_header_style, calculate_column_widths, 19 | draw_model_name, draw_exported_at, 20 | draw_page_number, draw_logo, 21 | get_page_size 22 | ) 23 | 24 | 25 | def reshape_to_arabic(columns, font_name, font_size, queryset, max_chars_per_line, pdf_settings=None, modeladmin=None): 26 | """Process and reshape Arabic text if present""" 27 | # Create header style with larger font and bold for column headers 28 | header_style = create_header_style(pdf_settings, font_name, is_header=True) 29 | body_style = create_header_style(pdf_settings, font_name, is_header=False) 30 | 31 | # Get RTL setting 32 | rtl_enabled = pdf_settings and hasattr(pdf_settings, 'rtl_support') and pdf_settings.rtl_support 33 | 34 | # If RTL is enabled, reverse the columns order to display right-to-left 35 | if rtl_enabled: 36 | columns = list(reversed(columns)) 37 | 38 | # Process column headers - capitalize and format 39 | headers = [] 40 | for column in columns: 41 | # Get header text from different sources 42 | header = None 43 | 44 | # First, try to get verbose name from model field 45 | if hasattr(queryset.model, column): 46 | try: 47 | field = queryset.model._meta.get_field(column) 48 | header = capfirst(field.verbose_name) if hasattr(field, 'verbose_name') else capfirst(column) 49 | except: 50 | # Field might exist but not be a model field 51 | header = capfirst(column.replace('_', ' ')) 52 | elif modeladmin and hasattr(modeladmin, column): 53 | # Check if the method has a short_description attribute 54 | method = getattr(modeladmin, column) 55 | if hasattr(method, 'short_description'): 56 | header = str(method.short_description) 57 | else: 58 | header = capfirst(column.replace('_', ' ')) 59 | else: 60 | header = capfirst(column.replace('_', ' ')) 61 | 62 | # Apply RTL processing to headers if enabled 63 | if rtl_enabled and isinstance(header, str): 64 | header = arabic_reshaper.reshape(header) 65 | header = get_display(header) 66 | 67 | headers.append(Paragraph(str(header), header_style)) 68 | 69 | data = [headers] 70 | 71 | for obj in queryset: 72 | row = [] 73 | for column in columns: 74 | # Try to get the value from different sources 75 | value = None 76 | 77 | # First, try to get from the object directly (model field or property) 78 | if hasattr(obj, column): 79 | value = getattr(obj, column) 80 | # If that fails and we have a modeladmin, try to call the admin method 81 | elif modeladmin and hasattr(modeladmin, column): 82 | try: 83 | method = getattr(modeladmin, column) 84 | if callable(method): 85 | value = method(obj) 86 | else: 87 | value = method 88 | except: 89 | value = f"Error: {column}" 90 | else: 91 | value = f"Missing: {column}" 92 | 93 | # Convert to string 94 | value = str(value) if value is not None else "" 95 | 96 | if isinstance(value, str): 97 | # Only reshape if RTL is enabled and the string contains text 98 | if rtl_enabled: 99 | value = arabic_reshaper.reshape(value) 100 | value = get_display(value) 101 | 102 | # Handle line wrapping for long text 103 | if len(value) > max_chars_per_line: 104 | lines = [value[i:i + max_chars_per_line] for i in range(0, len(value), max_chars_per_line)] 105 | # Reverse lines for RTL text to display properly from top to bottom 106 | if rtl_enabled: 107 | lines.reverse() 108 | value = "
".join(lines) 109 | row.append(Paragraph(str(value), body_style)) 110 | data.append(row) 111 | return data 112 | 113 | 114 | def export_to_pdf_portrait(modeladmin, request, queryset): 115 | """Export data to PDF in portrait orientation""" 116 | # Get active settings 117 | pdf_settings = get_active_settings() 118 | 119 | # Get page size from settings 120 | pagesize = get_page_size(pdf_settings) 121 | 122 | # Create the response object with content type as PDF 123 | response = HttpResponse(content_type='application/pdf') 124 | response[ 125 | 'Content-Disposition'] = f'attachment; filename="{modeladmin.model.__name__}_export_{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}.pdf"' 126 | buffer = io.BytesIO() 127 | 128 | # Initialize canvas with portrait orientation 129 | p = canvas.Canvas(buffer, pagesize=pagesize) 130 | canvas_width, canvas_height = pagesize 131 | 132 | # Use settings or defaults - optimized for portrait 133 | ROWS_PER_PAGE = pdf_settings.items_per_page if pdf_settings else 20 # More rows in portrait due to less width 134 | max_chars_per_line = pdf_settings.max_chars_per_line if pdf_settings else 40 # Less chars per line in portrait 135 | page_margin = (pdf_settings.page_margin_mm if pdf_settings else 15) * mm 136 | 137 | # Setup font and colors 138 | font_name = setup_font(pdf_settings) 139 | logo_file = get_logo_path(pdf_settings) 140 | header_bg_color = hex_to_rgb(pdf_settings.header_background_color) if pdf_settings else colors.lightgrey 141 | grid_color = hex_to_rgb(pdf_settings.grid_line_color) if pdf_settings else colors.black 142 | 143 | # Create table style optimized for portrait 144 | table_style = create_table_style(pdf_settings, font_name, header_bg_color, grid_color) 145 | 146 | # Calculate available space for table 147 | table_width = canvas_width - (2 * page_margin) 148 | table_height = canvas_height - (3 * page_margin) # Leave space for header and footer 149 | 150 | # Prepare data - include all fields from list_display (both model fields and admin methods) 151 | valid_fields = list(modeladmin.list_display) 152 | data = reshape_to_arabic(valid_fields, font_name, 153 | pdf_settings.body_font_size if pdf_settings else 7, 154 | queryset, max_chars_per_line, pdf_settings, modeladmin) 155 | 156 | # Calculate column widths and pages 157 | col_widths = calculate_column_widths(data, table_width, font_name, 158 | pdf_settings.body_font_size if pdf_settings else 7) 159 | total_rows = len(data) - 1 160 | total_pages = int((total_rows + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE) 161 | 162 | # Define margins - optimized for portrait 163 | header_margin = page_margin + (10 * mm) # Space for title 164 | table_top_margin = header_margin + (8 * mm) # Start table below header 165 | footer_margin = page_margin + (5 * mm) 166 | 167 | # Draw pages 168 | for page in range(total_pages): 169 | if not pdf_settings or pdf_settings.show_header: 170 | draw_model_name(p, modeladmin, font_name, 171 | pdf_settings.header_font_size if pdf_settings else 12, 172 | canvas_width, canvas_height, header_margin) 173 | 174 | # Draw the table with adjusted positioning - centered 175 | start_row = page * ROWS_PER_PAGE 176 | end_row = min((page + 1) * ROWS_PER_PAGE, len(data)) 177 | page_data = data[0:1] + data[start_row + 1:end_row] # Include header row 178 | 179 | table = Table(page_data, colWidths=col_widths, style=table_style) 180 | table.wrapOn(p, table_width, table_height) 181 | table_x = (canvas_width - table_width) / 2 # Center the table 182 | table_y = canvas_height - table_top_margin - table._height 183 | table.drawOn(p, table_x, table_y) 184 | 185 | # Draw footer elements 186 | if not pdf_settings or pdf_settings.show_export_time: 187 | draw_exported_at(p, font_name, 188 | pdf_settings.body_font_size if pdf_settings else 7, 189 | canvas_width, footer_margin) 190 | 191 | if not pdf_settings or pdf_settings.show_page_numbers: 192 | draw_page_number(p, page, total_pages, font_name, 193 | pdf_settings.body_font_size if pdf_settings else 7, 194 | canvas_width, footer_margin) 195 | 196 | if (not pdf_settings or pdf_settings.show_logo) and os.path.exists(logo_file): 197 | draw_logo(p, logo_file, canvas_width, canvas_height) 198 | 199 | p.showPage() 200 | 201 | p.save() 202 | pdf = buffer.getvalue() 203 | buffer.close() 204 | response.write(pdf) 205 | return response 206 | -------------------------------------------------------------------------------- /django_pdf_actions/actions/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for PDF export actions""" 2 | 3 | import os 4 | 5 | from django.conf import settings 6 | from django.utils.text import capfirst 7 | from reportlab.lib import colors 8 | from reportlab.lib.pagesizes import A4, A3, A2, A1 9 | from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet 10 | from reportlab.lib.units import mm 11 | from reportlab.pdfbase import pdfmetrics 12 | from reportlab.pdfbase.ttfonts import TTFont 13 | from reportlab.platypus import Table, TableStyle 14 | 15 | from ..models import ExportPDFSettings 16 | 17 | # Page size mapping 18 | PAGE_SIZE_MAP = { 19 | 'A4': A4, 20 | 'A3': A3, 21 | 'A2': A2, 22 | 'A1': A1, 23 | } 24 | 25 | 26 | def get_page_size(pdf_settings): 27 | """Get the page size from settings or default to A4""" 28 | if pdf_settings and pdf_settings.page_size: 29 | return PAGE_SIZE_MAP.get(pdf_settings.page_size, A4) 30 | return A4 31 | 32 | 33 | def get_active_settings(): 34 | """Get the active PDF export settings or return default values""" 35 | try: 36 | return ExportPDFSettings.objects.get(active=True) 37 | except ExportPDFSettings.DoesNotExist: 38 | return None 39 | 40 | 41 | def hex_to_rgb(hex_color): 42 | """Convert hex color to RGB tuple""" 43 | hex_color = hex_color.lstrip('#') 44 | return tuple(int(hex_color[i:i + 2], 16) / 255.0 for i in (0, 2, 4)) 45 | 46 | 47 | def setup_font(pdf_settings): 48 | """Setup and register font""" 49 | font_name = 'PDF_Font' 50 | 51 | # Try to get font from settings 52 | if pdf_settings and pdf_settings.font_name: 53 | font_path = os.path.join(settings.BASE_DIR, 'static', 'assets', 'fonts', pdf_settings.font_name) 54 | if os.path.exists(font_path): 55 | try: 56 | pdfmetrics.registerFont(TTFont(font_name, font_path, 'utf-8')) 57 | return font_name 58 | except Exception as e: 59 | print(f"Error loading font {pdf_settings.font_name}: {str(e)}") 60 | 61 | # Try default font 62 | default_font = os.path.join(settings.BASE_DIR, 'static', 'assets', 'fonts', 'DejaVuSans.ttf') 63 | if os.path.exists(default_font): 64 | try: 65 | pdfmetrics.registerFont(TTFont(font_name, default_font, 'utf-8')) 66 | return font_name 67 | except Exception as e: 68 | print(f"Error loading default font: {str(e)}") 69 | 70 | # If all else fails, use Helvetica (built into ReportLab) 71 | return 'Helvetica' 72 | 73 | 74 | def get_logo_path(pdf_settings): 75 | """Get logo file path""" 76 | if pdf_settings and pdf_settings.logo and pdf_settings.show_logo: 77 | return pdf_settings.logo.path 78 | return os.path.join(settings.MEDIA_ROOT, 'export_pdf/logo.png') 79 | 80 | 81 | def create_table_style(pdf_settings, font_name, header_bg_color, grid_color): 82 | """Create table style based on settings""" 83 | # Get font sizes from settings 84 | header_font_size = pdf_settings.header_font_size if pdf_settings else 12 85 | body_font_size = pdf_settings.body_font_size if pdf_settings else 8 86 | grid_line_width = pdf_settings.grid_line_width if pdf_settings else 0.25 87 | table_spacing = pdf_settings.table_spacing if pdf_settings else 1.5 88 | 89 | # Determine cell alignment based on RTL setting and content_alignment 90 | # 'LEFT', 'CENTER', 'RIGHT' 91 | cell_alignment = 'CENTER' # Default is center 92 | 93 | # Use explicit alignment settings if available 94 | if pdf_settings and hasattr(pdf_settings, 'content_alignment'): 95 | cell_alignment = pdf_settings.content_alignment 96 | # Otherwise fall back to RTL-based alignment 97 | elif pdf_settings and hasattr(pdf_settings, 'rtl_support') and pdf_settings.rtl_support: 98 | cell_alignment = 'RIGHT' # For RTL languages, default to right alignment 99 | 100 | # For header alignment 101 | header_alignment = 'CENTER' # Default for headers 102 | if pdf_settings and hasattr(pdf_settings, 'header_alignment'): 103 | header_alignment = pdf_settings.header_alignment 104 | 105 | # Build table style 106 | style = [ 107 | ('FONT', (0, 0), (-1, -1), font_name, body_font_size), # Body font 108 | ('FONT', (0, 0), (-1, 0), font_name, header_font_size), # Header font 109 | ('FONTWEIGHT', (0, 0), (-1, 0), 'bold'), # Make header row bold 110 | ('TEXTCOLOR', (0, 0), (-1, -1), colors.black), 111 | ('BACKGROUND', (0, 0), (-1, 0), header_bg_color), 112 | ('GRID', (0, 0), (-1, -1), grid_line_width, grid_color), 113 | ('ALIGN', (0, 1), (-1, -1), cell_alignment), # Content alignment 114 | ('ALIGN', (0, 0), (-1, 0), header_alignment), # Header alignment 115 | ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), 116 | ('BOX', (0, 0), (-1, -1), grid_line_width, grid_color), 117 | ('INNERGRID', (0, 0), (-1, -1), grid_line_width, grid_color), 118 | ('TOPPADDING', (0, 0), (-1, -1), table_spacing * mm), 119 | ('BOTTOMPADDING', (0, 0), (-1, -1), table_spacing * mm), 120 | ('LEFTPADDING', (0, 0), (-1, -1), table_spacing * 2 * mm), 121 | ('RIGHTPADDING', (0, 0), (-1, -1), table_spacing * 2 * mm), 122 | ] 123 | 124 | return TableStyle(style) 125 | 126 | 127 | def create_header_style(pdf_settings, font_name, is_header=False): 128 | """Create style for column headers and body text""" 129 | styles = getSampleStyleSheet() 130 | 131 | # Use proper font sizes from settings 132 | if pdf_settings: 133 | font_size = pdf_settings.header_font_size if is_header else pdf_settings.body_font_size 134 | else: 135 | font_size = 12 if is_header else 8 136 | 137 | # Determine text alignment based on RTL setting and alignment settings 138 | # 0 = left, 1 = center, 2 = right 139 | alignment = 1 # Default is center 140 | 141 | # Check for explicit alignment settings 142 | if pdf_settings: 143 | if is_header and hasattr(pdf_settings, 'header_alignment'): 144 | if pdf_settings.header_alignment == 'LEFT': 145 | alignment = 0 146 | elif pdf_settings.header_alignment == 'RIGHT': 147 | alignment = 2 148 | elif not is_header and hasattr(pdf_settings, 'content_alignment'): 149 | if pdf_settings.content_alignment == 'LEFT': 150 | alignment = 0 151 | elif pdf_settings.content_alignment == 'RIGHT': 152 | alignment = 2 153 | 154 | # Override alignment based on RTL if no explicit setting 155 | if pdf_settings and hasattr(pdf_settings, 'rtl_support') and pdf_settings.rtl_support: 156 | # For RTL languages, apply right alignment if not explicitly set 157 | if not (hasattr(pdf_settings, 'content_alignment') or hasattr(pdf_settings, 'header_alignment')): 158 | alignment = 2 if not is_header else 1 # Right-align for body text in RTL mode, center for headers 159 | 160 | style = ParagraphStyle( 161 | 'CustomHeader' if is_header else 'CustomBody', 162 | parent=styles['Normal'], 163 | fontSize=font_size, 164 | fontName=font_name, 165 | alignment=alignment, 166 | spaceAfter=2 * mm, 167 | leading=font_size * 1.2, # Line height 168 | textColor=colors.black, 169 | fontWeight='bold' if is_header else 'normal' # Make headers bold 170 | ) 171 | 172 | # ReportLab doesn't support direct CSS for RTL 173 | # The text direction is handled by the arabic_reshaper and get_display functions 174 | 175 | return style 176 | 177 | 178 | def calculate_column_widths(data, table_width, font_name, font_size): 179 | """Calculate optimal column widths based on content""" 180 | num_cols = len(data[0]) 181 | max_widths = [0] * num_cols 182 | 183 | # Find maximum content width for each column 184 | for row in data: 185 | for i, cell in enumerate(row): 186 | content = str(cell) 187 | # Headers get more weight in width calculation 188 | multiplier = 1.2 if row == data[0] else 1.0 189 | width = len(content) * font_size * 0.6 * multiplier 190 | max_widths[i] = max(max_widths[i], width) 191 | 192 | # Ensure minimum width for each column 193 | min_width = table_width * 0.05 # 5% of table width 194 | max_widths = [max(width, min_width) for width in max_widths] 195 | 196 | # Normalize widths to fit table_width 197 | total_width = sum(max_widths) 198 | return [width / total_width * table_width for width in max_widths] 199 | 200 | 201 | def draw_table_data(p, page, rows_per_page, total_rows, col_widths, table_style, canvas_height, table_top_margin, data): 202 | """Draw table data for current page""" 203 | start_row = page * rows_per_page + 1 204 | end_row = min((page + 1) * rows_per_page + 1, total_rows + 1) 205 | page_data = data[start_row:end_row] 206 | table = Table(page_data, colWidths=col_widths, style=table_style) 207 | table.wrapOn(p, 100, 100) 208 | table_height = table._height 209 | y = canvas_height - table_top_margin - table_height 210 | table.drawOn(p, 100, y) 211 | 212 | 213 | def draw_model_name(p, modeladmin, font_name, font_size, canvas_width, canvas_height, page_margin): 214 | """Draw model name header""" 215 | # Try to get verbose_name_plural, then verbose_name, then fall back to __name__ 216 | model = modeladmin.model 217 | 218 | if hasattr(model._meta, 'verbose_name_plural') and model._meta.verbose_name_plural: 219 | model_name = str(capfirst(model._meta.verbose_name_plural)) 220 | elif hasattr(model._meta, 'verbose_name') and model._meta.verbose_name: 221 | model_name = str(capfirst(model._meta.verbose_name)) 222 | else: 223 | model_name = model.__name__ 224 | 225 | # Get settings to check if RTL is enabled 226 | pdf_settings = get_active_settings() 227 | 228 | # Apply Arabic reshaping and bidirectional algorithm if RTL support is enabled 229 | if pdf_settings and hasattr(pdf_settings, 'rtl_support') and pdf_settings.rtl_support: 230 | import arabic_reshaper 231 | from bidi.algorithm import get_display 232 | model_name = arabic_reshaper.reshape(model_name) 233 | model_name = get_display(model_name) 234 | 235 | p.setFont(font_name, font_size) 236 | model_name_string_width = p.stringWidth(model_name, font_name, font_size) 237 | 238 | # Use title_alignment if available 239 | if pdf_settings and hasattr(pdf_settings, 'title_alignment'): 240 | alignment = pdf_settings.title_alignment 241 | if alignment == 'LEFT': 242 | x = page_margin + 10 # Left aligned with margin 243 | elif alignment == 'RIGHT': 244 | x = canvas_width - model_name_string_width - page_margin - 10 # Right aligned with margin 245 | else: # CENTER is default 246 | x = canvas_width / 2 247 | else: 248 | # Default center alignment 249 | x = canvas_width / 2 250 | 251 | # Check if we should use drawString or drawCentredString based on alignment 252 | if (pdf_settings and hasattr(pdf_settings, 'title_alignment') and pdf_settings.title_alignment == 'CENTER') or not hasattr(pdf_settings, 'title_alignment'): 253 | p.drawCentredString(x, canvas_height - page_margin, model_name) 254 | else: 255 | p.drawString(x, canvas_height - page_margin, model_name) 256 | 257 | 258 | def draw_exported_at(p, font_name, font_size, canvas_width, footer_margin): 259 | """Draw export timestamp""" 260 | from datetime import datetime 261 | export_date_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 262 | 263 | # Get settings to check if RTL is enabled 264 | pdf_settings = get_active_settings() 265 | 266 | exported_at_string = f"Exported at: {export_date_time}" 267 | 268 | # Apply Arabic reshaping and bidirectional algorithm if RTL support is enabled 269 | if pdf_settings and hasattr(pdf_settings, 'rtl_support') and pdf_settings.rtl_support: 270 | import arabic_reshaper 271 | from bidi.algorithm import get_display 272 | exported_at_string = arabic_reshaper.reshape(exported_at_string) 273 | exported_at_string = get_display(exported_at_string) 274 | 275 | p.setFont(font_name, font_size) 276 | exported_at_string_width = p.stringWidth(exported_at_string, font_name, font_size) 277 | 278 | # Position string appropriately based on RTL setting 279 | if pdf_settings and hasattr(pdf_settings, 'rtl_support') and pdf_settings.rtl_support: 280 | x = 100 # For RTL, align to the left side with margin 281 | else: 282 | x = canvas_width - exported_at_string_width - 100 # For LTR, align to the right side with margin 283 | 284 | p.drawString(x, footer_margin, exported_at_string) 285 | 286 | 287 | def draw_page_number(p, page, total_pages, font_name, font_size, canvas_width, footer_margin): 288 | """Draw page numbers""" 289 | # Get settings to check if RTL is enabled 290 | pdf_settings = get_active_settings() 291 | 292 | page_string = f"Page {page + 1} of {total_pages}" 293 | 294 | # Apply Arabic reshaping and bidirectional algorithm if RTL support is enabled 295 | if pdf_settings and hasattr(pdf_settings, 'rtl_support') and pdf_settings.rtl_support: 296 | import arabic_reshaper 297 | from bidi.algorithm import get_display 298 | page_string = arabic_reshaper.reshape(page_string) 299 | page_string = get_display(page_string) 300 | 301 | p.setFont(font_name, font_size) 302 | page_string_width = p.stringWidth(page_string, font_name, font_size) 303 | x = canvas_width / 2 304 | p.drawCentredString(x, footer_margin, page_string) 305 | 306 | 307 | def draw_logo(p, logo_file, canvas_width, canvas_height): 308 | """Draw logo if it exists""" 309 | if os.path.exists(logo_file): 310 | from reportlab.platypus import Image 311 | logo_width = 100 312 | logo_height = 50 313 | logo_offset = 20 314 | logo_x = canvas_width - logo_width - logo_offset 315 | logo_y = canvas_height - logo_height - logo_offset 316 | logo = Image(logo_file, width=logo_width, height=logo_height) 317 | logo.drawOn(p, logo_x, logo_y) 318 | -------------------------------------------------------------------------------- /django_pdf_actions/admin.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.contrib import admin 4 | from django.utils.html import format_html 5 | 6 | from . import models 7 | from .actions import export_to_pdf_landscape, export_to_pdf_portrait 8 | 9 | 10 | @admin.register(models.ExportPDFSettings) 11 | class PdfAdmin(admin.ModelAdmin): 12 | list_display = ('title', 'active', 'font_name_display', 'logo_display', 'items_per_page', 'page_size', 13 | 'rtl_support', 14 | 'content_alignment', 'header_alignment', 'modified', 15 | 'header_background_color_preview', 'grid_line_color_preview') 16 | list_filter = ('active', 'show_header', 'show_logo', 'show_export_time', 'rtl_support', 17 | 'content_alignment', 'header_alignment', 'title_alignment', 'font_name', 'page_size') 18 | search_fields = ('title',) 19 | readonly_fields = ( 20 | 'modified', 'created', 'header_background_color_preview', 'grid_line_color_preview', 'logo_display') 21 | 22 | def font_name_display(self, obj): 23 | font_name = os.path.splitext(obj.font_name)[0] # Remove .ttf extension 24 | return format_html( 25 | '{}', 26 | font_name 27 | ) 28 | 29 | font_name_display.short_description = 'Font' 30 | 31 | def logo_display(self, obj): 32 | if obj.logo: 33 | return format_html( 34 | ' {}', 35 | obj.logo.url, 36 | os.path.basename(obj.logo.name) 37 | ) 38 | return "No logo" 39 | 40 | logo_display.short_description = 'Logo' 41 | 42 | def header_background_color_preview(self, obj): 43 | return format_html( 44 | '
{}', 45 | obj.header_background_color, 46 | obj.header_background_color 47 | ) 48 | 49 | header_background_color_preview.short_description = 'Header Background' 50 | 51 | def grid_line_color_preview(self, obj): 52 | return format_html( 53 | '
{}', 54 | obj.grid_line_color, 55 | obj.grid_line_color 56 | ) 57 | 58 | grid_line_color_preview.short_description = 'Grid Line Color' 59 | 60 | fieldsets = ( 61 | ('General', { 62 | 'fields': ('title', 'active') 63 | }), 64 | ('Page Layout', { 65 | 'fields': ('page_size', 'items_per_page', 'page_margin_mm') 66 | }), 67 | ('Font Settings', { 68 | 'fields': ('font_name', 'header_font_size', 'body_font_size') 69 | }), 70 | ('Visual Settings', { 71 | 'fields': ( 72 | ('logo', 'logo_display'), 73 | ('header_background_color', 'header_background_color_preview'), 74 | ('grid_line_color', 'grid_line_color_preview'), 75 | 'grid_line_width' 76 | ) 77 | }), 78 | ('Display Options', { 79 | 'fields': ( 80 | 'show_header', 'show_logo', 81 | 'show_export_time', 'show_page_numbers', 82 | 'rtl_support' 83 | ) 84 | }), 85 | ('Alignment Settings', { 86 | 'fields': ( 87 | 'title_alignment', 'header_alignment', 'content_alignment' 88 | ) 89 | }), 90 | ('Table Settings', { 91 | 'fields': ('table_spacing', 'max_chars_per_line') 92 | }), 93 | ('Metadata', { 94 | 'fields': ('created', 'modified'), 95 | 'classes': ('collapse',) 96 | }) 97 | ) 98 | 99 | actions = [export_to_pdf_landscape, export_to_pdf_portrait] 100 | 101 | def save_model(self, request, obj, form, change): 102 | # Ensure validation is called 103 | obj.full_clean() 104 | super().save_model(request, obj, form, change) 105 | 106 | class Media: 107 | css = { 108 | 'all': ('admin/css/widgets.css',) 109 | } 110 | -------------------------------------------------------------------------------- /django_pdf_actions/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ExportPDFConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'django_pdf_actions' 7 | label = 'django_pdf_actions' 8 | verbose_name = 'Django PDF Actions Export PDF' 9 | -------------------------------------------------------------------------------- /django_pdf_actions/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibrahimroshdy/django-pdf-actions/e15a313306b1659529e75c4ebe779fa3d0be47b9/django_pdf_actions/management/__init__.py -------------------------------------------------------------------------------- /django_pdf_actions/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibrahimroshdy/django-pdf-actions/e15a313306b1659529e75c4ebe779fa3d0be47b9/django_pdf_actions/management/commands/__init__.py -------------------------------------------------------------------------------- /django_pdf_actions/management/commands/setup_fonts.py: -------------------------------------------------------------------------------- 1 | """Management command to set up fonts for PDF export""" 2 | 3 | import os 4 | import shutil 5 | import tempfile 6 | import zipfile 7 | 8 | import requests 9 | from django.conf import settings 10 | from django.core.management.base import BaseCommand 11 | 12 | 13 | class Command(BaseCommand): 14 | help = 'Downloads and sets up default fonts for PDF export' 15 | 16 | def add_arguments(self, parser): 17 | parser.add_argument( 18 | '--font-url', 19 | type=str, 20 | help='URL to download additional font from' 21 | ) 22 | parser.add_argument( 23 | '--font-name', 24 | type=str, 25 | help='Name for the font file (e.g., "CustomFont.ttf")' 26 | ) 27 | 28 | def download_and_process_font(self, url, target_path, font_name): 29 | """Download and process font file, handling both direct TTF files and zip archives.""" 30 | headers = { 31 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 32 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 33 | 'Accept-Language': 'en-US,en;q=0.5', 34 | 'Connection': 'keep-alive', 35 | } 36 | 37 | with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as temp_file: 38 | # Download the file with browser headers 39 | response = requests.get(url, stream=True, headers=headers) 40 | response.raise_for_status() 41 | 42 | # Check content type for zip 43 | content_type = response.headers.get('content-type', '').lower() 44 | is_zip = 'zip' in content_type or url.lower().endswith('.zip') 45 | 46 | # Save the downloaded content 47 | shutil.copyfileobj(response.raw, temp_file) 48 | temp_file.flush() 49 | 50 | try: 51 | # Try to open as zip even if not detected as zip (some servers don't set content-type) 52 | if is_zip or zipfile.is_zipfile(temp_file.name): 53 | with zipfile.ZipFile(temp_file.name) as zip_ref: 54 | # List all TTF files in the zip 55 | ttf_files = [f for f in zip_ref.namelist() if f.lower().endswith('.ttf')] 56 | if not ttf_files: 57 | raise Exception("No TTF files found in the zip archive") 58 | 59 | # For DejaVu Sans, find the specific file we want 60 | if font_name.lower() == 'dejavusans.ttf': 61 | target_ttf = next(f for f in ttf_files if 'DejaVuSans.ttf' in f) 62 | else: 63 | # For other fonts, try to match the name or use the first TTF 64 | target_ttf = next( 65 | (f for f in ttf_files if font_name.lower() in f.lower()), 66 | ttf_files[0] 67 | ) 68 | 69 | # Extract only the needed TTF file 70 | with zip_ref.open(target_ttf) as source, open(target_path, 'wb') as target: 71 | shutil.copyfileobj(source, target) 72 | self.stdout.write(f"Extracted {target_ttf} from zip to {target_path}") 73 | else: 74 | # Direct TTF file, just move it to the target location 75 | shutil.move(temp_file.name, target_path) 76 | except zipfile.BadZipFile: 77 | # If not a valid zip, assume it's a direct TTF 78 | shutil.move(temp_file.name, target_path) 79 | 80 | def handle(self, *args, **options): 81 | # Create fonts directory in static/assets/fonts 82 | fonts_dir = os.path.join(settings.BASE_DIR, 'static', 'assets', 'fonts') 83 | os.makedirs(fonts_dir, exist_ok=True) 84 | 85 | # List of default fonts to download 86 | fonts = [ 87 | { 88 | 'name': 'DejaVuSans.ttf', 89 | 'url': 'https://github.com/dejavu-fonts/dejavu-fonts/releases/download/version_2_37/dejavu-fonts-ttf-2.37.zip' 90 | }, 91 | ] 92 | 93 | # Add custom font if URL is provided 94 | if options['font_url']: 95 | if not options['font_name']: 96 | font_name = os.path.basename(options['font_url']) 97 | if not font_name.endswith('.ttf'): 98 | font_name += '.ttf' 99 | else: 100 | font_name = options['font_name'] 101 | if not font_name.endswith('.ttf'): 102 | font_name += '.ttf' 103 | 104 | fonts.append({ 105 | 'name': font_name, 106 | 'url': options['font_url'] 107 | }) 108 | self.stdout.write( 109 | self.style.NOTICE(f"Adding custom font: {font_name} from {options['font_url']}") 110 | ) 111 | 112 | for font in fonts: 113 | font_path = os.path.join(fonts_dir, font['name']) 114 | 115 | # Skip if font already exists 116 | if os.path.exists(font_path): 117 | self.stdout.write( 118 | self.style.SUCCESS(f"Font {font['name']} already exists at {font_path}") 119 | ) 120 | continue 121 | 122 | try: 123 | # Download and process font 124 | self.stdout.write(f"Downloading {font['name']} to {font_path}...") 125 | self.download_and_process_font(font['url'], font_path, font['name']) 126 | self.stdout.write( 127 | self.style.SUCCESS(f"Successfully installed {font['name']} to {font_path}") 128 | ) 129 | 130 | except Exception as e: 131 | self.stdout.write( 132 | self.style.ERROR(f"Error processing {font['name']}: {str(e)}") 133 | ) 134 | self.stdout.write( 135 | self.style.NOTICE( 136 | f"You can manually download the font from {font['url']} and place it in {fonts_dir}") 137 | ) 138 | 139 | self.stdout.write(self.style.SUCCESS(f'Font setup complete. Fonts directory: {fonts_dir}')) 140 | -------------------------------------------------------------------------------- /django_pdf_actions/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.19 on 2025-02-08 12:08 2 | 3 | import django.core.validators 4 | import django.utils.timezone 5 | import django_pdf_actions.models 6 | import model_utils.fields 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='ExportPDFSettings', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, 22 | verbose_name='created')), 23 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, 24 | verbose_name='modified')), 25 | ('title', models.CharField(help_text='Name of this configuration', max_length=100)), 26 | ('active', 27 | models.BooleanField(default=False, help_text='Only one configuration can be active at a time')), 28 | ('items_per_page', 29 | models.PositiveIntegerField(default=10, help_text='Number of items to display per page', 30 | validators=[django.core.validators.MinValueValidator(1), 31 | django.core.validators.MaxValueValidator(50)])), 32 | ('page_margin_mm', models.PositiveIntegerField(default=15, help_text='Page margin in millimeters', 33 | validators=[django.core.validators.MinValueValidator(5), 34 | django.core.validators.MaxValueValidator( 35 | 50)])), 36 | ('font_name', models.CharField(choices=[('DejaVuSans.ttf', 'DejaVuSans'), ('SIXTY.ttf', 'SIXTY')], 37 | default='DejaVuSans.ttf', 38 | help_text='Select font from available system fonts', max_length=100)), 39 | ('header_font_size', models.PositiveIntegerField(default=10, help_text='Font size for headers', 40 | validators=[ 41 | django.core.validators.MinValueValidator(6), 42 | django.core.validators.MaxValueValidator(24)])), 43 | ('body_font_size', models.PositiveIntegerField(default=7, help_text='Font size for table content', 44 | validators=[django.core.validators.MinValueValidator(6), 45 | django.core.validators.MaxValueValidator( 46 | 18)])), 47 | ('logo', models.ImageField(blank=True, help_text='Logo to display on PDF', null=True, 48 | upload_to='export_pdf/logos/')), 49 | ('header_background_color', django_pdf_actions.models.ColorField(default='#F0F0F0', 50 | help_text='Header background color (hex format, e.g. #F0F0F0)', 51 | max_length=7, validators=[ 52 | django_pdf_actions.models.validate_hex_color])), 53 | ('grid_line_color', django_pdf_actions.models.ColorField(default='#000000', 54 | help_text='Grid line color (hex format, e.g. #000000)', 55 | max_length=7, validators=[ 56 | django_pdf_actions.models.validate_hex_color])), 57 | ('grid_line_width', models.FloatField(default=0.25, help_text='Grid line width in points', 58 | validators=[django.core.validators.MinValueValidator(0.1), 59 | django.core.validators.MaxValueValidator(2.0)])), 60 | ('show_header', models.BooleanField(default=True, help_text='Display the header with model name')), 61 | ('show_logo', models.BooleanField(default=True, help_text='Display the logo in the PDF')), 62 | ('show_export_time', models.BooleanField(default=True, help_text='Display export timestamp')), 63 | ('show_page_numbers', models.BooleanField(default=True, help_text='Display page numbers')), 64 | ('table_spacing', models.FloatField(default=1.0, help_text='Spacing between table cells in millimeters', 65 | validators=[django.core.validators.MinValueValidator(0.5), 66 | django.core.validators.MaxValueValidator(5.0)])), 67 | ('max_chars_per_line', 68 | models.PositiveIntegerField(default=45, help_text='Maximum characters per line before wrapping', 69 | validators=[django.core.validators.MinValueValidator(20), 70 | django.core.validators.MaxValueValidator(100)])), 71 | ], 72 | options={ 73 | 'verbose_name': 'Export PDF Settings', 74 | 'verbose_name_plural': 'Export PDF Settings', 75 | 'ordering': ['-active', '-modified'], 76 | }, 77 | ), 78 | ] 79 | -------------------------------------------------------------------------------- /django_pdf_actions/migrations/0002_add_page_size_field.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ('django_pdf_actions', '0001_initial'), 7 | ] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name='exportpdfsettings', 12 | name='page_size', 13 | field=models.CharField( 14 | choices=[ 15 | ('A4', 'A4 (210mm × 297mm)'), 16 | ('A3', 'A3 (297mm × 420mm)'), 17 | ('A2', 'A2 (420mm × 594mm)'), 18 | ('A1', 'A1 (594mm × 841mm)') 19 | ], 20 | default='A4', 21 | help_text='Select the page size for the PDF', 22 | max_length=2 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /django_pdf_actions/migrations/0003_exportpdfsettings_rtl_support_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.19 on 2025-04-11 09:55 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("django_pdf_actions", "0002_add_page_size_field"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="exportpdfsettings", 15 | name="rtl_support", 16 | field=models.BooleanField( 17 | default=False, 18 | help_text="Enable right-to-left (RTL) text support for Arabic and other RTL languages", 19 | ), 20 | ), 21 | migrations.AlterField( 22 | model_name="exportpdfsettings", 23 | name="items_per_page", 24 | field=models.PositiveIntegerField( 25 | default=10, 26 | help_text="Number of items to display per page", 27 | validators=[ 28 | django.core.validators.MinValueValidator(1), 29 | django.core.validators.MaxValueValidator(100), 30 | ], 31 | ), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /django_pdf_actions/migrations/0004_add_alignment_fields.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ('django_pdf_actions', '0003_exportpdfsettings_rtl_support_and_more'), 7 | ] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name='exportpdfsettings', 12 | name='content_alignment', 13 | field=models.CharField(choices=[('LEFT', 'Left'), ('CENTER', 'Center'), ('RIGHT', 'Right')], 14 | default='CENTER', help_text='Text alignment for table content', max_length=10), 15 | ), 16 | migrations.AddField( 17 | model_name='exportpdfsettings', 18 | name='header_alignment', 19 | field=models.CharField(choices=[('LEFT', 'Left'), ('CENTER', 'Center'), ('RIGHT', 'Right')], 20 | default='CENTER', help_text='Text alignment for table headers', max_length=10), 21 | ), 22 | migrations.AddField( 23 | model_name='exportpdfsettings', 24 | name='title_alignment', 25 | field=models.CharField(choices=[('LEFT', 'Left'), ('CENTER', 'Center'), ('RIGHT', 'Right')], 26 | default='CENTER', help_text='Text alignment for the title', max_length=10), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /django_pdf_actions/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibrahimroshdy/django-pdf-actions/e15a313306b1659529e75c4ebe779fa3d0be47b9/django_pdf_actions/migrations/__init__.py -------------------------------------------------------------------------------- /django_pdf_actions/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from django.conf import settings 5 | from django.core.exceptions import ValidationError 6 | from django.core.validators import MinValueValidator, MaxValueValidator 7 | from django.db import models 8 | from django.db.models.signals import pre_save 9 | from django.dispatch import receiver 10 | from django.forms import TextInput 11 | from django.utils.translation import gettext_lazy as _ 12 | from model_utils.models import TimeStampedModel 13 | 14 | # Page size choices with dimensions in points (1 point = 1/72 inch) 15 | PAGE_SIZES = [ 16 | ('A4', 'A4 (210mm × 297mm)'), 17 | ('A3', 'A3 (297mm × 420mm)'), 18 | ('A2', 'A2 (420mm × 594mm)'), 19 | ('A1', 'A1 (594mm × 841mm)'), 20 | ] 21 | 22 | # Define alignment choices 23 | ALIGNMENT_CHOICES = [ 24 | ('LEFT', 'Left'), 25 | ('CENTER', 'Center'), 26 | ('RIGHT', 'Right'), 27 | ] 28 | 29 | 30 | def validate_hex_color(value): 31 | """Validate hex color format""" 32 | if not re.match(r'^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$', value): 33 | raise ValidationError( 34 | _('%(value)s is not a valid hex color. Format should be #RRGGBB or #RGB'), 35 | params={'value': value}, 36 | ) 37 | 38 | 39 | def get_available_fonts(): 40 | """Get list of available fonts from the static/assets/fonts directory""" 41 | fonts_dir = os.path.join(settings.BASE_DIR, 'static', 'assets', 'fonts') 42 | fonts = [] 43 | 44 | if os.path.exists(fonts_dir): 45 | for file in os.listdir(fonts_dir): 46 | if file.lower().endswith(('.ttf', '.otf')): 47 | # Store just the filename as the value, but show name without extension as label 48 | name = os.path.splitext(file)[0] 49 | fonts.append((file, name)) 50 | 51 | # Always include the default font 52 | if not fonts or 'DejaVuSans.ttf' not in [f[0] for f in fonts]: 53 | fonts.append(('DejaVuSans.ttf', 'DejaVu Sans (Default)')) 54 | 55 | return sorted(fonts, key=lambda x: x[1]) 56 | 57 | 58 | class ColorField(models.CharField): 59 | def __init__(self, *args, **kwargs): 60 | kwargs['max_length'] = 7 61 | kwargs['validators'] = [validate_hex_color] 62 | super().__init__(*args, **kwargs) 63 | 64 | def formfield(self, **kwargs): 65 | kwargs['widget'] = TextInput(attrs={'type': 'color'}) 66 | return super().formfield(**kwargs) 67 | 68 | 69 | # Define the ExportPDFSettings model 70 | class ExportPDFSettings(TimeStampedModel): 71 | title = models.CharField( 72 | max_length=100, 73 | help_text=_("Name of this configuration") 74 | ) 75 | active = models.BooleanField( 76 | default=False, 77 | help_text=_("Only one configuration can be active at a time") 78 | ) 79 | 80 | # Page Layout Settings 81 | page_size = models.CharField( 82 | max_length=2, 83 | choices=PAGE_SIZES, 84 | default='A4', 85 | help_text=_("Select the page size for the PDF") 86 | ) 87 | items_per_page = models.PositiveIntegerField( 88 | default=10, 89 | validators=[MinValueValidator(1), MaxValueValidator(100)], 90 | help_text=_("Number of items to display per page") 91 | ) 92 | page_margin_mm = models.PositiveIntegerField( 93 | default=15, 94 | validators=[MinValueValidator(5), MaxValueValidator(50)], 95 | help_text=_("Page margin in millimeters") 96 | ) 97 | 98 | # Font Settings 99 | font_name = models.CharField( 100 | max_length=100, 101 | choices=get_available_fonts(), 102 | default='DejaVuSans.ttf', 103 | help_text=_("Select font from available system fonts") 104 | ) 105 | header_font_size = models.PositiveIntegerField( 106 | default=10, 107 | validators=[MinValueValidator(6), MaxValueValidator(24)], 108 | help_text=_("Font size for headers") 109 | ) 110 | body_font_size = models.PositiveIntegerField( 111 | default=7, 112 | validators=[MinValueValidator(6), MaxValueValidator(18)], 113 | help_text=_("Font size for table content") 114 | ) 115 | 116 | # Visual Settings 117 | logo = models.ImageField( 118 | upload_to='export_pdf/logos/', 119 | help_text=_("Logo to display on PDF"), 120 | null=True, 121 | blank=True 122 | ) 123 | header_background_color = ColorField( 124 | default='#F0F0F0', 125 | help_text=_("Header background color (hex format, e.g. #F0F0F0)") 126 | ) 127 | grid_line_color = ColorField( 128 | default='#000000', 129 | help_text=_("Grid line color (hex format, e.g. #000000)") 130 | ) 131 | grid_line_width = models.FloatField( 132 | default=0.25, 133 | validators=[MinValueValidator(0.1), MaxValueValidator(2.0)], 134 | help_text=_("Grid line width in points") 135 | ) 136 | 137 | # Display Options 138 | show_header = models.BooleanField( 139 | default=True, 140 | help_text=_("Display the header with model name") 141 | ) 142 | show_logo = models.BooleanField( 143 | default=True, 144 | help_text=_("Display the logo in the PDF") 145 | ) 146 | show_export_time = models.BooleanField( 147 | default=True, 148 | help_text=_("Display export timestamp") 149 | ) 150 | show_page_numbers = models.BooleanField( 151 | default=True, 152 | help_text=_("Display page numbers") 153 | ) 154 | rtl_support = models.BooleanField( 155 | default=False, 156 | help_text=_("Enable right-to-left (RTL) text support for Arabic and other RTL languages") 157 | ) 158 | content_alignment = models.CharField( 159 | max_length=10, 160 | choices=ALIGNMENT_CHOICES, 161 | default='CENTER', 162 | help_text=_("Text alignment for table content") 163 | ) 164 | header_alignment = models.CharField( 165 | max_length=10, 166 | choices=ALIGNMENT_CHOICES, 167 | default='CENTER', 168 | help_text=_("Text alignment for table headers") 169 | ) 170 | title_alignment = models.CharField( 171 | max_length=10, 172 | choices=ALIGNMENT_CHOICES, 173 | default='CENTER', 174 | help_text=_("Text alignment for the title") 175 | ) 176 | 177 | # Table Settings 178 | table_spacing = models.FloatField( 179 | default=1.0, 180 | validators=[MinValueValidator(0.5), MaxValueValidator(5.0)], 181 | help_text=_("Spacing between table cells in millimeters") 182 | ) 183 | max_chars_per_line = models.PositiveIntegerField( 184 | default=45, 185 | validators=[MinValueValidator(20), MaxValueValidator(100)], 186 | help_text=_("Maximum characters per line before wrapping") 187 | ) 188 | 189 | def __str__(self): 190 | return f"{self.title} ({'Active' if self.active else 'Inactive'})" 191 | 192 | def clean(self): 193 | if self.active: 194 | # Check if there's already an active configuration 195 | active_configs = ExportPDFSettings.objects.filter(active=True) 196 | if self.pk: 197 | active_configs = active_configs.exclude(pk=self.pk) 198 | if active_configs.exists(): 199 | raise ValidationError( 200 | _("There can only be one active configuration. Please deactivate the current active configuration first.") 201 | ) 202 | 203 | class Meta: 204 | verbose_name = _('Export PDF Settings') 205 | verbose_name_plural = _('Export PDF Settings') 206 | ordering = ['-active', '-modified'] 207 | 208 | 209 | @receiver(pre_save, sender=ExportPDFSettings) 210 | def deactivate_other_settings(sender, instance, **kwargs): 211 | if instance.active: 212 | # Deactivate all other configurations 213 | ExportPDFSettings.objects.exclude(pk=instance.pk).update(active=False) 214 | -------------------------------------------------------------------------------- /django_pdf_actions/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /django_pdf_actions/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibrahimroshdy/django-pdf-actions/e15a313306b1659529e75c4ebe779fa3d0be47b9/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | PDF 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/custom-methods.md: -------------------------------------------------------------------------------- 1 | # Custom Admin Methods Guide 2 | 3 | Master the art of creating powerful custom admin methods that seamlessly integrate with Django PDF Actions for enhanced business intelligence. 4 | 5 | ## 🎯 Understanding Custom Admin Methods 6 | 7 | Custom admin methods allow you to add computed fields, formatted data, and business logic to your Django admin list views and PDF exports. 8 | 9 | ```mermaid 10 | graph TD 11 | A[Model Fields] --> B[Django Admin] 12 | C[Custom Methods] --> B 13 | B --> D[List Display] 14 | D --> E[PDF Export] 15 | 16 | F[Business Logic] --> C 17 | G[Calculations] --> C 18 | H[Formatting] --> C 19 | I[Related Data] --> C 20 | 21 | style A fill:#e3f2fd 22 | style C fill:#fff3e0 23 | style E fill:#e8f5e8 24 | ``` 25 | 26 | ## 📋 Basic Custom Method Structure 27 | 28 | ### Standard Method Template 29 | 30 | ```python 31 | def get_custom_field(self, obj): 32 | """ 33 | Custom method description 34 | """ 35 | # Your logic here 36 | return "Formatted result" 37 | 38 | # Method configuration 39 | get_custom_field.short_description = 'Display Name' # Column header 40 | get_custom_field.admin_order_field = 'model_field' # Enable sorting (optional) 41 | get_custom_field.boolean = False # Boolean field styling (optional) 42 | get_custom_field.allow_tags = False # Allow HTML tags (deprecated) 43 | ``` 44 | 45 | ### Essential Components 46 | 47 | === "📝 Method Name" 48 | - Use descriptive names starting with `get_` 49 | - Follow Python naming conventions 50 | - Be specific about what the method returns 51 | 52 | === "📖 Documentation" 53 | - Add docstring explaining the method's purpose 54 | - Document any complex logic or calculations 55 | - Include parameter descriptions if applicable 56 | 57 | === "🏷️ Configuration" 58 | - Set `short_description` for column headers 59 | - Use `admin_order_field` to enable sorting 60 | - Configure display options as needed 61 | 62 | ## 🔧 Common Custom Method Patterns 63 | 64 | ### 1. Data Formatting Methods 65 | 66 | ```python 67 | class ProductAdmin(admin.ModelAdmin): 68 | list_display = ('name', 'get_price_formatted', 'get_status_display', 'get_category_info') 69 | 70 | def get_price_formatted(self, obj): 71 | """Format price with currency and styling""" 72 | if obj.price >= 1000: 73 | return f"${obj.price:,.2f} 💰" 74 | elif obj.price >= 100: 75 | return f"${obj.price:.2f} 💵" 76 | else: 77 | return f"${obj.price:.2f}" 78 | get_price_formatted.short_description = 'السعر' # Arabic: Price 79 | get_price_formatted.admin_order_field = 'price' 80 | 81 | def get_status_display(self, obj): 82 | """Enhanced status display with icons""" 83 | status_icons = { 84 | 'active': '✅ Active', 85 | 'inactive': '❌ Inactive', 86 | 'pending': '⏳ Pending', 87 | 'discontinued': '🚫 Discontinued' 88 | } 89 | return status_icons.get(obj.status, f"❓ {obj.status}") 90 | get_status_display.short_description = 'الحالة' # Arabic: Status 91 | 92 | def get_category_info(self, obj): 93 | """Display category with additional info""" 94 | if obj.category: 95 | return f"{obj.category.name} ({obj.category.code})" 96 | return "No Category" 97 | get_category_info.short_description = 'Category' 98 | get_category_info.admin_order_field = 'category__name' 99 | ``` 100 | 101 | ### 2. Calculation Methods 102 | 103 | ```python 104 | class EmployeeAdmin(admin.ModelAdmin): 105 | list_display = ('name', 'get_tenure', 'get_performance_score', 'get_salary_grade') 106 | 107 | def get_tenure(self, obj): 108 | """Calculate employee tenure""" 109 | from datetime import date 110 | today = date.today() 111 | tenure = today - obj.hire_date 112 | 113 | years = tenure.days // 365 114 | months = (tenure.days % 365) // 30 115 | 116 | if years > 0: 117 | return f"{years}y {months}m" 118 | elif months > 0: 119 | return f"{months}m" 120 | else: 121 | return f"{tenure.days}d" 122 | get_tenure.short_description = 'مدت خدمت' # Arabic: Service Period 123 | get_tenure.admin_order_field = 'hire_date' 124 | 125 | def get_performance_score(self, obj): 126 | """Calculate performance score based on multiple factors""" 127 | # Example calculation 128 | base_score = 70 129 | tenure_bonus = min((date.today() - obj.hire_date).days // 365 * 2, 20) 130 | department_bonus = 5 if obj.department.code == 'TECH' else 0 131 | 132 | total_score = base_score + tenure_bonus + department_bonus 133 | 134 | if total_score >= 90: 135 | return f"⭐ {total_score}% (Excellent)" 136 | elif total_score >= 80: 137 | return f"✅ {total_score}% (Good)" 138 | elif total_score >= 70: 139 | return f"📊 {total_score}% (Average)" 140 | else: 141 | return f"📉 {total_score}% (Needs Improvement)" 142 | get_performance_score.short_description = 'Performance' 143 | 144 | def get_salary_grade(self, obj): 145 | """Categorize salary into grades""" 146 | if obj.salary >= 120000: 147 | return "Grade A (Executive)" 148 | elif obj.salary >= 80000: 149 | return "Grade B (Senior)" 150 | elif obj.salary >= 50000: 151 | return "Grade C (Mid-level)" 152 | else: 153 | return "Grade D (Entry)" 154 | get_salary_grade.short_description = 'درجة الراتب' # Arabic: Salary Grade 155 | ``` 156 | 157 | ### 3. Related Data Methods 158 | 159 | ```python 160 | class OrderAdmin(admin.ModelAdmin): 161 | list_display = ('order_id', 'get_customer_info', 'get_items_summary', 'get_shipping_status') 162 | 163 | def get_customer_info(self, obj): 164 | """Get comprehensive customer information""" 165 | customer = obj.customer 166 | company_info = f" ({customer.company})" if customer.company else "" 167 | return f"{customer.name}{company_info} - {customer.email}" 168 | get_customer_info.short_description = 'معلومات العميل' # Arabic: Customer Info 169 | get_customer_info.admin_order_field = 'customer__name' 170 | 171 | def get_items_summary(self, obj): 172 | """Summarize order items""" 173 | items = obj.orderitem_set.all() 174 | total_items = sum(item.quantity for item in items) 175 | unique_products = items.count() 176 | 177 | return f"{total_items} items ({unique_products} products)" 178 | get_items_summary.short_description = 'Items Summary' 179 | 180 | def get_shipping_status(self, obj): 181 | """Get shipping status with estimated delivery""" 182 | if obj.shipping_date: 183 | days_shipped = (date.today() - obj.shipping_date).days 184 | if days_shipped <= 1: 185 | return "🚚 In Transit (Just shipped)" 186 | elif days_shipped <= 3: 187 | return f"📦 In Transit ({days_shipped} days)" 188 | else: 189 | return f"⏰ Delayed ({days_shipped} days)" 190 | else: 191 | return "📋 Processing" 192 | get_shipping_status.short_description = 'حالة الشحن' # Arabic: Shipping Status 193 | ``` 194 | 195 | ## 🌍 Multi-Language Custom Methods 196 | 197 | ### Bilingual Display Methods 198 | 199 | ```python 200 | class ProductAdmin(admin.ModelAdmin): 201 | list_display = ('sku', 'get_name_bilingual', 'get_description_summary') 202 | 203 | def get_name_bilingual(self, obj): 204 | """Display product name in both languages""" 205 | if hasattr(obj, 'name_ar') and obj.name_ar: 206 | return f"{obj.name} | {obj.name_ar}" 207 | return obj.name 208 | get_name_bilingual.short_description = 'Product Name | اسم المنتج' 209 | 210 | def get_description_summary(self, obj): 211 | """Create smart description summary""" 212 | desc = obj.description_en if obj.description_en else obj.description_ar 213 | if len(desc) > 50: 214 | return f"{desc[:47]}..." 215 | return desc 216 | get_description_summary.short_description = 'Description | الوصف' 217 | ``` 218 | 219 | ### Currency Conversion Methods 220 | 221 | ```python 222 | class SalesAdmin(admin.ModelAdmin): 223 | list_display = ('sale_id', 'get_amount_multi_currency', 'get_commission_details') 224 | 225 | def get_amount_multi_currency(self, obj): 226 | """Display amount in multiple currencies""" 227 | usd_amount = obj.amount 228 | eur_amount = usd_amount * 0.85 # Example conversion rate 229 | aed_amount = usd_amount * 3.67 # USD to AED 230 | 231 | return f"${usd_amount:,.2f} | €{eur_amount:,.2f} | {aed_amount:,.2f} د.إ" 232 | get_amount_multi_currency.short_description = 'Amount | المبلغ' 233 | 234 | def get_commission_details(self, obj): 235 | """Calculate and display commission details""" 236 | commission_amount = obj.amount * (obj.commission_rate / 100) 237 | return f"{obj.commission_rate}% = ${commission_amount:,.2f}" 238 | get_commission_details.short_description = 'Commission | العمولة' 239 | ``` 240 | 241 | ## 🎨 Advanced Formatting Techniques 242 | 243 | ### Conditional Formatting 244 | 245 | ```python 246 | class InventoryAdmin(admin.ModelAdmin): 247 | list_display = ('product', 'get_stock_level_indicator', 'get_reorder_status') 248 | 249 | def get_stock_level_indicator(self, obj): 250 | """Visual stock level indicator""" 251 | percentage = (obj.current_stock / obj.max_stock) * 100 if obj.max_stock > 0 else 0 252 | 253 | if percentage >= 75: 254 | return f"🟢 {obj.current_stock} units ({percentage:.0f}%)" 255 | elif percentage >= 50: 256 | return f"🟡 {obj.current_stock} units ({percentage:.0f}%)" 257 | elif percentage >= 25: 258 | return f"🟠 {obj.current_stock} units ({percentage:.0f}%)" 259 | else: 260 | return f"🔴 {obj.current_stock} units ({percentage:.0f}%)" 261 | get_stock_level_indicator.short_description = 'مستوى المخزون' # Arabic: Stock Level 262 | 263 | def get_reorder_status(self, obj): 264 | """Smart reorder recommendations""" 265 | if obj.current_stock <= obj.reorder_point: 266 | shortage = obj.reorder_point - obj.current_stock 267 | return f"⚠️ REORDER NOW! (Short by {shortage})" 268 | else: 269 | buffer = obj.current_stock - obj.reorder_point 270 | return f"✅ OK (+{buffer} buffer)" 271 | get_reorder_status.short_description = 'إعادة الطلب' # Arabic: Reorder 272 | ``` 273 | 274 | ### Time-Based Methods 275 | 276 | ```python 277 | class TaskAdmin(admin.ModelAdmin): 278 | list_display = ('title', 'get_deadline_status', 'get_time_tracking') 279 | 280 | def get_deadline_status(self, obj): 281 | """Show deadline status with urgency indicators""" 282 | from datetime import datetime, timedelta 283 | 284 | if not obj.deadline: 285 | return "No deadline set" 286 | 287 | now = datetime.now().date() 288 | days_until = (obj.deadline - now).days 289 | 290 | if days_until < 0: 291 | return f"🔴 OVERDUE by {abs(days_until)} days" 292 | elif days_until == 0: 293 | return "🟠 DUE TODAY" 294 | elif days_until <= 3: 295 | return f"🟡 Due in {days_until} days" 296 | elif days_until <= 7: 297 | return f"🟢 Due in {days_until} days" 298 | else: 299 | return f"📅 Due in {days_until} days" 300 | get_deadline_status.short_description = 'الموعد النهائي' # Arabic: Deadline 301 | 302 | def get_time_tracking(self, obj): 303 | """Calculate time spent and remaining""" 304 | if obj.estimated_hours and obj.actual_hours: 305 | efficiency = (obj.estimated_hours / obj.actual_hours) * 100 306 | if efficiency >= 100: 307 | return f"⚡ {obj.actual_hours}h (Efficient: {efficiency:.0f}%)" 308 | else: 309 | return f"⏱️ {obj.actual_hours}h (Over: {efficiency:.0f}%)" 310 | return f"📊 {obj.actual_hours or 0}h logged" 311 | get_time_tracking.short_description = 'Time Tracking' 312 | ``` 313 | 314 | ## 🔧 Performance Optimization 315 | 316 | ### Efficient Database Queries 317 | 318 | ```python 319 | class OrderAdmin(admin.ModelAdmin): 320 | # Use select_related to avoid N+1 queries 321 | def get_queryset(self, request): 322 | queryset = super().get_queryset(request) 323 | return queryset.select_related('customer', 'salesperson').prefetch_related('orderitem_set__product') 324 | 325 | def get_customer_details(self, obj): 326 | """Efficiently access related customer data""" 327 | # No additional database query thanks to select_related 328 | return f"{obj.customer.name} ({obj.customer.company})" 329 | get_customer_details.short_description = 'العميل' # Arabic: Customer 330 | 331 | def get_order_value(self, obj): 332 | """Calculate total using prefetched data""" 333 | # No additional queries thanks to prefetch_related 334 | total = sum(item.quantity * item.unit_price for item in obj.orderitem_set.all()) 335 | return f"${total:,.2f}" 336 | get_order_value.short_description = 'قيمة الطلب' # Arabic: Order Value 337 | ``` 338 | 339 | ### Caching Expensive Calculations 340 | 341 | ```python 342 | from django.core.cache import cache 343 | from django.utils.functional import cached_property 344 | 345 | class AnalyticsAdmin(admin.ModelAdmin): 346 | def get_conversion_rate(self, obj): 347 | """Cache expensive conversion rate calculation""" 348 | cache_key = f"conversion_rate_{obj.id}" 349 | rate = cache.get(cache_key) 350 | 351 | if rate is None: 352 | # Expensive calculation 353 | total_visits = obj.visits.count() 354 | conversions = obj.visits.filter(converted=True).count() 355 | rate = (conversions / total_visits * 100) if total_visits > 0 else 0 356 | 357 | # Cache for 1 hour 358 | cache.set(cache_key, rate, 3600) 359 | 360 | return f"{rate:.2f}%" 361 | get_conversion_rate.short_description = 'Conversion Rate' 362 | ``` 363 | 364 | ## 📊 Export Workflow Optimization 365 | 366 | ### Custom Method Processing Flow 367 | 368 | ```mermaid 369 | sequenceDiagram 370 | participant Admin as Django Admin 371 | participant Method as Custom Method 372 | participant DB as Database 373 | participant Cache as Cache Layer 374 | participant PDF as PDF Generator 375 | 376 | Admin->>Method: Call get_custom_field() 377 | Method->>Cache: Check cached result 378 | 379 | alt Cache Hit 380 | Cache-->>Method: Return cached value 381 | else Cache Miss 382 | Method->>DB: Execute query 383 | DB-->>Method: Return data 384 | Method->>Cache: Store result 385 | end 386 | 387 | Method-->>Admin: Return formatted value 388 | Admin->>PDF: Include in export 389 | PDF-->>Admin: Generate PDF 390 | ``` 391 | 392 | ## ❌ Common Pitfalls to Avoid 393 | 394 | !!! warning "Performance Issues" 395 | 396 | === "🚫 N+1 Query Problem" 397 | ```python 398 | # BAD: Creates a query for each row 399 | def get_category_name(self, obj): 400 | return obj.category.name # Query per object! 401 | 402 | # GOOD: Use select_related 403 | def get_queryset(self, request): 404 | return super().get_queryset(request).select_related('category') 405 | ``` 406 | 407 | === "🚫 Expensive Calculations" 408 | ```python 409 | # BAD: Complex calculation on every call 410 | def get_total_sales(self, obj): 411 | return obj.sales.aggregate(Sum('amount'))['amount__sum'] 412 | 413 | # GOOD: Cache or annotate 414 | def get_queryset(self, request): 415 | return super().get_queryset(request).annotate( 416 | total_sales=Sum('sales__amount') 417 | ) 418 | ``` 419 | 420 | === "🚫 Missing Error Handling" 421 | ```python 422 | # BAD: No error handling 423 | def get_ratio(self, obj): 424 | return obj.numerator / obj.denominator 425 | 426 | # GOOD: Handle edge cases 427 | def get_ratio(self, obj): 428 | if obj.denominator and obj.denominator != 0: 429 | return f"{(obj.numerator / obj.denominator):.2f}" 430 | return "N/A" 431 | ``` 432 | 433 | ## 🎯 Best Practices Summary 434 | 435 | !!! tip "Method Design Guidelines" 436 | 437 | === "📝 Naming & Documentation" 438 | - Use descriptive method names with `get_` prefix 439 | - Add comprehensive docstrings 440 | - Set meaningful `short_description` values 441 | - Use bilingual headers when appropriate 442 | 443 | === "⚡ Performance" 444 | - Optimize database queries with `select_related()` 445 | - Cache expensive calculations 446 | - Handle edge cases gracefully 447 | - Avoid complex logic in display methods 448 | 449 | === "🎨 User Experience" 450 | - Use visual indicators (emojis, colors) 451 | - Format data consistently 452 | - Provide context in displays 453 | - Consider mobile/print readability 454 | 455 | === "🌐 Internationalization" 456 | - Support multiple languages 457 | - Use appropriate text direction 458 | - Format numbers/dates per locale 459 | - Handle currency conversions 460 | 461 | ## 🚀 Next Steps 462 | 463 | Master these concepts and then explore: 464 | 465 | 1. [Advanced Settings Configuration →](settings.md) 466 | 2. [Real-world Implementation Examples →](examples.md) 467 | 3. [API Reference Documentation →](api/actions.md) 468 | 469 | --- 470 | 471 | !!! success "Custom Method Mastery!" 472 | You now have the tools to create powerful, efficient custom admin methods that enhance both your Django admin interface and PDF exports! -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Real-World Examples 2 | 3 | Explore practical, real-world implementations of Django PDF Actions across different industries and use cases. 4 | 5 | ## 🏢 Business Applications Overview 6 | 7 | ```mermaid 8 | mindmap 9 | root((PDF Export 10 | Use Cases)) 11 | HR Management 12 | Employee Reports 13 | Payroll Summaries 14 | Performance Reviews 15 | Training Records 16 | Sales & Marketing 17 | Customer Lists 18 | Sales Reports 19 | Product Catalogs 20 | Commission Reports 21 | Finance & Accounting 22 | Invoice Generation 23 | Expense Reports 24 | Financial Statements 25 | Budget Reports 26 | Operations 27 | Inventory Reports 28 | Shipping Lists 29 | Quality Control 30 | Maintenance Logs 31 | ``` 32 | 33 | ## 📊 Example 1: HR Employee Management System 34 | 35 | ### Model Structure 36 | 37 | ```python 38 | # models.py 39 | from django.db import models 40 | from django.contrib.auth.models import User 41 | 42 | class Department(models.Model): 43 | name = models.CharField(max_length=100) 44 | code = models.CharField(max_length=10, unique=True) 45 | manager = models.ForeignKey('Employee', on_delete=models.SET_NULL, null=True, blank=True) 46 | budget = models.DecimalField(max_digits=12, decimal_places=2) 47 | 48 | def __str__(self): 49 | return self.name 50 | 51 | class Employee(models.Model): 52 | STATUS_CHOICES = [ 53 | ('active', 'Active'), 54 | ('inactive', 'Inactive'), 55 | ('vacation', 'On Vacation'), 56 | ('terminated', 'Terminated'), 57 | ] 58 | 59 | employee_id = models.CharField(max_length=20, unique=True) 60 | user = models.OneToOneField(User, on_delete=models.CASCADE) 61 | department = models.ForeignKey(Department, on_delete=models.CASCADE) 62 | position = models.CharField(max_length=100) 63 | hire_date = models.DateField() 64 | salary = models.DecimalField(max_digits=10, decimal_places=2) 65 | status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active') 66 | manager = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True) 67 | 68 | def __str__(self): 69 | return f"{self.user.first_name} {self.user.last_name}" 70 | ``` 71 | 72 | ### Admin Configuration with Custom Methods 73 | 74 | ```python 75 | # admin.py 76 | from django.contrib import admin 77 | from django.utils.html import format_html 78 | from django_pdf_actions.actions import export_to_pdf_landscape, export_to_pdf_portrait 79 | from .models import Employee, Department 80 | from datetime import date 81 | 82 | @admin.register(Employee) 83 | class EmployeeAdmin(admin.ModelAdmin): 84 | list_display = ( 85 | 'employee_id', 'get_full_name', 'get_email', 'department', 86 | 'position', 'get_tenure', 'get_salary_range', 'status' 87 | ) 88 | list_filter = ('department', 'status', 'hire_date', 'position') 89 | search_fields = ('employee_id', 'user__first_name', 'user__last_name', 'user__email') 90 | 91 | def get_full_name(self, obj): 92 | """Get employee's full name""" 93 | return f"{obj.user.first_name} {obj.user.last_name}" 94 | get_full_name.short_description = 'نام کامل' # Arabic: Full Name 95 | get_full_name.admin_order_field = 'user__last_name' 96 | 97 | def get_email(self, obj): 98 | """Get employee's email""" 99 | return obj.user.email 100 | get_email.short_description = 'ایمیل' # Arabic: Email 101 | get_email.admin_order_field = 'user__email' 102 | 103 | def get_tenure(self, obj): 104 | """Calculate years of service""" 105 | today = date.today() 106 | tenure = today - obj.hire_date 107 | years = tenure.days // 365 108 | months = (tenure.days % 365) // 30 109 | 110 | if years > 0: 111 | return f"{years}y {months}m" 112 | else: 113 | return f"{months}m" 114 | get_tenure.short_description = 'مدت خدمت' # Arabic: Service Period 115 | 116 | def get_salary_range(self, obj): 117 | """Categorize salary into ranges""" 118 | if obj.salary < 50000: 119 | return "Entry Level ($0-50K)" 120 | elif obj.salary < 100000: 121 | return "Mid Level ($50K-100K)" 122 | elif obj.salary < 150000: 123 | return "Senior Level ($100K-150K)" 124 | else: 125 | return "Executive Level ($150K+)" 126 | get_salary_range.short_description = 'Salary Range' 127 | 128 | actions = [export_to_pdf_landscape, export_to_pdf_portrait] 129 | 130 | @admin.register(Department) 131 | class DepartmentAdmin(admin.ModelAdmin): 132 | list_display = ( 133 | 'code', 'name', 'get_manager_name', 'get_employee_count', 134 | 'get_budget_formatted', 'get_avg_salary' 135 | ) 136 | 137 | def get_manager_name(self, obj): 138 | """Get department manager name""" 139 | if obj.manager: 140 | return f"{obj.manager.user.first_name} {obj.manager.user.last_name}" 141 | return "No Manager Assigned" 142 | get_manager_name.short_description = 'مدیر بخش' # Arabic: Department Manager 143 | 144 | def get_employee_count(self, obj): 145 | """Count employees in department""" 146 | count = obj.employee_set.filter(status='active').count() 147 | return f"{count} employees" 148 | get_employee_count.short_description = 'Employee Count' 149 | 150 | def get_budget_formatted(self, obj): 151 | """Format budget with currency""" 152 | return f"${obj.budget:,.2f}" 153 | get_budget_formatted.short_description = 'Budget' 154 | 155 | def get_avg_salary(self, obj): 156 | """Calculate average salary in department""" 157 | from django.db.models import Avg 158 | avg = obj.employee_set.filter(status='active').aggregate(Avg('salary'))['salary__avg'] 159 | if avg: 160 | return f"${avg:,.0f}" 161 | return "N/A" 162 | get_avg_salary.short_description = 'Avg Salary' 163 | 164 | actions = [export_to_pdf_landscape, export_to_pdf_portrait] 165 | ``` 166 | 167 | ### Data Flow Visualization 168 | 169 | ```mermaid 170 | graph TD 171 | A[Employee Database] --> B{Export Type} 172 | B -->|Employee List| C[Employee Report] 173 | B -->|Department Summary| D[Department Report] 174 | 175 | C --> E[Personal Info + Custom Calculations] 176 | D --> F[Manager Info + Statistics] 177 | 178 | E --> G[Professional PDF] 179 | F --> G 180 | 181 | H[Custom Methods] --> E 182 | I[Arabic Headers] --> G 183 | J[RTL Support] --> G 184 | 185 | style A fill:#e3f2fd 186 | style G fill:#e8f5e8 187 | ``` 188 | 189 | ## 🛒 Example 2: E-Commerce Product Catalog 190 | 191 | ### Models for Product Management 192 | 193 | ```python 194 | # models.py 195 | class Category(models.Model): 196 | name = models.CharField(max_length=100) 197 | description = models.TextField(blank=True) 198 | 199 | def __str__(self): 200 | return self.name 201 | 202 | class Supplier(models.Model): 203 | name = models.CharField(max_length=100) 204 | contact_email = models.EmailField() 205 | phone = models.CharField(max_length=20) 206 | country = models.CharField(max_length=50) 207 | 208 | def __str__(self): 209 | return self.name 210 | 211 | class Product(models.Model): 212 | STOCK_STATUS = [ 213 | ('in_stock', 'In Stock'), 214 | ('low_stock', 'Low Stock'), 215 | ('out_of_stock', 'Out of Stock'), 216 | ('discontinued', 'Discontinued'), 217 | ] 218 | 219 | sku = models.CharField(max_length=50, unique=True) 220 | name = models.CharField(max_length=200) 221 | category = models.ForeignKey(Category, on_delete=models.CASCADE) 222 | supplier = models.ForeignKey(Supplier, on_delete=models.CASCADE) 223 | cost_price = models.DecimalField(max_digits=10, decimal_places=2) 224 | selling_price = models.DecimalField(max_digits=10, decimal_places=2) 225 | stock_quantity = models.PositiveIntegerField() 226 | reorder_level = models.PositiveIntegerField(default=10) 227 | status = models.CharField(max_length=20, choices=STOCK_STATUS, default='in_stock') 228 | created_date = models.DateTimeField(auto_now_add=True) 229 | 230 | def __str__(self): 231 | return self.name 232 | ``` 233 | 234 | ### Advanced Admin with Business Logic 235 | 236 | ```python 237 | # admin.py 238 | @admin.register(Product) 239 | class ProductAdmin(admin.ModelAdmin): 240 | list_display = ( 241 | 'sku', 'name', 'category', 'get_supplier_info', 242 | 'get_pricing_info', 'get_stock_status', 'get_profit_margin', 243 | 'get_stock_value', 'get_reorder_needed' 244 | ) 245 | list_filter = ('category', 'supplier', 'status', 'created_date') 246 | search_fields = ('sku', 'name', 'supplier__name') 247 | 248 | def get_supplier_info(self, obj): 249 | """Get supplier name and country""" 250 | return f"{obj.supplier.name} ({obj.supplier.country})" 251 | get_supplier_info.short_description = 'مورد تامین' # Arabic: Supplier 252 | 253 | def get_pricing_info(self, obj): 254 | """Show cost and selling price""" 255 | return f"Cost: ${obj.cost_price} | Sale: ${obj.selling_price}" 256 | get_pricing_info.short_description = 'Pricing' 257 | 258 | def get_stock_status(self, obj): 259 | """Enhanced stock status with quantity""" 260 | status_emoji = { 261 | 'in_stock': '✅', 262 | 'low_stock': '⚠️', 263 | 'out_of_stock': '❌', 264 | 'discontinued': '🚫' 265 | } 266 | emoji = status_emoji.get(obj.status, '❓') 267 | return f"{emoji} {obj.get_status_display()} ({obj.stock_quantity})" 268 | get_stock_status.short_description = 'حالت موجودی' # Arabic: Stock Status 269 | 270 | def get_profit_margin(self, obj): 271 | """Calculate profit margin percentage""" 272 | if obj.cost_price > 0: 273 | margin = ((obj.selling_price - obj.cost_price) / obj.cost_price) * 100 274 | return f"{margin:.1f}%" 275 | return "N/A" 276 | get_profit_margin.short_description = 'Profit %' 277 | 278 | def get_stock_value(self, obj): 279 | """Calculate total stock value""" 280 | value = obj.stock_quantity * obj.cost_price 281 | return f"${value:,.2f}" 282 | get_stock_value.short_description = 'Stock Value' 283 | 284 | def get_reorder_needed(self, obj): 285 | """Check if reorder is needed""" 286 | if obj.stock_quantity <= obj.reorder_level: 287 | return f"🔴 Reorder Now!" 288 | else: 289 | remaining = obj.stock_quantity - obj.reorder_level 290 | return f"🟢 OK (+{remaining})" 291 | get_reorder_needed.short_description = 'سفارش مجدد' # Arabic: Reorder 292 | 293 | actions = [export_to_pdf_landscape, export_to_pdf_portrait] 294 | ``` 295 | 296 | ### Product Catalog Workflow 297 | 298 | ```mermaid 299 | sequenceDiagram 300 | participant M as Manager 301 | participant A as Admin Interface 302 | participant P as PDF Generator 303 | participant R as Report 304 | 305 | M->>A: Filter products by category 306 | M->>A: Search by supplier/SKU 307 | M->>A: Select products for catalog 308 | A->>P: Generate product catalog 309 | 310 | Note over P: Calculate profit margins 311 | Note over P: Check stock levels 312 | Note over P: Format pricing info 313 | Note over P: Add supplier details 314 | 315 | P->>R: Create professional PDF 316 | R->>M: Download product catalog 317 | ``` 318 | 319 | ## 📈 Example 3: Sales Analytics Dashboard 320 | 321 | ### Sales Models with Analytics 322 | 323 | ```python 324 | # models.py 325 | class Customer(models.Model): 326 | name = models.CharField(max_length=100) 327 | email = models.EmailField() 328 | phone = models.CharField(max_length=20) 329 | company = models.CharField(max_length=100, blank=True) 330 | registration_date = models.DateTimeField(auto_now_add=True) 331 | 332 | def __str__(self): 333 | return self.name 334 | 335 | class Sale(models.Model): 336 | PAYMENT_STATUS = [ 337 | ('pending', 'Pending'), 338 | ('paid', 'Paid'), 339 | ('overdue', 'Overdue'), 340 | ('cancelled', 'Cancelled'), 341 | ] 342 | 343 | sale_id = models.CharField(max_length=20, unique=True) 344 | customer = models.ForeignKey(Customer, on_delete=models.CASCADE) 345 | salesperson = models.ForeignKey(Employee, on_delete=models.CASCADE) 346 | sale_date = models.DateTimeField() 347 | total_amount = models.DecimalField(max_digits=12, decimal_places=2) 348 | commission_rate = models.DecimalField(max_digits=5, decimal_places=2, default=5.0) 349 | payment_status = models.CharField(max_length=20, choices=PAYMENT_STATUS, default='pending') 350 | notes = models.TextField(blank=True) 351 | 352 | def __str__(self): 353 | return f"Sale {self.sale_id}" 354 | ``` 355 | 356 | ### Sales Analytics Admin 357 | 358 | ```python 359 | @admin.register(Sale) 360 | class SaleAdmin(admin.ModelAdmin): 361 | list_display = ( 362 | 'sale_id', 'get_customer_info', 'get_salesperson_name', 363 | 'sale_date', 'get_amount_formatted', 'get_commission_amount', 364 | 'payment_status', 'get_days_since_sale', 'get_performance_indicator' 365 | ) 366 | list_filter = ('payment_status', 'sale_date', 'salesperson', 'customer') 367 | search_fields = ('sale_id', 'customer__name', 'salesperson__user__first_name') 368 | date_hierarchy = 'sale_date' 369 | 370 | def get_customer_info(self, obj): 371 | """Get customer name and company""" 372 | company = f" ({obj.customer.company})" if obj.customer.company else "" 373 | return f"{obj.customer.name}{company}" 374 | get_customer_info.short_description = 'مشتری' # Arabic: Customer 375 | 376 | def get_salesperson_name(self, obj): 377 | """Get salesperson full name""" 378 | return f"{obj.salesperson.user.first_name} {obj.salesperson.user.last_name}" 379 | get_salesperson_name.short_description = 'فروشنده' # Arabic: Salesperson 380 | 381 | def get_amount_formatted(self, obj): 382 | """Format sale amount with currency""" 383 | return f"${obj.total_amount:,.2f}" 384 | get_amount_formatted.short_description = 'مبلغ فروش' # Arabic: Sale Amount 385 | 386 | def get_commission_amount(self, obj): 387 | """Calculate commission amount""" 388 | commission = (obj.total_amount * obj.commission_rate) / 100 389 | return f"${commission:,.2f} ({obj.commission_rate}%)" 390 | get_commission_amount.short_description = 'Commission' 391 | 392 | def get_days_since_sale(self, obj): 393 | """Calculate days since sale""" 394 | from django.utils import timezone 395 | days = (timezone.now().date() - obj.sale_date.date()).days 396 | return f"{days} days ago" 397 | get_days_since_sale.short_description = 'Age' 398 | 399 | def get_performance_indicator(self, obj): 400 | """Performance indicator based on amount and payment status""" 401 | if obj.payment_status == 'paid' and obj.total_amount > 10000: 402 | return "⭐ Excellent" 403 | elif obj.payment_status == 'paid' and obj.total_amount > 5000: 404 | return "✅ Good" 405 | elif obj.payment_status == 'pending': 406 | return "⏳ Pending" 407 | elif obj.payment_status == 'overdue': 408 | return "🔴 Attention" 409 | else: 410 | return "❌ Issue" 411 | get_performance_indicator.short_description = 'Status' 412 | 413 | actions = [export_to_pdf_landscape, export_to_pdf_portrait] 414 | ``` 415 | 416 | ### Sales Performance Analysis 417 | 418 | ```mermaid 419 | graph TD 420 | A[Sales Data] --> B[Performance Analysis] 421 | B --> C{Payment Status} 422 | C -->|Paid| D[Commission Calculation] 423 | C -->|Pending| E[Follow-up Needed] 424 | C -->|Overdue| F[Collection Action] 425 | 426 | D --> G[Performance Rating] 427 | E --> H[Pending Report] 428 | F --> I[Overdue Report] 429 | 430 | G --> J[PDF Export] 431 | H --> J 432 | I --> J 433 | 434 | style A fill:#e3f2fd 435 | style J fill:#e8f5e8 436 | ``` 437 | 438 | ## 🎨 Customization Examples 439 | 440 | ### RTL Language Support Setup 441 | 442 | For Arabic/Persian content, configure your settings: 443 | 444 | ```python 445 | # Through Django Admin > Export PDF Settings 446 | { 447 | 'title': 'تقرير الموظفين', # Arabic: Employee Report 448 | 'rtl_support': True, 449 | 'font_name': 'Cairo-Regular.ttf', # Arabic font 450 | 'header_alignment': 'RIGHT', 451 | 'content_alignment': 'RIGHT', 452 | 'title_alignment': 'CENTER' 453 | } 454 | ``` 455 | 456 | ### Multi-Language Headers Example 457 | 458 | ```python 459 | class BilingualProductAdmin(admin.ModelAdmin): 460 | list_display = ( 461 | 'sku', 'get_name_bilingual', 'get_price_with_currency', 462 | 'get_category_bilingual', 'stock_quantity' 463 | ) 464 | 465 | def get_name_bilingual(self, obj): 466 | return f"{obj.name_en} | {obj.name_ar}" 467 | get_name_bilingual.short_description = 'Product Name | اسم المنتج' 468 | 469 | def get_price_with_currency(self, obj): 470 | return f"${obj.price} | {obj.price * 3.75:.2f} ريال" 471 | get_price_with_currency.short_description = 'Price USD | السعر ريال' 472 | 473 | def get_category_bilingual(self, obj): 474 | return f"{obj.category.name_en} | {obj.category.name_ar}" 475 | get_category_bilingual.short_description = 'Category | الفئة' 476 | ``` 477 | 478 | ## 📊 Performance Comparison 479 | 480 | ### Export Performance by Data Size 481 | 482 | ```mermaid 483 | graph LR 484 | A[Small Dataset
< 100 records] --> B[~2 seconds] 485 | C[Medium Dataset
100-1000 records] --> D[~5-10 seconds] 486 | E[Large Dataset
1000+ records] --> F[~15-30 seconds] 487 | 488 | B --> G[Excellent UX] 489 | D --> H[Good UX] 490 | F --> I[Consider Pagination] 491 | 492 | style A fill:#c8e6c9 493 | style C fill:#fff3e0 494 | style E fill:#ffebee 495 | ``` 496 | 497 | ## 🎯 Best Practices Summary 498 | 499 | !!! tip "Implementation Tips" 500 | 501 | === "🗂️ Data Organization" 502 | - Use meaningful field names in `list_display` 503 | - Group related custom methods together 504 | - Implement proper `admin_order_field` for sorting 505 | 506 | === "🌐 Internationalization" 507 | - Use Arabic headers for person-related fields 508 | - English for technical/system fields 509 | - Enable RTL support for Arabic content 510 | 511 | === "📈 Performance" 512 | - Limit custom method complexity 513 | - Use `select_related()` for foreign keys 514 | - Consider pagination for large datasets 515 | 516 | === "🎨 User Experience" 517 | - Choose appropriate orientation (landscape vs portrait) 518 | - Use consistent formatting in custom methods 519 | - Add visual indicators (emojis, status colors) 520 | 521 | ## 🚀 Next Steps 522 | 523 | Ready to implement these examples in your project? 524 | 525 | 1. [Configure Advanced Settings →](settings.md) 526 | 2. [Learn Custom Admin Methods →](custom-methods.md) 527 | 3. [Check API Reference →](api/actions.md) 528 | 529 | --- 530 | 531 | !!! success "Professional Results!" 532 | These examples demonstrate how Django PDF Actions can transform your admin interface into a powerful business reporting tool! -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Django PDF Actions 2 | 3 |
4 | Django PDF Actions Logo 5 | 6 | **A powerful Django application for generating PDF exports from the Django admin interface** 7 |
8 | 9 | --- 10 | 11 | [![PyPI version](https://img.shields.io/pypi/v/django-pdf-actions.svg?cache=no)](https://pypi.org/project/django-pdf-actions/) 12 | [![Python Versions](https://img.shields.io/pypi/pyversions/django-pdf-actions.svg)](https://pypi.org/project/django-pdf-actions/) 13 | [![Django Versions](https://img.shields.io/badge/django-3.2%20%7C%204.0%20%7C%204.1%20%7C%204.2%20%7C%205.0-green.svg)](https://pypi.org/project/django-pdf-actions/) 14 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 15 | [![Downloads](https://img.shields.io/pypi/dm/django-pdf-actions.svg)](https://pypistats.org/packages/django-pdf-actions) 16 | 17 | ## 🚀 Overview 18 | 19 | Transform your Django admin interface into a powerful PDF export engine! Django PDF Actions seamlessly integrates professional PDF generation capabilities into your existing Django models with minimal configuration. 20 | 21 | ```mermaid 22 | graph LR 23 | A[Django Admin] --> B[Select Records] 24 | B --> C[Choose Export Action] 25 | C --> D[PDF Generation] 26 | D --> E[Download PDF] 27 | 28 | style A fill:#e1f5fe 29 | style E fill:#c8e6c9 30 | ``` 31 | 32 | ## ✨ Key Features 33 | 34 | === "🎯 Easy Integration" 35 | 36 | Add PDF export to any Django model with just 2 lines of code: 37 | 38 | ```python 39 | actions = [export_to_pdf_landscape, export_to_pdf_portrait] 40 | ``` 41 | 42 | === "🎨 Beautiful Design" 43 | 44 | - Professional layouts with customizable styling 45 | - Company branding with logos and headers 46 | - Multiple page orientations and sizes 47 | - RTL language support (Arabic, Persian) 48 | 49 | === "⚡ Performance" 50 | 51 | - Efficient handling of large datasets 52 | - Optimized memory usage 53 | - Background processing for large exports 54 | - Pagination for better performance 55 | 56 | === "🔧 Flexible" 57 | 58 | - Custom admin methods support 59 | - Configurable fonts and colors 60 | - Multiple export formats 61 | - Advanced table styling 62 | 63 | ## 🏃‍♂️ Quick Start 64 | 65 | Get up and running in 60 seconds: 66 | 67 | ```bash 68 | # 1. Install the package 69 | pip install django-pdf-actions 70 | 71 | # 2. Add to your settings 72 | INSTALLED_APPS = [ 73 | # ... 74 | 'django_pdf_actions', 75 | ] 76 | 77 | # 3. Run migrations 78 | python manage.py migrate 79 | ``` 80 | 81 | ```python 82 | # 4. Add to your admin.py 83 | from django_pdf_actions.actions import export_to_pdf_landscape, export_to_pdf_portrait 84 | 85 | @admin.register(YourModel) 86 | class YourModelAdmin(admin.ModelAdmin): 87 | list_display = ('name', 'email', 'created_at') 88 | actions = [export_to_pdf_landscape, export_to_pdf_portrait] 89 | ``` 90 | 91 | !!! success "That's it!" 92 | Your Django admin now has professional PDF export capabilities! 93 | 94 | ## 🔄 How It Works 95 | 96 | ```mermaid 97 | sequenceDiagram 98 | participant U as User 99 | participant A as Django Admin 100 | participant P as PDF Actions 101 | participant R as ReportLab 102 | participant F as File System 103 | 104 | U->>A: Select records 105 | U->>A: Choose PDF export action 106 | A->>P: Process export request 107 | P->>P: Get model data & settings 108 | P->>R: Generate PDF document 109 | R->>F: Create PDF file 110 | F->>U: Download PDF 111 | ``` 112 | 113 | ## 🌍 Internationalization Support 114 | 115 | Perfect for global applications with built-in RTL support: 116 | 117 | ```mermaid 118 | graph TD 119 | A[Multi-language Content] --> B{Text Direction} 120 | B -->|LTR| C[English, French, German...] 121 | B -->|RTL| D[Arabic, Persian, ...] 122 | C --> E[Left-aligned Layout] 123 | D --> F[Right-aligned Layout] 124 | E --> G[Professional PDF Output] 125 | F --> G 126 | 127 | style A fill:#fff3e0 128 | style G fill:#e8f5e8 129 | ``` 130 | 131 | ## 📊 Use Cases 132 | 133 | !!! example "Real-World Applications" 134 | 135 | === "📋 Reports" 136 | - Employee lists 137 | - Sales reports 138 | - Inventory management 139 | - Financial statements 140 | 141 | === "📄 Documents" 142 | - Invoices and receipts 143 | - Certificates 144 | - ID cards 145 | - Product catalogs 146 | 147 | === "📈 Analytics" 148 | - Data exports 149 | - Performance reports 150 | - Survey results 151 | - Customer lists 152 | 153 | ## 🎯 Perfect For 154 | 155 | - **Businesses** needing professional document generation 156 | - **Developers** wanting quick PDF export functionality 157 | - **Multi-language** applications requiring RTL support 158 | - **Large datasets** requiring optimized performance 159 | 160 | ## 🛠️ Technical Specifications 161 | 162 | | Feature | Specification | 163 | |---------|--------------| 164 | | **Python** | 3.8+ | 165 | | **Django** | 3.2+ | 166 | | **PDF Engine** | ReportLab | 167 | | **Languages** | Unicode support, RTL languages | 168 | | **File Sizes** | Optimized for large datasets | 169 | | **Performance** | Memory-efficient processing | 170 | 171 | ## 📚 Documentation Structure 172 | 173 | ```mermaid 174 | mindmap 175 | root((Documentation)) 176 | Getting Started 177 | Installation 178 | Quick Start 179 | Basic Usage 180 | Configuration 181 | Settings 182 | Advanced Config 183 | Examples 184 | Real-world Cases 185 | Custom Methods 186 | API Reference 187 | Actions 188 | Models 189 | Utils 190 | ``` 191 | 192 | ## 🤝 Community & Support 193 | 194 | - 💬 [GitHub Discussions](https://github.com/ibrahimroshdy/django-pdf-actions/discussions) 195 | - 🐛 [Issue Tracker](https://github.com/ibrahimroshdy/django-pdf-actions/issues) 196 | - 📖 [Documentation](https://ibrahimroshdy.github.io/django-pdf-actions/) 197 | - 📦 [PyPI Package](https://pypi.org/project/django-pdf-actions/) 198 | 199 | ## 📄 License 200 | 201 | This project is licensed under the MIT License - see the [LICENSE](https://github.com/ibrahimroshdy/django-pdf-actions/blob/main/LICENSE) file for details. 202 | 203 | --- 204 | 205 |
206 | Ready to transform your Django admin? Let's get started! 207 | 208 | [Get Started →](installation.md){ .md-button .md-button--primary } 209 | [View Examples →](examples.md){ .md-button } 210 |
211 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation Guide 2 | 3 |

4 | Django PDF Actions Logo 5 |

6 | 7 | ## Prerequisites 8 | 9 | Before installing Django PDF Export, ensure you have: 10 | 11 | - Python 3.8 or higher 12 | - Django 3.2 or higher 13 | - pip (Python package installer) 14 | 15 | ### Technical Requirements 16 | - **PDF Engine**: ReportLab (automatically installed) 17 | - **Character Encoding**: UTF-8 18 | - **Default Paper Size**: A4 19 | - **Disk Space**: Minimal (~30MB including fonts) 20 | - **Memory**: Depends on the size of exported data 21 | 22 | ## Installation Methods 23 | 24 | ### Using pip (Recommended) 25 | 26 | The easiest way to install Django PDF Export is using pip: 27 | 28 | ```bash 29 | pip install django-pdf-actions 30 | ``` 31 | 32 | ### From Source 33 | 34 | If you want to install the latest development version: 35 | 36 | ```bash 37 | git clone https://github.com/ibrahimroshdy/django-pdf-actions.git 38 | cd django-pdf-actions 39 | pip install -e . 40 | ``` 41 | 42 | ## Configuration 43 | 44 | ### 1. Add to INSTALLED_APPS 45 | 46 | Add 'django_pdf_actions' to your `INSTALLED_APPS` in `settings.py`: 47 | 48 | ```python 49 | INSTALLED_APPS = [ 50 | ... 51 | 'django_pdf_actions', 52 | ] 53 | ``` 54 | 55 | ### 2. Run Migrations 56 | 57 | Apply the database migrations: 58 | 59 | ```bash 60 | python manage.py migrate 61 | ``` 62 | 63 | ### 3. Set up Fonts 64 | 65 | The package uses fonts from your project's `static/assets/fonts` directory. Here's how to set them up: 66 | 67 | 1. Create the fonts directory: 68 | ```bash 69 | mkdir -p static/assets/fonts 70 | ``` 71 | 72 | 2. Install the default font (DejaVu Sans): 73 | ```bash 74 | python manage.py setup_fonts 75 | ``` 76 | 77 | 3. Add custom fonts (optional): 78 | ```bash 79 | # Example: Installing Roboto font 80 | python manage.py setup_fonts --font-url "https://github.com/google/fonts/raw/main/apache/roboto/Roboto-Regular.ttf" --font-name "Roboto-Regular.ttf" 81 | 82 | # Example: Installing Cairo font for Arabic support 83 | python manage.py setup_fonts --font-url "https://github.com/google/fonts/raw/main/ofl/cairo/Cairo-Regular.ttf" --font-name "Cairo-Regular.ttf" 84 | ``` 85 | 86 | ### Font Directory Structure 87 | 88 | After setup, your project should have this structure: 89 | ``` 90 | your_project/ 91 | ├── static/ 92 | │ └── assets/ 93 | │ └── fonts/ 94 | │ ├── DejaVuSans.ttf 95 | │ ├── Roboto-Regular.ttf (optional) 96 | │ └── Cairo-Regular.ttf (optional) 97 | ``` 98 | 99 | ### 4. Configure Static Files 100 | 101 | Ensure your Django project is configured to serve static files: 102 | 103 | 1. Add static files settings to your `settings.py`: 104 | ```python 105 | STATIC_URL = '/static/' 106 | STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') 107 | STATICFILES_DIRS = [ 108 | os.path.join(BASE_DIR, 'static'), 109 | ] 110 | ``` 111 | 112 | 2. Run collectstatic: 113 | ```bash 114 | python manage.py collectstatic 115 | ``` 116 | 117 | ## Verify Installation 118 | 119 | To verify the installation: 120 | 121 | 1. Start your Django development server: 122 | ```bash 123 | python manage.py runserver 124 | ``` 125 | 126 | 2. Navigate to the Django admin interface 127 | 3. Select any model with list view 128 | 4. You should see "Export to PDF (Portrait)" and "Export to PDF (Landscape)" in the actions dropdown 129 | 130 | ### Troubleshooting 131 | 132 | If you encounter issues during installation: 133 | 134 | 1. Font Installation Issues: 135 | - Ensure your fonts directory exists at `static/assets/fonts/` 136 | - Verify font files are in TTF format 137 | - Check file permissions 138 | - Run `python manage.py collectstatic` after adding fonts 139 | 140 | 2. PDF Generation Issues: 141 | - Ensure your model fields are properly defined in list_display 142 | - Check that an active PDF Export Settings configuration exists 143 | - Verify logo file paths if using custom logos 144 | 145 | 3. Database Issues: 146 | - Ensure migrations are applied correctly 147 | - Check database permissions 148 | - Verify Django database settings 149 | 150 | 4. Static Files Issues: 151 | - Verify STATIC_URL and STATIC_ROOT settings 152 | - Ensure collectstatic command was run 153 | - Check web server configuration for static files 154 | 155 | ## Next Steps 156 | 157 | - Check out the [Quick Start Guide](quickstart.md) to begin using the package 158 | - Configure your [PDF Export Settings](settings.md) 159 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quick Start Guide 2 | 3 |

4 | Django PDF Actions Logo 5 |

6 | 7 | This guide will help you get started with Django PDF Export quickly. 8 | 9 | ## Basic Setup 10 | 11 | ### 1. Install the Package 12 | 13 | ```bash 14 | pip install django-pdf-actions 15 | ``` 16 | 17 | ### 2. Update INSTALLED_APPS 18 | 19 | Add to your Django settings: 20 | 21 | ```python 22 | INSTALLED_APPS = [ 23 | ... 24 | 'django_pdf_actions', 25 | ] 26 | ``` 27 | 28 | ### 3. Run Migrations 29 | 30 | ```bash 31 | python manage.py migrate 32 | ``` 33 | 34 | ## Adding Export Actions 35 | 36 | ### 1. Import Actions 37 | 38 | In your `admin.py`: 39 | 40 | ```python 41 | from django.contrib import admin 42 | from django_pdf_actions.actions import export_to_pdf_landscape, export_to_pdf_portrait 43 | ``` 44 | 45 | ### 2. Add to ModelAdmin 46 | 47 | ```python 48 | @admin.register(YourModel) 49 | class YourModelAdmin(admin.ModelAdmin): 50 | list_display = ('id', 'name', 'created_at') # Your fields 51 | actions = [export_to_pdf_landscape, export_to_pdf_portrait] 52 | ``` 53 | 54 | ## Basic Usage 55 | 56 | 1. Go to your model's list view in Django admin 57 | 2. Select the records you want to export 58 | 3. Choose either: 59 | - "Export to PDF (Portrait)" 60 | - "Export to PDF (Landscape)" 61 | 4. Click "Go" 62 | 5. Your PDF will download automatically 63 | 64 | ## Example Configuration 65 | 66 | ### 1. Create Export Settings 67 | 68 | 1. Go to Admin > Django PDF > Export PDF Settings 69 | 2. Click "Add Export PDF Settings" 70 | 3. Configure basic settings: 71 | ```python 72 | { 73 | 'title': 'My Export Settings', 74 | 'header_font_size': 12, 75 | 'body_font_size': 8, 76 | 'page_margin_mm': 20, 77 | 'items_per_page': 15 78 | } 79 | ``` 80 | 4. Mark as active 81 | 5. Save 82 | 83 | ### 2. Test the Export 84 | 85 | 1. Go to your model's admin list view 86 | 2. Select a few records 87 | 3. Try both portrait and landscape exports 88 | 89 | ## Next Steps 90 | 91 | - Configure [Custom Settings](settings.md) 92 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # Documentation Requirements for Django PDF Actions 2 | # Simple and essential packages only 3 | 4 | # Core documentation tools 5 | mkdocs>=1.4.0 6 | mkdocs-material>=9.5.0 7 | 8 | # Essential plugins 9 | mkdocs-mermaid2-plugin>=0.6.0 10 | 11 | # Markdown extensions (included with pymdown-extensions) 12 | pymdown-extensions>=10.0.0 13 | 14 | # Network utilities 15 | urllib3>=1.26.14 -------------------------------------------------------------------------------- /docs/settings.md: -------------------------------------------------------------------------------- 1 | # Settings Reference 2 | 3 |

4 | Django PDF Actions Logo 5 |

6 | 7 | ## Configuration Guide 8 | 9 | ## PDF Export Settings 10 | 11 | ### Page Layout Settings 12 | 13 | #### Page Size 14 | - **Description**: Select the size of the PDF pages 15 | - **Default**: A4 16 | - **Options**: 17 | - A4 (210mm × 297mm) 18 | - A3 (297mm × 420mm) 19 | - A2 (420mm × 594mm) 20 | - A1 (594mm × 841mm) 21 | - **Impact**: Affects the available space for content, number of rows per page, and overall document dimensions 22 | 23 | #### Items Per Page 24 | - **Description**: Number of items to display per page 25 | - **Default**: 10 26 | - **Range**: 1-50 27 | - **Note**: The actual number of items that fit may vary based on content and selected page size 28 | 29 | Django PDF Export provides a comprehensive set of settings that can be configured through the Django admin interface. 30 | 31 | ## Page Layout Settings 32 | 33 | | Setting | Description | Default | Range | 34 | |---------|-------------|---------|--------| 35 | | `items_per_page` | Number of rows per page | 10 | 1-50 | 36 | | `page_margin_mm` | Page margins in millimeters | 15 | 5-50 | 37 | | `header_height_mm` | Header height in millimeters | 20 | 10-50 | 38 | | `footer_height_mm` | Footer height in millimeters | 15 | 10-50 | 39 | 40 | ## Font Settings 41 | 42 | | Setting | Description | Default | Range | 43 | |---------|-------------|---------|--------| 44 | | `font_name` | TTF font file name | DejaVuSans.ttf | Any TTF | 45 | | `header_font_size` | Header text size | 10 | 6-24 | 46 | | `body_font_size` | Body text size | 7 | 6-18 | 47 | 48 | ## Table Settings 49 | 50 | | Setting | Description | Default | Range | 51 | |---------|-------------|---------|--------| 52 | | `table_spacing` | Cell padding in millimeters | 1.5 | 0.5-5.0 | 53 | | `table_line_height` | Row height multiplier | 1.2 | 1.0-2.0 | 54 | | `table_header_height` | Header row height | 30 | 20-50 | 55 | | `max_chars_per_line` | Maximum characters per line | 50 | 30-100 | 56 | 57 | ## Visual Settings 58 | 59 | | Setting | Description | Default | 60 | |---------|-------------|---------| 61 | | `header_background_color` | Header background color | #F0F0F0 | 62 | | `grid_line_color` | Table grid line color | #000000 | 63 | | `grid_line_width` | Grid line thickness | 0.25 | 64 | 65 | ## Display Options 66 | 67 | | Setting | Description | Default | 68 | |---------|-------------|---------| 69 | | `show_header` | Display page header | True | 70 | | `show_logo` | Display company logo | True | 71 | | `show_export_time` | Show export timestamp | True | 72 | | `show_page_numbers` | Show page numbers | True | 73 | | `rtl_support` | Enable right-to-left text support | False | 74 | 75 | ## Text Alignment Options 76 | 77 | | Setting | Description | Default | Options | 78 | |---------|-------------|---------|---------| 79 | | `title_alignment` | Alignment for document title | CENTER | LEFT, CENTER, RIGHT | 80 | | `header_alignment` | Alignment for table headers | CENTER | LEFT, CENTER, RIGHT | 81 | | `content_alignment` | Alignment for table content | CENTER | LEFT, CENTER, RIGHT | 82 | 83 | ## RTL Support 84 | 85 | Django PDF Actions provides comprehensive support for right-to-left languages like Arabic and Persian. 86 | 87 | ### Features: 88 | - Automatic text reshaping for proper RTL script display 89 | - Reversal of column ordering to match RTL reading direction 90 | - Proper bidirectional text handling 91 | - RTL-appropriate alignment options 92 | 93 | To enable RTL support: 94 | 1. Navigate to Admin > Django PDF > Export PDF Settings 95 | 2. Enable the "RTL Support" option 96 | 3. Select appropriate alignment options for your content 97 | 4. Use fonts that support your target language (e.g., Cairo for Arabic) 98 | 99 | ## Configuration Example 100 | 101 | Here's an example of how to configure the settings through the Django admin interface: 102 | 103 | 1. Navigate to Admin > Django PDF > Export PDF Settings 104 | 2. Click "Add Export PDF Settings" 105 | 3. Configure your settings: 106 | ```python 107 | { 108 | 'title': 'Default Export Settings', 109 | 'active': True, 110 | 'header_font_size': 12, 111 | 'body_font_size': 8, 112 | 'page_margin_mm': 20, 113 | 'items_per_page': 15, 114 | 'table_spacing': 2.0, 115 | 'show_logo': True, 116 | 'show_page_numbers': True 117 | } 118 | ``` 119 | 4. Save your settings 120 | 121 | ## Notes 122 | 123 | - Only one configuration can be active at a time 124 | - Changes take effect immediately 125 | - Font files must be in the correct directory 126 | - Colors should be in hexadecimal format (#RRGGBB) 127 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Basic Usage Guide 2 | 3 | Learn how to effectively use Django PDF Actions in your projects with practical examples and best practices. 4 | 5 | ## 🎯 Getting Started 6 | 7 | ### Step-by-Step Integration 8 | 9 | ```mermaid 10 | graph TD 11 | A[Install Package] --> B[Configure Settings] 12 | B --> C[Update Admin] 13 | C --> D[Test Export] 14 | D --> E[Customize Settings] 15 | 16 | style A fill:#e8f5e8 17 | style E fill:#fff3e0 18 | ``` 19 | 20 | ### 1. Basic Model Setup 21 | 22 | Let's start with a simple Employee model: 23 | 24 | ```python 25 | # models.py 26 | from django.db import models 27 | 28 | class Employee(models.Model): 29 | first_name = models.CharField(max_length=50) 30 | last_name = models.CharField(max_length=50) 31 | email = models.EmailField() 32 | department = models.CharField(max_length=100) 33 | hire_date = models.DateField() 34 | salary = models.DecimalField(max_digits=10, decimal_places=2) 35 | 36 | def __str__(self): 37 | return f"{self.first_name} {self.last_name}" 38 | ``` 39 | 40 | ### 2. Admin Configuration 41 | 42 | ```python 43 | # admin.py 44 | from django.contrib import admin 45 | from django_pdf_actions.actions import export_to_pdf_landscape, export_to_pdf_portrait 46 | from .models import Employee 47 | 48 | @admin.register(Employee) 49 | class EmployeeAdmin(admin.ModelAdmin): 50 | list_display = ( 51 | 'first_name', 'last_name', 'email', 52 | 'department', 'hire_date', 'salary' 53 | ) 54 | list_filter = ('department', 'hire_date') 55 | search_fields = ('first_name', 'last_name', 'email') 56 | 57 | # Add PDF export actions 58 | actions = [export_to_pdf_landscape, export_to_pdf_portrait] 59 | ``` 60 | 61 | !!! tip "Pro Tip" 62 | Always include the fields you want in your PDF in the `list_display` tuple! 63 | 64 | ## 🚀 Basic Export Process 65 | 66 | ### Admin Interface Workflow 67 | 68 | ```mermaid 69 | sequenceDiagram 70 | participant User as Admin User 71 | participant List as List View 72 | participant Action as Action Menu 73 | participant PDF as PDF Generator 74 | participant Download as File Download 75 | 76 | User->>List: Navigate to model list 77 | User->>List: Select records (optional) 78 | User->>Action: Choose PDF export action 79 | Action->>PDF: Process selected data 80 | PDF->>PDF: Apply settings & formatting 81 | PDF->>Download: Generate and serve file 82 | Download->>User: Download PDF 83 | ``` 84 | 85 | ### Export Options 86 | 87 | === "🖼️ Landscape Export" 88 | 89 | **Best for:** Wide tables with many columns 90 | 91 | ```python 92 | actions = [export_to_pdf_landscape] 93 | ``` 94 | 95 | - **Page Size:** A4 Landscape (297mm × 210mm) 96 | - **Columns:** More space for wider tables 97 | - **Use Case:** Reports with many fields 98 | 99 | === "📄 Portrait Export" 100 | 101 | **Best for:** Standard documents with fewer columns 102 | 103 | ```python 104 | actions = [export_to_pdf_portrait] 105 | ``` 106 | 107 | - **Page Size:** A4 Portrait (210mm × 297mm) 108 | - **Rows:** More rows per page 109 | - **Use Case:** Standard lists and catalogs 110 | 111 | ## 📊 Advanced Usage Examples 112 | 113 | ### Custom Admin Methods 114 | 115 | You can include custom admin methods in your PDF exports: 116 | 117 | ```python 118 | @admin.register(Employee) 119 | class EmployeeAdmin(admin.ModelAdmin): 120 | list_display = ( 121 | 'full_name', 'email', 'department_info', 122 | 'years_employed', 'salary_range' 123 | ) 124 | 125 | def full_name(self, obj): 126 | """Combine first and last name""" 127 | return f"{obj.first_name} {obj.last_name}" 128 | full_name.short_description = 'Full Name' 129 | full_name.admin_order_field = 'last_name' 130 | 131 | def department_info(self, obj): 132 | """Add department with location""" 133 | return f"{obj.department} (HQ)" 134 | department_info.short_description = 'Department' 135 | 136 | def years_employed(self, obj): 137 | """Calculate years of employment""" 138 | from datetime import date 139 | years = (date.today() - obj.hire_date).days // 365 140 | return f"{years} years" 141 | years_employed.short_description = 'Experience' 142 | 143 | def salary_range(self, obj): 144 | """Categorize salary range""" 145 | if obj.salary < 50000: 146 | return "Entry Level" 147 | elif obj.salary < 80000: 148 | return "Mid Level" 149 | else: 150 | return "Senior Level" 151 | salary_range.short_description = 'Level' 152 | 153 | actions = [export_to_pdf_landscape, export_to_pdf_portrait] 154 | ``` 155 | 156 | !!! success "Custom Methods Work!" 157 | All your custom admin methods will appear in the PDF export automatically! 158 | 159 | ### Multi-Language Content 160 | 161 | For international applications with Arabic/RTL support: 162 | 163 | ```python 164 | class Product(models.Model): 165 | name_en = models.CharField(max_length=100, verbose_name="Product Name") 166 | name_ar = models.CharField(max_length=100, verbose_name="اسم المنتج") 167 | price = models.DecimalField(max_digits=10, decimal_places=2) 168 | category = models.CharField(max_length=50) 169 | 170 | @admin.register(Product) 171 | class ProductAdmin(admin.ModelAdmin): 172 | list_display = ('name_en', 'name_ar', 'get_price_formatted', 'category') 173 | 174 | def get_price_formatted(self, obj): 175 | return f"${obj.price:.2f}" 176 | get_price_formatted.short_description = 'السعر' # Arabic: Price 177 | 178 | actions = [export_to_pdf_landscape, export_to_pdf_portrait] 179 | ``` 180 | 181 | ## 🎨 Customization Examples 182 | 183 | ### Basic Settings Configuration 184 | 185 | Create your PDF export settings through the Django admin: 186 | 187 | 1. Navigate to **Admin > Django PDF Actions > Export PDF Settings** 188 | 2. Click **"Add Export PDF Settings"** 189 | 3. Configure your settings: 190 | 191 | ```python 192 | # Example settings (configured via admin interface) 193 | { 194 | 'title': 'Employee Report', 195 | 'header_font_size': 12, 196 | 'body_font_size': 9, 197 | 'page_margin_mm': 20, 198 | 'items_per_page': 25, 199 | 'show_logo': True, 200 | 'rtl_support': False, # Set to True for Arabic/RTL languages 201 | 'header_background_color': '#4A90E2', 202 | 'grid_line_color': '#CCCCCC' 203 | } 204 | ``` 205 | 206 | ### Export Flow Visualization 207 | 208 | ```mermaid 209 | graph TD 210 | A[Django Model Data] --> B{Export Type} 211 | B -->|Portrait| C[Portrait Layout] 212 | B -->|Landscape| D[Landscape Layout] 213 | C --> E[Apply Settings] 214 | D --> E 215 | E --> F[Generate PDF] 216 | F --> G[Download File] 217 | 218 | H[Custom Admin Methods] --> A 219 | I[Filter/Search Results] --> A 220 | J[Selected Records] --> A 221 | 222 | style A fill:#e8f5e8 223 | style G fill:#fff3e0 224 | ``` 225 | 226 | ## 📋 Common Use Cases 227 | 228 | ### 1. Employee Directory 229 | 230 | ```python 231 | class EmployeeAdmin(admin.ModelAdmin): 232 | list_display = ( 233 | 'employee_id', 'full_name', 'department', 234 | 'email', 'phone', 'hire_date', 'status' 235 | ) 236 | list_filter = ('department', 'status', 'hire_date') 237 | actions = [export_to_pdf_portrait] # Portrait for employee lists 238 | ``` 239 | 240 | ### 2. Sales Report 241 | 242 | ```python 243 | class SaleAdmin(admin.ModelAdmin): 244 | list_display = ( 245 | 'sale_date', 'customer_name', 'product', 246 | 'quantity', 'unit_price', 'total_amount', 'commission' 247 | ) 248 | actions = [export_to_pdf_landscape] # Landscape for financial data 249 | ``` 250 | 251 | ### 3. Inventory Report 252 | 253 | ```python 254 | class ProductAdmin(admin.ModelAdmin): 255 | list_display = ( 256 | 'sku', 'name', 'category', 'stock_quantity', 257 | 'reorder_level', 'supplier', 'last_updated' 258 | ) 259 | actions = [export_to_pdf_landscape] 260 | ``` 261 | 262 | ## 🔧 Performance Optimization 263 | 264 | ### Large Datasets 265 | 266 | ```mermaid 267 | graph LR 268 | A[Large Dataset] --> B{Records Count} 269 | B -->|< 1000| C[Direct Export] 270 | B -->|> 1000| D[Paginated Export] 271 | D --> E[Chunked Processing] 272 | E --> F[Memory Optimization] 273 | F --> G[Efficient PDF] 274 | 275 | style A fill:#ffebee 276 | style G fill:#e8f5e8 277 | ``` 278 | 279 | ### Best Practices for Performance 280 | 281 | !!! tip "Optimization Tips" 282 | 283 | === "📊 Data Selection" 284 | - Use `list_filter` to reduce dataset size 285 | - Implement search functionality 286 | - Select only necessary records 287 | 288 | === "🎯 Field Selection" 289 | - Include only essential fields in `list_display` 290 | - Avoid expensive database queries in custom methods 291 | - Use `select_related()` for foreign key fields 292 | 293 | === "⚙️ Settings Tuning" 294 | - Adjust `items_per_page` based on content 295 | - Use appropriate font sizes 296 | - Optimize page margins 297 | 298 | ### Memory-Efficient Custom Methods 299 | 300 | ```python 301 | def get_related_data(self, obj): 302 | """Efficient way to get related data""" 303 | # Good: Uses select_related 304 | return obj.department.name if obj.department else "N/A" 305 | 306 | def get_expensive_calculation(self, obj): 307 | """Avoid this in large exports""" 308 | # Bad: Expensive calculation in every row 309 | return SomeModel.objects.filter(related_field=obj).count() 310 | ``` 311 | 312 | ## ❌ Common Mistakes to Avoid 313 | 314 | !!! warning "Avoid These Issues" 315 | 316 | 1. **Missing list_display**: Custom methods not in `list_display` won't appear 317 | 2. **Database queries in methods**: Can cause N+1 query problems 318 | 3. **Large images**: Heavy logos can slow down generation 319 | 4. **Wrong orientation**: Use landscape for wide tables, portrait for tall lists 320 | 5. **Missing RTL settings**: Enable RTL support for Arabic/Persian content 321 | 322 | ## 🎉 Next Steps 323 | 324 | Now that you understand the basics: 325 | 326 | 1. [Configure Advanced Settings →](settings.md) 327 | 2. [Explore Real-world Examples →](examples.md) 328 | 3. [Learn Custom Admin Methods →](custom-methods.md) 329 | 330 | --- 331 | 332 | !!! success "You're Ready!" 333 | You now have everything you need to create professional PDF exports from your Django admin interface! -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # mkdocs.yml - Simple Documentation Configuration 2 | 3 | # Site Information 4 | site_name: Django PDF Actions 5 | site_description: A powerful Django application that adds PDF export capabilities to your Django admin interface. 6 | site_author: Ibrahim Roshdy 7 | site_url: https://ibrahimroshdy.github.io/django-pdf-actions/ 8 | 9 | # Copyright Information 10 | copyright: Copyright © 2025 Ibrahim Roshdy 11 | 12 | # Repository Information 13 | repo_url: https://github.com/ibrahimroshdy/django-pdf-actions 14 | repo_name: ibrahimroshdy/django-pdf-actions 15 | 16 | # Plugins Configuration 17 | plugins: 18 | - search 19 | - mermaid2 20 | 21 | # Theme Configuration 22 | theme: 23 | name: material 24 | icon: 25 | repo: fontawesome/brands/github 26 | features: 27 | - content.code.copy 28 | - navigation.footer 29 | - navigation.sections 30 | - search.highlight 31 | - search.suggest 32 | palette: 33 | - media: "(prefers-color-scheme: light)" 34 | scheme: default 35 | primary: indigo 36 | accent: indigo 37 | toggle: 38 | icon: material/weather-sunny 39 | name: Switch to dark mode 40 | - media: "(prefers-color-scheme: dark)" 41 | scheme: slate 42 | primary: indigo 43 | accent: indigo 44 | toggle: 45 | icon: material/weather-night 46 | name: Switch to light mode 47 | 48 | # Navigation Configuration 49 | nav: 50 | - Home: index.md 51 | - Getting Started: 52 | - Installation: installation.md 53 | - Quick Start: quickstart.md 54 | - Basic Usage: usage.md 55 | - Configuration: 56 | - Settings: settings.md 57 | - Examples: 58 | - Real-world Examples: examples.md 59 | - Custom Admin Methods: custom-methods.md 60 | 61 | # Markdown Extensions 62 | markdown_extensions: 63 | - admonition 64 | - attr_list 65 | - def_list 66 | - footnotes 67 | - md_in_html 68 | - tables 69 | - pymdownx.details 70 | - pymdownx.highlight: 71 | anchor_linenums: true 72 | - pymdownx.inlinehilite 73 | - pymdownx.snippets 74 | - pymdownx.superfences: 75 | custom_fences: 76 | - name: mermaid 77 | class: mermaid 78 | format: !!python/name:pymdownx.superfences.fence_code_format 79 | - pymdownx.tabbed: 80 | alternate_style: true 81 | - toc: 82 | permalink: true 83 | -------------------------------------------------------------------------------- /module.py: -------------------------------------------------------------------------------- 1 | # Import necessary modules 2 | import os 3 | 4 | import django 5 | from django.db import transaction 6 | from faker import Faker 7 | 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dj_actions_pdf.settings') 9 | django.setup() 10 | from .django_pdf_actions.models import ExportPDFSettings 11 | 12 | # Create a Faker instance 13 | fake = Faker() 14 | 15 | 16 | # Define a function to create fake export PDF settings 17 | def create_fake_export_pdf_settings(): 18 | title = fake.word() 19 | active = fake.boolean() 20 | dummy = fake.word() 21 | dummy1 = fake.word() 22 | dummy2 = fake.word() 23 | dummy3 = fake.word() 24 | dummy4 = fake.word() 25 | dummy5 = fake.word() 26 | dummy6 = fake.word() 27 | dummy7 = fake.word() 28 | dummy8 = fake.word() 29 | dummy9 = fake.word() 30 | # Create a fake image file 31 | image_path = os.path.join(os.path.dirname(__file__), 'media/export_pdf/logo.png') 32 | 33 | export_pdf_settings = ExportPDFSettings.objects.create( 34 | title=title, 35 | active=active, 36 | dummy=dummy, 37 | dummy1=dummy1, 38 | dummy2=dummy2, 39 | dummy3=dummy3, 40 | dummy4=dummy4, 41 | dummy5=dummy5, 42 | dummy6=dummy6, 43 | dummy7=dummy7, 44 | dummy8=dummy8, 45 | dummy9=dummy9 46 | 47 | ) 48 | return export_pdf_settings 49 | 50 | 51 | # Define a function to create multiple samples of export PDF settings 52 | @transaction.atomic 53 | def create_fake_export_pdf_settings_samples(num_samples=50): 54 | for _ in range(num_samples): 55 | create_fake_export_pdf_settings() 56 | 57 | 58 | # Call the function to create 50 samples 59 | create_fake_export_pdf_settings_samples() 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-pdf-actions" 3 | version = "0.1.52" 4 | description = "A Django app to export PDFs from admin actions" 5 | authors = [ 6 | "Ibrahim Roshdy ", 7 | ] 8 | license = "MIT" 9 | readme = "README.md" 10 | homepage = "https://github.com/ibrahimroshdy/django-pdf-actions" 11 | repository = "https://github.com/ibrahimroshdy/django-pdf-actions" 12 | documentation = "https://ibrahimroshdy.github.io/django-pdf-actions" 13 | keywords = [ 14 | "django", 15 | "pdf", 16 | "admin", 17 | "export", 18 | ] 19 | classifiers = [ 20 | "Development Status :: 5 - Production/Stable", 21 | "Environment :: Web Environment", 22 | "Framework :: Django", 23 | "Framework :: Django :: 3.2", 24 | "Framework :: Django :: 4.0", 25 | "Framework :: Django :: 4.1", 26 | "Framework :: Django :: 4.2", 27 | "Framework :: Django :: 5.0", 28 | "Intended Audience :: Developers", 29 | "License :: OSI Approved :: MIT License", 30 | "Operating System :: OS Independent", 31 | "Programming Language :: Python :: 3", 32 | "Programming Language :: Python :: 3.8", 33 | "Programming Language :: Python :: 3.9", 34 | "Programming Language :: Python :: 3.10", 35 | "Programming Language :: Python :: 3.11", 36 | "Programming Language :: Python :: 3.12", 37 | "Topic :: Software Development :: Libraries :: Python Modules", 38 | ] 39 | packages = [ 40 | { include = "django_pdf_actions" }, 41 | ] 42 | 43 | [tool.poetry.dependencies] 44 | python = ">=3.8" 45 | Django = ">=3.2" 46 | reportlab = "^4.0.4" 47 | arabic-reshaper = "^3.0.0" 48 | python-bidi = ">=0.4.0" 49 | django-model-utils = ">=4.0.0" 50 | 51 | [tool.poetry.group.dev.dependencies] 52 | pre-commit = "^3.5.0" 53 | tomli = "^2.0.1" 54 | 55 | [tool.poetry.group.docs] 56 | optional = true 57 | 58 | [tool.poetry.group.docs.dependencies] 59 | mkdocs = "^1.5.0" 60 | mkdocs-material = "^9.0.0" 61 | 62 | [tool.poetry.group.docs.dependencies.mkdocstrings] 63 | extras = [ 64 | "python", 65 | ] 66 | version = "^0.24.0" 67 | 68 | [build-system] 69 | requires = [ 70 | "poetry-core", 71 | ] 72 | build-backend = "poetry.core.masonry.api" 73 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings 3 | python_files = tests.py test_*.py *_tests.py 4 | addopts = --reuse-db --ds=tests.settings 5 | testpaths = tests -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-pdf-export 3 | version = 0.1.0 4 | description = Export Django model data to beautifully formatted PDF documents 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | author = Ibrahim Roshdy 8 | author_email = your.email@example.com 9 | url = https://github.com/ibrahimroshdy/django-pdf-export 10 | project_urls = 11 | Documentation = https://django-pdf-export.readthedocs.io/ 12 | Bug Tracker = https://github.com/ibrahimroshdy/django-pdf-export/issues 13 | license = MIT 14 | classifiers = 15 | Development Status :: 5 - Production/Stable 16 | Environment :: Web Environment 17 | Framework :: Django 18 | Framework :: Django :: 3.2 19 | Framework :: Django :: 4.0 20 | Framework :: Django :: 4.1 21 | Framework :: Django :: 4.2 22 | Intended Audience :: Developers 23 | License :: OSI Approved :: MIT License 24 | Operating System :: OS Independent 25 | Programming Language :: Python 26 | Programming Language :: Python :: 3 27 | Programming Language :: Python :: 3.8 28 | Programming Language :: Python :: 3.9 29 | Programming Language :: Python :: 3.10 30 | Programming Language :: Python :: 3.11 31 | Topic :: Internet :: WWW/HTTP 32 | Topic :: Internet :: WWW/HTTP :: Dynamic Content 33 | Topic :: Software Development :: Libraries :: Python Modules 34 | 35 | [options] 36 | packages = find: 37 | python_requires = >=3.8 38 | install_requires = 39 | Django>=3.2 40 | reportlab>=3.6.12 41 | xhtml2pdf>=0.2.11 42 | arabic-reshaper>=3.0.0 43 | python-bidi>=0.4.2 44 | django-model-utils>=4.3.1 45 | 46 | [options.packages.find] 47 | exclude = 48 | tests* 49 | docs* 50 | 51 | [options.package_data] 52 | django_pdf_actions = 53 | static/django_pdf_actions/fonts/*.ttf 54 | static/django_pdf_actions/images/*.png 55 | templates/django_pdf_actions/*.html -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | if __name__ == "__main__": 6 | setup() 7 | -------------------------------------------------------------------------------- /templates/django_pdf/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibrahimroshdy/django-pdf-actions/e15a313306b1659529e75c4ebe779fa3d0be47b9/templates/django_pdf/.gitkeep -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibrahimroshdy/django-pdf-actions/e15a313306b1659529e75c4ebe779fa3d0be47b9/tests/.gitkeep -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test suite for django-pdf-export.""" -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | """Django settings for running tests.""" 2 | 3 | import os 4 | from pathlib import Path 5 | 6 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 7 | BASE_DIR = Path(__file__).resolve().parent.parent 8 | 9 | # SECURITY WARNING: keep the secret key used in production secret! 10 | SECRET_KEY = 'django-insecure-test-key-not-for-production' 11 | 12 | # SECURITY WARNING: don't run with debug turned on in production! 13 | DEBUG = True 14 | 15 | ALLOWED_HOSTS = [] 16 | 17 | # Application definition 18 | INSTALLED_APPS = [ 19 | 'django.contrib.admin', 20 | 'django.contrib.auth', 21 | 'django.contrib.contenttypes', 22 | 'django.contrib.sessions', 23 | 'django.contrib.messages', 24 | 'django.contrib.staticfiles', 25 | 'model_utils', 26 | 'django_pdf_actions.apps.ExportPDFConfig', 27 | ] 28 | 29 | MIDDLEWARE = [ 30 | 'django.middleware.security.SecurityMiddleware', 31 | 'django.contrib.sessions.middleware.SessionMiddleware', 32 | 'django.middleware.common.CommonMiddleware', 33 | 'django.middleware.csrf.CsrfViewMiddleware', 34 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 35 | 'django.contrib.messages.middleware.MessageMiddleware', 36 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 37 | ] 38 | 39 | ROOT_URLCONF = 'tests.urls' 40 | 41 | TEMPLATES = [ 42 | { 43 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 44 | 'DIRS': [], 45 | 'APP_DIRS': True, 46 | 'OPTIONS': { 47 | 'context_processors': [ 48 | 'django.template.context_processors.debug', 49 | 'django.template.context_processors.request', 50 | 'django.contrib.auth.context_processors.auth', 51 | 'django.contrib.messages.context_processors.messages', 52 | ], 53 | }, 54 | }, 55 | ] 56 | 57 | # Database 58 | DATABASES = { 59 | 'default': { 60 | 'ENGINE': 'django.db.backends.sqlite3', 61 | 'NAME': ':memory:', # Use in-memory database for tests 62 | } 63 | } 64 | 65 | # Password validation 66 | AUTH_PASSWORD_VALIDATORS = [] 67 | 68 | # Internationalization 69 | LANGUAGE_CODE = 'en-us' 70 | TIME_ZONE = 'UTC' 71 | USE_I18N = True 72 | USE_TZ = True # Required for Django 5.0 compatibility 73 | 74 | # Static files (CSS, JavaScript, Images) 75 | STATIC_URL = '/static/' 76 | STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') 77 | STATICFILES_DIRS = [ 78 | os.path.join(BASE_DIR, 'static'), 79 | ] 80 | 81 | # Media files 82 | MEDIA_URL = '/media/' 83 | MEDIA_ROOT = os.path.join(BASE_DIR, 'media') 84 | 85 | # Default primary key field type 86 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -------------------------------------------------------------------------------- /tests/test_actions.py: -------------------------------------------------------------------------------- 1 | """Tests for PDF export actions.""" 2 | 3 | from unittest.mock import patch 4 | from django.test import SimpleTestCase, RequestFactory 5 | from django.http import HttpResponse 6 | from django.contrib.auth.models import AnonymousUser 7 | from reportlab.lib.pagesizes import A4, landscape 8 | from django_pdf_actions.actions import export_to_pdf_landscape, export_to_pdf_portrait 9 | from django_pdf_actions.models import ExportPDFSettings 10 | from .utils import MockModel, MockQuerySet, MockModelAdmin 11 | 12 | 13 | class PDFExportActionsTest(SimpleTestCase): 14 | """Test cases for PDF export actions.""" 15 | 16 | # Allow database access for these tests 17 | databases = ['default'] 18 | 19 | def setUp(self): 20 | """Set up test environment.""" 21 | self.factory = RequestFactory() 22 | self.user = AnonymousUser() 23 | 24 | # Create mock objects 25 | self.mock_obj1 = MockModel(id=1, name='Test User 1', email='test1@example.com') 26 | self.mock_obj2 = MockModel(id=2, name='Test User 2', email='test2@example.com') 27 | self.queryset = MockQuerySet([self.mock_obj1, self.mock_obj2]) 28 | 29 | # Initialize ModelAdmin with proper site 30 | from django.contrib.admin.sites import AdminSite 31 | self.modeladmin = MockModelAdmin(MockModel, AdminSite()) 32 | 33 | # Create test settings with all required attributes 34 | self.settings = type('MockSettings', (), { 35 | 'title': 'Test Settings', 36 | 'active': True, 37 | 'header_font_size': 12, 38 | 'body_font_size': 10, 39 | 'page_margin_mm': 15, 40 | 'items_per_page': 10, 41 | 'header_background_color': '#F0F0F0', 42 | 'grid_line_color': '#000000', 43 | 'grid_line_width': 0.25, 44 | 'font_name': 'DejaVuSans', 45 | 'logo': None, 46 | 'show_logo': True, 47 | 'show_header': True, 48 | 'show_export_time': True, 49 | 'show_page_numbers': True, 50 | 'rtl_support': False, 51 | 'max_chars_per_line': 50, 52 | 'table_spacing': 1.5, 53 | 'header_height_mm': 20, 54 | 'footer_height_mm': 15, 55 | 'table_line_height': 1.2, 56 | 'table_header_height': 30, 57 | 'page_size': 'A4' 58 | })() 59 | 60 | @patch('django_pdf_actions.actions.landscape.get_active_settings') 61 | def test_landscape_export(self, mock_get_settings): 62 | """Test landscape PDF export.""" 63 | mock_get_settings.return_value = self.settings 64 | request = self.factory.get('/admin') 65 | request.user = self.user 66 | 67 | response = export_to_pdf_landscape( 68 | self.modeladmin, request, self.queryset 69 | ) 70 | 71 | self.assertIsInstance(response, HttpResponse) 72 | self.assertEqual(response['Content-Type'], 'application/pdf') 73 | self.assertTrue(response['Content-Disposition'].startswith( 74 | 'attachment; filename="MockModel_export_' 75 | )) 76 | 77 | @patch('django_pdf_actions.actions.portrait.get_active_settings') 78 | def test_portrait_export(self, mock_get_settings): 79 | """Test portrait PDF export.""" 80 | mock_get_settings.return_value = self.settings 81 | request = self.factory.get('/admin') 82 | request.user = self.user 83 | 84 | response = export_to_pdf_portrait( 85 | self.modeladmin, request, self.queryset 86 | ) 87 | 88 | self.assertIsInstance(response, HttpResponse) 89 | self.assertEqual(response['Content-Type'], 'application/pdf') 90 | self.assertTrue(response['Content-Disposition'].startswith( 91 | 'attachment; filename="MockModel_export_' 92 | )) 93 | 94 | @patch('django_pdf_actions.actions.landscape.get_active_settings') 95 | def test_export_with_no_settings(self, mock_get_settings): 96 | """Test PDF export with no active settings.""" 97 | mock_get_settings.return_value = None 98 | request = self.factory.get('/admin') 99 | request.user = self.user 100 | 101 | response = export_to_pdf_landscape( 102 | self.modeladmin, request, self.queryset 103 | ) 104 | 105 | self.assertIsInstance(response, HttpResponse) 106 | self.assertEqual(response['Content-Type'], 'application/pdf') -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | """Tests for PDF export models.""" 2 | 3 | from django.test import SimpleTestCase 4 | from django.core.exceptions import ValidationError 5 | from django_pdf_actions.models import ExportPDFSettings 6 | 7 | 8 | class ExportPDFSettingsValidationTest(SimpleTestCase): 9 | """Test cases for ExportPDFSettings model validation.""" 10 | 11 | def test_font_size_validation(self): 12 | """Test font size validation.""" 13 | settings = ExportPDFSettings( 14 | title='Invalid Font Size', 15 | header_font_size=5, # Too small 16 | body_font_size=20 # Too large 17 | ) 18 | with self.assertRaises(ValidationError) as cm: 19 | settings.clean_fields() 20 | settings.clean() 21 | 22 | errors = cm.exception.message_dict 23 | self.assertIn('header_font_size', errors) 24 | self.assertIn('body_font_size', errors) 25 | 26 | def test_page_margin_validation(self): 27 | """Test page margin validation.""" 28 | settings = ExportPDFSettings( 29 | title='Invalid Margin', 30 | page_margin_mm=3 # Too small 31 | ) 32 | with self.assertRaises(ValidationError) as cm: 33 | settings.clean_fields() 34 | settings.clean() 35 | 36 | errors = cm.exception.message_dict 37 | self.assertIn('page_margin_mm', errors) 38 | 39 | def test_string_representation(self): 40 | """Test the string representation of settings.""" 41 | settings = ExportPDFSettings( 42 | title='Test Settings', 43 | active=True 44 | ) 45 | self.assertEqual( 46 | str(settings), 47 | 'Test Settings (Active)' 48 | ) 49 | 50 | def test_default_values(self): 51 | """Test default values.""" 52 | settings = ExportPDFSettings( 53 | title='Default Settings' 54 | ) 55 | 56 | self.assertEqual(settings.header_font_size, 10) 57 | self.assertEqual(settings.body_font_size, 7) 58 | self.assertEqual(settings.page_margin_mm, 15) 59 | self.assertEqual(settings.items_per_page, 10) 60 | self.assertTrue(settings.show_header) 61 | self.assertTrue(settings.show_logo) 62 | self.assertTrue(settings.show_export_time) 63 | self.assertTrue(settings.show_page_numbers) -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for PDF export utility functions.""" 2 | 3 | from django.test import SimpleTestCase 4 | from reportlab.lib import colors 5 | from reportlab.platypus import TableStyle 6 | from django_pdf_actions.actions.utils import ( 7 | hex_to_rgb, setup_font, get_logo_path, 8 | create_table_style, create_header_style, 9 | calculate_column_widths 10 | ) 11 | 12 | 13 | class PDFUtilsTest(SimpleTestCase): 14 | """Test cases for PDF utilities.""" 15 | 16 | def setUp(self): 17 | """Set up test environment.""" 18 | self.settings = type('MockSettings', (), { 19 | 'title': 'Test Settings', 20 | 'active': True, 21 | 'header_font_size': 12, 22 | 'body_font_size': 10, 23 | 'page_margin_mm': 15, 24 | 'items_per_page': 10, 25 | 'header_background_color': '#F0F0F0', 26 | 'grid_line_color': '#000000', 27 | 'grid_line_width': 0.25, 28 | 'font_name': 'DejaVuSans', 29 | 'logo': None, 30 | 'show_logo': True, 31 | 'show_header': True, 32 | 'show_export_time': True, 33 | 'show_page_numbers': True, 34 | 'rtl_support': False, 35 | 'max_chars_per_line': 50, 36 | 'table_spacing': 1.5, 37 | 'header_height_mm': 20, 38 | 'footer_height_mm': 15, 39 | 'table_line_height': 1.2, 40 | 'table_header_height': 30 41 | })() 42 | 43 | def test_hex_to_rgb(self): 44 | """Test hex color to RGB conversion.""" 45 | # Test white 46 | self.assertEqual( 47 | hex_to_rgb('#FFFFFF'), 48 | (1.0, 1.0, 1.0) 49 | ) 50 | # Test black 51 | self.assertEqual( 52 | hex_to_rgb('#000000'), 53 | (0.0, 0.0, 0.0) 54 | ) 55 | # Test gray 56 | self.assertEqual( 57 | hex_to_rgb('#808080'), 58 | (0.5019607843137255, 0.5019607843137255, 0.5019607843137255) 59 | ) 60 | 61 | def test_setup_font(self): 62 | """Test font setup.""" 63 | font_name = setup_font(self.settings) 64 | self.assertIsInstance(font_name, str) 65 | self.assertTrue(len(font_name) > 0) 66 | 67 | def test_get_logo_path(self): 68 | """Test logo path retrieval.""" 69 | path = get_logo_path(self.settings) 70 | self.assertTrue('export_pdf/logo.png' in path) 71 | 72 | def test_create_table_style(self): 73 | """Test table style creation.""" 74 | style = create_table_style( 75 | self.settings, 76 | 'Helvetica', 77 | colors.lightgrey, 78 | colors.black 79 | ) 80 | self.assertIsInstance(style, TableStyle) 81 | self.assertTrue(hasattr(style, '_cmds')) 82 | self.assertTrue(len(style._cmds) > 0) 83 | 84 | def test_create_header_style(self): 85 | """Test header style creation.""" 86 | style = create_header_style(self.settings, 'Helvetica', True) 87 | self.assertEqual(style.fontSize, self.settings.header_font_size) 88 | self.assertEqual(style.fontName, 'Helvetica') 89 | 90 | def test_calculate_column_widths(self): 91 | """Test column width calculation.""" 92 | data = [ 93 | ['Short', 'Medium Column', 'Very Long Column Header'], 94 | ['Data1', 'Data2', 'Data3'], 95 | ] 96 | table_width = 500 97 | widths = calculate_column_widths( 98 | data, table_width, 'Helvetica', 10 99 | ) 100 | 101 | self.assertEqual(len(widths), 3) 102 | self.assertTrue(all(w > 0 for w in widths)) 103 | self.assertAlmostEqual(sum(widths), table_width, places=2) -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | """Test utilities.""" 2 | 3 | from django.contrib.admin import ModelAdmin 4 | from django.db import models 5 | 6 | 7 | class MockModel(models.Model): 8 | """Mock model for testing.""" 9 | name = models.CharField(max_length=100) 10 | email = models.EmailField() 11 | 12 | class Meta: 13 | app_label = 'django_pdf_actions' 14 | managed = False 15 | 16 | 17 | class MockQuerySet: 18 | """Mock queryset for testing.""" 19 | def __init__(self, items): 20 | self.items = items 21 | self.model = MockModel 22 | 23 | def __iter__(self): 24 | return iter(self.items) 25 | 26 | def __len__(self): 27 | return len(self.items) 28 | 29 | 30 | class MockModelAdmin(ModelAdmin): 31 | """Mock model admin for testing.""" 32 | list_display = ('id', 'name', 'email') 33 | model = MockModel --------------------------------------------------------------------------------