├── scripts ├── __init__.py └── releaser_hooks.py ├── docs ├── .gitignore ├── requirements.txt ├── pages │ ├── changelog_page.rst │ ├── usage.rst │ ├── customising │ │ ├── templates.rst │ │ ├── admin.rst │ │ ├── settings.rst │ │ ├── models.rst │ │ └── views.rst │ ├── contributing.rst │ └── installation.rst ├── index.rst ├── make.bat ├── Makefile └── conf.py ├── photologue ├── tests │ ├── __init__.py │ ├── templates │ │ └── base.html │ ├── test_urls.py │ ├── test_effect.py │ ├── helpers.py │ ├── test_photosize.py │ ├── test_sitemap.py │ ├── test_views_gallery.py │ ├── test_gallery.py │ ├── test_views_photo.py │ ├── factories.py │ ├── test_resize.py │ ├── test_sites.py │ └── test_zipupload.py ├── utils │ ├── __init__.py │ ├── watermark.py │ └── reflection.py ├── management │ ├── __init__.py │ └── commands │ │ ├── plcreatesize.py │ │ ├── plflush.py │ │ ├── plcache.py │ │ └── __init__.py ├── migrations │ ├── __init__.py │ ├── 0006_auto_20141028_2005.py │ ├── 0008_auto_20150509_1557.py │ ├── 0005_auto_20141027_1552.py │ ├── 0003_auto_20140822_1716.py │ ├── 0013_alter_watermark_image.py │ ├── 0009_auto_20160102_0904.py │ ├── 0012_alter_photo_effect.py │ ├── 0011_auto_20190223_2138.py │ ├── 0007_auto_20150404_1737.py │ ├── 0010_auto_20160105_1307.py │ ├── 0004_auto_20140915_1259.py │ └── 0002_photosize_data.py ├── templatetags │ ├── __init__.py │ └── photologue_tags.py ├── templates │ ├── photologue │ │ ├── root.html │ │ ├── tags │ │ │ ├── next_in_gallery.html │ │ │ └── prev_in_gallery.html │ │ ├── includes │ │ │ ├── gallery_sample.html │ │ │ └── paginator.html │ │ ├── gallery_list.html │ │ ├── photo_list.html │ │ ├── gallery_detail.html │ │ ├── gallery_archive.html │ │ ├── gallery_archive_day.html │ │ ├── photo_archive_day.html │ │ ├── photo_archive.html │ │ ├── gallery_archive_year.html │ │ ├── photo_detail.html │ │ ├── gallery_archive_month.html │ │ ├── photo_archive_year.html │ │ └── photo_archive_month.html │ └── admin │ │ └── photologue │ │ └── photo │ │ ├── change_list.html │ │ └── upload_zip.html ├── res │ ├── test_nonsense.jpg │ ├── sample.jpg │ ├── zips │ │ ├── sample.zip │ │ ├── not_image.zip │ │ └── ignored_files.zip │ ├── test_unicode_®.jpg │ ├── test_photologue_"ing.jpg │ ├── test_photologue_portrait.jpg │ ├── test_photologue_square.jpg │ └── test_photologue_landscape.jpg ├── __init__.py ├── locale │ ├── ca │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── cs │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── da │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── de │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── en │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── eu │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── fr │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── hu │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── it │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── nl │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── no │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── pl │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── pt │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ru │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── sk │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── tr │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── uk │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── en_US │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── es_ES │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── pt_BR │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── tr_TR │ │ └── LC_MESSAGES │ │ │ └── django.mo │ └── zh_Hans │ │ └── LC_MESSAGES │ │ └── django.mo ├── apps.py ├── managers.py ├── sitemaps.py ├── views.py ├── urls.py ├── forms.py └── admin.py ├── example_project ├── example_project │ ├── __init__.py │ ├── example_storages │ │ ├── __init__.py │ │ ├── s3_requirements.txt │ │ ├── README.txt │ │ ├── s3utils.py │ │ └── settings_s3boto.py │ ├── fixtures │ │ └── .gitdirectory │ ├── wsgi.py │ ├── urls.py │ ├── templates │ │ ├── homepage.html │ │ └── base.html │ ├── static │ │ └── css │ │ │ └── styles.css │ └── settings.py ├── public │ ├── .gitdirectory │ ├── static │ │ └── .gitdirectory │ └── media │ │ └── .gitignore ├── requirements.txt ├── manage.py └── README.rst ├── .coveragerc ├── .isort.cfg ├── .gitignore ├── .tx └── config ├── requirements.txt ├── setup.cfg ├── SECURITY.md ├── MANIFEST.in ├── .readthedocs.yml ├── tox.ini ├── CONTRIBUTORS.txt ├── setup.py ├── .github └── workflows │ └── ci.yml ├── LICENSE.txt └── README.rst /scripts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | -------------------------------------------------------------------------------- /photologue/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photologue/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photologue/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photologue/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photologue/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photologue/tests/templates/base.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example_project/example_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | django 2 | -r ../requirements.txt 3 | -------------------------------------------------------------------------------- /example_project/example_project/example_storages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photologue/templates/photologue/root.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = photologue 3 | omit = *migrations*, *wsgi*, */tests/* 4 | 5 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | extend_skip_glob = photologue/migrations 3 | line_length = 119 4 | -------------------------------------------------------------------------------- /photologue/res/test_nonsense.jpg: -------------------------------------------------------------------------------- 1 | fvner nbotobio 2 | gn n vbjfgvbjgnb' bjk;dfsv j'dfasvmk'bmg 3 | -------------------------------------------------------------------------------- /example_project/example_project/example_storages/s3_requirements.txt: -------------------------------------------------------------------------------- 1 | boto>=2.29.1 2 | django-storages>=1.1.8 3 | -------------------------------------------------------------------------------- /example_project/public/.gitdirectory: -------------------------------------------------------------------------------- 1 | Placeholder so that this directory will be added to the git repository. 2 | -------------------------------------------------------------------------------- /photologue/res/sample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/res/sample.jpg -------------------------------------------------------------------------------- /example_project/public/static/.gitdirectory: -------------------------------------------------------------------------------- 1 | Placeholder so that this directory will be added to the git repository. 2 | -------------------------------------------------------------------------------- /example_project/public/media/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /photologue/res/zips/sample.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/res/zips/sample.zip -------------------------------------------------------------------------------- /example_project/example_project/fixtures/.gitdirectory: -------------------------------------------------------------------------------- 1 | Placeholder so that this directory will be added to the git repository. 2 | -------------------------------------------------------------------------------- /photologue/res/test_unicode_®.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/res/test_unicode_®.jpg -------------------------------------------------------------------------------- /photologue/res/zips/not_image.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/res/zips/not_image.zip -------------------------------------------------------------------------------- /photologue/res/zips/ignored_files.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/res/zips/ignored_files.zip -------------------------------------------------------------------------------- /example_project/example_project/example_storages/README.txt: -------------------------------------------------------------------------------- 1 | In this folder we keep configuration files for non-default media stores, e.g. Amazon S3. -------------------------------------------------------------------------------- /photologue/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | __version__ = '3.19.dev0' 4 | 5 | PHOTOLOGUE_APP_DIR = os.path.dirname(os.path.abspath(__file__)) 6 | -------------------------------------------------------------------------------- /photologue/locale/ca/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/ca/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/cs/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/cs/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/da/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/da/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/eu/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/eu/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/hu/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/hu/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/it/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/it/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/nl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/nl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/no/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/no/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/pt/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/pt/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/sk/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/sk/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/tr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/tr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/uk/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/uk/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/res/test_photologue_"ing.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/res/test_photologue_"ing.jpg -------------------------------------------------------------------------------- /photologue/res/test_photologue_portrait.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/res/test_photologue_portrait.jpg -------------------------------------------------------------------------------- /photologue/res/test_photologue_square.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/res/test_photologue_square.jpg -------------------------------------------------------------------------------- /photologue/locale/en_US/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/en_US/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/es_ES/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/es_ES/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/pt_BR/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/pt_BR/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/locale/tr_TR/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/tr_TR/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/res/test_photologue_landscape.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/res/test_photologue_landscape.jpg -------------------------------------------------------------------------------- /photologue/locale/zh_Hans/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardbarran/django-photologue/HEAD/photologue/locale/zh_Hans/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /photologue/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PhotologueConfig(AppConfig): 5 | default_auto_field = 'django.db.models.AutoField' 6 | name = 'photologue' 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *__pycache__* 3 | django_photologue.egg-info 4 | build 5 | .idea 6 | example_project/db.sqlite3 7 | htmlcov 8 | .coverage 9 | 10 | # Tox workfiles 11 | .tox/* 12 | -------------------------------------------------------------------------------- /docs/pages/changelog_page.rst: -------------------------------------------------------------------------------- 1 | .. We want the CHANGELOG to also appear in the docs but we don't want to break 2 | the DRY principle. So we simply include the CHANGELOG from the base directory. 3 | 4 | .. include:: ../../CHANGELOG.txt 5 | -------------------------------------------------------------------------------- /example_project/example_project/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /example_project/example_project/example_storages/s3utils.py: -------------------------------------------------------------------------------- 1 | from storages.backends.s3boto import S3BotoStorage 2 | 3 | StaticS3BotoStorage = lambda: S3BotoStorage(location='static') 4 | MediaS3BotoStorage = lambda: S3BotoStorage(location='media') 5 | -------------------------------------------------------------------------------- /photologue/templates/photologue/tags/next_in_gallery.html: -------------------------------------------------------------------------------- 1 | {% if photo %} 2 | 3 | {{ photo.title }} 4 | 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /photologue/templates/photologue/tags/prev_in_gallery.html: -------------------------------------------------------------------------------- 1 | {% if photo %} 2 | 3 | {{ photo.title }} 4 | 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | lang_map = sr@latin:sr_Latn, zh-Hans:zh_Hans 4 | 5 | [django-photologue.core] 6 | file_filter = photologue/locale//LC_MESSAGES/django.po 7 | source_file = photologue/locale/en_US/LC_MESSAGES/django.po 8 | source_lang = en_US 9 | type = PO 10 | -------------------------------------------------------------------------------- /example_project/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ../requirements.txt 2 | # The following is only required if developing for Photologue. 3 | factory-boy>=3.3.2 # Note: version that formally supports Dj5.2 not yet released. 4 | # The following is only required if you plan on using Amazon AWS S3 5 | # -r example_storages/s3_requirements.txt 6 | 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Note: Specifying django here crashes tox; it's autoinstalled with other packages it 2 | # so can be removed from this file. 3 | # Cannot force a precise Pillow version as we need to support Py3.8-Py3.13 and there's 4 | # no Pillow version that meets both requirements. 5 | Pillow>=10 6 | django-sortedm2m>=4.0.0 # Support for Django 5.1. 7 | ExifRead>=3 8 | -------------------------------------------------------------------------------- /photologue/templates/admin/photologue/photo/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load i18n %} 3 | 4 | 5 | {% block object-tools-items %} 6 | {{ block.super }} 7 |
  • 8 | 9 | {% trans "Upload a zip archive" %} 10 | 11 |
  • 12 | {% endblock %} 13 | 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [zest.releaser] 2 | python-file-with-version = photologue/__init__.py 3 | prereleaser.before = scripts.releaser_hooks.prereleaser_before 4 | 5 | [flake8] 6 | # Follow Django style conventions - allow longer lines. 7 | max-line-length = 119 8 | exclude = docs,photologue/migrations,example_project/example_project/example_storages/s3utils.py,.tox 9 | ignore = E265,E722 10 | 11 | [bdist_wheel] 12 | universal = 1 13 | -------------------------------------------------------------------------------- /photologue/migrations/0006_auto_20141028_2005.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('photologue', '0005_auto_20141027_1552'), 8 | ] 9 | 10 | operations = [ 11 | migrations.RemoveField( 12 | model_name='galleryupload', 13 | name='gallery', 14 | ), 15 | migrations.DeleteModel( 16 | name='GalleryUpload', 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Please note that in the case of a security issue being reported, only the latest release will be fixed. Users of earlier releases of Photologue can of course patch the version that they use and open an MR - it will be reviewed and a micro-release published. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Please report vulnerabilities via the contact form at the personal website of the main project contributor: https://arbee.design/en-gb/contact/ 10 | -------------------------------------------------------------------------------- /photologue/migrations/0008_auto_20150509_1557.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('photologue', '0007_auto_20150404_1737'), 8 | ] 9 | 10 | operations = [ 11 | migrations.RemoveField( 12 | model_name='gallery', 13 | name='tags', 14 | ), 15 | migrations.RemoveField( 16 | model_name='photo', 17 | name='tags', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /photologue/tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sitemaps.views import sitemap 2 | from django.urls import include, path 3 | 4 | from ..sitemaps import GallerySitemap, PhotoSitemap 5 | 6 | urlpatterns = [ 7 | path('ptests/', include('photologue.urls', namespace='photologue')), 8 | ] 9 | 10 | sitemaps = {'photologue_galleries': GallerySitemap, 11 | 'photologue_photos': PhotoSitemap, 12 | } 13 | 14 | urlpatterns += [ 15 | path('sitemap.xml', sitemap, {'sitemaps': sitemaps}), 16 | ] 17 | -------------------------------------------------------------------------------- /example_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example_project.settings') 7 | 8 | # Add parent folder to path so that we can import Photologue itself. 9 | PROJECT_PATH = os.path.abspath(os.path.split(__file__)[0]) 10 | sys.path.append(os.path.join(PROJECT_PATH, "..")) 11 | 12 | from django.core.management import execute_from_command_line 13 | execute_from_command_line(sys.argv) 14 | -------------------------------------------------------------------------------- /photologue/migrations/0005_auto_20141027_1552.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('photologue', '0004_auto_20140915_1259'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name='photo', 13 | name='title', 14 | field=models.CharField(unique=True, max_length=60, verbose_name='title'), 15 | preserve_default=True, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /photologue/tests/test_effect.py: -------------------------------------------------------------------------------- 1 | from ..models import Image, PhotoEffect 2 | from .helpers import PhotologueBaseTest 3 | 4 | 5 | class PhotoEffectTest(PhotologueBaseTest): 6 | 7 | def test(self): 8 | effect = PhotoEffect(name='test') 9 | im = Image.open(self.pl.image.storage.open(self.pl.image.name)) 10 | self.assertIsInstance(effect.pre_process(im), Image.Image) 11 | self.assertIsInstance(effect.post_process(im), Image.Image) 12 | self.assertIsInstance(effect.process(im), Image.Image) 13 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.txt 2 | include CONTRIBUTORS.txt 3 | include LICENSE.txt 4 | include requirements.txt 5 | recursive-include photologue/locale * 6 | recursive-include photologue/res *.jpg 7 | recursive-include photologue/res/zips *.zip 8 | recursive-include photologue/templates *.html 9 | recursive-include photologue/contrib * 10 | recursive-exclude * *.py[co] 11 | recursive-exclude * __pycache__ 12 | exclude photologue/res/test_unicode*.jpg 13 | exclude example_project 14 | recursive-exclude example_project * 15 | exclude tox.ini 16 | -------------------------------------------------------------------------------- /example_project/example_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.contrib import admin 4 | from django.urls import include, path 5 | from django.views.generic import TemplateView 6 | 7 | urlpatterns = [path('admin/', admin.site.urls), 8 | path('photologue/', include('photologue.urls')), 9 | path('', TemplateView.as_view(template_name="homepage.html"), name='homepage'), 10 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 11 | -------------------------------------------------------------------------------- /photologue/migrations/0003_auto_20140822_1716.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('photologue', '0002_photosize_data'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name='galleryupload', 13 | name='title', 14 | field=models.CharField(null=True, help_text='All uploaded photos will be given a title made up of this title + a sequential number.', max_length=50, verbose_name='title', blank=True), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Optionally build your docs in additional formats such as PDF and ePub 13 | formats: 14 | - htmlzip 15 | 16 | # Optionally set the version of Python and requirements required to build your docs 17 | python: 18 | version: 3.7 19 | install: 20 | - requirements: docs/requirements.txt 21 | -------------------------------------------------------------------------------- /photologue/migrations/0013_alter_watermark_image.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-07-28 18:39 2 | 3 | from django.db import migrations, models 4 | import pathlib 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('photologue', '0012_alter_photo_effect'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='watermark', 16 | name='image', 17 | field=models.ImageField(upload_to=pathlib.PurePosixPath('photologue/watermarks'), verbose_name='image'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /photologue/migrations/0009_auto_20160102_0904.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9 on 2016-01-02 09:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('photologue', '0008_auto_20150509_1557'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='photo', 15 | name='date_taken', 16 | field=models.DateTimeField(blank=True, help_text='Date image was taken; is obtained from the image EXIF data.', null=True, verbose_name='date taken'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /photologue/management/commands/plcreatesize.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from photologue.management.commands import create_photosize 4 | 5 | 6 | class Command(BaseCommand): 7 | help = ('Creates a new Photologue photo size interactively.') 8 | requires_model_validation = True 9 | can_import_settings = True 10 | 11 | def add_arguments(self, parser): 12 | parser.add_argument('name', 13 | type=str, 14 | help='Name of the new photo size') 15 | 16 | def handle(self, *args, **options): 17 | create_photosize(options['name']) 18 | -------------------------------------------------------------------------------- /photologue/migrations/0012_alter_photo_effect.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.2 on 2022-02-23 09:50 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('photologue', '0011_auto_20190223_2138'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='photo', 16 | name='effect', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_related', to='photologue.photoeffect', verbose_name='effect'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /photologue/managers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db.models.query import QuerySet 3 | 4 | 5 | class SharedQueries: 6 | 7 | """Some queries that are identical for Gallery and Photo.""" 8 | 9 | def is_public(self): 10 | """Trivial filter - will probably become more complex as time goes by!""" 11 | return self.filter(is_public=True) 12 | 13 | def on_site(self): 14 | """Return objects linked to the current site only.""" 15 | return self.filter(sites__id=settings.SITE_ID) 16 | 17 | 18 | class GalleryQuerySet(SharedQueries, QuerySet): 19 | pass 20 | 21 | 22 | class PhotoQuerySet(SharedQueries, QuerySet): 23 | pass 24 | -------------------------------------------------------------------------------- /photologue/templates/photologue/includes/gallery_sample.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {# Display a randomnly-selected set of photos from a given gallery #} 4 | 5 | 18 | -------------------------------------------------------------------------------- /example_project/example_project/templates/homepage.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Photologue example project{% endblock %} 4 | 5 | {% block content %} 6 |
    7 |
    8 | 11 |

    This is a quick demo of the Photologue application - just click on 12 | the menu options above.

    13 |

    It uses the built-in Bootstrap-compatible 14 | templates that you can use to get you quickly up and running, or completely 15 | replace with your own templates.

    16 |
    17 |
    18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /example_project/example_project/static/css/styles.css: -------------------------------------------------------------------------------- 1 | /* Some customisation for the Photologue demo site. */ 2 | 3 | /* Page structure */ 4 | 5 | h1 { 6 | margin-top: 1rem; 7 | margin-bottom: 2rem; 8 | border-bottom: 1px solid lightgray; 9 | padding-bottom: 0.25rem; 10 | } 11 | 12 | /* Photo galleries */ 13 | 14 | .gallery-sample { 15 | margin-bottom: 2rem; 16 | } 17 | 18 | a.btn { 19 | margin-top: 2rem; 20 | } 21 | 22 | td { 23 | width: 100px; 24 | } 25 | 26 | .gallery-sample a { 27 | text-decoration: none; 28 | } 29 | 30 | .gallery-list a { 31 | text-decoration: none; 32 | } 33 | 34 | .photo-list a { 35 | text-decoration: none; 36 | } 37 | 38 | ul.pagination { 39 | margin-top: 2rem; 40 | } 41 | 42 | .img-thumbnail { 43 | margin-bottom: 0.25rem; 44 | } 45 | -------------------------------------------------------------------------------- /photologue/templates/photologue/gallery_list.html: -------------------------------------------------------------------------------- 1 | {% extends "photologue/root.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "All galleries" %}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
    9 |
    10 |

    {% trans "All galleries" %}

    11 |
    12 |
    13 | 14 | {% if object_list %} 15 | {% for gallery in object_list %} 16 |
    17 |
    18 | {% include "photologue/includes/gallery_sample.html" %} 19 |
    20 |
    21 | {% endfor %} 22 | {% else %} 23 |
    24 |
    {% trans "No galleries were found" %}.
    25 |
    26 | {% endif %} 27 | 28 | {% include "photologue/includes/paginator.html" %} 29 | 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /photologue/migrations/0011_auto_20190223_2138.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-02-23 21:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('photologue', '0010_auto_20160105_1307'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='photoeffect', 15 | name='filters', 16 | field=models.CharField(blank=True, help_text='Chain multiple filters using the following pattern "FILTER_ONE->FILTER_TWO->FILTER_THREE". Image filters will be applied in order. The following filters are available: BLUR, CONTOUR, DETAIL, EDGE_ENHANCE, EDGE_ENHANCE_MORE, EMBOSS, FIND_EDGES, Kernel, SHARPEN, SMOOTH, SMOOTH_MORE.', max_length=200, verbose_name='filters'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = 8 | py{38,39,310,311,312}-django42 9 | py{310,311,312,313}-django51 10 | py{310,311,312,313}-django52 11 | 12 | [testenv] 13 | deps = 14 | django42: Django>=4.2,<5.0 15 | django51: Django>=5.1,<5.2 16 | django52: Django>=5.2,<6.0 17 | -r{toxinidir}/example_project/requirements.txt 18 | changedir = 19 | {toxinidir}/example_project/ 20 | commands = 21 | python manage.py test photologue 22 | 23 | [gh-actions] 24 | python = 25 | 3.8: py38 26 | 3.9: py39 27 | 3.10: py310 28 | 3.11: py311 29 | 3.12: py312 30 | 3.13: py313 31 | -------------------------------------------------------------------------------- /photologue/templates/photologue/photo_list.html: -------------------------------------------------------------------------------- 1 | {% extends "photologue/root.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "All photos" %}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
    9 |
    10 |

    {% trans "All photos" %}

    11 |
    12 |
    13 | {% if object_list %} 14 |
    15 |
    16 | {% for photo in object_list %} 17 | 18 | {{ photo.title }} 19 | 20 | {% endfor %} 21 |
    22 |
    23 | {% else %} 24 |
    {% trans "No photos were found" %}.
    25 | {% endif %} 26 | 27 | {% include "photologue/includes/paginator.html" %} 28 | 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /photologue/tests/helpers.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.test import TestCase 4 | 5 | from .factories import PhotoFactory, PhotoSizeFactory 6 | 7 | 8 | class PhotologueBaseTest(TestCase): 9 | 10 | def setUp(self): 11 | self.s = PhotoSizeFactory(name='testPhotoSize', 12 | width=100, 13 | height=100) 14 | try: 15 | # Squash lots of ResourceWarning generated by the Pillow library during unit tests. 16 | warnings.simplefilter("ignore", ResourceWarning) 17 | except NameError: 18 | # Doesn't exist in Python 2.7. 19 | pass 20 | self.pl = PhotoFactory(title='Landscape', 21 | slug='landscape') 22 | 23 | def tearDown(self): 24 | # Need to manually remove the files created during testing. 25 | self.pl.delete() 26 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-photologue documentation master file, created by 2 | sphinx-quickstart on Mon Sep 3 16:31:44 2012. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to django-photologue's documentation! 7 | ============================================= 8 | 9 | .. include:: ../README.rst 10 | :start-line: 0 11 | :end-line: 25 12 | 13 | .. include:: ../README.rst 14 | :start-line: 31 15 | :end-line: 46 16 | 17 | Contents: 18 | ========= 19 | 20 | .. toctree:: 21 | :maxdepth: 2 22 | 23 | pages/installation 24 | pages/usage 25 | pages/customising/templates 26 | pages/customising/settings 27 | pages/customising/admin 28 | pages/customising/views 29 | pages/customising/models 30 | pages/contributing 31 | pages/changelog_page 32 | 33 | Indices and tables 34 | ================== 35 | 36 | * :ref:`genindex` 37 | * :ref:`modindex` 38 | * :ref:`search` 39 | 40 | -------------------------------------------------------------------------------- /photologue/migrations/0007_auto_20150404_1737.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | import sortedm2m.fields 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('photologue', '0006_auto_20141028_2005'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='gallery', 14 | name='photos', 15 | field=sortedm2m.fields.SortedManyToManyField(help_text=None, related_name='galleries', verbose_name='photos', to='photologue.Photo', blank=True), 16 | ), 17 | migrations.AlterField( 18 | model_name='gallery', 19 | name='sites', 20 | field=models.ManyToManyField(to='sites.Site', verbose_name='sites', blank=True), 21 | ), 22 | migrations.AlterField( 23 | model_name='photo', 24 | name='sites', 25 | field=models.ManyToManyField(to='sites.Site', verbose_name='sites', blank=True), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /photologue/templates/photologue/gallery_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "photologue/root.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{{ gallery.title }}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
    9 |
    10 |

    {{ gallery.title }}

    11 |

    {% trans "Published" %} {{ gallery.date_added }}

    12 | {% if gallery.description %}{{ gallery.description|safe }}{% endif %} 13 | 20 |
    21 | {% trans "View all galleries" %} 22 |
    23 |
    24 |
    25 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | Photologue is made possible by all the people who have contributed to it. A non-exhaustive list follows: 2 | 3 | Justin Driscoll 4 | Richard Barran 5 | Marcos Daniel Petry 6 | Jannis 7 | martijnverkleij 8 | hedleyroos 9 | Celia Oakley 10 | Jonas Haag 11 | drazen 12 | Jasper Maes 13 | Krilivye 14 | Jakub Dorňák 15 | Andrew Gerard 16 | Karol Majta 17 | Urtzi Odriozola 18 | Alexey Vasilyev 19 | kdeebee 20 | lausek 21 | Alexandre Iooss 22 | Alasdair Nicol 23 | lizwalsh 24 | Steffen Goertz 25 | Baptiste Mispelon 26 | FilippoIOVINE 27 | ferhat elmas 28 | Benjamin Liles 29 | Emir Isman 30 | Adam Piskorek 31 | r0668522 32 | i_82 33 | Steve Wills 34 | Timothy Hobbs 35 | Chris Frisina 36 | nickledave 37 | cblignaut 38 | Justin Dugger 39 | Mikel Larreategi 40 | wf94 41 | Guilhem Saurel 42 | saboter 43 | Mathieu Hinderyckx 44 | Thomas Güttler 45 | Tomas Babej 46 | Martin Sabo 47 | python-consulting 48 | jscott1971 49 | Vivek Agarwal 50 | Ben Spaulding 51 | Reavis Sutphin-Gray 52 | Herman Schaaf 53 | Antti Kaihola 54 | Federico Maggi 55 | cubells 56 | AduchiMergen 57 | abc Def 58 | -------------------------------------------------------------------------------- /photologue/templates/photologue/gallery_archive.html: -------------------------------------------------------------------------------- 1 | {% extends "photologue/root.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Latest photo galleries" %}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
    9 |
    10 |

    {% trans "Latest photo galleries" %}

    11 |
    12 |
    13 | 14 |
    15 | 16 | 26 | 27 |
    28 | 29 | {% if latest %} 30 | {% for gallery in latest %} 31 | {% include "photologue/includes/gallery_sample.html" %} 32 | {% endfor %} 33 | {% else %} 34 |

    {% trans "No galleries were found" %}.

    35 | {% endif %} 36 | 37 |
    38 | 39 |
    40 | 41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /photologue/templates/photologue/gallery_archive_day.html: -------------------------------------------------------------------------------- 1 | {% extends "photologue/root.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% blocktrans with show_day=day|date:"d F Y" %}Galleries for {{ show_day }}{% endblocktrans %}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
    9 |
    10 |

    {% blocktrans with show_day=day|date:"d F Y" %}Galleries for {{ show_day }}{% endblocktrans %}

    11 |
    12 |
    13 | 14 |
    15 |
    16 | {% if object_list %} 17 | {% for gallery in object_list %} 18 | {% include "photologue/includes/gallery_sample.html" %} 19 | {% endfor %} 20 | {% else %} 21 |

    {% trans "No galleries were found." %}

    22 | {% endif %} 23 |
    24 |
    25 | 26 |
    {% trans "View all galleries for month" %}
    27 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /photologue/templates/photologue/photo_archive_day.html: -------------------------------------------------------------------------------- 1 | {% extends "photologue/root.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% blocktrans with show_day=day|date:"d F Y" %}Photos for {{ show_day }}{% endblocktrans %}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
    9 |
    10 |

    {% blocktrans with show_day=day|date:"d F Y" %}Photos for {{ show_day }}{% endblocktrans %}

    11 |
    12 |
    13 | 14 | {% if object_list %} 15 |
    16 |
    17 | {% for photo in object_list %} 18 | 19 | {{ photo.title }} 20 | 21 | {% endfor %} 22 |
    23 |
    24 | {% else %} 25 |

    {% trans "No photos were found" %}.

    26 | {% endif %} 27 | 28 |
    {% trans "View all photos for month" %}
    29 | 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /photologue/management/commands/plflush.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | 3 | from photologue.models import ImageModel, PhotoSize 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Clears the Photologue cache for the given sizes.' 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument('sizes', 11 | nargs='*', 12 | type=str, 13 | help='Name of the photosize.') 14 | 15 | def handle(self, *args, **options): 16 | sizes = options['sizes'] 17 | 18 | if not sizes: 19 | photosizes = PhotoSize.objects.all() 20 | else: 21 | photosizes = PhotoSize.objects.filter(name__in=sizes) 22 | 23 | if not len(photosizes): 24 | raise CommandError('No photo sizes were found.') 25 | 26 | print('Flushing cache...') 27 | 28 | for cls in ImageModel.__subclasses__(): 29 | for photosize in photosizes: 30 | print('Flushing %s size images' % photosize.name) 31 | for obj in cls.objects.all(): 32 | obj.remove_size(photosize) 33 | -------------------------------------------------------------------------------- /photologue/tests/test_photosize.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | 3 | from .factories import PhotoSizeFactory 4 | from .helpers import PhotologueBaseTest 5 | 6 | 7 | class PhotoSizeNameTest(PhotologueBaseTest): 8 | 9 | def test_valid_name(self): 10 | """We are restricted in what names we can enter.""" 11 | 12 | photosize = PhotoSizeFactory() 13 | photosize.name = None 14 | with self.assertRaisesMessage(ValidationError, 'This field cannot be null.'): 15 | photosize.full_clean() 16 | 17 | photosize = PhotoSizeFactory(name='') 18 | with self.assertRaisesMessage(ValidationError, 'This field cannot be blank.'): 19 | photosize.full_clean() 20 | 21 | for name in ('a space', 'UPPERCASE', 'bad?chars'): 22 | photosize = PhotoSizeFactory(name=name) 23 | with self.assertRaisesMessage(ValidationError, 24 | 'Use only plain lowercase letters (ASCII), numbers and underscores.'): 25 | photosize.full_clean() 26 | 27 | for name in ('label', '2_words'): 28 | photosize = PhotoSizeFactory(name=name) 29 | photosize.full_clean() 30 | -------------------------------------------------------------------------------- /photologue/templates/photologue/photo_archive.html: -------------------------------------------------------------------------------- 1 | {% extends "photologue/root.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Latest photos" %}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
    9 |
    10 |

    {% trans "Latest photos" %}

    11 |
    12 |
    13 | 14 |
    15 | 16 | 26 | 27 |
    28 | 29 | {% if latest %} 30 | {% for photo in latest %} 31 | 32 | {{ photo.title }} 33 | 34 | {% endfor %} 35 | {% else %} 36 |

    {% trans "No photos were found" %}.

    37 | {% endif %} 38 | 39 |
    40 | 41 |
    42 | 43 | {% endblock %} 44 | 45 | 46 | -------------------------------------------------------------------------------- /photologue/migrations/0010_auto_20160105_1307.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9 on 2016-01-05 13:07 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('photologue', '0009_auto_20160102_0904'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='gallery', 15 | name='slug', 16 | field=models.SlugField(help_text='A "slug" is a unique URL-friendly title for an object.', max_length=250, unique=True, verbose_name='title slug'), 17 | ), 18 | migrations.AlterField( 19 | model_name='gallery', 20 | name='title', 21 | field=models.CharField(max_length=250, unique=True, verbose_name='title'), 22 | ), 23 | migrations.AlterField( 24 | model_name='photo', 25 | name='slug', 26 | field=models.SlugField(help_text='A "slug" is a unique URL-friendly title for an object.', max_length=250, unique=True, verbose_name='slug'), 27 | ), 28 | migrations.AlterField( 29 | model_name='photo', 30 | name='title', 31 | field=models.CharField(max_length=250, unique=True, verbose_name='title'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /photologue/templates/photologue/gallery_archive_year.html: -------------------------------------------------------------------------------- 1 | {% extends "photologue/root.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% blocktrans with show_year=year|date:"Y" %}Galleries for {{ show_year }}{% endblocktrans %}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
    9 |
    10 |

    {% blocktrans with show_year=year|date:"Y" %}Galleries for {{ show_year }}{% endblocktrans %}

    11 |
    12 |
    13 |
    14 | 15 | 25 | 26 |
    27 | 28 | {% if object_list %} 29 | {% for gallery in object_list %} 30 | {% include "photologue/includes/gallery_sample.html" %} 31 | {% endfor %} 32 | {% else %} 33 |

    {% trans "No galleries were found." %}

    34 | {% endif %} 35 | 36 |
    {% trans "View all galleries" %}
    37 | 38 |
    39 | 40 |
    41 | 42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /example_project/example_project/example_storages/settings_s3boto.py: -------------------------------------------------------------------------------- 1 | # S3Boto storage settings for photologue example project. 2 | 3 | import os 4 | 5 | DEFAULT_FILE_STORAGE = 'example_project.example_storages.s3utils.MediaS3BotoStorage' 6 | STATICFILES_STORAGE = 'example_project.example_storages.s3utils.StaticS3BotoStorage' 7 | 8 | try: 9 | # If you want to test the example_project with S3, you'll have to configure the 10 | # environment variables as specified below. 11 | # (Secret keys are stored in environment variables for security - you don't want to 12 | # accidentally commit and push them to a public repository). 13 | AWS_ACCESS_KEY_ID = os.environ['AWS_ACCESS_KEY_ID'] 14 | AWS_SECRET_ACCESS_KEY = os.environ['AWS_SECRET_ACCESS_KEY'] 15 | AWS_STORAGE_BUCKET_NAME = os.environ['AWS_STORAGE_BUCKET_NAME'] 16 | except KeyError: 17 | raise KeyError('Need to define AWS environment variables: ' 18 | 'AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_STORAGE_BUCKET_NAME') 19 | 20 | # Default Django Storage API behavior - don't overwrite files with same name 21 | AWS_S3_FILE_OVERWRITE = False 22 | 23 | MEDIA_ROOT = '/media/' 24 | MEDIA_URL = 'http://%s.s3.amazonaws.com/media/' % AWS_STORAGE_BUCKET_NAME 25 | 26 | STATIC_ROOT = '/static/' 27 | STATIC_URL = 'http://%s.s3.amazonaws.com/static/' % AWS_STORAGE_BUCKET_NAME 28 | 29 | ADMIN_MEDIA_PREFIX = STATIC_URL + 'admin/' 30 | -------------------------------------------------------------------------------- /photologue/templates/photologue/photo_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "photologue/root.html" %} 2 | {% load photologue_tags i18n %} 3 | 4 | {% block title %}{{ object.title }}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
    9 |
    10 |

    {{ object.title }}

    11 |

    {% trans "Published" %} {{ object.date_added }}

    12 |
    13 |
    14 | 15 |
    16 |
    17 | {% if object.caption %}

    {{ object.caption }}

    {% endif %} 18 | 19 | {{ object.title }} 20 | 21 |
    22 |
    23 | {% if object.public_galleries %} 24 |

    {% trans "This photo is found in the following galleries" %}:

    25 | 26 | {% for gallery in object.public_galleries %} 27 | 28 | 29 | 30 | 31 | 32 | {% endfor %} 33 |
    {% previous_in_gallery object gallery %}{{ gallery.title }}{% next_in_gallery object gallery %}
    34 | {% endif %} 35 |
    36 |
    37 | 38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /photologue/migrations/0004_auto_20140915_1259.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | import sortedm2m.fields 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('photologue', '0003_auto_20140822_1716'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='gallery', 14 | name='photos', 15 | field=sortedm2m.fields.SortedManyToManyField(to='photologue.Photo', related_name='galleries', null=True, verbose_name='photos', blank=True, help_text=None), 16 | ), 17 | migrations.AlterField( 18 | model_name='photo', 19 | name='effect', 20 | field=models.ForeignKey(to='photologue.PhotoEffect', blank=True, related_name='photo_related', verbose_name='effect', null=True, on_delete=models.CASCADE), 21 | ), 22 | migrations.AlterField( 23 | model_name='photosize', 24 | name='effect', 25 | field=models.ForeignKey(to='photologue.PhotoEffect', blank=True, related_name='photo_sizes', verbose_name='photo effect', null=True, on_delete=models.CASCADE), 26 | ), 27 | migrations.AlterField( 28 | model_name='photosize', 29 | name='watermark', 30 | field=models.ForeignKey(to='photologue.Watermark', blank=True, related_name='photo_sizes', verbose_name='watermark image', null=True, on_delete=models.CASCADE), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /photologue/templates/photologue/includes/paginator.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% if is_paginated %} 3 |
    4 |
    5 | 29 | 30 |
    31 |
    32 | {% endif %} 33 | -------------------------------------------------------------------------------- /photologue/migrations/0002_photosize_data.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | 3 | 4 | def initial_photosizes(apps, schema_editor): 5 | 6 | PhotoSize = apps.get_model('photologue', 'PhotoSize') 7 | 8 | # If there are already Photosizes, then we are upgrading an existing 9 | # installation, we don't want to auto-create some PhotoSizes. 10 | if PhotoSize.objects.all().count() > 0: 11 | return 12 | PhotoSize.objects.create(name='admin_thumbnail', 13 | width=100, 14 | height=75, 15 | crop=True, 16 | pre_cache=True, 17 | increment_count=False) 18 | PhotoSize.objects.create(name='thumbnail', 19 | width=100, 20 | height=75, 21 | crop=True, 22 | pre_cache=True, 23 | increment_count=False) 24 | PhotoSize.objects.create(name='display', 25 | width=400, 26 | crop=False, 27 | pre_cache=True, 28 | increment_count=True) 29 | 30 | 31 | class Migration(migrations.Migration): 32 | 33 | dependencies = [ 34 | ('photologue', '0001_initial'), 35 | ('contenttypes', '0002_remove_content_type_name'), 36 | ] 37 | 38 | operations = [ 39 | migrations.RunPython(initial_photosizes), 40 | ] 41 | -------------------------------------------------------------------------------- /photologue/templates/photologue/gallery_archive_month.html: -------------------------------------------------------------------------------- 1 | {% extends "photologue/root.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% blocktrans with show_month=month|date:"F Y" %}Galleries for {{ show_month }}{% endblocktrans %}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
    9 |
    10 |

    {% blocktrans with show_month=month|date:"F Y" %}Galleries for {{ show_month }}{% endblocktrans %}

    11 |
    12 |
    13 | 14 |
    15 | 16 | 26 | 27 |
    28 | 29 | {% if object_list %} 30 | {% for gallery in object_list %} 31 | {% include "photologue/includes/gallery_sample.html" %} 32 | {% endfor %} 33 | {% else %} 34 |

    {% trans "No galleries were found." %}

    35 | {% endif %} 36 | 37 |
    {% trans "View all galleries for year" %}
    38 | 39 |
    40 | 41 |
    42 | 43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /photologue/tests/test_sitemap.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from django.conf import settings 4 | from django.test import override_settings 5 | 6 | from .factories import GalleryFactory 7 | from .helpers import PhotologueBaseTest 8 | 9 | 10 | @unittest.skipUnless('django.contrib.sitemaps' in settings.INSTALLED_APPS, 11 | 'Sitemaps not installed in this project, nothing to test.') 12 | @override_settings(ROOT_URLCONF='photologue.tests.test_urls') 13 | class SitemapTest(PhotologueBaseTest): 14 | 15 | def test_get_photo(self): 16 | """Default test setup contains one photo, this should appear in the sitemap.""" 17 | response = self.client.get('/sitemap.xml') 18 | self.assertContains(response, 19 | 'http://example.com/ptests/photo/landscape/' 20 | '2011-12-23') 21 | 22 | def test_get_gallery(self): 23 | """if we add a gallery to the site, we should see both the gallery and 24 | the photo in the sitemap.""" 25 | self.gallery = GalleryFactory(slug='test-gallery') 26 | 27 | response = self.client.get('/sitemap.xml') 28 | self.assertContains(response, 29 | 'http://example.com/ptests/photo/landscape/' 30 | '2011-12-23') 31 | self.assertContains(response, 32 | 'http://example.com/ptests/gallery/test-gallery/' 33 | '2011-12-23') 34 | -------------------------------------------------------------------------------- /photologue/templates/photologue/photo_archive_year.html: -------------------------------------------------------------------------------- 1 | {% extends "photologue/root.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% blocktrans with show_year=year|date:"Y" %}Photos for {{ show_year }}{% endblocktrans %}{% endblock %} 5 | 6 | 7 | {% block content %} 8 | 9 |
    10 |
    11 |

    {% blocktrans with show_year=year|date:"Y" %}Photos for {{ show_year }}{% endblocktrans %}

    12 |
    13 |
    14 | 15 |
    16 | 17 | 27 | 28 |
    29 | 30 | {% if object_list %} 31 |
    32 |
    33 | {% for photo in object_list %} 34 | 35 | {{ photo.title }} 36 | 37 | {% endfor %} 38 |
    39 |
    40 | {% else %} 41 |

    {% trans "No photos were found" %}.

    42 | {% endif %} 43 | 44 |
    {% trans "View all photos" %}
    45 | 46 |
    47 | 48 |
    49 | 50 | {% endblock %} 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /photologue/management/commands/plcache.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | 3 | from photologue.models import ImageModel, PhotoSize 4 | 5 | 6 | class Command(BaseCommand): 7 | 8 | help = 'Manages Photologue cache file for the given sizes.' 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument('sizes', 12 | nargs='*', 13 | type=str, 14 | help='Name of the photosize.') 15 | parser.add_argument('--reset', 16 | action='store_true', 17 | default=False, 18 | dest='reset', 19 | help='Reset photo cache before generating.') 20 | 21 | def handle(self, *args, **options): 22 | reset = options['reset'] 23 | sizes = options['sizes'] 24 | 25 | if not sizes: 26 | photosizes = PhotoSize.objects.all() 27 | else: 28 | photosizes = PhotoSize.objects.filter(name__in=sizes) 29 | 30 | if not len(photosizes): 31 | raise CommandError('No photo sizes were found.') 32 | 33 | print('Caching photos, this may take a while...') 34 | 35 | for cls in ImageModel.__subclasses__(): 36 | for photosize in photosizes: 37 | print('Cacheing %s size images' % photosize.name) 38 | for obj in cls.objects.all(): 39 | if reset: 40 | obj.remove_size(photosize) 41 | obj.create_size(photosize) 42 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # /usr/bin/env python 2 | from pkg_resources import parse_requirements 3 | from setuptools import find_packages, setup 4 | 5 | import photologue 6 | 7 | 8 | def get_requirements(source): 9 | with open(source) as f: 10 | return sorted({str(req) for req in parse_requirements(f.read())}) 11 | 12 | 13 | setup( 14 | name="django-photologue", 15 | version=photologue.__version__, 16 | description="Powerful image management for the Django web framework.", 17 | author="Justin Driscoll, Marcos Daniel Petry, Richard Barran", 18 | author_email="justin@driscolldev.com, marcospetry@gmail.com, richard@arbee-design.co.uk", 19 | url="https://github.com/richardbarran/django-photologue", 20 | packages=find_packages(), 21 | include_package_data=True, 22 | zip_safe=False, 23 | classifiers=['Development Status :: 5 - Production/Stable', 24 | 'Environment :: Web Environment', 25 | 'Framework :: Django', 26 | 'Intended Audience :: Developers', 27 | 'License :: OSI Approved :: BSD License', 28 | 'Operating System :: OS Independent', 29 | 'Programming Language :: Python', 30 | 'Programming Language :: Python :: 2.7', 31 | 'Programming Language :: Python :: 3', 32 | 'Programming Language :: Python :: 3.4', 33 | 'Programming Language :: Python :: 3.5', 34 | 'Programming Language :: Python :: 3.6', 35 | 'Topic :: Utilities'], 36 | install_requires=get_requirements('requirements.txt'), 37 | ) 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 2 | 3 | name: CI 4 | 5 | on: [ push, pull_request ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false # If one test fails, complete the other jobs. 12 | matrix: 13 | python: [ 3.8, 3.9, '3.10', '3.11', '3.12', '3.13' ] 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install -r requirements.txt 24 | pip install tox tox-gh-actions 25 | working-directory: ./example_project 26 | - name: Run tests 27 | run: tox 28 | - name: Flake8 29 | if: ${{ matrix.python == '3.12' }} 30 | run: | 31 | pip install flake8 32 | flake8 ./ 33 | - name: Isort 34 | if: ${{ matrix.python == '3.12' }} 35 | uses: jamescurtin/isort-action@master 36 | - name: Coverage 37 | if: ${{ matrix.python == '3.12' }} 38 | run: | 39 | pip install coverage[toml] django 40 | coverage run ./example_project/manage.py test photologue 41 | - name: Upload coverage 42 | if: ${{ matrix.python == '3.12' }} 43 | uses: codecov/codecov-action@v1 44 | with: 45 | name: Python ${{ matrix.python }} 46 | -------------------------------------------------------------------------------- /photologue/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from photologue.models import PhotoSize 2 | 3 | 4 | def get_response(msg, func=int, default=None): 5 | while True: 6 | resp = input(msg) 7 | if not resp and default is not None: 8 | return default 9 | try: 10 | return func(resp) 11 | except: 12 | print('Invalid input.') 13 | 14 | 15 | def create_photosize(name, width=0, height=0, crop=False, pre_cache=False, increment_count=False): 16 | try: 17 | size = PhotoSize.objects.get(name=name) 18 | exists = True 19 | except PhotoSize.DoesNotExist: 20 | size = PhotoSize(name=name) 21 | exists = False 22 | if exists: 23 | msg = 'A "%s" photo size already exists. Do you want to replace it? (yes, no):' % name 24 | if not get_response(msg, lambda inp: inp == 'yes', False): 25 | return 26 | print('\nWe will now define the "%s" photo size:\n' % size) 27 | w = get_response('Width (in pixels):', lambda inp: int(inp), width) 28 | h = get_response('Height (in pixels):', lambda inp: int(inp), height) 29 | c = get_response('Crop to fit? (yes, no):', lambda inp: inp == 'yes', crop) 30 | p = get_response('Pre-cache? (yes, no):', lambda inp: inp == 'yes', pre_cache) 31 | i = get_response('Increment count? (yes, no):', lambda inp: inp == 'yes', increment_count) 32 | size.width = w 33 | size.height = h 34 | size.crop = c 35 | size.pre_cache = p 36 | size.increment_count = i 37 | size.save() 38 | print('\nA "%s" photo size has been created.\n' % name) 39 | return size 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2007-2025, Justin C. Driscoll and all the people named in CONTRIBUTORS.txt. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of django-photologue nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /photologue/templates/photologue/photo_archive_month.html: -------------------------------------------------------------------------------- 1 | {% extends "photologue/root.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% blocktrans with show_month=month|date:"F Y" %}Photos for {{ show_month }}{% endblocktrans %}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
    9 |
    10 |

    {% blocktrans with show_month=month|date:"F Y" %}Photos for {{ show_month }}{% endblocktrans %}

    11 |
    12 |
    13 | 14 |
    15 | 16 | 26 | 27 |
    28 | 29 | {% if object_list %} 30 |
    31 |
    32 | {% for photo in object_list %} 33 | 34 | {{ photo.title }} 35 | 36 | {% endfor %} 37 |
    38 |
    39 | {% else %} 40 |

    {% trans "No photos were found" %}.

    41 | {% endif %} 42 | 43 |
    {% trans "View all photos for year" %}
    44 | 45 |
    46 | 47 |
    48 | 49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django-photologue 2 | ================= 3 | .. image:: https://img.shields.io/pypi/v/django-photologue.svg 4 | :target: https://pypi.python.org/pypi/django-photologue/ 5 | :alt: Latest PyPI version 6 | 7 | .. image:: https://github.com/richardbarran/django-photologue/workflows/CI/badge.svg?branch=master 8 | :target: https://github.com/richardbarran/django-photologue/actions?workflow=CI 9 | :alt: CI Status 10 | 11 | .. image:: https://codecov.io/github/richardbarran/django-photologue/coverage.svg?branch=master 12 | :target: https://codecov.io/github/richardbarran/django-photologue?branch=master 13 | 14 | 15 | A powerful image management and gallery application for the Django web framework. Upload photos, group them into 16 | galleries, apply effects such as watermarks. 17 | 18 | Project status 19 | -------------- 20 | Message from the current maintainer: I no longer need django-photologue for my own projects, so I've stopped working on it. I will occasionally 21 | update it to run with current Django releases, but that's all. Any help in running this project is welcome. 22 | 23 | Take a closer look 24 | ------------------ 25 | - We have a `demo website `_. 26 | - We also have some `examples of sites using Photologue 27 | `_ on the wiki. 28 | 29 | Documentation 30 | ------------- 31 | Please head over to our `online documentation at ReadTheDocs `_ 32 | for instructions on installing and configuring this application. 33 | 34 | Amazon S3 35 | --------- 36 | 37 | S3 also works with the standard BOTO package. 38 | 39 | Support 40 | ------- 41 | If you have any questions or need help with any aspect of Photologue then please `join our mailing list 42 | `_. 43 | -------------------------------------------------------------------------------- /photologue/utils/watermark.py: -------------------------------------------------------------------------------- 1 | """ Function for applying watermarks to images. 2 | 3 | Original found here: 4 | http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/362879 5 | 6 | """ 7 | 8 | try: 9 | import Image 10 | import ImageEnhance 11 | except ImportError: 12 | try: 13 | from PIL import Image, ImageEnhance 14 | except ImportError: 15 | raise ImportError("The Python Imaging Library was not found.") 16 | 17 | 18 | def reduce_opacity(im, opacity): 19 | """Returns an image with reduced opacity.""" 20 | assert opacity >= 0 and opacity <= 1 21 | if im.mode != 'RGBA': 22 | im = im.convert('RGBA') 23 | else: 24 | im = im.copy() 25 | alpha = im.split()[3] 26 | alpha = ImageEnhance.Brightness(alpha).enhance(opacity) 27 | im.putalpha(alpha) 28 | return im 29 | 30 | 31 | def apply_watermark(im, mark, position, opacity=1): 32 | """Adds a watermark to an image.""" 33 | if opacity < 1: 34 | mark = reduce_opacity(mark, opacity) 35 | if im.mode != 'RGBA': 36 | im = im.convert('RGBA') 37 | # create a transparent layer the size of the image and draw the 38 | # watermark in that layer. 39 | layer = Image.new('RGBA', im.size, (0, 0, 0, 0)) 40 | if position == 'tile': 41 | for y in range(0, im.size[1], mark.size[1]): 42 | for x in range(0, im.size[0], mark.size[0]): 43 | layer.paste(mark, (x, y)) 44 | elif position == 'scale': 45 | # scale, but preserve the aspect ratio 46 | ratio = min( 47 | float(im.size[0]) / mark.size[0], float(im.size[1]) / mark.size[1]) 48 | w = int(mark.size[0] * ratio) 49 | h = int(mark.size[1] * ratio) 50 | mark = mark.resize((w, h)) 51 | layer.paste(mark, (int((im.size[0] - w) / 2), int((im.size[1] - h) / 2))) 52 | else: 53 | layer.paste(mark, position) 54 | # composite the watermark with the layer 55 | return Image.composite(layer, im, layer) 56 | -------------------------------------------------------------------------------- /photologue/sitemaps.py: -------------------------------------------------------------------------------- 1 | """ 2 | The `Sitemaps protocol `_ allows a webmaster 3 | to inform search engines about URLs on a website that are available for crawling. 4 | Django comes with a high-level framework that makes generating sitemap XML files easy. 5 | 6 | Install the sitemap application as per the `instructions in the django documentation 7 | `_, then edit your 8 | project's ``urls.py`` and add a reference to Photologue's Sitemap classes in order to 9 | included all the publicly-viewable Photologue pages: 10 | 11 | .. code-block:: python 12 | 13 | ... 14 | from photologue.sitemaps import GallerySitemap, PhotoSitemap 15 | 16 | sitemaps = {... 17 | 'photologue_galleries': GallerySitemap, 18 | 'photologue_photos': PhotoSitemap, 19 | ... 20 | } 21 | etc... 22 | 23 | There are 2 sitemap classes, as in some cases you may want to have gallery pages, 24 | but no photo detail page (e.g. if all photos are displayed via a javascript 25 | lightbox). 26 | 27 | """ 28 | from django.contrib.sitemaps import Sitemap 29 | 30 | from .models import Gallery, Photo 31 | 32 | # Note: Gallery and Photo are split, because there are use cases for having galleries 33 | # in the sitemap, but not photos (e.g. if the photos are displayed with a lightbox). 34 | 35 | 36 | class GallerySitemap(Sitemap): 37 | 38 | def items(self): 39 | # The following code is very basic and will probably cause problems with 40 | # large querysets. 41 | return Gallery.objects.on_site().is_public() 42 | 43 | def lastmod(self, obj): 44 | return obj.date_added 45 | 46 | 47 | class PhotoSitemap(Sitemap): 48 | 49 | def items(self): 50 | # The following code is very basic and will probably cause problems with 51 | # large querysets. 52 | return Photo.objects.on_site().is_public() 53 | 54 | def lastmod(self, obj): 55 | return obj.date_added 56 | -------------------------------------------------------------------------------- /photologue/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic.dates import (ArchiveIndexView, DateDetailView, DayArchiveView, MonthArchiveView, 2 | YearArchiveView) 3 | from django.views.generic.detail import DetailView 4 | from django.views.generic.list import ListView 5 | 6 | from .models import Gallery, Photo 7 | 8 | # Gallery views. 9 | 10 | 11 | class GalleryListView(ListView): 12 | queryset = Gallery.objects.on_site().is_public() 13 | paginate_by = 20 14 | 15 | 16 | class GalleryDetailView(DetailView): 17 | queryset = Gallery.objects.on_site().is_public() 18 | 19 | 20 | class GalleryDateView: 21 | queryset = Gallery.objects.on_site().is_public() 22 | date_field = 'date_added' 23 | allow_empty = True 24 | 25 | 26 | class GalleryDateDetailView(GalleryDateView, DateDetailView): 27 | pass 28 | 29 | 30 | class GalleryArchiveIndexView(GalleryDateView, ArchiveIndexView): 31 | pass 32 | 33 | 34 | class GalleryDayArchiveView(GalleryDateView, DayArchiveView): 35 | pass 36 | 37 | 38 | class GalleryMonthArchiveView(GalleryDateView, MonthArchiveView): 39 | pass 40 | 41 | 42 | class GalleryYearArchiveView(GalleryDateView, YearArchiveView): 43 | make_object_list = True 44 | 45 | # Photo views. 46 | 47 | 48 | class PhotoListView(ListView): 49 | queryset = Photo.objects.on_site().is_public() 50 | paginate_by = 20 51 | 52 | 53 | class PhotoDetailView(DetailView): 54 | queryset = Photo.objects.on_site().is_public() 55 | 56 | 57 | class PhotoDateView: 58 | queryset = Photo.objects.on_site().is_public() 59 | date_field = 'date_added' 60 | allow_empty = True 61 | 62 | 63 | class PhotoDateDetailView(PhotoDateView, DateDetailView): 64 | pass 65 | 66 | 67 | class PhotoArchiveIndexView(PhotoDateView, ArchiveIndexView): 68 | pass 69 | 70 | 71 | class PhotoDayArchiveView(PhotoDateView, DayArchiveView): 72 | pass 73 | 74 | 75 | class PhotoMonthArchiveView(PhotoDateView, MonthArchiveView): 76 | pass 77 | 78 | 79 | class PhotoYearArchiveView(PhotoDateView, YearArchiveView): 80 | make_object_list = True 81 | -------------------------------------------------------------------------------- /photologue/templates/admin/photologue/photo/upload_zip.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n admin_urls static %} 3 | 4 | {# Admin styling code largely taken from http://www.dmertl.com/blog/?p=116 #} 5 | 6 | {% block extrastyle %} 7 | {{ block.super }} 8 | 9 | {% endblock %} 10 | 11 | {% block bodyclass %}{{ opts.app_label }}-{{ opts.object_name.lower }} change-form{% endblock %} 12 | 13 | {% block breadcrumbs %} 14 | 21 | {% endblock %} 22 | 23 | {% block content_title %}{% endblock %} 24 | 25 | {% block content %} 26 | 27 |

    {% trans "Upload a zip archive of photos" %}

    28 | {% blocktrans %} 29 |

    On this page you can upload many photos at once, as long as you have 30 | put them all in a zip archive. The photos can be either:

    31 |
      32 |
    • Added to an existing gallery.
    • 33 |
    • Otherwise, a new gallery is created with the supplied title.
    • 34 |
    35 | {% endblocktrans %} 36 | 37 | {% if form.errors %} 38 |

    39 | {% if form.errors|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %} 40 |

    41 | {{ form.non_field_errors }} 42 | {% endif %} 43 | 44 |
    46 | {% csrf_token %} 47 |
    48 | {% for fieldset in adminform %} 49 | {% include "admin/includes/fieldset.html" %} 50 | {% endfor %} 51 |
    52 |
    53 | 54 |
    55 |
    56 | 57 | {% endblock %} 58 | -------------------------------------------------------------------------------- /photologue/tests/test_views_gallery.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, override_settings 2 | 3 | from .factories import GalleryFactory 4 | 5 | 6 | @override_settings(ROOT_URLCONF='photologue.tests.test_urls') 7 | class RequestGalleryTest(TestCase): 8 | 9 | def setUp(self): 10 | super().setUp() 11 | self.gallery = GalleryFactory(slug='test-gallery') 12 | 13 | def test_archive_gallery_url_works(self): 14 | response = self.client.get('/ptests/gallery/') 15 | self.assertEqual(response.status_code, 200) 16 | 17 | def test_archive_gallery_empty(self): 18 | """If there are no galleries to show, tell the visitor - don't show a 19 | 404.""" 20 | 21 | self.gallery.is_public = False 22 | self.gallery.save() 23 | 24 | response = self.client.get('/ptests/gallery/') 25 | self.assertEqual(response.status_code, 200) 26 | 27 | self.assertEqual(response.context['latest'].count(), 28 | 0) 29 | 30 | def test_paginated_gallery_url_works(self): 31 | response = self.client.get('/ptests/gallerylist/') 32 | self.assertEqual(response.status_code, 200) 33 | 34 | def test_gallery_works(self): 35 | response = self.client.get('/ptests/gallery/test-gallery/') 36 | self.assertEqual(response.status_code, 200) 37 | 38 | def test_archive_year_gallery_works(self): 39 | response = self.client.get('/ptests/gallery/2011/') 40 | self.assertEqual(response.status_code, 200) 41 | 42 | def test_archive_month_gallery_works(self): 43 | response = self.client.get('/ptests/gallery/2011/12/') 44 | self.assertEqual(response.status_code, 200) 45 | 46 | def test_archive_day_gallery_works(self): 47 | response = self.client.get('/ptests/gallery/2011/12/23/') 48 | self.assertEqual(response.status_code, 200) 49 | 50 | def test_detail_gallery_works(self): 51 | response = self.client.get('/ptests/gallery/2011/12/23/test-gallery/') 52 | self.assertEqual(response.status_code, 200) 53 | 54 | def test_redirect_to_list(self): 55 | """Trivial test - if someone requests the root url of the app 56 | (i.e. /ptests/'), redirect them to the gallery list page.""" 57 | response = self.client.get('/ptests/') 58 | self.assertRedirects(response, '/ptests/gallery/', 301, 200) 59 | -------------------------------------------------------------------------------- /photologue/tests/test_gallery.py: -------------------------------------------------------------------------------- 1 | from .. import models 2 | from .factories import GalleryFactory, PhotoFactory 3 | from .helpers import PhotologueBaseTest 4 | 5 | 6 | class GalleryTest(PhotologueBaseTest): 7 | 8 | def setUp(self): 9 | """Create a test gallery with 2 photos.""" 10 | super().setUp() 11 | self.test_gallery = GalleryFactory() 12 | self.pl2 = PhotoFactory() 13 | self.test_gallery.photos.add(self.pl) 14 | self.test_gallery.photos.add(self.pl2) 15 | 16 | def tearDown(self): 17 | super().tearDown() 18 | self.pl2.delete() 19 | 20 | def test_public(self): 21 | """Method 'public' should only return photos flagged as public.""" 22 | self.assertEqual(self.test_gallery.public().count(), 2) 23 | self.pl.is_public = False 24 | self.pl.save() 25 | self.assertEqual(self.test_gallery.public().count(), 1) 26 | 27 | def test_photo_count(self): 28 | """Method 'photo_count' should return the count of the photos in this 29 | gallery.""" 30 | self.assertEqual(self.test_gallery.photo_count(), 2) 31 | self.pl.is_public = False 32 | self.pl.save() 33 | self.assertEqual(self.test_gallery.photo_count(), 1) 34 | 35 | # Method takes an optional 'public' kwarg. 36 | self.assertEqual(self.test_gallery.photo_count(public=False), 2) 37 | 38 | def test_sample(self): 39 | """Method 'sample' should return a random queryset of photos from the 40 | gallery.""" 41 | 42 | # By default we return all photos from the gallery (but ordered at random). 43 | _current_sample_size = models.SAMPLE_SIZE 44 | models.SAMPLE_SIZE = 5 45 | self.assertEqual(len(self.test_gallery.sample()), 2) 46 | 47 | # We can state how many photos we want. 48 | self.assertEqual(len(self.test_gallery.sample(count=1)), 1) 49 | 50 | # If only one photo is public then the sample cannot have more than one 51 | # photo. 52 | self.pl.is_public = False 53 | self.pl.save() 54 | self.assertEqual(len(self.test_gallery.sample(count=2)), 1) 55 | 56 | self.pl.is_public = True 57 | self.pl.save() 58 | 59 | # We can limit the number of photos by changing settings. 60 | models.SAMPLE_SIZE = 1 61 | self.assertEqual(len(self.test_gallery.sample()), 1) 62 | 63 | models.SAMPLE_SIZE = _current_sample_size 64 | -------------------------------------------------------------------------------- /docs/pages/usage.rst: -------------------------------------------------------------------------------- 1 | ##### 2 | Usage 3 | ##### 4 | 5 | Now that you've installed Photologue, here are a few suggestions on how to use it: 6 | 7 | Upload some photos in the admin 8 | ------------------------------- 9 | The ``Photo`` model in the admin allows you to add new photos to Photologue. You can add photos one by one - and 10 | it the top-right corner there is a 'Upload a Zip archive' button that will allow you to upload many photos at once. 11 | 12 | Define some Photosizes 13 | ---------------------- 14 | Photologue will create thumbnails of the photos that you upload, and the thumbnails are what is displayed in the 15 | public website. By default Photologue comes with a few Photosizes to get you started - feel free to tweak them, or 16 | to create new ones. 17 | 18 | Just note that the ``admin_thumbnail`` size is used by the admin pages, so it's not a good idea to delete it! 19 | 20 | Built-in pages and templates 21 | ---------------------------- 22 | 23 | If you've followed all the instructions in the installation page, you will have included Photologue's 24 | urls at ``/photologue/`` - you can use these, tweak them, or discard them if they do not fit in with your website's 25 | requirements. 26 | 27 | Custom usage 28 | ------------ 29 | The base of Photologue is the ``Photo`` model. When an instance is created, we automatically add methods to retrieve 30 | photos at various photosizes. E.g. if you have an instance of ``Photo`` called ``photo``, then the 31 | following methods will have been added automatically:: 32 | 33 | photo.get_thumbnail_url() 34 | photo.get_display_url() 35 | photo.get_admin_thumbnail_url() 36 | 37 | These can be used in a custom template to display a thumbnail, e.g.:: 38 | 39 | 40 | {{ photo.title }} 41 | 42 | 43 | This will display an image, sized to the dimensions specified in the Photosize ``display``, 44 | and provide a clickable link to the raw image. Please refer to the example templates for ideas on how to use 45 | ``Photo`` and ``Gallery`` instances! 46 | 47 | Data integrity 48 | -------------- 49 | Photologue will store 'as-is' any data stored for galleries and photos. 50 | You may want to enforce some data integrity rules - e.g. to sanitise 51 | any javascript injected into a ``Photo`` ``caption`` field. An easy way to do this 52 | would be to add extra processing on a ``post-save`` signal. 53 | 54 | Photologue does not sanitise data itself as you may legitimately want to store html and 55 | javascript in a caption field e.g. if you use a rich-text editor. 56 | 57 | -------------------------------------------------------------------------------- /docs/pages/customising/templates.rst: -------------------------------------------------------------------------------- 1 | ################################## 2 | Customisation: extending templates 3 | ################################## 4 | 5 | Photologue comes with a set of basic templates to get you started quickly - you 6 | can of course replace them with your own. That said, it is possible to extend the basic templates in 7 | your own project and override various blocks, for example to add css classes. 8 | Often this will be enough. 9 | 10 | The trick to extending the templates is not special to Photologue, it's used 11 | in other projects such as `Oscar `_. 12 | 13 | First, set up your template configuration as so: 14 | 15 | .. code-block:: python 16 | 17 | # for Django versions < 1.8 18 | TEMPLATE_LOADERS = ( 19 | 'django.template.loaders.filesystem.Loader', 20 | 'django.template.loaders.app_directories.Loader', 21 | ) 22 | 23 | from photologue import PHOTOLOGUE_APP_DIR 24 | TEMPLATE_DIRS = ( 25 | ...other template folders..., 26 | PHOTOLOGUE_APP_DIR 27 | ) 28 | 29 | # for Django versions >= 1.8 30 | TEMPLATES = [ 31 | { 32 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 33 | 'DIRS': [os.path.join(BASE_DIR, 'templates')], 34 | # note: if you set APP_DIRS to True, you won't need to add 'loaders' under OPTIONS 35 | # proceeding as if APP_DIRS is False 36 | 'APP_DIRS': False, 37 | 'OPTIONS': { 38 | 'context_processors': [ 39 | ... context processors ..., 40 | ], 41 | # start - please add only if APP_DIRS is False 42 | 'loaders': [ 43 | 'django.template.loaders.filesystem.Loader', 44 | 'django.template.loaders.app_directories.Loader', 45 | ], 46 | # end - please add only if APP_DIRS is False 47 | }, 48 | }, 49 | ] 50 | 51 | The ``PHOTOLOGUE_APP_DIR`` points to the directory above Photologue's normal 52 | templates directory. This means that ``path/to/photologue/template.html`` can also 53 | be reached via ``templates/path/to/photologue/template.html``. 54 | 55 | For example, to customise ``photologue/gallery_list.html``, you can have an implementation like: 56 | 57 | .. code-block:: html+django 58 | 59 | # Create your own photologue/gallery_list.html 60 | {% extends "templates/photologue/gallery_list.html" %} 61 | 62 | ... we are now extending the built-in gallery_list.html and we can override 63 | the content blocks that we want to customise ... 64 | 65 | 66 | -------------------------------------------------------------------------------- /photologue/tests/test_views_photo.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, override_settings 2 | 3 | from ..models import Photo 4 | from .factories import PhotoFactory 5 | 6 | 7 | @override_settings(ROOT_URLCONF='photologue.tests.test_urls') 8 | class RequestPhotoTest(TestCase): 9 | 10 | def setUp(self): 11 | super().setUp() 12 | self.photo = PhotoFactory(slug='fake-photo') 13 | 14 | def tearDown(self): 15 | super().tearDown() 16 | self.photo.delete() 17 | 18 | def test_archive_photo_url_works(self): 19 | response = self.client.get('/ptests/photo/') 20 | self.assertEqual(response.status_code, 200) 21 | 22 | def test_archive_photo_empty(self): 23 | """If there are no photo to show, tell the visitor - don't show a 24 | 404.""" 25 | 26 | Photo.objects.all().update(is_public=False) 27 | 28 | response = self.client.get('/ptests/photo/') 29 | self.assertEqual(response.status_code, 200) 30 | 31 | self.assertEqual(response.context['latest'].count(), 32 | 0) 33 | 34 | def test_paginated_photo_url_works(self): 35 | response = self.client.get('/ptests/photolist/') 36 | self.assertEqual(response.status_code, 200) 37 | 38 | def test_photo_works(self): 39 | response = self.client.get('/ptests/photo/fake-photo/') 40 | self.assertEqual(response.status_code, 200) 41 | 42 | def test_archive_year_photo_works(self): 43 | response = self.client.get('/ptests/photo/2011/') 44 | self.assertEqual(response.status_code, 200) 45 | 46 | def test_archive_month_photo_works(self): 47 | response = self.client.get('/ptests/photo/2011/12/') 48 | self.assertEqual(response.status_code, 200) 49 | 50 | def test_archive_day_photo_works(self): 51 | response = self.client.get('/ptests/photo/2011/12/23/') 52 | self.assertEqual(response.status_code, 200) 53 | 54 | def test_detail_photo_works(self): 55 | response = self.client.get('/ptests/photo/2011/12/23/fake-photo/') 56 | self.assertEqual(response.status_code, 200) 57 | 58 | def test_detail_photo_xss(self): 59 | """Check that the default templates handle XSS.""" 60 | self.photo.title = '' 61 | self.photo.caption = '' 62 | self.photo.save() 63 | response = self.client.get('/ptests/photo/2011/12/23/fake-photo/') 64 | self.assertContains(response, 'Photologue Demo - <img src=x onerror=alert("title")>') 65 | self.assertNotContains(response, '') 66 | self.assertContains(response, '<img src=x onerror=alert(origin)>') 67 | self.assertNotContains(response, '') 68 | -------------------------------------------------------------------------------- /example_project/example_project/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | Photologue Demo - {% block title %}{% endblock title %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 49 | 50 |
    51 | 52 |
    53 | {% block content %}{% endblock content %} 54 |
    55 | 56 |
    57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /example_project/README.rst: -------------------------------------------------------------------------------- 1 | ####################### 2 | Photologue Demo Project 3 | ####################### 4 | 5 | About 6 | ===== 7 | This project serves 3 purposes: 8 | 9 | - It's a quick demo of django-photologue for people who wish to try it out. 10 | - It's an easy way for contributors to the project to have both django-photologue, 11 | and a project that uses it. 12 | - It's used for Travis CI testing of django-photologue. 13 | 14 | It uses the Bootstrap-friendly templates that come with Photologue. 15 | 16 | The rest of the README will assume that you want to set up the test project in 17 | order to work on django-photologue itself. 18 | 19 | Prerequisites 20 | ============= 21 | 22 | - python 3. 23 | - virtualenvwrapper makes it easy to manage your virtualenvs. Strongly recommended! 24 | 25 | Installation 26 | ============ 27 | **Note**: the project is configured so that it can run immediately with zero configuration 28 | (especially of settings files). 29 | 30 | Create a virtual python environment for the project. The use of virtualenvwrapper 31 | is strongly recommended:: 32 | 33 | mkproject --no-site-packages django-photologue 34 | or for more sophisticated setups: 35 | mkvirtualenv --no-site-packages django-photologue 36 | 37 | 38 | Clone this code into your project folder:: 39 | 40 | (cd to the new virtualenv) 41 | git clone https://github.com/richardbarran/django-photologue.git . 42 | 43 | **Note**: if you plan to contribute code back to django-photologue, then you'll 44 | probably want instead to fork the project on Github, and clone your fork instead. 45 | 46 | Install requirements:: 47 | 48 | cd example_project 49 | pip install -r requirements.txt 50 | 51 | **Note**: this will install Pillow, which is not always straightforward; sometimes it 52 | will install smoothly out of the box, sometimes you can spend hours figuring it out - installation 53 | issues vary from platform to platform, and from one OS release to the next. Google 54 | is your friend here, and it's worth noting that Pillow is a fork of PIL, 55 | so googling 'PIL installation ' can also help. 56 | 57 | The project is set up to run SQLite in dev so that it can be quickly started 58 | with no configuration required (you can of course specify another database in 59 | the settings file). To setup the database:: 60 | 61 | ./manage.py migrate 62 | 63 | Follow the instructions to configure photologue here: `Photologue Docs `_ 64 | 65 | And finally run the project (it defaults to a safe set of settings for a dev 66 | environment):: 67 | 68 | ./manage.py runserver 69 | 70 | Open browser to http://127.0.0.1:8000 71 | 72 | Thank you 73 | ========= 74 | This example project is based on the earlier `photologue_demo project `_. 75 | This project included contributions and input from: crainbf, tomkingston, bmcorser. 76 | 77 | 78 | .. 79 | Note: this README is formatted as reStructuredText so that it's in the same 80 | format as the Sphinx docs. 81 | -------------------------------------------------------------------------------- /docs/pages/customising/admin.rst: -------------------------------------------------------------------------------- 1 | .. _customisation-admin-label: 2 | 3 | #################### 4 | Customisation: Admin 5 | #################### 6 | 7 | The Photologue admin can easily be customised to your project's requirements. The technique described on this page 8 | is not specific to Photologue - it can be applied to any 3rd party library. 9 | 10 | Create a customisation application 11 | ---------------------------------- 12 | For clarity, it's best to put our customisation code in a new application; let's call it 13 | ``photologue_custom``; create the application and add it to your ``INSTALLED_APPS`` setting. 14 | 15 | 16 | Changing the admin 17 | ------------------ 18 | In the new ``photologue_custom`` application, create a new empty ``admin.py`` file. In this file we 19 | can replace the admin configuration supplied by Photologue, with a configuration specific to your project. 20 | For example: 21 | 22 | .. code-block:: python 23 | 24 | from django import forms 25 | from django.contrib import admin 26 | 27 | from photologue.admin import GalleryAdmin as GalleryAdminDefault 28 | from photologue.models import Gallery 29 | 30 | 31 | class GalleryAdminForm(forms.ModelForm): 32 | """Users never need to enter a description on a gallery.""" 33 | 34 | class Meta: 35 | model = Gallery 36 | exclude = ['description'] 37 | 38 | 39 | class GalleryAdmin(GalleryAdminDefault): 40 | form = GalleryAdminForm 41 | 42 | admin.site.unregister(Gallery) 43 | admin.site.register(Gallery, GalleryAdmin) 44 | 45 | 46 | This snippet will define a new Gallery admin class based on Photologue's own. The only change we make 47 | is to exclude the ``description`` field from the change form. 48 | 49 | We then unregister the default admin for the Gallery model and replace it with our new class. 50 | 51 | Possible uses 52 | ------------- 53 | 54 | The technique outlined above can be used to make many changes to the admin; here are a couple of suggestions. 55 | 56 | Custom rich text editors 57 | ~~~~~~~~~~~~~~~~~~~~~~~~ 58 | The description field on the Gallery model (and the caption field on the Photo model) are plain text fields. 59 | With the above technique, it's easy to use a rich text editor to manage these fields in the admin. For example, 60 | if you have `django-ckeditor `_ installed: 61 | 62 | .. code-block:: python 63 | 64 | from django import forms 65 | from django.contrib import admin 66 | 67 | from ckeditor.widgets import CKEditorWidget 68 | from photologue.admin import GalleryAdmin as GalleryAdminDefault 69 | from photologue.models import Gallery 70 | 71 | 72 | class GalleryAdminForm(forms.ModelForm): 73 | """Replace the default description field, with one that uses a custom widget.""" 74 | 75 | description = forms.CharField(widget=CKEditorWidget()) 76 | 77 | class Meta: 78 | model = Gallery 79 | exclude = [''] 80 | 81 | 82 | class GalleryAdmin(GalleryAdminDefault): 83 | form = GalleryAdminForm 84 | 85 | admin.site.unregister(Gallery) 86 | admin.site.register(Gallery, GalleryAdmin) 87 | 88 | -------------------------------------------------------------------------------- /docs/pages/customising/settings.rst: -------------------------------------------------------------------------------- 1 | ####################### 2 | Customisation: Settings 3 | ####################### 4 | 5 | 6 | Photologue has several settings to customise behaviour. 7 | 8 | PHOTOLOGUE_GALLERY_LATEST_LIMIT 9 | ------------------------------- 10 | 11 | Default: ``None`` 12 | 13 | Default limit for gallery.latest 14 | 15 | 16 | PHOTOLOGUE_GALLERY_SAMPLE_SIZE 17 | ------------------------------ 18 | 19 | Default: ``5`` 20 | 21 | Number of random images from the gallery to display. 22 | 23 | 24 | PHOTOLOGUE_IMAGE_FIELD_MAX_LENGTH 25 | --------------------------------- 26 | 27 | Default: ``100`` 28 | 29 | max_length setting for the ImageModel ImageField 30 | 31 | 32 | PHOTOLOGUE_SAMPLE_IMAGE_PATH 33 | ---------------------------- 34 | 35 | Default: ``os.path.join(os.path.dirname(__file__), 'res', 'sample.jpg'))`` 36 | 37 | Path to sample image 38 | 39 | 40 | PHOTOLOGUE_MAXBLOCK 41 | ------------------- 42 | 43 | Default: ``256 * 2 ** 10`` 44 | 45 | Modify image file buffer size. 46 | 47 | 48 | PHOTOLOGUE_DIR 49 | -------------- 50 | 51 | Default: ``'photologue'`` 52 | 53 | The relative path from your ``MEDIA_ROOT`` setting where Photologue will save image files. If your ``MEDIA_ROOT`` is set to "/home/user/media", photologue will upload your images to "/home/user/media/photologue" 54 | 55 | 56 | PHOTOLOGUE_PATH 57 | --------------- 58 | 59 | Default: ``None`` 60 | 61 | Look for user function to define file paths. Specifies a "callable" that takes a model instance and the original uploaded filename and returns a relative path from your ``MEDIA_ROOT`` that the file will be saved. This function can be set directly. 62 | 63 | For example you could use the following code in a util module:: 64 | 65 | # myapp/utils.py: 66 | 67 | import os 68 | 69 | def get_image_path(instance, filename): 70 | return os.path.join('path', 'to', 'my', 'files', filename) 71 | 72 | Then set in settings:: 73 | 74 | # settings.py: 75 | 76 | from utils import get_image_path 77 | 78 | PHOTOLOGUE_PATH = get_image_path 79 | 80 | Or instead, pass a string path:: 81 | 82 | # settings.py: 83 | 84 | PHOTOLOGUE_PATH = 'myapp.utils.get_image_path' 85 | 86 | .. _settings-photologue-multisite-label: 87 | 88 | PHOTOLOGUE_MULTISITE 89 | -------------------- 90 | 91 | Default: ``False`` 92 | 93 | Photologue can integrate galleries and photos with `Django's site framework`_. 94 | The default is for this feature to be switched off, and new galleries and photos to be automatically 95 | linked to the current site (``SITE_ID = 1``). The Sites many-to-many field is hidden is the admin, as there is no 96 | need for a user to see it. 97 | 98 | If the setting is ``True``, the admin interface is slightly changed: 99 | 100 | * The Sites many-to-many field is displayed on Gallery and Photos models. 101 | * The Gallery Upload allows you to associate one more sites to the uploaded photos (and gallery). 102 | 103 | .. note:: Gallery Uploads (zip archives) are always associated with the current site. Pull requests to 104 | fix this would be welcome! 105 | 106 | .. _Django's site framework: http://django.readthedocs.org/en/latest/ref/contrib/sites.html 107 | -------------------------------------------------------------------------------- /photologue/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path, reverse_lazy 2 | from django.views.generic import RedirectView 3 | 4 | from .views import (GalleryArchiveIndexView, GalleryDateDetailView, GalleryDayArchiveView, GalleryDetailView, 5 | GalleryListView, GalleryMonthArchiveView, GalleryYearArchiveView, PhotoArchiveIndexView, 6 | PhotoDateDetailView, PhotoDayArchiveView, PhotoDetailView, PhotoListView, PhotoMonthArchiveView, 7 | PhotoYearArchiveView) 8 | 9 | """NOTE: the url names are changing. In the long term, I want to remove the 'pl-' 10 | prefix on all urls, and instead rely on an application namespace 'photologue'. 11 | 12 | At the same time, I want to change some URL patterns, e.g. for pagination. Changing the urls 13 | twice within a few releases, could be confusing, so instead I am updating URLs bit by bit. 14 | 15 | The new style will coexist with the existing 'pl-' prefix for a couple of releases. 16 | 17 | """ 18 | 19 | app_name = 'photologue' 20 | urlpatterns = [ 21 | re_path(r'^gallery/(?P\d{4})/(?P[0-9]{2})/(?P\w{1,2})/(?P[\-\d\w]+)/$', 22 | GalleryDateDetailView.as_view(month_format='%m'), 23 | name='gallery-detail'), 24 | re_path(r'^gallery/(?P\d{4})/(?P[0-9]{2})/(?P\w{1,2})/$', 25 | GalleryDayArchiveView.as_view(month_format='%m'), 26 | name='gallery-archive-day'), 27 | re_path(r'^gallery/(?P\d{4})/(?P[0-9]{2})/$', 28 | GalleryMonthArchiveView.as_view(month_format='%m'), 29 | name='gallery-archive-month'), 30 | re_path(r'^gallery/(?P\d{4})/$', 31 | GalleryYearArchiveView.as_view(), 32 | name='pl-gallery-archive-year'), 33 | path('gallery/', 34 | GalleryArchiveIndexView.as_view(), 35 | name='pl-gallery-archive'), 36 | path('', 37 | RedirectView.as_view( 38 | url=reverse_lazy('photologue:pl-gallery-archive'), permanent=True), 39 | name='pl-photologue-root'), 40 | re_path(r'^gallery/(?P[\-\d\w]+)/$', 41 | GalleryDetailView.as_view(), name='pl-gallery'), 42 | path('gallerylist/', 43 | GalleryListView.as_view(), 44 | name='gallery-list'), 45 | 46 | re_path(r'^photo/(?P\d{4})/(?P[0-9]{2})/(?P\w{1,2})/(?P[\-\d\w]+)/$', 47 | PhotoDateDetailView.as_view(month_format='%m'), 48 | name='photo-detail'), 49 | re_path(r'^photo/(?P\d{4})/(?P[0-9]{2})/(?P\w{1,2})/$', 50 | PhotoDayArchiveView.as_view(month_format='%m'), 51 | name='photo-archive-day'), 52 | re_path(r'^photo/(?P\d{4})/(?P[0-9]{2})/$', 53 | PhotoMonthArchiveView.as_view(month_format='%m'), 54 | name='photo-archive-month'), 55 | re_path(r'^photo/(?P\d{4})/$', 56 | PhotoYearArchiveView.as_view(), 57 | name='pl-photo-archive-year'), 58 | path('photo/', 59 | PhotoArchiveIndexView.as_view(), 60 | name='pl-photo-archive'), 61 | 62 | re_path(r'^photo/(?P[\-\d\w]+)/$', 63 | PhotoDetailView.as_view(), 64 | name='pl-photo'), 65 | path('photolist/', 66 | PhotoListView.as_view(), 67 | name='photo-list'), 68 | ] 69 | -------------------------------------------------------------------------------- /scripts/releaser_hooks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | try: 5 | import polib 6 | except ImportError: 7 | print('Msg to the package releaser: prerelease hooks will not work as you have not installed polib.') 8 | raise 9 | import codecs 10 | import copy 11 | 12 | 13 | def prereleaser_before(data): 14 | """ 15 | 1. Run the unit tests one last time before we make a release. 16 | 2. Update the CONTRIBUTORS.txt file. 17 | 18 | Note: Install * polib (https://pypi.python.org/pypi/polib). 19 | * pep8. 20 | 21 | """ 22 | print('Running unit tests.') 23 | subprocess.check_output(["python", "example_project/manage.py", "test", "photologue"]) 24 | 25 | print('Checking that we have no outstanding DB migrations.') 26 | output = subprocess.check_output(["python", "example_project/manage.py", "makemigrations", "--dry-run", 27 | "photologue"]) 28 | if not output == b"No changes detected in app 'photologue'\n": 29 | raise Exception('There are outstanding migrations for Photologue.') 30 | 31 | print('Updating CONTRIBUTORS.txt') 32 | 33 | # This command will get the author of every commit. 34 | output = subprocess.check_output(["git", "log", "--format='%aN'"]) 35 | 36 | # Convert to a list. 37 | contributors_list = [contributor.strip("'") for contributor in output.decode('utf-8').split('\n')] 38 | 39 | # Now add info from the translator files. This is incomplete, we can only list 40 | # the 'last contributor' to each translation. 41 | for language in os.listdir('photologue/locale/'): 42 | filename = f'photologue/locale/{language}/LC_MESSAGES/django.po' 43 | po = polib.pofile(filename) 44 | last_translator = po.metadata['Last-Translator'] 45 | contributors_list.append(last_translator[:last_translator.find('<') - 1]) 46 | 47 | # Now we want to only show each contributor once, and to list them by how many 48 | # contributions they have made - a rough guide to the effort they have put in. 49 | contributors_dict = {} 50 | 51 | for author in contributors_list: 52 | author_copy = copy.copy(author) 53 | 54 | if author_copy in ('', '(no author)', 'FULL NAME'): 55 | # Skip bad data. 56 | continue 57 | 58 | # The creator of this project should always appear first in the list - so 59 | # don't add him to this list, but hard-code his name. 60 | if author_copy in ('Justin Driscoll', 'justin.driscoll'): 61 | continue 62 | 63 | # Handle contributors who appear under multiple names. 64 | if author_copy == 'richardbarran': 65 | author_copy = 'Richard Barran' 66 | 67 | if author_copy in contributors_dict: 68 | contributors_dict[author_copy] += 1 69 | else: 70 | contributors_dict[author_copy] = 1 71 | 72 | with codecs.open('CONTRIBUTORS.txt', 'w', encoding='utf8') as f: 73 | f.write('Photologue is made possible by all the people who have contributed' 74 | ' to it. A non-exhaustive list follows:\n\n') 75 | f.write('Justin Driscoll\n') 76 | for i in sorted(contributors_dict, key=contributors_dict.get, reverse=True): 77 | f.write(i + '\n') 78 | 79 | # And commit the new contributors file. 80 | if subprocess.check_output(["git", "diff", "CONTRIBUTORS.txt"]): 81 | subprocess.check_output(["git", "commit", "-m", "Updated the list of contributors.", "CONTRIBUTORS.txt"]) 82 | -------------------------------------------------------------------------------- /docs/pages/customising/models.rst: -------------------------------------------------------------------------------- 1 | .. _customising-models-label: 2 | 3 | ##################### 4 | Customisation: Models 5 | ##################### 6 | 7 | The photologue models can be extended to better suit your project. The technique described on this page 8 | is not specific to Photologue - it can be applied to any 3rd party library. 9 | 10 | The models within Photologue cannot be directly modified (unlike, for example, Django's own User model). 11 | There are a number of reasons behind this decision, including: 12 | 13 | - If code within a project modifies directly the Photologue models' fields, it leaves the Photologue 14 | schema migrations in an ambiguous state. 15 | - Likewise, model methods can no longer be trusted to behave as intended (as fields on which they 16 | depend may have been overridden). 17 | 18 | However, it's easy to create new models linked by one-to-one relationships to Photologue's own 19 | ``Gallery`` and ``Photo`` models. 20 | 21 | On this page we will show how you can add tags to the ``Gallery`` model. For this we will use 22 | the popular 3rd party application `django-taggit `_. 23 | 24 | .. note:: 25 | 26 | The ``Gallery`` and ``Photo`` models currently have tag fields, however these are based on the 27 | abandonware `django-tagging `_ application. Instead, 28 | tagging is being entirely removed from Photologue, as it is a non-core functionality of a 29 | gallery application, and is easy to add back in - as this page shows! 30 | 31 | Create a customisation application 32 | ---------------------------------- 33 | For clarity, it's best to put our customisation code in a new application; let's call it 34 | ``photologue_custom``; create the application and add it to your ``INSTALLED_APPS`` setting. 35 | 36 | Extending 37 | --------- 38 | 39 | Within the ``photologue_custom`` application, we will edit 2 files: 40 | 41 | Models.py 42 | ~~~~~~~~~ 43 | 44 | .. code-block:: python 45 | 46 | from django.db import models 47 | 48 | from taggit.managers import TaggableManager 49 | 50 | from photologue.models import Gallery 51 | 52 | 53 | class GalleryExtended(models.Model): 54 | 55 | # Link back to Photologue's Gallery model. 56 | gallery = models.OneToOneField(Gallery, related_name='extended') 57 | 58 | # This is the important bit - where we add in the tags. 59 | tags = TaggableManager(blank=True) 60 | 61 | # Boilerplate code to make a prettier display in the admin interface. 62 | class Meta: 63 | verbose_name = u'Extra fields' 64 | verbose_name_plural = u'Extra fields' 65 | 66 | def __str__(self): 67 | return self.gallery.title 68 | 69 | 70 | Admin.py 71 | ~~~~~~~~ 72 | 73 | .. code-block:: python 74 | 75 | from django.contrib import admin 76 | 77 | from photologue.admin import GalleryAdmin as GalleryAdminDefault 78 | from photologue.models import Gallery 79 | from .models import GalleryExtended 80 | 81 | 82 | class GalleryExtendedInline(admin.StackedInline): 83 | model = GalleryExtended 84 | can_delete = False 85 | 86 | 87 | class GalleryAdmin(GalleryAdminDefault): 88 | 89 | """Define our new one-to-one model as an inline of Photologue's Gallery model.""" 90 | 91 | inlines = [GalleryExtendedInline, ] 92 | 93 | admin.site.unregister(Gallery) 94 | admin.site.register(Gallery, GalleryAdmin) 95 | 96 | The above code is enough to start entering tags in the admin interface. To use/display them in the front 97 | end, you will also need to override Photologue's own templates - as the templates are likely to be 98 | heavily customised for your specific project, an example is not included here. 99 | 100 | 101 | -------------------------------------------------------------------------------- /photologue/utils/reflection.py: -------------------------------------------------------------------------------- 1 | """ Function for generating web 2.0 style image reflection effects. 2 | 3 | Copyright (c) 2007, Justin C. Driscoll 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, 7 | are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, 10 | this list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright 13 | notice, this list of conditions and the following disclaimer in the 14 | documentation and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of reflection.py nor the names of its contributors may be used 17 | to endorse or promote products derived from this software without 18 | specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 24 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 26 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 27 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | 31 | """ 32 | 33 | try: 34 | import Image 35 | import ImageColor 36 | except ImportError: 37 | try: 38 | from PIL import Image, ImageColor 39 | except ImportError: 40 | raise ImportError("The Python Imaging Library was not found.") 41 | 42 | 43 | def add_reflection(im, bgcolor="#00000", amount=0.4, opacity=0.6): 44 | """ Returns the supplied PIL Image (im) with a reflection effect 45 | 46 | bgcolor The background color of the reflection gradient 47 | amount The height of the reflection as a percentage of the orignal image 48 | opacity The initial opacity of the reflection gradient 49 | 50 | Originally written for the Photologue image management system for Django 51 | and Based on the original concept by Bernd Schlapsi 52 | 53 | """ 54 | # convert bgcolor string to rgb value 55 | background_color = ImageColor.getrgb(bgcolor) 56 | 57 | # copy orignial image and flip the orientation 58 | reflection = im.copy().transpose(Image.FLIP_TOP_BOTTOM) 59 | 60 | # create a new image filled with the bgcolor the same size 61 | background = Image.new("RGB", im.size, background_color) 62 | 63 | # calculate our alpha mask 64 | start = int(255 - (255 * opacity)) # The start of our gradient 65 | steps = int(255 * amount) # the number of intermedite values 66 | increment = (255 - start) / float(steps) 67 | mask = Image.new('L', (1, 255)) 68 | for y in range(255): 69 | if y < steps: 70 | val = int(y * increment + start) 71 | else: 72 | val = 255 73 | mask.putpixel((0, y), val) 74 | alpha_mask = mask.resize(im.size) 75 | 76 | # merge the reflection onto our background color using the alpha mask 77 | reflection = Image.composite(background, reflection, alpha_mask) 78 | 79 | # crop the reflection 80 | reflection_height = int(im.size[1] * amount) 81 | reflection = reflection.crop((0, 0, im.size[0], reflection_height)) 82 | 83 | # create new image sized to hold both the original image and the reflection 84 | composite = Image.new("RGB", (im.size[0], im.size[1] + reflection_height), background_color) 85 | 86 | # paste the orignal image and the reflection into the composite image 87 | composite.paste(im, (0, 0)) 88 | composite.paste(reflection, (0, im.size[1])) 89 | 90 | # return the image complete with reflection effect 91 | return composite 92 | -------------------------------------------------------------------------------- /docs/pages/customising/views.rst: -------------------------------------------------------------------------------- 1 | .. _customisation-views-label: 2 | 3 | ############################# 4 | Customisation: Views and Urls 5 | ############################# 6 | 7 | The photologue views and urls can be tweaked to better suit your project. The technique described on this page 8 | is not specific to Photologue - it can be applied to any 3rd party library. 9 | 10 | Create a customisation application 11 | ---------------------------------- 12 | For clarity, it's best to put our customisation code in a new application; let's call it 13 | ``photologue_custom``; create the application and add it to your ``INSTALLED_APPS`` setting. 14 | 15 | We will also want to customise urls: 16 | 17 | 1. Create a urls.py that will contain our customised urls: 18 | 19 | 20 | .. code-block:: python 21 | 22 | from django.conf.urls import * 23 | 24 | urlpatterns = [ 25 | # Nothing to see here... for now. 26 | ] 27 | 28 | 29 | 2. These custom urls will override the main Photologue urls, so place them just before Photologue 30 | in the project's main urls.py file: 31 | 32 | .. code-block:: python 33 | 34 | ... other code 35 | (r'^photologue/', include('photologue_custom.urls')), 36 | url(r'^photologue/', include('photologue.urls', namespace='photologue')), 37 | 38 | ... other code 39 | 40 | Now we're ready to make some changes. 41 | 42 | Changing pagination from our new urls.py 43 | ---------------------------------------- 44 | 45 | The list pages of Photologue (both Gallery and Photo) display 20 objects per page. Let's change this value. 46 | Edit our new urls.py file, and add: 47 | 48 | 49 | .. code-block:: python 50 | 51 | from django.conf.urls import * 52 | 53 | from photologue.views import GalleryListView 54 | 55 | urlpatterns = [ 56 | url(r'^gallerylist/$', 57 | GalleryListView.as_view(paginate_by=5), 58 | name='photologue_custom-gallery-list'), 59 | ] 60 | 61 | 62 | We've copied the urlpattern for 63 | `the gallery list view from Photologue itself `_, 64 | and changed it slightly by passing in ``paginate_by=5``. 65 | 66 | And that's it - now when that page is requested, our customised urls.py will be called first, with pagination 67 | set to 5 items. 68 | 69 | Values that can be overridden from urls.py 70 | ------------------------------------------ 71 | 72 | GalleryListView 73 | ~~~~~~~~~~~~~~~ 74 | 75 | * paginate_by: number of items to display per page. 76 | 77 | PhotoListView 78 | ~~~~~~~~~~~~~ 79 | 80 | * paginate_by: number of items to display per page. 81 | 82 | Changing views.py to create a RESTful api 83 | ----------------------------------------- 84 | More substantial customisation can be carried out by writing custom views. For example, 85 | it's easy to change a Photologue view to return JSON objects rather than html webpages. For this 86 | quick demo, we'll use the 87 | `django-braces library `_ 88 | to override the view returning a list of all photos. 89 | 90 | Add the following code to views.py in ``photologue_custom``: 91 | 92 | .. code-block:: python 93 | 94 | from photologue.views import PhotoListView 95 | 96 | from braces.views import JSONResponseMixin 97 | 98 | 99 | class PhotoJSONListView(JSONResponseMixin, PhotoListView): 100 | 101 | def render_to_response(self, context, **response_kwargs): 102 | return self.render_json_object_response(context['object_list'], 103 | **response_kwargs) 104 | 105 | And call this new view from urls.py; here we are replacing the standard Photo list page provided by Photologue: 106 | 107 | .. code-block:: python 108 | 109 | from .views import PhotoJSONListView 110 | 111 | urlpatterns = [ 112 | # Other urls... 113 | url(r'^photolist/$', 114 | PhotoJSONListView.as_view(), 115 | name='photologue_custom-photo-json-list'), 116 | # Other urls as required... 117 | ] 118 | 119 | 120 | And that's it! Of course, this is simply a demo and a real RESTful api would be rather more complex. 121 | 122 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /photologue/tests/factories.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | from django.conf import settings 5 | from django.utils.text import slugify 6 | 7 | try: 8 | import factory 9 | except ImportError: 10 | raise ImportError( 11 | "No module named factory. To run photologue's tests you need to install factory-boy.") 12 | 13 | from ..models import Gallery, ImageModel, Photo, PhotoEffect, PhotoSize 14 | 15 | RES_DIR = os.path.join(os.path.dirname(__file__), '../res') 16 | LANDSCAPE_IMAGE_PATH = os.path.join(RES_DIR, 'test_photologue_landscape.jpg') 17 | PORTRAIT_IMAGE_PATH = os.path.join(RES_DIR, 'test_photologue_portrait.jpg') 18 | SQUARE_IMAGE_PATH = os.path.join(RES_DIR, 'test_photologue_square.jpg') 19 | QUOTING_IMAGE_PATH = os.path.join(RES_DIR, 'test_photologue_"ing.jpg') 20 | UNICODE_IMAGE_PATH = os.path.join(RES_DIR, 'test_unicode_®.jpg') 21 | NONSENSE_IMAGE_PATH = os.path.join(RES_DIR, 'test_nonsense.jpg') 22 | SAMPLE_ZIP_PATH = os.path.join(RES_DIR, 'zips/sample.zip') 23 | SAMPLE_NOT_IMAGE_ZIP_PATH = os.path.join(RES_DIR, 'zips/not_image.zip') 24 | IGNORED_FILES_ZIP_PATH = os.path.join(RES_DIR, 'zips/ignored_files.zip') 25 | 26 | 27 | class GalleryFactory(factory.django.DjangoModelFactory): 28 | 29 | class Meta: 30 | model = Gallery 31 | 32 | title = factory.Sequence(lambda n: f'gallery{n:0>3}') 33 | slug = factory.LazyAttribute(lambda a: slugify(a.title)) 34 | 35 | @factory.sequence 36 | def date_added(n): 37 | # Have to cater projects being non-timezone aware. 38 | if settings.USE_TZ: 39 | sample_date = datetime.datetime( 40 | year=2011, month=12, day=23, hour=17, minute=40, tzinfo=datetime.timezone.utc) 41 | else: 42 | sample_date = datetime.datetime(year=2011, month=12, day=23, hour=17, minute=40) 43 | return sample_date + datetime.timedelta(minutes=n) 44 | 45 | @factory.post_generation 46 | def sites(self, create, extracted, **kwargs): 47 | """ 48 | Associates the object with the current site unless ``sites`` was passed, 49 | in which case the each item in ``sites`` is associated with the object. 50 | 51 | Note that if PHOTOLOGUE_MULTISITE is False, all Gallery/Photos are automatically 52 | associated with the current site - bear this in mind when writing tests. 53 | """ 54 | if not create: 55 | return 56 | if extracted: 57 | for site in extracted: 58 | self.sites.add(site) 59 | 60 | 61 | class ImageModelFactory(factory.django.DjangoModelFactory): 62 | 63 | class Meta: 64 | model = ImageModel 65 | abstract = True 66 | 67 | 68 | class PhotoFactory(ImageModelFactory): 69 | 70 | """Note: after creating Photo instances for tests, remember to manually 71 | delete them. 72 | """ 73 | 74 | class Meta: 75 | model = Photo 76 | 77 | title = factory.Sequence(lambda n: f'photo{n:0>3}') 78 | slug = factory.LazyAttribute(lambda a: slugify(a.title)) 79 | image = factory.django.ImageField(from_path=LANDSCAPE_IMAGE_PATH) 80 | 81 | @factory.sequence 82 | def date_added(n): 83 | # Have to cater projects being non-timezone aware. 84 | if settings.USE_TZ: 85 | sample_date = datetime.datetime( 86 | year=2011, month=12, day=23, hour=17, minute=40, tzinfo=datetime.timezone.utc) 87 | else: 88 | sample_date = datetime.datetime(year=2011, month=12, day=23, hour=17, minute=40) 89 | return sample_date + datetime.timedelta(minutes=n) 90 | 91 | @factory.post_generation 92 | def sites(self, create, extracted, **kwargs): 93 | """ 94 | Associates the object with the current site unless ``sites`` was passed, 95 | in which case the each item in ``sites`` is associated with the object. 96 | 97 | Note that if PHOTOLOGUE_MULTISITE is False, all Gallery/Photos are automatically 98 | associated with the current site - bear this in mind when writing tests. 99 | """ 100 | if not create: 101 | return 102 | if extracted: 103 | for site in extracted: 104 | self.sites.add(site) 105 | 106 | 107 | class PhotoSizeFactory(factory.django.DjangoModelFactory): 108 | 109 | class Meta: 110 | model = PhotoSize 111 | 112 | name = factory.Sequence(lambda n: f'name{n:0>3}') 113 | 114 | 115 | class PhotoEffectFactory(factory.django.DjangoModelFactory): 116 | 117 | class Meta: 118 | model = PhotoEffect 119 | 120 | name = factory.Sequence(lambda n: f'effect{n:0>3}') 121 | -------------------------------------------------------------------------------- /photologue/templatetags/photologue_tags.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from django import template 4 | 5 | from ..models import Gallery, Photo 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.inclusion_tag('photologue/tags/next_in_gallery.html') 11 | def next_in_gallery(photo, gallery): 12 | return {'photo': photo.get_next_in_gallery(gallery)} 13 | 14 | 15 | @register.inclusion_tag('photologue/tags/prev_in_gallery.html') 16 | def previous_in_gallery(photo, gallery): 17 | return {'photo': photo.get_previous_in_gallery(gallery)} 18 | 19 | 20 | @register.simple_tag 21 | def cycle_lite_gallery(gallery_title, height, width): 22 | """Generate image tags for jquery slideshow gallery. 23 | See http://malsup.com/jquery/cycle/lite/""" 24 | html = "" 25 | first = 'class="first"' 26 | for p in Gallery.objects.get(title=gallery_title).public(): 27 | html += '{}'.format( 28 | p.get_display_url(), p.title, height, width, first) 29 | first = None 30 | return html 31 | 32 | 33 | @register.tag 34 | def get_photo(parser, token): 35 | """Get a single photo from the photologue library and return the img tag to display it. 36 | 37 | Takes 3 args: 38 | - the photo to display. This can be either the slug of a photo, or a variable that holds either a photo instance or 39 | a integer (photo id) 40 | - the photosize to use. 41 | - a CSS class to apply to the img tag. 42 | """ 43 | try: 44 | # Split the contents of the tag, i.e. tag name + argument. 45 | tag_name, photo, photosize, css_class = token.split_contents() 46 | except ValueError: 47 | msg = '%r tag requires 3 arguments' % token.contents[0] 48 | raise template.TemplateSyntaxError(msg) 49 | return PhotoNode(photo, photosize[1:-1], css_class[1:-1]) 50 | 51 | 52 | class PhotoNode(template.Node): 53 | 54 | def __init__(self, photo, photosize, css_class): 55 | self.photo = photo 56 | self.photosize = photosize 57 | self.css_class = css_class 58 | 59 | def render(self, context): 60 | try: 61 | a = template.Variable(self.photo).resolve(context) 62 | except: 63 | a = self.photo 64 | if isinstance(a, Photo): 65 | p = a 66 | else: 67 | try: 68 | p = Photo.objects.get(slug=a) 69 | except Photo.DoesNotExist: 70 | # Ooops. Fail silently 71 | return None 72 | if not p.is_public: 73 | return None 74 | func = getattr(p, 'get_%s_url' % (self.photosize), None) 75 | if func is None: 76 | return 'A "%s" photo size has not been defined.' % (self.photosize) 77 | else: 78 | return f'{p.title}' 79 | 80 | 81 | @register.tag 82 | def get_rotating_photo(parser, token): 83 | """Pick at random a photo from a given photologue gallery and return the img tag to display it. 84 | 85 | Takes 3 args: 86 | - the gallery to pick a photo from. This can be either the slug of a gallery, or a variable that holds either a 87 | gallery instance or a gallery slug. 88 | - the photosize to use. 89 | - a CSS class to apply to the img tag. 90 | """ 91 | try: 92 | # Split the contents of the tag, i.e. tag name + argument. 93 | tag_name, gallery, photosize, css_class = token.split_contents() 94 | except ValueError: 95 | msg = '%r tag requires 3 arguments' % token.contents[0] 96 | raise template.TemplateSyntaxError(msg) 97 | return PhotoGalleryNode(gallery, photosize[1:-1], css_class[1:-1]) 98 | 99 | 100 | class PhotoGalleryNode(template.Node): 101 | 102 | def __init__(self, gallery, photosize, css_class): 103 | self.gallery = gallery 104 | self.photosize = photosize 105 | self.css_class = css_class 106 | 107 | def render(self, context): 108 | try: 109 | a = template.resolve_variable(self.gallery, context) 110 | except: 111 | a = self.gallery 112 | if isinstance(a, Gallery): 113 | g = a 114 | else: 115 | try: 116 | g = Gallery.objects.get(slug=a) 117 | except Gallery.DoesNotExist: 118 | return None 119 | photos = g.public() 120 | if len(photos) > 1: 121 | r = random.randint(0, len(photos) - 1) 122 | p = photos[r] 123 | elif len(photos) == 1: 124 | p = photos[0] 125 | else: 126 | return None 127 | func = getattr(p, 'get_%s_url' % (self.photosize), None) 128 | if func is None: 129 | return 'A "%s" photo size has not been defined.' % (self.photosize) 130 | else: 131 | return f'{p.title}' 132 | -------------------------------------------------------------------------------- /example_project/example_project/settings.py: -------------------------------------------------------------------------------- 1 | # Global settings for photologue example project. 2 | 3 | import os 4 | import sys 5 | 6 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 7 | 8 | 9 | SECRET_KEY = '=_v6sfp8u2uuhdncdz9t1_nu8(#8q4=40$f$4rorj4q3)f-nlc' 10 | 11 | DEBUG = True 12 | 13 | ALLOWED_HOSTS = ['*'] 14 | 15 | 16 | INSTALLED_APPS = ( 17 | 'django.contrib.admin', 18 | 'django.contrib.auth', 19 | 'django.contrib.contenttypes', 20 | 'django.contrib.sessions', 21 | 'django.contrib.sites', 22 | 'django.contrib.messages', 23 | 'django.contrib.staticfiles', 24 | # Note: added sitemaps to the INSTALLED_APPS just so that unit tests run, 25 | # but not actually added a sitemap in urls.py. 26 | 'django.contrib.sitemaps', 27 | 'photologue', 28 | 'sortedm2m', 29 | 'example_project', 30 | ) 31 | 32 | MIDDLEWARE = ( 33 | 'django.contrib.sessions.middleware.SessionMiddleware', 34 | 'django.middleware.common.CommonMiddleware', 35 | 'django.middleware.csrf.CsrfViewMiddleware', 36 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 37 | 'django.contrib.messages.middleware.MessageMiddleware', 38 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 39 | ) 40 | 41 | ROOT_URLCONF = 'example_project.urls' 42 | 43 | TEMPLATES = [ 44 | { 45 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 46 | 'DIRS': [os.path.join(BASE_DIR, 'example_project/templates'), ], 47 | 'APP_DIRS': True, 48 | 'OPTIONS': { 49 | 'context_processors': [ 50 | 'django.template.context_processors.debug', 51 | 'django.template.context_processors.request', 52 | 'django.contrib.auth.context_processors.auth', 53 | 'django.contrib.messages.context_processors.messages', 54 | 'django.template.context_processors.i18n', 55 | 'django.template.context_processors.media', 56 | 'django.template.context_processors.static', 57 | ], 58 | 'debug': True, 59 | }, 60 | }, 61 | ] 62 | 63 | WSGI_APPLICATION = 'example_project.wsgi.application' 64 | 65 | 66 | # Database 67 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 68 | 69 | DATABASES = { 70 | 'default': { 71 | 'ENGINE': 'django.db.backends.sqlite3', 72 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 73 | } 74 | } 75 | 76 | 77 | # Internationalization 78 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 79 | 80 | LANGUAGE_CODE = 'en-gb' 81 | 82 | TIME_ZONE = 'UTC' 83 | 84 | USE_I18N = True 85 | 86 | USE_TZ = True 87 | 88 | 89 | # Static files (CSS, JavaScript, Images) 90 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 91 | 92 | STATIC_ROOT = os.path.join(BASE_DIR, 'public', 'static') 93 | STATIC_URL = '/static/' 94 | 95 | MEDIA_ROOT = os.path.join(BASE_DIR, 'public', 'media') 96 | MEDIA_URL = '/media/' 97 | 98 | STATICFILES_DIRS = ( 99 | os.path.join(BASE_DIR, 'example_project/static'), 100 | ) 101 | 102 | SITE_ID = 1 103 | 104 | # LOGGING CONFIGURATION 105 | # A logging configuration that writes log messages to the console. 106 | LOGGING = { 107 | 'version': 1, 108 | 'disable_existing_loggers': True, 109 | # Formatting of messages. 110 | 'formatters': { 111 | # Don't need to show the time when logging to console. 112 | 'console': { 113 | 'format': '%(levelname)s %(name)s.%(funcName)s (%(lineno)d) %(message)s' 114 | } 115 | }, 116 | # The handlers decide what we should do with a logging message - do we email 117 | # it, ditch it, or write it to a file? 118 | 'handlers': { 119 | # Writing to console. Use only in dev. 120 | 'console': { 121 | 'level': 'DEBUG', 122 | 'class': 'logging.StreamHandler', 123 | 'formatter': 'console' 124 | }, 125 | # Send logs to /dev/null. 126 | 'null': { 127 | 'level': 'DEBUG', 128 | 'class': 'logging.NullHandler', 129 | }, 130 | }, 131 | # Loggers decide what is logged. 132 | 'loggers': { 133 | '': { 134 | # Default (suitable for dev) is to log to console. 135 | 'handlers': ['console'], 136 | 'level': 'INFO', 137 | 'propagate': False, 138 | }, 139 | 'photologue': { 140 | # Default (suitable for dev) is to log to console. 141 | 'handlers': ['console'], 142 | 'level': 'DEBUG', 143 | 'propagate': False, 144 | }, 145 | # logging of SQL statements. Default is to ditch them (send them to 146 | # null). Note that this logger only works if DEBUG = True. 147 | 'django.db.backends': { 148 | 'handlers': ['null'], 149 | 'level': 'DEBUG', 150 | 'propagate': False, 151 | }, 152 | } 153 | } 154 | 155 | # Don't display logging messages to console during unit test runs. 156 | if len(sys.argv) > 1 and sys.argv[1] == 'test': 157 | LOGGING['loggers']['']['handlers'] = ['null'] 158 | LOGGING['loggers']['photologue']['handlers'] = ['null'] 159 | 160 | # Uncomment this for Amazon S3 file storage 161 | # from example_storages.settings_s3boto import * 162 | -------------------------------------------------------------------------------- /docs/pages/contributing.rst: -------------------------------------------------------------------------------- 1 | ########################## 2 | Contributing to Photologue 3 | ########################## 4 | 5 | Contributions are always very welcome. Even if you have never contributed to an 6 | open-source project before - please do not hesitate to offer help. Fixes for typos in the 7 | documentation, extra unit tests, etc... are welcome. And look in the issues 8 | list for anything tagged "easy_win". 9 | 10 | Example project 11 | --------------- 12 | Photologue includes an example project under ``/example_project/`` to get you quickly ready for 13 | contributing to the project - do not hesitate to use it! Please refer to ``/example_project/README.rst`` 14 | for installation instructions. 15 | 16 | You'll probably also want to manually install 17 | `Sphinx `_ if you're going to update the documentation. 18 | 19 | Workflow 20 | -------- 21 | Photologue is hosted on Github, so if you have not already done so, read the excellent 22 | `Github help pages `_. We try to keep the workflow 23 | as simple as possible; most pull requests are merged straight into the master branch. Please 24 | ensure your pull requests are on separate branches, and please try to only include one new 25 | feature per pull request! 26 | 27 | Features that will take a while to develop might warrant a separate branch in the project; 28 | at present only the ImageKit integration project is run on a separate branch. 29 | 30 | Coding style 31 | ------------ 32 | No surprises here - just try to `follow the conventions used by Django itself 33 | `_. 34 | 35 | Unit tests 36 | ---------- 37 | Including unit tests with your contributions will earn you bonus points, maybe even a beer. So write 38 | plenty of tests, and run them from the ``/example_project/`` with a 39 | ``python manage.py test photologue``. 40 | 41 | There's also a `Tox `_ configuration file - so if 42 | you have tox installed, run ``tox`` from the ``/example_project/`` folder, and it will run the entire 43 | test suite against all versions of Python and Django that are supported. 44 | 45 | Documentation 46 | ------------- 47 | Keeping the documentation up-to-date is very important - so if your code changes 48 | how Photologue works, or adds a new feature, please check that the documentation is still accurate, and 49 | update it if required. 50 | 51 | We use `Sphinx `_ to prepare the documentation; please refer to the excellent docs 52 | on that site for help. 53 | 54 | .. note:: 55 | 56 | The CHANGELOG is part of the documentation, so if your patch needs the 57 | end user to do something - e.g. run a database migration - don't forget to update 58 | it! 59 | 60 | Translations 61 | ------------ 62 | `Photologue manages string translations with Transifex 63 | `_. The easiest way to help is 64 | to add new/updated translations there. 65 | 66 | Once you've added translations, give the maintainer a wave and he will pull the updated 67 | translations into the master branch, so that you can install Photologue directly from the 68 | Github repository (see :ref:`installing-photologue-label`) and use your translations straight away. Or you can do nothing - just before a release 69 | any new/updated translations get pulled from Transifex and added to the Photologue project. 70 | 71 | New features 72 | ------------ 73 | In the wiki there is a `wishlist of new features already planned 74 | for Photologue `_ - you are welcome to suggest other useful improvements. 75 | 76 | If you’re interested in developing a new feature, it is recommended that you first 77 | discuss it on the `mailing list `_ 78 | or open a new ticket in Github, in order to avoid working on a feature that will 79 | not get accepted as it is judged to not fit in with the goals of Photologue. 80 | 81 | A bit of history 82 | ~~~~~~~~~~~~~~~~ 83 | Photologue was started by Justin Driscoll in 2007. He quickly built it into a powerful 84 | photo gallery and image processing application, and it became successful. 85 | 86 | Justin then moved onto other projects, and no longer had the time required to maintain 87 | Photologue - there was only one commit between August 2009 and August 2012, and 88 | approximately 70 open tickets on the Google Code project page. 89 | 90 | At this point Richard Barran took over as maintainer of the project. First priority 91 | was to improve the infrastructure of the project: moving to Github, adding South, 92 | Sphinx for documentation, Transifex for translations, Travis for continuous integration, 93 | zest.releaser. 94 | 95 | The codebase has not changed much so far - and it needs quite a bit of TLC 96 | (Tender Loving Care), and new features are waiting to be added. This is where you step in... 97 | 98 | And finally... 99 | -------------- 100 | Please remember that the maintainer looks after Photologue in his spare time - 101 | so it might be a few weeks before your pull request gets looked at... and the pull 102 | requests that are nicely formatted, with code, tests and docs included, will 103 | always get reviewed first ;-) 104 | -------------------------------------------------------------------------------- /photologue/tests/test_resize.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from django.core.exceptions import ValidationError 4 | 5 | from ..models import PhotoSize, PhotoSizeCache 6 | from .factories import PORTRAIT_IMAGE_PATH, SQUARE_IMAGE_PATH, PhotoFactory 7 | from .helpers import PhotologueBaseTest 8 | 9 | 10 | class PhotoSizeTest(unittest.TestCase): 11 | 12 | def test_clean_wont_allow_zero_dimension_and_crop(self): 13 | """Tests if ValidationError is raised by clean method if with or height 14 | is set to 0 and crop is set to true""" 15 | s = PhotoSize(name='test', width=400, crop=True) 16 | self.assertRaises(ValidationError, s.clean) 17 | 18 | 19 | class ImageResizeTest(PhotologueBaseTest): 20 | 21 | def setUp(self): 22 | super().setUp() 23 | # Portrait. 24 | self.pp = PhotoFactory(image__from_path=PORTRAIT_IMAGE_PATH) 25 | # Square. 26 | self.ps = PhotoFactory(image__from_path=SQUARE_IMAGE_PATH) 27 | 28 | def tearDown(self): 29 | super().tearDown() 30 | self.pp.delete() 31 | self.ps.delete() 32 | 33 | def test_resize_to_fit(self): 34 | self.assertEqual(self.pl.get_testPhotoSize_size(), (100, 75)) 35 | self.assertEqual(self.pp.get_testPhotoSize_size(), (75, 100)) 36 | self.assertEqual(self.ps.get_testPhotoSize_size(), (100, 100)) 37 | 38 | def test_resize_to_fit_width(self): 39 | self.s.size = (100, 0) 40 | self.s.save() 41 | self.assertEqual(self.pl.get_testPhotoSize_size(), (100, 75)) 42 | self.assertEqual(self.pp.get_testPhotoSize_size(), (100, 133)) 43 | self.assertEqual(self.ps.get_testPhotoSize_size(), (100, 100)) 44 | 45 | def test_resize_to_fit_width_enlarge(self): 46 | self.s.size = (400, 0) 47 | self.s.upscale = True 48 | self.s.save() 49 | self.assertEqual(self.pl.get_testPhotoSize_size(), (400, 300)) 50 | self.assertEqual(self.pp.get_testPhotoSize_size(), (400, 533)) 51 | self.assertEqual(self.ps.get_testPhotoSize_size(), (400, 400)) 52 | 53 | def test_resize_to_fit_height(self): 54 | self.s.size = (0, 100) 55 | self.s.save() 56 | self.assertEqual(self.pl.get_testPhotoSize_size(), (133, 100)) 57 | self.assertEqual(self.pp.get_testPhotoSize_size(), (75, 100)) 58 | self.assertEqual(self.ps.get_testPhotoSize_size(), (100, 100)) 59 | 60 | def test_resize_to_fit_height_enlarge(self): 61 | self.s.size = (0, 400) 62 | self.s.upscale = True 63 | self.s.save() 64 | self.assertEqual(self.pl.get_testPhotoSize_size(), (533, 400)) 65 | self.assertEqual(self.pp.get_testPhotoSize_size(), (300, 400)) 66 | self.assertEqual(self.ps.get_testPhotoSize_size(), (400, 400)) 67 | 68 | def test_resize_and_crop(self): 69 | self.s.crop = True 70 | self.s.save() 71 | self.assertEqual(self.pl.get_testPhotoSize_size(), self.s.size) 72 | self.assertEqual(self.pp.get_testPhotoSize_size(), self.s.size) 73 | self.assertEqual(self.ps.get_testPhotoSize_size(), self.s.size) 74 | 75 | def test_resize_rounding_to_fit(self): 76 | self.s.size = (113, 113) 77 | self.s.save() 78 | self.assertEqual(self.pl.get_testPhotoSize_size(), (113, 85)) 79 | self.assertEqual(self.pp.get_testPhotoSize_size(), (85, 113)) 80 | self.assertEqual(self.ps.get_testPhotoSize_size(), (113, 113)) 81 | 82 | def test_resize_rounding_cropped(self): 83 | self.s.size = (113, 113) 84 | self.s.crop = True 85 | self.s.save() 86 | self.assertEqual(self.pl.get_testPhotoSize_size(), self.s.size) 87 | self.assertEqual(self.pp.get_testPhotoSize_size(), self.s.size) 88 | self.assertEqual(self.ps.get_testPhotoSize_size(), self.s.size) 89 | 90 | def test_resize_one_dimension_width(self): 91 | self.s.size = (100, 150) 92 | self.s.save() 93 | self.assertEqual(self.pl.get_testPhotoSize_size(), (100, 75)) 94 | 95 | def test_resize_one_dimension_height(self): 96 | self.s.size = (200, 75) 97 | self.s.save() 98 | self.assertEqual(self.pl.get_testPhotoSize_size(), (100, 75)) 99 | 100 | def test_resize_no_upscale(self): 101 | self.s.size = (1000, 1000) 102 | self.s.save() 103 | self.assertEqual(self.pl.get_testPhotoSize_size(), (200, 150)) 104 | 105 | def test_resize_no_upscale_mixed_height(self): 106 | self.s.size = (400, 75) 107 | self.s.save() 108 | self.assertEqual(self.pl.get_testPhotoSize_size(), (100, 75)) 109 | 110 | def test_resize_no_upscale_mixed_width(self): 111 | self.s.size = (100, 300) 112 | self.s.save() 113 | self.assertEqual(self.pl.get_testPhotoSize_size(), (100, 75)) 114 | 115 | def test_resize_no_upscale_crop(self): 116 | self.s.size = (1000, 1000) 117 | self.s.crop = True 118 | self.s.save() 119 | self.assertEqual(self.pl.get_testPhotoSize_size(), (1000, 1000)) 120 | 121 | def test_resize_upscale(self): 122 | self.s.size = (1000, 1000) 123 | self.s.upscale = True 124 | self.s.save() 125 | self.assertEqual(self.pl.get_testPhotoSize_size(), (1000, 750)) 126 | self.assertEqual(self.pp.get_testPhotoSize_size(), (750, 1000)) 127 | self.assertEqual(self.ps.get_testPhotoSize_size(), (1000, 1000)) 128 | 129 | 130 | class PhotoSizeCacheTest(PhotologueBaseTest): 131 | 132 | def test(self): 133 | cache = PhotoSizeCache() 134 | self.assertEqual(cache.sizes['testPhotoSize'], self.s) 135 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-photologue.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-photologue.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-photologue.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-photologue.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-photologue" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-photologue" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /photologue/tests/test_sites.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from django.conf import settings 4 | from django.contrib.sites.models import Site 5 | from django.test import TestCase, override_settings 6 | 7 | from .factories import GalleryFactory, PhotoFactory 8 | 9 | 10 | @override_settings(ROOT_URLCONF='photologue.tests.test_urls') 11 | class SitesTest(TestCase): 12 | 13 | def setUp(self): 14 | """ 15 | Create two example sites that we can use to test what gets displayed 16 | where. 17 | """ 18 | super().setUp() 19 | 20 | self.site1, created1 = Site.objects.get_or_create( 21 | domain="example.com", name="example.com") 22 | self.site2, created2 = Site.objects.get_or_create( 23 | domain="example.org", name="example.org") 24 | 25 | with self.settings(PHOTOLOGUE_MULTISITE=True): 26 | # Be explicit about linking Galleries/Photos to Sites.""" 27 | self.gallery1 = GalleryFactory(slug='test-gallery', sites=[self.site1]) 28 | self.gallery2 = GalleryFactory(slug='not-on-site-gallery') 29 | self.photo1 = PhotoFactory(slug='test-photo', sites=[self.site1]) 30 | self.photo2 = PhotoFactory(slug='not-on-site-photo') 31 | self.gallery1.photos.add(self.photo1, self.photo2) 32 | 33 | # I'd like to use factory_boy's mute_signal decorator but that 34 | # will only available once factory_boy 2.4 is released. So long 35 | # we'll have to remove the site association manually 36 | self.photo2.sites.clear() 37 | 38 | def tearDown(self): 39 | super().tearDown() 40 | self.gallery1.delete() 41 | self.gallery2.delete() 42 | self.photo1.delete() 43 | self.photo2.delete() 44 | 45 | def test_basics(self): 46 | """ See if objects were added automatically (by the factory) to the current site. """ 47 | self.assertEqual(list(self.gallery1.sites.all()), [self.site1]) 48 | self.assertEqual(list(self.photo1.sites.all()), [self.site1]) 49 | 50 | def test_auto_add_sites(self): 51 | """ 52 | Objects should not be automatically associated with a particular site when 53 | ``PHOTOLOGUE_MULTISITE`` is ``True``. 54 | """ 55 | 56 | with self.settings(PHOTOLOGUE_MULTISITE=False): 57 | gallery = GalleryFactory() 58 | photo = PhotoFactory() 59 | self.assertEqual(list(gallery.sites.all()), [self.site1]) 60 | self.assertEqual(list(photo.sites.all()), [self.site1]) 61 | 62 | photo.delete() 63 | 64 | with self.settings(PHOTOLOGUE_MULTISITE=True): 65 | gallery = GalleryFactory() 66 | photo = PhotoFactory() 67 | self.assertEqual(list(gallery.sites.all()), []) 68 | self.assertEqual(list(photo.sites.all()), []) 69 | 70 | photo.delete() 71 | 72 | def test_gallery_list(self): 73 | response = self.client.get('/ptests/gallerylist/') 74 | self.assertEqual(list(response.context['object_list']), [self.gallery1]) 75 | 76 | def test_gallery_detail(self): 77 | response = self.client.get('/ptests/gallery/test-gallery/') 78 | self.assertEqual(response.context['object'], self.gallery1) 79 | 80 | response = self.client.get('/ptests/gallery/not-on-site-gallery/') 81 | self.assertEqual(response.status_code, 404) 82 | 83 | def test_photo_list(self): 84 | response = self.client.get('/ptests/photolist/') 85 | self.assertEqual(list(response.context['object_list']), [self.photo1]) 86 | 87 | def test_photo_detail(self): 88 | response = self.client.get('/ptests/photo/test-photo/') 89 | self.assertEqual(response.context['object'], self.photo1) 90 | 91 | response = self.client.get('/ptests/photo/not-on-site-photo/') 92 | self.assertEqual(response.status_code, 404) 93 | 94 | def test_photo_archive(self): 95 | response = self.client.get('/ptests/photo/') 96 | self.assertEqual(list(response.context['object_list']), [self.photo1]) 97 | 98 | def test_photos_in_gallery(self): 99 | """ 100 | Only those photos are supposed to be shown in a gallery that are 101 | also associated with the current site. 102 | """ 103 | response = self.client.get('/ptests/gallery/test-gallery/') 104 | self.assertEqual(list(response.context['object'].public()), [self.photo1]) 105 | 106 | @unittest.skipUnless('django.contrib.sitemaps' in settings.INSTALLED_APPS, 107 | 'Sitemaps not installed in this project, nothing to test.') 108 | def test_sitemap(self): 109 | """A sitemap should only show objects associated with the current site.""" 110 | response = self.client.get('/sitemap.xml') 111 | 112 | # Check photos. 113 | self.assertContains(response, 114 | 'http://example.com/ptests/photo/test-photo/' 115 | '2011-12-23') 116 | self.assertNotContains(response, 117 | 'http://example.com/ptests/photo/not-on-site-photo/' 118 | '2011-12-23') 119 | 120 | # Check galleries. 121 | self.assertContains(response, 122 | 'http://example.com/ptests/gallery/test-gallery/' 123 | '2011-12-23') 124 | self.assertNotContains(response, 125 | 'http://example.com/ptests/gallery/not-on-site-gallery/' 126 | '2011-12-23') 127 | 128 | def test_orphaned_photos(self): 129 | self.assertEqual(list(self.gallery1.orphaned_photos()), [self.photo2]) 130 | 131 | self.gallery2.photos.add(self.photo2) 132 | self.assertEqual(list(self.gallery1.orphaned_photos()), [self.photo2]) 133 | 134 | self.gallery1.sites.clear() 135 | self.assertEqual(list(self.gallery1.orphaned_photos()), [self.photo1, self.photo2]) 136 | 137 | self.photo1.sites.clear() 138 | self.photo2.sites.clear() 139 | self.assertEqual(list(self.gallery1.orphaned_photos()), [self.photo1, self.photo2]) 140 | -------------------------------------------------------------------------------- /docs/pages/installation.rst: -------------------------------------------------------------------------------- 1 | ############################ 2 | Installation & configuration 3 | ############################ 4 | 5 | 6 | .. _installing-photologue-label: 7 | 8 | Installation 9 | ------------ 10 | The easiest way to install Photologue is with `pip `_; this will give you the latest 11 | version available on `PyPi `_:: 12 | 13 | pip install django-photologue 14 | 15 | You can also take risks and install the latest code directly from the 16 | Github repository:: 17 | 18 | pip install -e git+https://github.com/richardbarran/django-photologue.git#egg=django-photologue 19 | 20 | This code should work ok - like `Django `_ 21 | itself, we try to keep the master branch bug-free. However, we strongly recommend that you 22 | stick with a release from the PyPi repository, unless if you're confident in your abilities 23 | to fix any potential bugs on your own! 24 | 25 | Python 3 26 | ~~~~~~~~ 27 | Photologue is compatible with Python 3 (3.3 or later). 28 | 29 | Dependencies 30 | ------------ 31 | 3 apps that will be installed automatically if required. 32 | 33 | * `Django `_. 34 | * `Pillow `_. 35 | * `Django-sortedm2m `_. 36 | 37 | And 1 dependency that you will have to manage yourself: 38 | 39 | * `Pytz `_. See the Django release notes `for more information 40 | `_. 41 | 42 | .. note:: 43 | 44 | Photologue tries to support the same Django version as are supported by the Django 45 | project itself. 46 | 47 | That troublesome Pillow... 48 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 49 | Pillow can be tricky to install; sometimes it will install smoothly 50 | out of the box, sometimes you can spend hours figuring it out - installation 51 | issues vary from platform to platform, and from one OS release to the next, so listing 52 | them all here would not be realistic. Google 53 | is your friend! 54 | 55 | #. Pillow is a fork of PIL; you should not have installed both - this can cause strange bugs. 56 | 57 | #. Sometimes Pillow will install... but is not actually installed. This 'undocumented feature' has been 58 | reported by a user on Windows. If you can't get Photologue to display any images, check 59 | that you can actually import Pillow:: 60 | 61 | $ python manage.py shell 62 | Python 3.3.1 (default, Sep 25 2013, 19:29:01) 63 | [GCC 4.7.3] on linux 64 | Type "help", "copyright", "credits" or "license" for more information. 65 | (InteractiveConsole) 66 | >>> from PIL import Image 67 | >>> 68 | 69 | 70 | Configure Your Django Settings file 71 | ----------------------------------- 72 | 73 | Follow these 4 steps: 74 | 75 | #. Add to your ``INSTALLED_APPS`` setting:: 76 | 77 | INSTALLED_APPS = ( 78 | # ...other installed applications... 79 | 'photologue', 80 | 'sortedm2m', 81 | ) 82 | 83 | #. Confirm that your `MEDIA_ROOT `_ and 84 | `MEDIA_URL `_ settings 85 | are correct (Photologue will store uploaded files in a folder called 'photologue' under your ``MEDIA_ROOT``). 86 | 87 | #. `Enable the admin app `_ if you have not already done so. 88 | 89 | #. `Enable the Django sites framework `_ 90 | if you have not already done so. This is not enabled by default in Django, but is required by Photologue. 91 | 92 | Add the urls 93 | ------------ 94 | 95 | Add photologue to your projects urls.py file:: 96 | 97 | urlpatterns += [ 98 | ... 99 | url(r'^photologue/', include('photologue.urls', namespace='photologue')), 100 | ] 101 | 102 | Sync Your Database 103 | ------------------ 104 | 105 | You can now sync your database:: 106 | 107 | python manage.py migrate photologue 108 | 109 | If you are installing Photologue for the first time, this will set up some 110 | default PhotoSizes to get you started - you are free to change them of course! 111 | 112 | Instant templates 113 | ----------------- 114 | 115 | Photologue comes with basic templates for galleries and photos, which are designed 116 | to work well with `Twitter-Bootstrap `_. 117 | You can of course use them, or override them, or completely replace them. Note that all 118 | Photologue templates inherit from ``photologue/root.html``, which itself expects your site's 119 | base template to be called ``base.html`` - you can change this to use a different base template. 120 | 121 | Sitemap 122 | ------- 123 | 124 | .. automodule:: photologue.sitemaps 125 | 126 | Sites 127 | ----- 128 | 129 | Photologue supports `Django's site framework`_ since version 2.8. That means 130 | that each Gallery and each Photo can be displayed on one or more sites. 131 | 132 | Please bear in mind that photos don't necessarily have to be assigned to the 133 | same sites as the gallery they're belonging to: each gallery will only display 134 | the photos that are on its site. When a gallery does not belong the current site 135 | but a single photo is, that photo is only accessible directly as the gallery 136 | won't be shown in the index. 137 | 138 | .. note:: If you're upgrading from a version earlier than 2.8 you don't need to 139 | worry about the assignment of already existing objects to a site because a 140 | datamigration will assign all your objects to the current site automatically. 141 | 142 | .. note:: This feature is switched off by default. :ref:`See here to enable it 143 | ` and for more information. 144 | 145 | .. _Django's site framework: http://django.readthedocs.org/en/latest/ref/contrib/sites.html 146 | 147 | Amazon S3 148 | --------- 149 | 150 | Photologue can use a custom file storage system, for example 151 | `Amazon's S3 `_. 152 | 153 | You will need to configure your Django project to use Amazon S3 for storing files; a full discussion of 154 | how to do this is outside the scope of this page. 155 | 156 | However, there is a quick demo of using Photologue with S3 in the ``example_project`` directory; if you look 157 | at these files: 158 | 159 | * ``example_project/example_project/settings.py`` 160 | * ``example_project/requirements.txt`` 161 | 162 | At the end of each file you will commented-out lines for configuring S3 functionality. These point to extra files 163 | stored under ``example_project/example_storages/``. Uncomment these lines, run the example 164 | project, then study these files for inspiration! After that, setting up S3 will consist of 165 | (at minimum) the following steps: 166 | 167 | #. Signup for Amazon AWS S3 at http://aws.amazon.com/s3/. 168 | #. Create a Bucket on S3 to store your media and static files. 169 | #. Set the environment variables: 170 | 171 | * ``AWS_ACCESS_KEY_ID`` - issued to your account by S3. 172 | * ``AWS_SECRET_ACCESS_KEY`` - issued to your account by S3. 173 | * ``AWS_STORAGE_BUCKET_NAME`` - name of your bucket on S3. 174 | 175 | #. To copy your static files into your S3 Bucket, type ``python manage.py collectstatic`` in the ``example_project`` directory. 176 | 177 | .. note:: This simple setup does not handle S3 regions. 178 | 179 | 180 | 181 | 182 | 183 | 184 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # django-photologue documentation build configuration file, created by 3 | # sphinx-quickstart on Mon Sep 3 16:31:44 2012. 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | import datetime 14 | import os 15 | import sys 16 | 17 | import django 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | sys.path.insert(0, os.path.abspath('..')) 23 | sys.path.append('../example_project/') 24 | os.environ['DJANGO_SETTINGS_MODULE'] = 'example_project.settings' 25 | django.setup() 26 | 27 | # -- General configuration ----------------------------------------------------- 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | #needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be extensions 33 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 34 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest'] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # The suffix of source filenames. 40 | source_suffix = '.rst' 41 | 42 | # The encoding of source files. 43 | #source_encoding = 'utf-8-sig' 44 | 45 | # The master toctree document. 46 | master_doc = 'index' 47 | 48 | # General information about the project. 49 | project = 'django-photologue' 50 | copyright = f'{datetime.datetime.now().year}, Justin Driscoll/Richard Barran' 51 | 52 | # The version info for the project you're documenting, acts as replacement for 53 | # |version| and |release|, also used in various other places throughout the 54 | # built documents. 55 | # 56 | 57 | # Dynamically get the version number from the photologue package. 58 | parent_dir = os.path.join(os.path.dirname(__file__), '..') 59 | sys.path.append(parent_dir) 60 | import photologue 61 | 62 | # The short X.Y version. 63 | version = '.'.join(photologue.__version__.split('.')[:1]) 64 | # The full version, including alpha/beta/rc tags. 65 | release = photologue.__version__ 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | #language = None 70 | 71 | # There are two options for replacing |today|: either, you set today to some 72 | # non-false value, then it is used: 73 | #today = '' 74 | # Else, today_fmt is used as the format for a strftime call. 75 | #today_fmt = '%B %d, %Y' 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | exclude_patterns = ['_build'] 80 | 81 | # The reST default role (used for this markup: `text`) to use for all documents. 82 | #default_role = None 83 | 84 | # If true, '()' will be appended to :func: etc. cross-reference text. 85 | #add_function_parentheses = True 86 | 87 | # If true, the current module name will be prepended to all description 88 | # unit titles (such as .. function::). 89 | #add_module_names = True 90 | 91 | # If true, sectionauthor and moduleauthor directives will be shown in the 92 | # output. They are ignored by default. 93 | #show_authors = False 94 | 95 | # The name of the Pygments (syntax highlighting) style to use. 96 | pygments_style = 'sphinx' 97 | 98 | # A list of ignored prefixes for module index sorting. 99 | #modindex_common_prefix = [] 100 | 101 | 102 | # -- Options for HTML output --------------------------------------------------- 103 | 104 | # The theme to use for HTML and HTML Help pages. See the documentation for 105 | # a list of builtin themes. 106 | html_theme = 'sphinx_rtd_theme' 107 | 108 | # Theme options are theme-specific and customize the look and feel of a theme 109 | # further. For a list of options available for each theme, see the 110 | # documentation. 111 | #html_theme_options = {} 112 | 113 | # Add any paths that contain custom themes here, relative to this directory. 114 | #html_theme_path = [] 115 | 116 | # The name for this set of Sphinx documents. If None, it defaults to 117 | # " v documentation". 118 | #html_title = None 119 | 120 | # A shorter title for the navigation bar. Default is the same as html_title. 121 | #html_short_title = None 122 | 123 | # The name of an image file (relative to this directory) to place at the top 124 | # of the sidebar. 125 | #html_logo = None 126 | 127 | # The name of an image file (within the static path) to use as favicon of the 128 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 129 | # pixels large. 130 | #html_favicon = None 131 | 132 | # Add any paths that contain custom static files (such as style sheets) here, 133 | # relative to this directory. They are copied after the builtin static files, 134 | # so a file named "default.css" will overwrite the builtin "default.css". 135 | #html_static_path = ['_static'] 136 | 137 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 138 | # using the given strftime format. 139 | #html_last_updated_fmt = '%b %d, %Y' 140 | 141 | # If true, SmartyPants will be used to convert quotes and dashes to 142 | # typographically correct entities. 143 | #html_use_smartypants = True 144 | 145 | # Custom sidebar templates, maps document names to template names. 146 | #html_sidebars = {} 147 | 148 | # Additional templates that should be rendered to pages, maps page names to 149 | # template names. 150 | #html_additional_pages = {} 151 | 152 | # If false, no module index is generated. 153 | #html_domain_indices = True 154 | 155 | # If false, no index is generated. 156 | #html_use_index = True 157 | 158 | # If true, the index is split into individual pages for each letter. 159 | #html_split_index = False 160 | 161 | # If true, links to the reST sources are added to the pages. 162 | #html_show_sourcelink = True 163 | 164 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 165 | #html_show_sphinx = True 166 | 167 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 168 | #html_show_copyright = True 169 | 170 | # If true, an OpenSearch description file will be output, and all pages will 171 | # contain a tag referring to it. The value of this option must be the 172 | # base URL from which the finished HTML is served. 173 | #html_use_opensearch = '' 174 | 175 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 176 | #html_file_suffix = None 177 | 178 | # Output file base name for HTML help builder. 179 | htmlhelp_basename = 'django-photologuedoc' 180 | 181 | 182 | # -- Options for LaTeX output -------------------------------------------------- 183 | 184 | latex_elements = { 185 | # The paper size ('letterpaper' or 'a4paper'). 186 | #'papersize': 'letterpaper', 187 | 188 | # The font size ('10pt', '11pt' or '12pt'). 189 | #'pointsize': '10pt', 190 | 191 | # Additional stuff for the LaTeX preamble. 192 | #'preamble': '', 193 | } 194 | 195 | # Grouping the document tree into LaTeX files. List of tuples 196 | # (source start file, target name, title, author, documentclass [howto/manual]). 197 | latex_documents = [ 198 | ('index', 'django-photologue.tex', 'django-photologue Documentation', 199 | 'Justin Driscoll/Richard Barran', 'manual'), 200 | ] 201 | 202 | # The name of an image file (relative to this directory) to place at the top of 203 | # the title page. 204 | #latex_logo = None 205 | 206 | # For "manual" documents, if this is true, then toplevel headings are parts, 207 | # not chapters. 208 | #latex_use_parts = False 209 | 210 | # If true, show page references after internal links. 211 | #latex_show_pagerefs = False 212 | 213 | # If true, show URL addresses after external links. 214 | #latex_show_urls = False 215 | 216 | # Documents to append as an appendix to all manuals. 217 | #latex_appendices = [] 218 | 219 | # If false, no module index is generated. 220 | #latex_domain_indices = True 221 | 222 | 223 | # -- Options for manual page output -------------------------------------------- 224 | 225 | # One entry per manual page. List of tuples 226 | # (source start file, name, description, authors, manual section). 227 | man_pages = [ 228 | ('index', 'django-photologue', 'django-photologue Documentation', 229 | ['Justin Driscoll/Richard Barran'], 1) 230 | ] 231 | 232 | # If true, show URL addresses after external links. 233 | #man_show_urls = False 234 | 235 | 236 | # -- Options for Texinfo output ------------------------------------------------ 237 | 238 | # Grouping the document tree into Texinfo files. List of tuples 239 | # (source start file, target name, title, author, 240 | # dir menu entry, description, category) 241 | texinfo_documents = [ 242 | ('index', 'django-photologue', 'django-photologue Documentation', 243 | 'Justin Driscoll/Richard Barran', 'django-photologue', 'One line description of project.', 244 | 'Miscellaneous'), 245 | ] 246 | 247 | # Documents to append as an appendix to all manuals. 248 | #texinfo_appendices = [] 249 | 250 | # If false, no module index is generated. 251 | #texinfo_domain_indices = True 252 | 253 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 254 | #texinfo_show_urls = 'footnote' 255 | -------------------------------------------------------------------------------- /photologue/tests/test_zipupload.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from django import VERSION 4 | from django.contrib.auth.models import User 5 | from django.test import TestCase 6 | 7 | from ..models import Gallery, Photo 8 | from .factories import (IGNORED_FILES_ZIP_PATH, LANDSCAPE_IMAGE_PATH, SAMPLE_NOT_IMAGE_ZIP_PATH, SAMPLE_ZIP_PATH, 9 | GalleryFactory, PhotoFactory) 10 | 11 | 12 | class GalleryUploadTest(TestCase): 13 | 14 | """Testing the admin page that allows users to upload zips.""" 15 | 16 | def setUp(self): 17 | super().setUp() 18 | user = User.objects.create_user('john.doe', 19 | 'john.doe@example.com', 20 | 'secret') 21 | user.is_staff = True 22 | user.save() 23 | self.assertTrue(self.client.login(username='john.doe', password='secret')) 24 | 25 | self.zip_file = open(SAMPLE_ZIP_PATH, mode='rb') 26 | 27 | self.sample_form_data = {'zip_file': self.zip_file, 28 | 'title': 'This is a test title'} 29 | 30 | def tearDown(self): 31 | super().tearDown() 32 | self.zip_file.close() 33 | for photo in Photo.objects.all(): 34 | photo.delete() 35 | 36 | def test_get(self): 37 | """We can get the custom admin page.""" 38 | 39 | response = self.client.get('/admin/photologue/photo/upload_zip/') 40 | self.assertEqual(response.status_code, 200) 41 | self.assertTemplateUsed(response, 'admin/photologue/photo/upload_zip.html') 42 | 43 | self.assertContains(response, 'Upload a zip archive of photos') 44 | 45 | def test_breadcrumbs(self): 46 | """Quick check that the breadcrumbs are generated correctly.""" 47 | 48 | response = self.client.get('/admin/photologue/photo/upload_zip/') 49 | self.assertContains( 50 | response, """""", html=True) 52 | 53 | def test_missing_fields(self): 54 | """Missing fields mean the form is redisplayed with errors.""" 55 | 56 | test_data = copy.copy(self.sample_form_data) 57 | del test_data['zip_file'] 58 | response = self.client.post('/admin/photologue/photo/upload_zip/', test_data) 59 | self.assertEqual(response.status_code, 200) 60 | self.assertTrue(response.context['form'].errors) 61 | 62 | def test_good_data(self): 63 | """Upload a zip with a single file it it: 'sample.jpg'. 64 | It gets assigned to a newly created gallery 'Test'.""" 65 | 66 | test_data = copy.copy(self.sample_form_data) 67 | response = self.client.post('/admin/photologue/photo/upload_zip/', test_data) 68 | # The redirect Location has changed in Django 1.9 - it used to be an absolute URI, now it returns 69 | # a relative one. 70 | if VERSION[0] == 1 and VERSION[1] <= 8: 71 | location = 'http://testserver/admin/photologue/photo/' 72 | else: 73 | location = '..' 74 | 75 | self.assertEqual(response['Location'], location) 76 | 77 | self.assertQuerySetEqual(Gallery.objects.all(), 78 | [''], 79 | transform=repr) 80 | self.assertQuerySetEqual(Photo.objects.all(), 81 | [''], 82 | transform=repr) 83 | 84 | # The photo is attached to the gallery. 85 | gallery = Gallery.objects.get(title='This is a test title') 86 | self.assertQuerySetEqual(gallery.photos.all(), 87 | [''], 88 | transform=repr) 89 | 90 | def test_duplicate_gallery(self): 91 | """If we try to create a Gallery with a title that duplicates an existing title, refuse to load.""" 92 | 93 | GalleryFactory(title='This is a test title') 94 | 95 | test_data = copy.copy(self.sample_form_data) 96 | response = self.client.post('/admin/photologue/photo/upload_zip/', test_data) 97 | self.assertEqual(response.status_code, 200) 98 | self.assertTrue(response.context['form']['title'].errors) 99 | 100 | def test_title_or_gallery(self): 101 | """We should supply either a title field or a gallery.""" 102 | 103 | test_data = copy.copy(self.sample_form_data) 104 | del test_data['title'] 105 | response = self.client.post('/admin/photologue/photo/upload_zip/', test_data) 106 | self.assertEqual(list(response.context['form'].non_field_errors()), 107 | ['Select an existing gallery, or enter a title for a new gallery.']) 108 | 109 | def test_not_image(self): 110 | """A zip with a file of the wrong format (.txt). 111 | That file gets ignored.""" 112 | 113 | test_data = copy.copy(self.sample_form_data) 114 | with open(SAMPLE_NOT_IMAGE_ZIP_PATH, mode='rb') as f: 115 | test_data['zip_file'] = f 116 | response = self.client.post('/admin/photologue/photo/upload_zip/', test_data) 117 | self.assertEqual(response.status_code, 302) 118 | 119 | self.assertQuerySetEqual(Gallery.objects.all(), 120 | [''], 121 | transform=repr) 122 | self.assertQuerySetEqual(Photo.objects.all(), 123 | [''], 124 | transform=repr) 125 | 126 | def test_ignored(self): 127 | """Ignore anything that does not look like a image file. 128 | E.g. hidden files, and folders. 129 | We have two images: one in the top level of the zip, and one in a subfolder. 130 | The second one gets ignored - we only process files at the zip root.""" 131 | 132 | test_data = copy.copy(self.sample_form_data) 133 | with open(IGNORED_FILES_ZIP_PATH, mode='rb') as f: 134 | test_data['zip_file'] = f 135 | response = self.client.post('/admin/photologue/photo/upload_zip/', test_data) 136 | self.assertEqual(response.status_code, 302) 137 | 138 | self.assertQuerySetEqual(Gallery.objects.all(), 139 | [''], 140 | transform=repr) 141 | self.assertQuerySetEqual(Photo.objects.all(), 142 | [''], 143 | transform=repr) 144 | 145 | def test_existing_gallery(self): 146 | """Add the photos in the zip to an existing gallery.""" 147 | 148 | existing_gallery = GalleryFactory(title='Existing') 149 | 150 | test_data = copy.copy(self.sample_form_data) 151 | test_data['gallery'] = existing_gallery.id 152 | del test_data['title'] 153 | response = self.client.post('/admin/photologue/photo/upload_zip/', test_data) 154 | self.assertEqual(response.status_code, 302) 155 | 156 | self.assertQuerySetEqual(Gallery.objects.all(), 157 | [''], 158 | transform=repr) 159 | self.assertQuerySetEqual(Photo.objects.all(), 160 | [''], 161 | transform=repr) 162 | 163 | # The photo is attached to the existing gallery. 164 | self.assertQuerySetEqual(existing_gallery.photos.all(), 165 | [''], 166 | transform=repr) 167 | 168 | def test_existing_gallery_custom_title(self): 169 | """Add the photos in the zip to an existing gallery, but specify a 170 | custom title for the photos.""" 171 | 172 | existing_gallery = GalleryFactory(title='Existing') 173 | 174 | test_data = copy.copy(self.sample_form_data) 175 | test_data['gallery'] = existing_gallery.id 176 | test_data['title'] = 'Custom title' 177 | response = self.client.post('/admin/photologue/photo/upload_zip/', test_data) 178 | self.assertEqual(response.status_code, 302) 179 | 180 | self.assertQuerySetEqual(Photo.objects.all(), 181 | [''], 182 | transform=repr) 183 | 184 | def test_duplicate_slug(self): 185 | """Uploading a zip, but a photo already exists with the target slug.""" 186 | 187 | PhotoFactory(title='This is a test title 1') 188 | PhotoFactory(title='This is a test title 2') 189 | 190 | test_data = copy.copy(self.sample_form_data) 191 | response = self.client.post('/admin/photologue/photo/upload_zip/', test_data) 192 | self.assertEqual(response.status_code, 302) 193 | 194 | self.assertQuerySetEqual(Photo.objects.all(), 195 | [ 196 | '', 197 | '', 198 | '' 199 | ], 200 | ordered=False, 201 | transform=repr) 202 | 203 | def test_bad_zip(self): 204 | """Supplied file is not a zip file - tell user.""" 205 | 206 | test_data = copy.copy(self.sample_form_data) 207 | with open(LANDSCAPE_IMAGE_PATH, mode='rb') as f: 208 | test_data['zip_file'] = f 209 | response = self.client.post('/admin/photologue/photo/upload_zip/', test_data) 210 | self.assertEqual(response.status_code, 200) 211 | self.assertTrue(response.context['form']['zip_file'].errors) 212 | -------------------------------------------------------------------------------- /photologue/forms.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import zipfile 4 | from io import BytesIO 5 | from typing import List 6 | from zipfile import BadZipFile 7 | 8 | from django import forms 9 | from django.conf import settings 10 | from django.contrib import messages 11 | from django.contrib.messages import constants 12 | from django.contrib.sites.models import Site 13 | from django.core.files.base import ContentFile 14 | from django.template.defaultfilters import slugify 15 | from django.utils.encoding import force_str 16 | from django.utils.translation import gettext_lazy as _ 17 | from PIL import Image 18 | 19 | from .models import Gallery, Photo 20 | 21 | logger = logging.getLogger('photologue.forms') 22 | 23 | MessageSeverity = int 24 | MessageContent = str 25 | 26 | 27 | class PhotoDefaults: 28 | title: str 29 | caption: str 30 | is_public: bool 31 | 32 | def __init__(self, title: str, caption: str, is_public: bool) -> "PhotoDefaults": 33 | self.title = title 34 | self.caption = caption 35 | self.is_public = is_public 36 | 37 | 38 | class UploadMessage: 39 | severity: MessageSeverity 40 | content: MessageContent 41 | 42 | def __init__(self, severity: MessageSeverity, content: MessageContent) -> "UploadMessage": 43 | self.severity = severity 44 | self.content = content 45 | 46 | def success(content: MessageContent): 47 | return UploadMessage(severity=constants.SUCCESS, content=content) 48 | 49 | def warning(content: MessageContent): 50 | return UploadMessage(severity=constants.WARNING, content=content) 51 | 52 | 53 | class UploadZipForm(forms.Form): 54 | zip_file = forms.FileField() 55 | 56 | title = forms.CharField(label=_('Title'), 57 | max_length=250, 58 | required=False, 59 | help_text=_('All uploaded photos will be given a title made up of this title + a ' 60 | 'sequential number.
    This field is required if creating a new ' 61 | 'gallery, but is optional when adding to an existing gallery - if ' 62 | 'not supplied, the photo titles will be creating from the existing ' 63 | 'gallery name.')) 64 | gallery = forms.ModelChoiceField(Gallery.objects.all(), 65 | label=_('Gallery'), 66 | required=False, 67 | help_text=_('Select a gallery to add these images to. Leave this empty to ' 68 | 'create a new gallery from the supplied title.')) 69 | caption = forms.CharField(label=_('Caption'), 70 | required=False, 71 | help_text=_('Caption will be added to all photos.')) 72 | description = forms.CharField(label=_('Description'), 73 | required=False, 74 | help_text=_('A description of this Gallery. Only required for new galleries.')) 75 | is_public = forms.BooleanField(label=_('Is public'), 76 | initial=True, 77 | required=False, 78 | help_text=_('Uncheck this to make the uploaded ' 79 | 'gallery and included photographs private.')) 80 | 81 | def clean_zip_file(self): 82 | """Open the zip file a first time, to check that it is a valid zip archive. 83 | We'll open it again in a moment, so we have some duplication, but let's focus 84 | on keeping the code easier to read! 85 | """ 86 | zip_file = self.cleaned_data['zip_file'] 87 | try: 88 | zip = zipfile.ZipFile(zip_file) 89 | except BadZipFile as e: 90 | raise forms.ValidationError(str(e)) 91 | bad_file = zip.testzip() 92 | if bad_file: 93 | zip.close() 94 | raise forms.ValidationError('"%s" in the .zip archive is corrupt.' % bad_file) 95 | zip.close() # Close file in all cases. 96 | return zip_file 97 | 98 | def clean_title(self): 99 | title = self.cleaned_data['title'] 100 | if title and Gallery.objects.filter(title=title).exists(): 101 | raise forms.ValidationError(_('A gallery with that title already exists.')) 102 | return title 103 | 104 | def clean(self): 105 | cleaned_data = super().clean() 106 | if not self['title'].errors: 107 | # If there's already an error in the title, no need to add another 108 | # error related to the same field. 109 | if not cleaned_data.get('title', None) and not cleaned_data['gallery']: 110 | raise forms.ValidationError( 111 | _('Select an existing gallery, or enter a title for a new gallery.')) 112 | return cleaned_data 113 | 114 | def save(self, request=None, zip_file=None): 115 | if not zip_file: 116 | zip_file = self.cleaned_data['zip_file'] 117 | 118 | zip = zipfile.ZipFile(zip_file) 119 | photo_defaults = PhotoDefaults( 120 | title=self.cleaned_data["title"], caption=self.cleaned_data["caption"], 121 | is_public=self.cleaned_data["is_public"]) 122 | current_site = Site.objects.get(id=settings.SITE_ID) 123 | 124 | gallery = self._reuse_or_create_gallery_in_site(current_site) 125 | 126 | upload_messages = upload_photos_to_site(current_site, zip, gallery, photo_defaults) 127 | 128 | if request: 129 | for upload_message in upload_messages: 130 | messages.add_message(request, upload_message.severity, upload_message.content, fail_silently=True) 131 | 132 | def _reuse_or_create_gallery_in_site(self, current_site): 133 | if self.cleaned_data['gallery']: 134 | logger.debug('Using pre-existing gallery.') 135 | gallery = self.cleaned_data['gallery'] 136 | else: 137 | logger.debug( 138 | force_str('Creating new gallery "{0}".').format(self.cleaned_data['title'])) 139 | gallery = create_gallery_in_site(current_site, 140 | title=self.cleaned_data['title'], 141 | description=self.cleaned_data['description'], 142 | is_public=self.cleaned_data['is_public']) 143 | 144 | return gallery 145 | 146 | 147 | def create_gallery_in_site(site: Site, title: str, description: str = "", is_public: bool = False) -> Gallery: 148 | gallery = Gallery.objects.create(title=title, 149 | slug=slugify(title), 150 | description=description, 151 | is_public=is_public) 152 | gallery.sites.add(site) 153 | return gallery 154 | 155 | 156 | def upload_photos_to_site(site: Site, zip: zipfile.ZipFile, gallery: Gallery, photo_defaults: PhotoDefaults)\ 157 | -> List[UploadMessage]: 158 | upload_messages = [] 159 | count = 1 160 | 161 | for filename in sorted(zip.namelist()): 162 | 163 | logger.debug(f'Reading file "{filename}".') 164 | 165 | if filename.startswith('__') or filename.startswith('.'): 166 | logger.debug(f'Ignoring file "{filename}".') 167 | continue 168 | 169 | if os.path.dirname(filename): 170 | logger.warning('Ignoring file "{}" as it is in a subfolder; all images should be in the top ' 171 | 'folder of the zip.'.format(filename)) 172 | upload_messages.append(UploadMessage.warning( 173 | _('Ignoring file "{filename}" as it is in a subfolder; all images should be in the top folder of the ' 174 | 'zip.').format(filename=filename))) 175 | continue 176 | 177 | data = zip.read(filename) 178 | 179 | if not len(data): 180 | logger.debug(f'File "{filename}" is empty.') 181 | continue 182 | 183 | photo_title_root = photo_defaults.title if photo_defaults.title else gallery.title 184 | 185 | # A photo might already exist with the same slug. So it's somewhat inefficient, 186 | # but we loop until we find a slug that's available. 187 | while True: 188 | photo_title = ' '.join([photo_title_root, str(count)]) 189 | slug = slugify(photo_title) 190 | if Photo.objects.filter(slug=slug).exists(): 191 | count += 1 192 | continue 193 | break 194 | 195 | photo = Photo(title=photo_title, 196 | slug=slug, 197 | caption=photo_defaults.caption, 198 | is_public=photo_defaults.is_public) 199 | 200 | # Basic check that we have a valid image. 201 | try: 202 | file = BytesIO(data) 203 | opened = Image.open(file) 204 | opened.verify() 205 | except Exception: 206 | # Pillow doesn't recognize it as an image. 207 | # If a "bad" file is found we just skip it. 208 | # But we do flag this both in the logs and to the user. 209 | logger.error('Could not process file "{}" in the .zip archive.'.format( 210 | filename)) 211 | upload_messages.append(UploadMessage.warning( 212 | _('Could not process file "{0}" in the .zip archive.').format(filename))) 213 | continue 214 | 215 | contentfile = ContentFile(data) 216 | photo.image.save(filename, contentfile) 217 | photo.save() 218 | photo.sites.add(site) 219 | gallery.photos.add(photo) 220 | count += 1 221 | 222 | zip.close() 223 | 224 | upload_messages.append(UploadMessage.success( 225 | _('The photos have been added to gallery "{0}".').format(gallery.title))) 226 | 227 | return upload_messages 228 | -------------------------------------------------------------------------------- /photologue/admin.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.conf import settings 3 | from django.contrib import admin, messages 4 | from django.contrib.admin import helpers 5 | from django.contrib.sites.models import Site 6 | from django.http import HttpResponseRedirect 7 | from django.shortcuts import render 8 | from django.urls import path 9 | from django.utils.translation import gettext_lazy as _ 10 | from django.utils.translation import ngettext 11 | 12 | from .forms import UploadZipForm 13 | from .models import Gallery, Photo, PhotoEffect, PhotoSize, Watermark 14 | 15 | MULTISITE = getattr(settings, 'PHOTOLOGUE_MULTISITE', False) 16 | 17 | 18 | class GalleryAdminForm(forms.ModelForm): 19 | class Meta: 20 | model = Gallery 21 | if MULTISITE: 22 | exclude = [] 23 | else: 24 | exclude = ['sites'] 25 | 26 | 27 | class GalleryAdmin(admin.ModelAdmin): 28 | list_display = ('title', 'date_added', 'photo_count', 'is_public') 29 | list_filter = ['date_added', 'is_public'] 30 | if MULTISITE: 31 | list_filter.append('sites') 32 | date_hierarchy = 'date_added' 33 | prepopulated_fields = {'slug': ('title',)} 34 | form = GalleryAdminForm 35 | if MULTISITE: 36 | filter_horizontal = ['sites'] 37 | if MULTISITE: 38 | actions = [ 39 | 'add_to_current_site', 40 | 'add_photos_to_current_site', 41 | 'remove_from_current_site', 42 | 'remove_photos_from_current_site' 43 | ] 44 | 45 | def formfield_for_manytomany(self, db_field, request, **kwargs): 46 | """ Set the current site as initial value. """ 47 | if db_field.name == "sites": 48 | kwargs["initial"] = [Site.objects.get_current()] 49 | return super().formfield_for_manytomany(db_field, request, **kwargs) 50 | 51 | def save_related(self, request, form, *args, **kwargs): 52 | """ 53 | If the user has saved a gallery with a photo that belongs only to 54 | different Sites - it might cause much confusion. So let them know. 55 | """ 56 | super().save_related(request, form, *args, **kwargs) 57 | orphaned_photos = form.instance.orphaned_photos() 58 | if orphaned_photos: 59 | msg = ngettext( 60 | 'The following photo does not belong to the same site(s)' 61 | ' as the gallery, so will never be displayed: %(photo_list)s.', 62 | 'The following photos do not belong to the same site(s)' 63 | ' as the gallery, so will never be displayed: %(photo_list)s.', 64 | len(orphaned_photos) 65 | ) % {'photo_list': ", ".join([photo.title for photo in orphaned_photos])} 66 | messages.warning(request, msg) 67 | 68 | def add_to_current_site(modeladmin, request, queryset): 69 | current_site = Site.objects.get_current() 70 | current_site.gallery_set.add(*queryset) 71 | msg = ngettext( 72 | "The gallery has been successfully added to %(site)s", 73 | "The galleries have been successfully added to %(site)s", 74 | len(queryset) 75 | ) % {'site': current_site.name} 76 | messages.success(request, msg) 77 | 78 | add_to_current_site.short_description = \ 79 | _("Add selected galleries to the current site") 80 | 81 | def remove_from_current_site(modeladmin, request, queryset): 82 | current_site = Site.objects.get_current() 83 | current_site.gallery_set.remove(*queryset) 84 | msg = ngettext( 85 | "The gallery has been successfully removed from %(site)s", 86 | "The selected galleries have been successfully removed from %(site)s", 87 | len(queryset) 88 | ) % {'site': current_site.name} 89 | messages.success(request, msg) 90 | 91 | remove_from_current_site.short_description = \ 92 | _("Remove selected galleries from the current site") 93 | 94 | def add_photos_to_current_site(modeladmin, request, queryset): 95 | photos = Photo.objects.filter(galleries__in=queryset) 96 | current_site = Site.objects.get_current() 97 | current_site.photo_set.add(*photos) 98 | msg = ngettext( 99 | 'All photos in gallery %(galleries)s have been successfully added to %(site)s', 100 | 'All photos in galleries %(galleries)s have been successfully added to %(site)s', 101 | len(queryset) 102 | ) % {'site': current_site.name, 103 | 'galleries': ", ".join([f"'{gallery.title}'" for gallery in queryset])} 104 | messages.success(request, msg) 105 | 106 | add_photos_to_current_site.short_description = \ 107 | _("Add all photos of selected galleries to the current site") 108 | 109 | def remove_photos_from_current_site(modeladmin, request, queryset): 110 | photos = Photo.objects.filter(galleries__in=queryset) 111 | current_site = Site.objects.get_current() 112 | current_site.photo_set.remove(*photos) 113 | msg = ngettext( 114 | 'All photos in gallery %(galleries)s have been successfully removed from %(site)s', 115 | 'All photos in galleries %(galleries)s have been successfully removed from %(site)s', 116 | len(queryset) 117 | ) % {'site': current_site.name, 118 | 'galleries': ", ".join([f"'{gallery.title}'" for gallery in queryset])} 119 | messages.success(request, msg) 120 | 121 | remove_photos_from_current_site.short_description = \ 122 | _("Remove all photos in selected galleries from the current site") 123 | 124 | 125 | admin.site.register(Gallery, GalleryAdmin) 126 | 127 | 128 | class PhotoAdminForm(forms.ModelForm): 129 | class Meta: 130 | model = Photo 131 | if MULTISITE: 132 | exclude = [] 133 | else: 134 | exclude = ['sites'] 135 | 136 | 137 | class PhotoAdmin(admin.ModelAdmin): 138 | list_display = ('title', 'date_taken', 'date_added', 139 | 'is_public', 'view_count', 'admin_thumbnail') 140 | list_filter = ['date_added', 'is_public'] 141 | if MULTISITE: 142 | list_filter.append('sites') 143 | search_fields = ['title', 'slug', 'caption'] 144 | list_per_page = 10 145 | prepopulated_fields = {'slug': ('title',)} 146 | readonly_fields = ('date_taken',) 147 | form = PhotoAdminForm 148 | if MULTISITE: 149 | filter_horizontal = ['sites'] 150 | if MULTISITE: 151 | actions = ['add_photos_to_current_site', 'remove_photos_from_current_site'] 152 | 153 | def formfield_for_manytomany(self, db_field, request, **kwargs): 154 | """ Set the current site as initial value. """ 155 | if db_field.name == "sites": 156 | kwargs["initial"] = [Site.objects.get_current()] 157 | return super().formfield_for_manytomany(db_field, request, **kwargs) 158 | 159 | def add_photos_to_current_site(modeladmin, request, queryset): 160 | current_site = Site.objects.get_current() 161 | current_site.photo_set.add(*queryset) 162 | msg = ngettext( 163 | 'The photo has been successfully added to %(site)s', 164 | 'The selected photos have been successfully added to %(site)s', 165 | len(queryset) 166 | ) % {'site': current_site.name} 167 | messages.success(request, msg) 168 | 169 | add_photos_to_current_site.short_description = \ 170 | _("Add selected photos to the current site") 171 | 172 | def remove_photos_from_current_site(modeladmin, request, queryset): 173 | current_site = Site.objects.get_current() 174 | current_site.photo_set.remove(*queryset) 175 | msg = ngettext( 176 | 'The photo has been successfully removed from %(site)s', 177 | 'The selected photos have been successfully removed from %(site)s', 178 | len(queryset) 179 | ) % {'site': current_site.name} 180 | messages.success(request, msg) 181 | 182 | remove_photos_from_current_site.short_description = \ 183 | _("Remove selected photos from the current site") 184 | 185 | def get_urls(self): 186 | urls = super().get_urls() 187 | custom_urls = [ 188 | path('upload_zip/', 189 | self.admin_site.admin_view(self.upload_zip), 190 | name='photologue_upload_zip') 191 | ] 192 | return custom_urls + urls 193 | 194 | def upload_zip(self, request): 195 | 196 | context = { 197 | 'title': _('Upload a zip archive of photos'), 198 | 'app_label': self.model._meta.app_label, 199 | 'opts': self.model._meta, 200 | 'has_change_permission': self.has_change_permission(request) 201 | } 202 | 203 | # Handle form request 204 | if request.method == 'POST': 205 | form = UploadZipForm(request.POST, request.FILES) 206 | if form.is_valid(): 207 | form.save(request=request) 208 | return HttpResponseRedirect('..') 209 | else: 210 | form = UploadZipForm() 211 | context['form'] = form 212 | context['adminform'] = helpers.AdminForm(form, 213 | list([(None, {'fields': form.base_fields})]), 214 | {}) 215 | return render(request, 'admin/photologue/photo/upload_zip.html', context) 216 | 217 | 218 | admin.site.register(Photo, PhotoAdmin) 219 | 220 | 221 | class PhotoEffectAdmin(admin.ModelAdmin): 222 | list_display = ('name', 'description', 'color', 'brightness', 223 | 'contrast', 'sharpness', 'filters', 'admin_sample') 224 | fieldsets = ( 225 | (None, { 226 | 'fields': ('name', 'description') 227 | }), 228 | ('Adjustments', { 229 | 'fields': ('color', 'brightness', 'contrast', 'sharpness') 230 | }), 231 | ('Filters', { 232 | 'fields': ('filters',) 233 | }), 234 | ('Reflection', { 235 | 'fields': ('reflection_size', 'reflection_strength', 'background_color') 236 | }), 237 | ('Transpose', { 238 | 'fields': ('transpose_method',) 239 | }), 240 | ) 241 | 242 | 243 | admin.site.register(PhotoEffect, PhotoEffectAdmin) 244 | 245 | 246 | class PhotoSizeAdmin(admin.ModelAdmin): 247 | list_display = ('name', 'width', 'height', 'crop', 'pre_cache', 'effect', 'increment_count') 248 | fieldsets = ( 249 | (None, { 250 | 'fields': ('name', 'width', 'height', 'quality') 251 | }), 252 | ('Options', { 253 | 'fields': ('upscale', 'crop', 'pre_cache', 'increment_count') 254 | }), 255 | ('Enhancements', { 256 | 'fields': ('effect', 'watermark',) 257 | }), 258 | ) 259 | 260 | 261 | admin.site.register(PhotoSize, PhotoSizeAdmin) 262 | 263 | 264 | class WatermarkAdmin(admin.ModelAdmin): 265 | list_display = ('name', 'opacity', 'style') 266 | 267 | 268 | admin.site.register(Watermark, WatermarkAdmin) 269 | --------------------------------------------------------------------------------