├── photomanager ├── apps │ ├── __init__.py │ ├── albums │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0003_albumsharelink_description.py │ │ │ ├── 0007_auto_20201203_0108.py │ │ │ ├── 0005_auto_20201201_0312.py │ │ │ ├── 0004_album_publicly_accessible.py │ │ │ ├── 0006_auto_20201202_1956.py │ │ │ ├── 0002_albumsharelink.py │ │ │ └── 0001_initial.py │ │ ├── admin.py │ │ ├── urls.py │ │ ├── models.py │ │ └── views.py │ ├── faces │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0002_auto_20201228_1836.py │ │ │ └── 0001_squashed_0003_auto_20201227_2248.py │ │ ├── admin.py │ │ ├── urls.py │ │ ├── models.py │ │ ├── views.py │ │ └── tests.py │ ├── photos │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0018_auto_20201204_2246.py │ │ │ ├── 0016_auto_20201204_2244.py │ │ │ ├── 0017_auto_20201204_2246.py │ │ │ ├── 0004_photo_file_type.py │ │ │ ├── 0011_auto_20201129_2034.py │ │ │ ├── 0003_photo_faces.py │ │ │ ├── 0003_auto_20201128_2210.py │ │ │ ├── 0005_auto_20201128_2211.py │ │ │ ├── 0006_auto_20201128_2213.py │ │ │ ├── 0004_auto_20201128_2211.py │ │ │ ├── 0009_auto_20201128_2351.py │ │ │ ├── 0010_auto_20201129_0228.py │ │ │ ├── 0019_phototag_is_auto_generated.py │ │ │ ├── 0013_photo_publicly_accessible.py │ │ │ ├── 0014_auto_20201201_0114.py │ │ │ ├── 0002_auto_20201209_0457.py │ │ │ ├── 0007_photo_user.py │ │ │ ├── 0020_auto_20201209_0438.py │ │ │ ├── 0002_auto_20201128_2159.py │ │ │ ├── 0012_photo_license.py │ │ │ ├── 0015_auto_20201204_2241.py │ │ │ ├── 0001_initial.py │ │ │ ├── 0008_auto_20201128_2344.py │ │ │ └── 0001_squashed_0020_auto_20201209_0438.py │ │ ├── urls.py │ │ ├── admin.py │ │ ├── models.py │ │ ├── views.py │ │ └── tasks.py │ ├── tags │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_initial.py │ │ ├── urls.py │ │ ├── models.py │ │ ├── views.py │ │ └── tests.py │ └── users │ │ ├── __init__.py │ │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_auto_20201128_1457.py │ │ ├── 0003_auto_20201128_1457.py │ │ └── 0001_initial.py │ │ ├── admin.py │ │ ├── models.py │ │ └── oauth.py ├── static │ ├── css │ │ └── main.css │ └── js │ │ └── main.js ├── test │ ├── __init__.py │ └── photomanger_test.py ├── __init__.py ├── celery.py ├── templates │ ├── albums │ │ ├── album_update.html │ │ ├── album_form.html │ │ ├── albumsharelink_update.html │ │ ├── albumsharelink_confirm_delete.html │ │ ├── album_confirm_delete.html │ │ ├── base.html │ │ ├── view_single_album.html │ │ ├── album_list.html │ │ └── albumsharelink_list.html │ ├── faces │ │ ├── face_form.html │ │ ├── face_list.html │ │ └── face_detail.html │ ├── tags │ │ ├── phototag_form.html │ │ ├── phototag_confirm_delete.html │ │ ├── phototag_list.html │ │ └── phototag_detail.html │ ├── photos │ │ ├── photo_update.html │ │ ├── photo_list.html │ │ └── view_single_photo.html │ └── base.html ├── asgi.py ├── wsgi.py ├── utils │ └── files │ │ ├── list_dir.py │ │ └── read_file.py ├── settings │ ├── secret.sample.py │ └── __init__.py └── urls.py ├── scripts ├── stop_servers.sh ├── format.sh ├── install_dependencies.sh ├── docker-entrypoint.sh ├── start_servers.sh └── vagrant-config │ └── provision.sh ├── .coveragerc ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── Dockerfile ├── manage.py ├── Pipfile ├── CONTRIBUTING.md ├── Vagrantfile ├── README.md ├── docker-compose.yml ├── .gitignore └── ThirdPartyNotices.md /photomanager/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photomanager/static/css/main.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photomanager/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photomanager/apps/albums/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photomanager/apps/faces/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photomanager/apps/photos/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photomanager/apps/tags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photomanager/apps/users/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photomanager/apps/albums/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photomanager/apps/faces/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photomanager/apps/tags/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /photomanager/apps/users/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/stop_servers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | tmux kill-session -t servers -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | [report] 5 | ignore_errors = True -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pipenv run isort . && pipenv run black . 4 | 5 | -------------------------------------------------------------------------------- /photomanager/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import app as celery_app 2 | 3 | __all__ = ("celery_app",) 4 | -------------------------------------------------------------------------------- /scripts/install_dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | pipenv install --dev --deploy 4 | sudo pipenv install --dev --deploy 5 | -------------------------------------------------------------------------------- /photomanager/apps/faces/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Face 4 | 5 | admin.site.register(Face) 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /photomanager/apps/users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.models import Group 3 | 4 | from .models import User 5 | 6 | admin.site.register(User) 7 | 8 | # We don't need Group 9 | admin.site.unregister(Group) 10 | -------------------------------------------------------------------------------- /photomanager/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "photomanager.settings") 6 | 7 | app = Celery("photomanager") 8 | 9 | app.config_from_object("django.conf:settings", namespace="CELERY") 10 | app.autodiscover_tasks() 11 | -------------------------------------------------------------------------------- /photomanager/apps/tags/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "tags" 6 | 7 | urlpatterns = [ 8 | path("", views.ListTagView.as_view(), name="list"), 9 | path("create", views.CreateTagView.as_view(), name="create"), 10 | path("", views.DetailTagView.as_view(), name="display"), 11 | ] 12 | -------------------------------------------------------------------------------- /photomanager/apps/faces/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "faces" 6 | 7 | urlpatterns = [ 8 | path("", views.FacesListView.as_view(), name="list"), 9 | path("", views.FaceDetailView.as_view(), name="display"), 10 | path("/edit", views.FaceUpdateView.as_view(), name="edit"), 11 | ] 12 | -------------------------------------------------------------------------------- /photomanager/templates/albums/album_update.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | 5 | {% block content %} 6 |

Update Album

7 |
8 | {{ form|crispy }} 9 | {% csrf_token %} 10 | 11 |
12 | {% endblock %} -------------------------------------------------------------------------------- /photomanager/templates/faces/face_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | 5 | {% block content %} 6 |

Update Face

7 |
8 | {{ form|crispy }} 9 | {% csrf_token %} 10 | 11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /photomanager/templates/albums/album_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | 5 | {% block content %} 6 |

Create Album

7 |
8 | {{ form|crispy }} 9 | {% csrf_token %} 10 | 11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /photomanager/templates/tags/phototag_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | 5 | {% block content %} 6 |

Create Tag

7 |
8 | {{ form|crispy }} 9 | {% csrf_token %} 10 | 11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /photomanager/templates/albums/albumsharelink_update.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | 5 | {% block content %} 6 |

Update Share Link

7 |
8 | {{ form|crispy }} 9 | {% csrf_token %} 10 | 11 |
12 | {% endblock %} -------------------------------------------------------------------------------- /scripts/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /app/ 4 | 5 | if [[ "$1" == "daphne" ]] 6 | then 7 | ./manage.py migrate 8 | daphne -b 0.0.0.0 -p 8000 photomanager.asgi:application 9 | elif [[ "$1" == "celery" ]] 10 | then 11 | celery -A photomanager worker 12 | elif [[ "$1" == "celerybeat" ]] 13 | then 14 | celery -A photomanager beat 15 | else 16 | exec "$@" 17 | fi -------------------------------------------------------------------------------- /photomanager/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for photomanager project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "photomanager.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /photomanager/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for photomanager project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "photomanager.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0018_auto_20201204_2246.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-04 22:46 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0017_auto_20201204_2246"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name="phototag", 15 | old_name="slug", 16 | new_name="tag", 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /photomanager/static/js/main.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | $(".img-spinner").on("load", function() { 3 | $(this).parent().children(".spinner-wrapper").hide(); 4 | }); 5 | 6 | $('.img-spinner-lazy').lazy({ 7 | afterLoad: function(element) { 8 | element.parent().children(".spinner-wrapper").hide(); 9 | } 10 | }); 11 | 12 | // Selectize 13 | $("#id_faces").selectize(); 14 | $("#id_tags").selectize({ 15 | // create: true, 16 | }); 17 | }); -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-buster 2 | 3 | COPY . /app 4 | WORKDIR /app 5 | 6 | RUN apt-get update && \ 7 | apt-get -y upgrade && \ 8 | apt-get -y install cmake && \ 9 | pip install pipenv && \ 10 | pipenv install --deploy --system && \ 11 | chmod +x /app/scripts/docker-entrypoint.sh && \ 12 | apt-get clean && rm -f /var/lib/apt/lists/*_* 13 | 14 | EXPOSE 8000 15 | VOLUME /data 16 | VOLUME /app/photomanager/settings/secret.py 17 | VOLUME /thumbs 18 | 19 | ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"] -------------------------------------------------------------------------------- /scripts/start_servers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /home/vagrant/photomanager 4 | 5 | tmux new-session -s servers "bash --init-file <(cd /home/vagrant/photomanager && sudo pipenv run celery -A photomanager worker -l DEBUG)" \; \ 6 | split-window -h "bash --init-file <(cd /home/vagrant/photomanager && pipenv run ./manage.py runserver 0.0.0.0:8000)" \; \ 7 | selectp -t 0 \; \ 8 | split-window -v "bash --init-file <(cd /home/vagrant/photomanager && pipenv run celery -A photomanager beat -l DEBUG)" \; \ 9 | selectp -t 0 10 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0016_auto_20201204_2244.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-04 22:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0015_auto_20201204_2241"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="photo", 15 | name="tags", 16 | field=models.ManyToManyField(blank=True, to="tags.PhotoTag"), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0017_auto_20201204_2246.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-04 22:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0016_auto_20201204_2244"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="phototag", 15 | name="slug", 16 | field=models.SlugField(primary_key=True, serialize=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /photomanager/apps/albums/migrations/0003_albumsharelink_description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-29 21:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("albums", "0002_albumsharelink"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="albumsharelink", 15 | name="description", 16 | field=models.CharField(blank=True, max_length=1000), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0004_photo_file_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.6 on 2021-02-19 20:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0003_photo_faces"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="photo", 15 | name="file_type", 16 | field=models.IntegerField(choices=[(1, "Image"), (2, "Video")], default=1), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0011_auto_20201129_2034.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-29 20:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0010_auto_20201129_0228"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="photo", 15 | name="description", 16 | field=models.TextField(blank=True, help_text="Description for this photo."), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0003_photo_faces.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-27 22:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("faces", "__first__"), 10 | ("photos", "0002_auto_20201209_0457"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="photo", 16 | name="faces", 17 | field=models.ManyToManyField(blank=True, to="faces.Face"), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /photomanager/apps/albums/migrations/0007_auto_20201203_0108.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-03 01:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0014_auto_20201201_0114"), 10 | ("albums", "0006_auto_20201202_1956"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="album", 16 | name="photos", 17 | field=models.ManyToManyField(blank=True, to="photos.Photo"), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0003_auto_20201128_2210.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-28 22:10 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0002_auto_20201128_2159"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="photo", 15 | name="file", 16 | field=models.FilePathField( 17 | help_text="Path to the photo file.", path="/data" 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0005_auto_20201128_2211.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-28 22:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0004_auto_20201128_2211"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="photo", 15 | name="file", 16 | field=models.FilePathField( 17 | help_text="Path to the photo file.", path="/data" 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /photomanager/apps/users/migrations/0002_auto_20201128_1457.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-28 14:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("users", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="user", 15 | name="subdirectory", 16 | field=models.FilePathField( 17 | allow_folders=True, default="/data/", path="/data", recursive=True 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0006_auto_20201128_2213.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-28 22:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0005_auto_20201128_2211"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="photo", 15 | name="file", 16 | field=models.FilePathField( 17 | help_text="Path to the photo file.", path="/data", recursive=True 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0004_auto_20201128_2211.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-28 22:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0003_auto_20201128_2210"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="photo", 15 | name="file", 16 | field=models.FilePathField( 17 | allow_folders=True, help_text="Path to the photo file.", path="/data" 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0009_auto_20201128_2351.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-28 23:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0008_auto_20201128_2344"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="photo", 15 | name="iso", 16 | field=models.PositiveIntegerField( 17 | help_text="Sensor sensitivity in ISO", null=True, verbose_name="ISO" 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0010_auto_20201129_0228.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-29 02:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0009_auto_20201128_2351"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="photo", 15 | name="image_size", 16 | field=models.PositiveIntegerField( 17 | help_text="File size (on disk, in bytes) of the image", null=True 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0019_phototag_is_auto_generated.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-04 23:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0018_auto_20201204_2246"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="phototag", 15 | name="is_auto_generated", 16 | field=models.BooleanField( 17 | default=False, 18 | help_text="Whether this tag was automatically generated.", 19 | verbose_name="Automatically generated", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /photomanager/apps/users/migrations/0003_auto_20201128_1457.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-28 14:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("users", "0002_auto_20201128_1457"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="user", 15 | name="subdirectory", 16 | field=models.FilePathField( 17 | allow_files=False, 18 | allow_folders=True, 19 | default="/data/", 20 | path="/data", 21 | recursive=True, 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0013_photo_publicly_accessible.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-30 23:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0012_photo_license"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="photo", 15 | name="publicly_accessible", 16 | field=models.BooleanField( 17 | default=False, 18 | help_text="Whether this photo is publicly accessible. If checked, this photo islisted on the front page and accessible without authentication.", 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /photomanager/apps/users/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | from django.db.models.fields import FilePathField 3 | 4 | 5 | class User(AbstractUser): 6 | """ 7 | User object. 8 | """ 9 | 10 | # Subdirectory under their `data` folder where photos belong. 11 | # We assume that the `data` folder is bind-mounted in using Docker 12 | # (or, in a development environment, just exists) at `/data`. 13 | # Each user has a folder in `/data`. 14 | subdirectory = FilePathField( 15 | path="/data", 16 | null=False, 17 | blank=False, 18 | default="/data/", 19 | recursive=True, 20 | allow_folders=True, 21 | allow_files=False, 22 | ) 23 | -------------------------------------------------------------------------------- /photomanager/apps/albums/migrations/0005_auto_20201201_0312.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-12-01 03:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("albums", "0004_album_publicly_accessible"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="album", 15 | name="publicly_accessible", 16 | field=models.BooleanField( 17 | default=False, 18 | help_text="Whether this album is publicly accessible. If checked, this album is listed on the front page and accessible without authentication.", 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0014_auto_20201201_0114.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-12-01 01:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0013_photo_publicly_accessible"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="photo", 15 | name="publicly_accessible", 16 | field=models.BooleanField( 17 | default=False, 18 | help_text="Whether this photo is publicly accessible. If checked, this photo is listed on the front page and accessible without authentication.", 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /photomanager/templates/albums/albumsharelink_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block titleprefix %}Confirm Delete Share Link{% endblock %} 4 | 5 | {% block content %} 6 |

Confirm Deletion

7 |
8 |

9 | Are you sure you want to delete the share link 10 | 11 | {{ request.get_host }}{% url "albums:share_display" object.album.id object.id %} 12 | , corresponding to the album {{ object.album.name }}? 13 |

14 | {% csrf_token %} 15 | 16 |
17 | {% endblock %} -------------------------------------------------------------------------------- /photomanager/apps/albums/migrations/0004_album_publicly_accessible.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-12-01 01:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("albums", "0003_albumsharelink_description"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="album", 15 | name="publicly_accessible", 16 | field=models.BooleanField( 17 | default=False, 18 | help_text="Whether this album is publicly accessible. If checked, this album islisted on the front page and accessible without authentication.", 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "photomanager.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /photomanager/templates/photos/photo_update.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load crispy_forms_tags %} 4 | 5 | {% block content %} 6 |

Update Photo

7 |
8 | {{ form|crispy }} 9 | {% csrf_token %} 10 | 11 |
12 | 13 |
14 |

Regenerate Metadata

15 |
16 |

This button will refresh the EXIF metadata shown below the image.

17 | {% csrf_token %} 18 | 19 |
20 |
21 | {% endblock %} -------------------------------------------------------------------------------- /photomanager/templates/albums/album_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block titleprefix %}Confirm Delete Album{% endblock %} 4 | 5 | {% block content %} 6 |

Confirm Deletion

7 |
8 |

9 | Are you sure you want to delete the album {{ object.name }}, consisting of {{ object.photos.count }} 10 | photo{{ object.photos.count|pluralize }}? 11 |

12 | {% if object.photos.count > 0 %} 13 |

14 | The {{ object.photos.count }} photo{{ object.photos.count|pluralize }} will remain intact. 15 |

16 | {% endif %} 17 | {% csrf_token %} 18 | 19 |
20 | {% endblock %} -------------------------------------------------------------------------------- /photomanager/apps/albums/migrations/0006_auto_20201202_1956.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-02 19:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("albums", "0005_auto_20201201_0312"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="album", 15 | name="publicly_accessible", 16 | field=models.BooleanField( 17 | default=False, 18 | help_text="Whether this album is publicly accessible. If checked, this album is listed on the front page and accessible without authentication. The photos within this album are not visible on the front page, however, they are publicly accessible with their link.", 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0002_auto_20201209_0457.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-09 04:57 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("photos", "0001_squashed_0020_auto_20201209_0438"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="photo", 18 | name="user", 19 | field=models.ForeignKey( 20 | help_text="The user that this photo belongs to.", 21 | on_delete=django.db.models.deletion.CASCADE, 22 | to=settings.AUTH_USER_MODEL, 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /photomanager/templates/tags/phototag_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block titleprefix %}Confirm Delete Tag{% endblock %} 4 | 5 | {% block content %} 6 |

Confirm Tag Deletion

7 | {% if object.is_auto_generated %} 8 |

This tag cannot be deleted because it was automatically generated.

9 | {% else %} 10 |
11 |

12 | Are you sure you want to delete the tag {{ object }}, 13 | consisting of {{ object.photo_set.all.count }} photo{{ object.photo_set.all.count|pluralize }}? 14 |

15 |

The photos will remain intact.

16 | {% csrf_token %} 17 | 18 |
19 | {% endif %} 20 | {% endblock %} -------------------------------------------------------------------------------- /scripts/vagrant-config/provision.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | cd /home/vagrant/photomanager 5 | 6 | sudo apt-get update 7 | sudo apt-get upgrade -y 8 | 9 | # Install pip 10 | sudo apt-get -y install python3-pip python3-dev 11 | 12 | # Install pipenv 13 | sudo pip3 install pipenv 14 | 15 | # face-recognition requires cmake 16 | apt-get -y install cmake 17 | 18 | # Install dependencies and create venv 19 | pipenv install --dev --deploy 20 | 21 | # Create /data 22 | sudo mkdir -p /data 23 | sudo chown vagrant:vagrant /data 24 | 25 | # Create /thumbs 26 | sudo mkdir -p /thumbs 27 | sudo chown vagrant:vagrant /thumbs 28 | 29 | # Install and configure Redis 30 | apt-get -y install redis 31 | sed -i 's/^#\(bind 127.0.0.1 ::1\)$/\1/' /etc/redis/redis.conf 32 | sed -i 's/^\(protected-mode\) no$/\1 yes/' /etc/redis/redis.conf 33 | systemctl restart redis-server 34 | systemctl enable redis-server 35 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0007_photo_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-28 22:15 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("photos", "0006_auto_20201128_2213"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name="photo", 18 | name="user", 19 | field=models.ForeignKey( 20 | default=None, 21 | help_text="The user that this photo belongs to.", 22 | on_delete=django.db.models.deletion.CASCADE, 23 | to="users.user", 24 | ), 25 | preserve_default=False, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | django = "*" 8 | celery = {extras = ["redis"], version = "*"} 9 | django-user-sessions = "*" 10 | whitenoise = "*" 11 | phonenumbers = "*" 12 | gunicorn = "*" 13 | psycopg2-binary = "*" 14 | social-auth-app-django = "*" 15 | python-magic = "*" 16 | django-celery-results = "*" 17 | django-redis = "*" 18 | exif = "*" 19 | timezonefinder = "*" 20 | pillow = "*" 21 | "hurry.filesize" = "*" 22 | django-extensions = "*" 23 | ipython = "*" 24 | django-crispy-forms = "*" 25 | django-celery-beat = "*" 26 | django-bootstrap-pagination = "*" 27 | tensorflow = "*" 28 | daphne = "*" 29 | channels = "*" 30 | face-recognition = "*" 31 | 32 | [dev-packages] 33 | black = "==20.8b1" 34 | isort = "*" 35 | coverage = "*" 36 | mypy = "*" 37 | django-stubs = "*" 38 | 39 | [requires] 40 | python_version = "3.8" 41 | -------------------------------------------------------------------------------- /photomanager/apps/photos/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "photos" 6 | 7 | urlpatterns = [ 8 | path("rescan", views.rescan_directory, name="rescan"), 9 | path("reprocess/", views.reprocess_file, name="reprocess"), 10 | path("raw_image/", views.get_raw_image, name="raw_image"), 11 | path( 12 | "raw_image//album_share/", 13 | views.get_raw_image_album_share, 14 | name="raw_image_album_share", 15 | ), 16 | path("", views.view_single_photo, name="view_single_photo"), 17 | path( 18 | "/album_share/", 19 | views.view_single_photo_album_share, 20 | name="view_single_photo_album_share", 21 | ), 22 | path("update/", views.PhotoUpdate.as_view(), name="update_photo"), 23 | ] 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to `photomanager`! 4 | 5 | ## Pull requests 6 | 7 | * PRs should target `master`. (Later, this may be changed to a `development` branch.) 8 | * Please run `isort` then `black` (`pipenv run isort . && pipenv run black .`). 9 | CI will likely fail if you do not. 10 | * Commit messages should follow the [Conventional Commits standard](https://conventionalcommits.org/) where possible. 11 | * Keep PRs to a minimum; many small PRs is better than one big PR. 12 | * Before merging, please squash all your commits. 13 | * I only do rebase merges, so you may need to rebase against the latest `master`: 14 | ```bash 15 | git remote add upstream git@github.com:etnguyen03/photomanager.git 16 | git fetch upstream 17 | git rebase upstream/master 18 | git log # check to make sure everything looks fine 19 | git push origin +[BRANCH] # Force push necessary 20 | ``` 21 | 22 | -------------------------------------------------------------------------------- /photomanager/apps/users/oauth.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from social_core.backends.oauth import BaseOAuth2 3 | 4 | 5 | class NextcloudOAuth2(BaseOAuth2): 6 | name = "nextcloud" 7 | AUTHORIZATION_URL = f"https://{settings.NEXTCLOUD_URI}/apps/oauth2/authorize" 8 | ACCESS_TOKEN_URL = f"https://{settings.NEXTCLOUD_URI}/apps/oauth2/api/v1/token" 9 | ACCESS_TOKEN_METHOD = "POST" 10 | 11 | def get_scope(self): 12 | return ["read"] 13 | 14 | def get_user_details(self, profile): 15 | return { 16 | "username": profile["ocs"]["data"]["id"], 17 | "email": profile["ocs"]["data"]["email"], 18 | } 19 | 20 | def get_user_id(self, details, response): 21 | return details["username"] 22 | 23 | def user_data(self, access_token, *args, **kwargs): 24 | return self.get_json( 25 | f"https://{settings.NEXTCLOUD_URI}/ocs/v2.php/cloud/user?format=json", 26 | headers={"Authorization": f"Bearer {access_token}"}, 27 | ) 28 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0020_auto_20201209_0438.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-09 04:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("tags", "0001_initial"), 10 | ("photos", "0019_phototag_is_auto_generated"), 11 | ] 12 | 13 | database_operations = [ 14 | migrations.AlterModelTable( 15 | name="PhotoTag", 16 | table="tags_phototag", 17 | ), 18 | migrations.AlterField( 19 | model_name="photo", 20 | name="tags", 21 | field=models.ManyToManyField(blank=True, to="tags.PhotoTag"), 22 | ), 23 | ] 24 | 25 | state_operations = [ 26 | migrations.DeleteModel( 27 | name="PhotoTag", 28 | ) 29 | ] 30 | 31 | operations = [ 32 | migrations.SeparateDatabaseAndState( 33 | database_operations=database_operations, 34 | state_operations=state_operations, 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | Vagrant.require_version ">= 2.1.0" 2 | 3 | Vagrant.configure("2") do |config| 4 | config.vm.box = "ubuntu/focal64" 5 | 6 | config.vm.boot_timeout = 1000 7 | 8 | # Django HTTP port 9 | config.vm.network "forwarded_port", guest: 8000, host: 8000, host_ip: "127.0.0.1" 10 | 11 | # Define the VM and set up some things 12 | config.vm.hostname = "photomanager-vm" 13 | config.vm.define "photomanager-vagrant" do |v| 14 | end 15 | config.vm.provider "virtualbox" do |vb| 16 | vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"] 17 | vb.customize ["modifyvm", :id, "--natdnsproxy1", "on"] 18 | vb.customize ["modifyvm", :id, "--nictype1", "virtio"] 19 | vb.name = "photomanager-vagrant" 20 | vb.memory = 6144 21 | end 22 | 23 | # Sync this repo to /home/vagrant/photomanager 24 | config.vm.synced_folder ".", "/home/vagrant/photomanager", SharedFoldersEnableSymlinksCreate: false 25 | 26 | # Provision from a script 27 | config.vm.provision "shell", path: "scripts/vagrant-config/provision.sh" 28 | 29 | # Set SSH username 30 | config.ssh.username = "vagrant" 31 | end 32 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0002_auto_20201128_2159.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-28 21:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="photo", 15 | name="creation_time", 16 | field=models.DateTimeField( 17 | auto_now_add=True, help_text="Photo creation time.", null=True 18 | ), 19 | ), 20 | migrations.AlterField( 21 | model_name="photo", 22 | name="last_modified_time", 23 | field=models.DateTimeField( 24 | auto_now=True, help_text="Photo modification time.", null=True 25 | ), 26 | ), 27 | migrations.AlterField( 28 | model_name="photo", 29 | name="photo_taken_time", 30 | field=models.DateTimeField( 31 | blank=True, help_text="Time the photo was taken.", null=True 32 | ), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /photomanager/templates/albums/base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |

{{ album.name }}

11 |

{{ album.description }}

12 | {# TODO: require the user to be the same user as the album owner #} 13 | {% if user.is_authenticated %} 14 |
15 |
16 | View Share Links 17 | Edit 18 |
19 |
20 | {% endif %} 21 |
22 |
23 | {% block album_content %}{% endblock %} 24 | {% endblock %} -------------------------------------------------------------------------------- /photomanager/apps/albums/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Album, AlbumShareLink 4 | 5 | 6 | class AlbumAdmin(admin.ModelAdmin): 7 | readonly_fields = [ 8 | "id", 9 | "creation_time", 10 | "last_modified_time", 11 | ] 12 | 13 | class Meta: 14 | model = Album 15 | 16 | 17 | class AlbumShareLinkAdmin(admin.ModelAdmin): 18 | readonly_fields = [ 19 | "id", 20 | "creation_time", 21 | ] 22 | 23 | fields = [ 24 | "id", 25 | "album", 26 | "creator", 27 | "creation_time", 28 | ] 29 | 30 | # We want the `album` and `creator` attribute to be read-only only when modifying 31 | # photos, not when creating them. 32 | def get_readonly_fields(self, request, obj=None): 33 | readonly_fields = super(AlbumShareLinkAdmin, self).get_readonly_fields( 34 | request, obj 35 | ) 36 | if obj: # If the object exists; aka we are editing an existing object 37 | return readonly_fields + ["album", "creator"] 38 | return readonly_fields 39 | 40 | class Meta: 41 | model = AlbumShareLink 42 | 43 | 44 | admin.site.register(Album, AlbumAdmin) 45 | admin.site.register(AlbumShareLink, AlbumShareLinkAdmin) 46 | -------------------------------------------------------------------------------- /photomanager/apps/albums/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "albums" 6 | 7 | urlpatterns = [ 8 | path("", views.AlbumListView.as_view(), name="list"), 9 | path("create", views.AlbumCreateView.as_view(), name="create"), 10 | path("", views.view_album, name="display"), 11 | path("/edit", views.AlbumEditView.as_view(), name="edit"), 12 | path("/delete", views.AlbumDeleteView.as_view(), name="delete"), 13 | path( 14 | "/share/", 15 | views.view_album_share, 16 | name="share_display", 17 | ), 18 | path( 19 | "/share/links", 20 | views.AlbumShareLinkList.as_view(), 21 | name="share_links", 22 | ), 23 | path( 24 | "/share/links/create", 25 | views.album_share_link_create, 26 | name="share_links_create", 27 | ), 28 | path( 29 | "/share/links/delete/", 30 | views.AlbumShareLinkDelete.as_view(), 31 | name="share_links_delete", 32 | ), 33 | path( 34 | "/share/links/edit/", 35 | views.AlbumShareLinkEdit.as_view(), 36 | name="share_links_edit", 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /photomanager/templates/faces/face_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load bootstrap_pagination %} 4 | 5 | {% block titleprefix %}Faces{% endblock %} 6 | 7 | {% block content %} 8 |
9 |

Faces

10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {% for face in object_list %} 19 | 20 | 21 | 22 | {% empty %} 23 | 24 | 25 | 26 | {% endfor %} 27 | 28 |
Face
{{ face }}
There are no faces to display.
29 |
30 |
31 |
32 | {% bootstrap_paginate page_obj range=10 extra_pagination_classes="justify-content-center" %} 33 |
34 |
35 |
36 | {% endblock %} -------------------------------------------------------------------------------- /photomanager/apps/tags/models.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.db import models 3 | 4 | from photomanager.apps.users.models import User 5 | 6 | 7 | class PhotoTag(models.Model): 8 | """ 9 | Represents a tag that can be applied to photos. 10 | """ 11 | 12 | tag = models.SlugField(primary_key=True) 13 | 14 | @property 15 | def human_readable_name(self) -> str: 16 | """ 17 | Return a human readable name based off the slug. 18 | 19 | :return: A string 20 | """ 21 | return self.tag.replace("_", " ") 22 | 23 | is_auto_generated = models.BooleanField( 24 | verbose_name="Automatically generated", 25 | default=False, 26 | help_text="Whether this tag was automatically generated.", 27 | ) 28 | 29 | creator = models.ForeignKey(User, models.SET_NULL, null=True) 30 | create_time = models.DateTimeField(verbose_name="Creation time", auto_now_add=True) 31 | 32 | def __str__(self): 33 | return self.human_readable_name 34 | 35 | def clean(self, *args, **kwargs): 36 | if self.tag == "create": 37 | raise ValidationError( 38 | {"tag": "create is a reserved tag name and cannot be used"} 39 | ) 40 | 41 | super(PhotoTag, self).clean(*args, **kwargs) 42 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0012_photo_license.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-30 04:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0011_auto_20201129_2034"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="photo", 15 | name="license", 16 | field=models.CharField( 17 | choices=[ 18 | ("ARR", "All rights reserved"), 19 | ("PDM", "Public Domain Mark"), 20 | ("CC0", "CC0"), 21 | ("CCBY", "Creative Commons Attribution"), 22 | ("CCBYSA", "Creative Commons Attribution Share-Alike"), 23 | ("CCBYND", "Creative Commons Attribution-NoDerivs"), 24 | ("CCBYNC", "Creative Commons Attribution-NonCommercial"), 25 | ( 26 | "CCBYNCSA", 27 | "Creative Commons Attribution-NonCommercial-ShareAlike", 28 | ), 29 | ("CCBYNCND", "Creative Commons Attribution-NonCommercial-NoDerivs"), 30 | ], 31 | default="ARR", 32 | max_length=50, 33 | ), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /photomanager/apps/faces/migrations/0002_auto_20201228_1836.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-28 18:36 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("faces", "0001_squashed_0003_auto_20201227_2248"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="face", 18 | name="creator", 19 | field=models.ForeignKey( 20 | blank=True, 21 | help_text="The user that created this object.", 22 | null=True, 23 | on_delete=django.db.models.deletion.SET_NULL, 24 | related_name="creator", 25 | to=settings.AUTH_USER_MODEL, 26 | ), 27 | ), 28 | migrations.AlterField( 29 | model_name="face", 30 | name="user", 31 | field=models.ForeignKey( 32 | blank=True, 33 | help_text="The user that whose face is represented by this object.", 34 | null=True, 35 | on_delete=django.db.models.deletion.SET_NULL, 36 | to=settings.AUTH_USER_MODEL, 37 | ), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0015_auto_20201204_2241.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-04 22:41 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("photos", "0014_auto_20201201_0114"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="PhotoTag", 18 | fields=[ 19 | ( 20 | "slug", 21 | models.SlugField(editable=False, primary_key=True, serialize=False), 22 | ), 23 | ( 24 | "create_time", 25 | models.DateTimeField( 26 | auto_now_add=True, verbose_name="Creation time" 27 | ), 28 | ), 29 | ( 30 | "creator", 31 | models.ForeignKey( 32 | null=True, 33 | on_delete=django.db.models.deletion.SET_NULL, 34 | to=settings.AUTH_USER_MODEL, 35 | ), 36 | ), 37 | ], 38 | ), 39 | migrations.AddField( 40 | model_name="photo", 41 | name="tags", 42 | field=models.ManyToManyField(to="photos.PhotoTag"), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /photomanager/utils/files/list_dir.py: -------------------------------------------------------------------------------- 1 | """ 2 | Recursively lists the contents of the directory passed as 3 | the only command line argument. This script chroots into 4 | that directory, then lists, to help mitigate security 5 | vulnerabilities (for instance, if a file is a cleverly 6 | designed symlink). 7 | 8 | The contents are printed to stdout as a JSON dump with 9 | their MIME types as determined by python-magic, like this: 10 | 11 | {"hello.jpeg": "image/jpeg", "hello/hello.txt": "text/plain"} 12 | """ 13 | 14 | import argparse 15 | import json 16 | import os 17 | 18 | import magic 19 | 20 | argparser = argparse.ArgumentParser() 21 | argparser.add_argument("directory", help="Directory to list/traverse", type=str) 22 | args = argparser.parse_args() 23 | 24 | if not os.path.isdir(args.directory): 25 | print(json.dumps({"error": 404, "message": "This directory does not exist"})) 26 | exit(1) 27 | 28 | # This must be created before chrooting 29 | m = magic.Magic(mime=True) 30 | 31 | # Chroot into this directory 32 | os.chdir(args.directory) 33 | os.chroot(args.directory) 34 | 35 | # List the files in this directory 36 | # https://stackoverflow.com/questions/19309667/recursive-os-listdir 37 | files = [os.path.join(dp, f) for dp, dn, fn in os.walk("/") for f in fn] 38 | 39 | # Get the MIME types of each file in this directory 40 | files_mime_dict = {} 41 | 42 | for file in files: 43 | if os.path.exists(file): 44 | files_mime_dict[file] = m.from_file(file) 45 | 46 | print(json.dumps(files_mime_dict)) 47 | -------------------------------------------------------------------------------- /photomanager/templates/tags/phototag_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load bootstrap_pagination %} 4 | 5 | {% block titleprefix %}Tags{% endblock %} 6 | 7 | {% block content %} 8 |
9 |

Tags

10 | {% if user.is_authenticated %} 11 |
12 | Create New Tag 13 |
14 | {% endif %} 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for tag in object_list %} 24 | 25 | 26 | 27 | {% empty %} 28 | 29 | 30 | 31 | {% endfor %} 32 | 33 |
Tag
{{ tag.human_readable_name }}
There are no tags to display.
34 |
35 |
36 |
37 | {% bootstrap_paginate page_obj range=10 extra_pagination_classes="justify-content-center" %} 38 |
39 |
40 |
41 | {% endblock %} -------------------------------------------------------------------------------- /photomanager/settings/secret.sample.py: -------------------------------------------------------------------------------- 1 | # Django secret key. 2 | SECRET_KEY = "" 3 | 4 | # Set to False in production. 5 | # If in production, also add to ALLOWED_HOSTS. 6 | DEBUG = False 7 | ALLOWED_HOSTS = [] 8 | 9 | # If running behind a reverse proxy and you are using HTTPS, set to True 10 | SOCIAL_AUTH_REDIRECT_IS_HTTPS = False 11 | 12 | SOCIAL_AUTH_NEXTCLOUD_KEY = "" 13 | SOCIAL_AUTH_NEXTCLOUD_SECRET = "" 14 | NEXTCLOUD_URI = "" # Hostname of your Nextcloud instance, like "nextcloud.example.com" 15 | 16 | # Whether to enable tagging using Tensorflow/Keras for images 17 | # or not. Be warned: you should have at least, at the bare minimum 18 | # 4 GiB of RAM to use automatic tagging. Even with 4 GiB of RAM, 19 | # you will experience slowdowns. 20 | ENABLE_TENSORFLOW_TAGGING = True 21 | 22 | # Same for face recognition. 23 | ENABLE_FACE_RECOGNITION = True 24 | 25 | 26 | # Configure your database and cache here. 27 | DATABASES = { 28 | "default": { 29 | "ENGINE": "django.db.backends.postgresql_psycopg2", 30 | "NAME": "", # Database name 31 | "USER": "", # Database user 32 | "PASSWORD": "", # Database password 33 | "HOST": "postgres", # Database hostname 34 | "PORT": "5432", # Database port number 35 | } 36 | } 37 | 38 | CACHES = { 39 | "default": { 40 | "BACKEND": "django_redis.cache.RedisCache", 41 | "LOCATION": "", # in the format redis://{{ HOSTNAME }}:{{ PORT NUMBER }}/1 42 | "OPTIONS": { 43 | "CLIENT_CLASS": "django_redis.client.DefaultClient", 44 | }, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /photomanager/urls.py: -------------------------------------------------------------------------------- 1 | """photomanager URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import url 17 | from django.contrib import admin 18 | from django.contrib.auth.views import LogoutView 19 | from django.urls import include, path 20 | 21 | from photomanager.apps.albums import urls as albums_urls 22 | from photomanager.apps.faces import urls as faces_urls 23 | from photomanager.apps.photos import urls as photos_urls 24 | from photomanager.apps.photos import views as photos_views 25 | from photomanager.apps.tags import urls as tags_urls 26 | 27 | urlpatterns = [ 28 | path("admin/", admin.site.urls), 29 | path("photos/", include(photos_urls)), 30 | path("albums/", include(albums_urls)), 31 | path("tags/", include(tags_urls)), 32 | path("faces/", include(faces_urls)), 33 | path("", photos_views.IndexView.as_view(), name="index"), 34 | path("logout", LogoutView.as_view(), name="logout"), 35 | url("", include("social_django.urls", namespace="social")), 36 | ] 37 | -------------------------------------------------------------------------------- /photomanager/utils/files/read_file.py: -------------------------------------------------------------------------------- 1 | """ 2 | Reads the file passed as the only command line argument. 3 | 4 | This script chroots into the parent directory to prevent 5 | symlinks from reading other files on disk. 6 | 7 | The contents are base64-ed and are printed to 8 | stdout as a JSON dump along with 9 | their MIME types, like this: 10 | 11 | {"hello.txt": {"data": "[base64 of file contents]", "mime": "text/plain"}} 12 | """ 13 | 14 | import argparse 15 | import base64 16 | import json 17 | import os 18 | from pathlib import Path 19 | 20 | import magic 21 | 22 | argparser = argparse.ArgumentParser() 23 | argparser.add_argument("file", help="File to read", type=Path) 24 | args = argparser.parse_args() 25 | 26 | # Exit if the file doesn't exist 27 | if not os.path.isfile(args.file): 28 | print(json.dumps({"error": 404, "message": "This file does not exist"})) 29 | exit(1) 30 | 31 | # chroot requires root 32 | if os.geteuid() != 0: 33 | print(json.dumps({"error": 500, "message": "This script must be ran as root"})) 34 | exit(1) 35 | 36 | # This must be created before chrooting 37 | m = magic.Magic(mime=True) 38 | 39 | # Chroot into this directory 40 | os.chdir(args.file.parent) 41 | os.chroot(args.file.parent) 42 | 43 | # Get the content of the file 44 | with open(args.file.name, "rb") as file: 45 | contents = file.read() 46 | 47 | print( 48 | json.dumps( 49 | { 50 | str(args.file): { 51 | "data": base64.b64encode(contents).decode(), 52 | "mime": m.from_file(str(args.file.name)), 53 | "size": os.path.getsize(args.file.name), 54 | } 55 | } 56 | ) 57 | ) 58 | -------------------------------------------------------------------------------- /photomanager/apps/tags/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-09 04:38 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | state_operations = [ 17 | migrations.CreateModel( 18 | name="PhotoTag", 19 | fields=[ 20 | ("tag", models.SlugField(primary_key=True, serialize=False)), 21 | ( 22 | "is_auto_generated", 23 | models.BooleanField( 24 | default=False, 25 | help_text="Whether this tag was automatically generated.", 26 | verbose_name="Automatically generated", 27 | ), 28 | ), 29 | ( 30 | "create_time", 31 | models.DateTimeField( 32 | auto_now_add=True, verbose_name="Creation time" 33 | ), 34 | ), 35 | ( 36 | "creator", 37 | models.ForeignKey( 38 | null=True, 39 | on_delete=django.db.models.deletion.SET_NULL, 40 | to=settings.AUTH_USER_MODEL, 41 | ), 42 | ), 43 | ], 44 | ), 45 | ] 46 | 47 | operations = [ 48 | migrations.SeparateDatabaseAndState(state_operations=state_operations) 49 | ] 50 | -------------------------------------------------------------------------------- /photomanager/apps/albums/migrations/0002_albumsharelink.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-29 20:50 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ("albums", "0001_initial"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="AlbumShareLink", 20 | fields=[ 21 | ( 22 | "id", 23 | models.UUIDField( 24 | default=uuid.uuid4, 25 | editable=False, 26 | primary_key=True, 27 | serialize=False, 28 | ), 29 | ), 30 | ( 31 | "creation_time", 32 | models.DateTimeField( 33 | auto_now_add=True, 34 | help_text="Album share link creation time.", 35 | null=True, 36 | ), 37 | ), 38 | ( 39 | "album", 40 | models.ForeignKey( 41 | on_delete=django.db.models.deletion.CASCADE, to="albums.album" 42 | ), 43 | ), 44 | ( 45 | "creator", 46 | models.ForeignKey( 47 | help_text="Creator of this Album share link", 48 | on_delete=django.db.models.deletion.CASCADE, 49 | to=settings.AUTH_USER_MODEL, 50 | ), 51 | ), 52 | ], 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-22 03:38 2 | 3 | import uuid 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Photo", 17 | fields=[ 18 | ( 19 | "id", 20 | models.UUIDField( 21 | default=uuid.uuid4, 22 | editable=False, 23 | primary_key=True, 24 | serialize=False, 25 | ), 26 | ), 27 | ( 28 | "file", 29 | models.FilePathField( 30 | editable=False, 31 | help_text="Path to the photo file.", 32 | path="/data", 33 | ), 34 | ), 35 | ( 36 | "description", 37 | models.TextField(help_text="Description for this photo."), 38 | ), 39 | ( 40 | "creation_time", 41 | models.DateTimeField( 42 | auto_now_add=True, help_text="Photo creation time." 43 | ), 44 | ), 45 | ( 46 | "last_modified_time", 47 | models.DateTimeField( 48 | auto_now=True, help_text="Photo modification time." 49 | ), 50 | ), 51 | ( 52 | "photo_taken_time", 53 | models.DateTimeField(help_text="Time the photo was taken."), 54 | ), 55 | ], 56 | ), 57 | ] 58 | -------------------------------------------------------------------------------- /photomanager/apps/faces/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | 5 | from photomanager.apps.users.models import User 6 | 7 | 8 | class Face(models.Model): 9 | """ 10 | Represents a face in an image. 11 | 12 | These faces can be tied to a User or they can not be. 13 | """ 14 | 15 | id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False) 16 | defined_name = models.CharField( 17 | max_length=250, 18 | help_text="Name for this face. Ignored if a user is defined", 19 | blank=True, 20 | ) 21 | 22 | # We use a ForeignKey because it might be possible that multiple "faces" are detected 23 | # but all of those "faces" correspond to the same person 24 | user = models.ForeignKey( 25 | User, 26 | models.SET_NULL, 27 | null=True, 28 | blank=True, 29 | help_text="The user that whose face is represented by this object.", 30 | ) 31 | 32 | creator = models.ForeignKey( 33 | User, 34 | models.SET_NULL, 35 | null=True, 36 | blank=True, 37 | help_text="The user that created this object.", 38 | related_name="creator", 39 | ) 40 | modify_time = models.DateTimeField(verbose_name="Last modified time", auto_now=True) 41 | create_time = models.DateTimeField(verbose_name="Creation time", auto_now_add=True) 42 | 43 | face_data = models.TextField( 44 | help_text="Internal data used to recognize this face.", max_length=5000 45 | ) 46 | 47 | @property 48 | def name(self) -> str: 49 | """ 50 | Gets the human readable name of this Face. 51 | 52 | :return: The corresponding User's full name, if one exists, or the defined name otherwise 53 | """ 54 | if self.user is User: # i.e. not None 55 | if self.user.get_full_name().strip() != "": 56 | return self.user.get_full_name().strip() 57 | return self.defined_name 58 | 59 | def __str__(self): 60 | return f"{self.name} ({self.id})" 61 | -------------------------------------------------------------------------------- /photomanager/templates/photos/photo_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load bootstrap_pagination %} 4 | 5 | {% block titleprefix %}Index{% endblock %} 6 | 7 | {% block content %} 8 |
9 |
10 | {% for photo in object_list %} 11 | 25 | {% if not forloop.last and forloop.counter|divisibleby:3 %} 26 | {# Close out the row and start a new one #} 27 |
28 |
29 | {% endif %} 30 | {% empty %} 31 |
32 |

There are no photos.

33 | {% if not user.is_authenticated %} 34 |

You are not authenticated; there may be more photos visible if you log in.

35 | {% endif %} 36 |
37 | {% endfor %} 38 |
39 |
40 |
41 | {% bootstrap_paginate page_obj range=10 extra_pagination_classes="justify-content-center" %} 42 |
43 |
44 |
45 | {% endblock %} -------------------------------------------------------------------------------- /photomanager/templates/albums/view_single_album.html: -------------------------------------------------------------------------------- 1 | {% extends "albums/base.html" %} 2 | 3 | {% load tz %} 4 | 5 | {% block titleprefix %}View Album - {{ album.name }}{% endblock %} 6 | 7 | {% block album_content %} 8 |
9 |
10 | {% for photo in photos %} 11 | 33 | {% if not forloop.last and forloop.counter|divisibleby:3 %} 34 | {# Close out the row and start a new one #} 35 |
36 |
37 | {% endif %} 38 | {% empty %} 39 |
40 |

There are no photos in this album.

41 |
42 | {% endfor %} 43 |
44 |
45 | {% endblock %} -------------------------------------------------------------------------------- /photomanager/apps/albums/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-29 18:51 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ("photos", "0010_auto_20201129_0228"), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name="Album", 22 | fields=[ 23 | ( 24 | "id", 25 | models.UUIDField( 26 | default=uuid.uuid4, 27 | editable=False, 28 | primary_key=True, 29 | serialize=False, 30 | ), 31 | ), 32 | ( 33 | "name", 34 | models.CharField(help_text="Name for this album.", max_length=300), 35 | ), 36 | ( 37 | "description", 38 | models.TextField( 39 | blank=True, help_text="Description for this album." 40 | ), 41 | ), 42 | ( 43 | "creation_time", 44 | models.DateTimeField( 45 | auto_now_add=True, help_text="Album creation time.", null=True 46 | ), 47 | ), 48 | ( 49 | "last_modified_time", 50 | models.DateTimeField( 51 | auto_now=True, help_text="Album modification time.", null=True 52 | ), 53 | ), 54 | ( 55 | "owner", 56 | models.ForeignKey( 57 | help_text="The user that this album belongs to.", 58 | on_delete=django.db.models.deletion.CASCADE, 59 | to=settings.AUTH_USER_MODEL, 60 | ), 61 | ), 62 | ("photos", models.ManyToManyField(to="photos.Photo")), 63 | ], 64 | ), 65 | ] 66 | -------------------------------------------------------------------------------- /photomanager/templates/tags/phototag_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load tz %} 4 | 5 | {% block titleprefix %}View Tag - {{ object.tag }}{% endblock %} 6 | 7 | {% block content %} 8 |
9 |
10 |

{{ object }}

11 |
12 |
13 |
14 |
15 | {% for photo in photos %} 16 | 38 | {% if not forloop.last and forloop.counter|divisibleby:3 %} 39 | {# Close out the row and start a new one #} 40 |
41 |
42 | {% endif %} 43 | {% empty %} 44 |
45 |

There are no photos tagged with this tag.

46 |
47 | {% endfor %} 48 |
49 |
50 | {% endblock %} -------------------------------------------------------------------------------- /photomanager/apps/albums/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | 5 | from ..photos.models import Photo 6 | from ..users.models import User 7 | 8 | 9 | class Album(models.Model): 10 | """ 11 | Represents an album; a collection of photos. 12 | """ 13 | 14 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 15 | owner = models.ForeignKey( 16 | User, help_text="The user that this album belongs to.", on_delete=models.CASCADE 17 | ) 18 | 19 | name = models.CharField( 20 | max_length=300, help_text="Name for this album.", blank=False, null=False 21 | ) 22 | description = models.TextField(help_text="Description for this album.", blank=True) 23 | 24 | creation_time = models.DateTimeField( 25 | auto_now_add=True, help_text="Album creation time.", null=True 26 | ) 27 | last_modified_time = models.DateTimeField( 28 | auto_now=True, help_text="Album modification time.", null=True 29 | ) 30 | 31 | publicly_accessible = models.BooleanField( 32 | default=False, 33 | null=False, 34 | help_text="Whether this album is publicly accessible. If checked, this album is " 35 | "listed on the front page and accessible without authentication. The photos within this album " 36 | "are not visible on the front page, however, they are publicly accessible with their link.", 37 | ) 38 | 39 | photos = models.ManyToManyField(Photo, blank=True) 40 | 41 | 42 | class AlbumShareLink(models.Model): 43 | """ 44 | Represents a share link tied to an album. 45 | 46 | Allows for public viewing of an album and its associated photos with a 47 | unique link, but without making all the photos in the album public. 48 | """ 49 | 50 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 51 | album = models.ForeignKey(Album, on_delete=models.CASCADE) 52 | 53 | description = models.CharField(max_length=1000, blank=True) 54 | 55 | # Currently, this may seem useless (just read the creator from the album), but 56 | # in the future when user to user sharing is implemented, this may be useful. 57 | creator = models.ForeignKey( 58 | User, help_text="Creator of this Album share link", on_delete=models.CASCADE 59 | ) 60 | 61 | creation_time = models.DateTimeField( 62 | auto_now_add=True, help_text="Album share link creation time.", null=True 63 | ) 64 | -------------------------------------------------------------------------------- /photomanager/templates/albums/album_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load bootstrap_pagination %} 4 | 5 | {% block titleprefix %}Albums{% endblock %} 6 | 7 | {% block content %} 8 |
9 |

Albums

10 | {% if user.is_authenticated %} 11 |
12 | Create New Album 13 |
14 | {% endif %} 15 |
16 | 17 | 18 | 19 | 20 | {# Contains buttons, but we don't want a header #} 21 | 22 | 23 | 24 | {% for album in object_list %} 25 | 26 | 27 | 39 | 40 | {% empty %} 41 | 42 | 43 | 44 | 45 | {% endfor %} 46 | 47 |
Name
{{ album.name }} 28 | {% if user.is_authenticated and user == album.owner %} 29 | {# Edit album #} 30 | 31 | 32 | 33 | {# Delete album #} 34 | 35 | 36 | 37 | {% endif %} 38 |
There are no albums to display.
48 |
49 |
50 |
51 | {% bootstrap_paginate page_obj range=10 extra_pagination_classes="justify-content-center" %} 52 |
53 |
54 |
55 | {% endblock %} -------------------------------------------------------------------------------- /photomanager/apps/photos/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Photo, PhotoTag 4 | 5 | 6 | class PhotoAdmin(admin.ModelAdmin): 7 | readonly_fields = [ 8 | "creation_time", 9 | "last_modified_time", 10 | "id", 11 | "photo_taken_time", 12 | "image_height", 13 | "image_width", 14 | "image_size", 15 | "camera_make", 16 | "camera_model", 17 | "aperture_value", 18 | "shutter_speed_value", 19 | "focal_length", 20 | "iso", 21 | "flash_fired", 22 | "flash_mode", 23 | ] 24 | 25 | fieldsets = ( 26 | ( 27 | None, 28 | { 29 | "fields": ( 30 | "id", 31 | "file", 32 | "file_type", 33 | "user", 34 | "description", 35 | "license", 36 | "creation_time", 37 | "last_modified_time", 38 | "tags", 39 | "faces", 40 | ) 41 | }, 42 | ), 43 | ( 44 | "Image EXIF data", 45 | { 46 | "fields": ( 47 | "photo_taken_time", 48 | "image_height", 49 | "image_width", 50 | "image_size", 51 | "camera_make", 52 | "camera_model", 53 | "aperture_value", 54 | "shutter_speed_value", 55 | "focal_length", 56 | "iso", 57 | "flash_fired", 58 | "flash_mode", 59 | ) 60 | }, 61 | ), 62 | ) 63 | 64 | # We want the `file` attribute to be read-only only when modifying 65 | # photos, not when creating them. 66 | def get_readonly_fields(self, request, obj=None): 67 | readonly_fields = super(PhotoAdmin, self).get_readonly_fields(request, obj) 68 | if obj: # If the object exists; aka we are editing an existing object 69 | return readonly_fields + ["file"] 70 | return readonly_fields 71 | 72 | class Meta: 73 | model = Photo 74 | 75 | 76 | class PhotoTagAdmin(admin.ModelAdmin): 77 | class Meta: 78 | model = PhotoTag 79 | 80 | readonly_fields = ["create_time"] 81 | fields = ["tag", "creator", "create_time"] 82 | 83 | 84 | admin.site.register(Photo, PhotoAdmin) 85 | admin.site.register(PhotoTag, PhotoTagAdmin) 86 | -------------------------------------------------------------------------------- /photomanager/apps/faces/views.py: -------------------------------------------------------------------------------- 1 | from django.db.models.functions import Lower 2 | from django.http import Http404 3 | from django.views.generic import DetailView, ListView, UpdateView 4 | 5 | from photomanager.apps.faces.models import Face 6 | 7 | 8 | class FacesListView(ListView): 9 | """View for listing all the faces.""" 10 | 11 | model = Face 12 | paginate_by = 100 13 | 14 | def get_queryset(self): 15 | if not self.request.user.is_authenticated: 16 | # If the user is not authenticated, they can only see publicly accessible images 17 | return ( 18 | Face.objects.filter(photo__publicly_accessible=True) 19 | .distinct() 20 | .order_by(Lower("user"), Lower("defined_name"), "id") 21 | ) 22 | else: 23 | # If the user is authenticated, they can see faces for which they have 24 | # at least one image 25 | return ( 26 | Face.objects.filter(photo__user=self.request.user) 27 | .distinct() 28 | .order_by(Lower("user"), Lower("defined_name"), "id") 29 | ) 30 | 31 | 32 | class FaceDetailView(DetailView): 33 | """View for listing all the photos that contain the given face.""" 34 | 35 | model = Face 36 | 37 | def get_context_data(self, **kwargs): 38 | context = super(FaceDetailView, self).get_context_data(**kwargs) 39 | 40 | # If the user is authenticated, they can see their photos that are tagged with this face 41 | if self.request.user.is_authenticated: 42 | # However, if there are no such photos, we need to 404. 43 | if self.object.photo_set.filter(user=self.request.user).count() == 0: 44 | raise Http404( 45 | "You cannot access this face because you have no photos with this face." 46 | ) 47 | 48 | context["photos"] = self.object.photo_set.filter(user=self.request.user) 49 | else: 50 | # If there are no publicly accessible images for this tag, we 404 51 | if self.object.photo_set.filter(publicly_accessible=True).count() == 0: 52 | raise Http404( 53 | "No publicly accessible images exist for this face. Are you logged in?" 54 | ) 55 | 56 | # Otherwise, we show the publicly accessible images 57 | context["photos"] = self.object.photo_set.filter(publicly_accessible=True) 58 | 59 | return context 60 | 61 | 62 | class FaceUpdateView(UpdateView): 63 | model = Face 64 | fields = ["user", "defined_name"] 65 | -------------------------------------------------------------------------------- /photomanager/apps/tags/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import LoginRequiredMixin 2 | from django.contrib.messages.views import SuccessMessageMixin 3 | from django.db.models.functions import Lower 4 | from django.http import Http404 5 | from django.urls import reverse_lazy 6 | from django.views.generic import CreateView, DetailView, ListView 7 | 8 | from .models import PhotoTag 9 | 10 | 11 | class CreateTagView(LoginRequiredMixin, SuccessMessageMixin, CreateView): 12 | """View to create a tag.""" 13 | 14 | model = PhotoTag 15 | 16 | # TODO: accept human readable name and convert to tag 17 | fields = ["tag"] 18 | 19 | success_message = "Tag created successfully." 20 | 21 | def form_valid(self, form): 22 | form.instance.creator = self.request.user 23 | form.instance.is_auto_generated = False 24 | return super(CreateTagView, self).form_valid(form) 25 | 26 | def get_success_url(self): 27 | return reverse_lazy("tags:display", kwargs={"pk": self.object.tag}) 28 | 29 | 30 | class ListTagView(ListView): 31 | """View to list existing tags.""" 32 | 33 | model = PhotoTag 34 | paginate_by = 25 35 | 36 | def get_queryset(self): 37 | if not self.request.user.is_authenticated: 38 | # If the user is not authenticated, they can only see publicly accessible images 39 | return PhotoTag.objects.filter(photo__publicly_accessible=True).order_by( 40 | Lower("tag") 41 | ) 42 | else: 43 | # If the user is authenticated, they can see all the tags 44 | return PhotoTag.objects.all().order_by(Lower("tag")) 45 | 46 | 47 | class DetailTagView(DetailView): 48 | """View to show the photos that are tagged with this tag.""" 49 | 50 | model = PhotoTag 51 | 52 | def get_context_data(self, **kwargs): 53 | context = super(DetailTagView, self).get_context_data(**kwargs) 54 | 55 | # TODO: pagination? 56 | # If the user is authenticated, they can see their photos that are tagged with this tag 57 | if self.request.user.is_authenticated: 58 | context["photos"] = self.object.photo_set.filter(user=self.request.user) 59 | else: 60 | # If there are no publicly accessible images for this tag, we 404 61 | if self.object.photo_set.filter(publicly_accessible=True).count() == 0: 62 | raise Http404( 63 | "No publicly accessible images exist for this tag. Are you logged in?" 64 | ) 65 | 66 | # Otherwise, we show the publicly accessible images 67 | context["photos"] = self.object.photo_set.filter(publicly_accessible=True) 68 | 69 | return context 70 | -------------------------------------------------------------------------------- /photomanager/templates/faces/face_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load tz %} 4 | 5 | {% block titleprefix %}View Face - {{ object }}{% endblock %} 6 | 7 | {% block content %} 8 |
9 |
10 |

{{ object }}

11 | {# TODO: restrict edit view to a select group of users? #} 12 | {% if user.is_authenticated %} 13 |
14 |
15 | Edit 16 |
17 |
18 | {% endif %} 19 |
20 |
21 |
22 |
23 | {% for photo in photos %} 24 | 46 | {% if not forloop.last and forloop.counter|divisibleby:3 %} 47 | {# Close out the row and start a new one #} 48 |
49 |
50 | {% endif %} 51 | {% empty %} 52 |
53 |

There are no photos associated with this face.

54 |
55 | {% endfor %} 56 |
57 |
58 | {% endblock %} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # photomanager 2 | 3 | _Because fuck Google Photos._ 4 | 5 | ![CI](https://github.com/etnguyen03/photomanager/workflows/CI/badge.svg?branch=master&event=push) 6 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e6d2ba974509498fbfe7885e9d94c9f3)](https://www.codacy.com/gh/etnguyen03/photomanager/dashboard?utm_source=github.com&utm_medium=referral&utm_content=etnguyen03/photomanager&utm_campaign=Badge_Grade) 7 | [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/e6d2ba974509498fbfe7885e9d94c9f3)](https://www.codacy.com/gh/etnguyen03/photomanager/dashboard?utm_source=github.com&utm_medium=referral&utm_content=etnguyen03/photomanager&utm_campaign=Badge_Coverage) 8 | [![GitHub stars](https://img.shields.io/github/stars/etnguyen03/photomanager)](https://github.com/etnguyen03/photomanager/stargazers) 9 | [![GitHub license](https://img.shields.io/github/license/etnguyen03/photomanager)](https://github.com/etnguyen03/photomanager/blob/master/LICENSE.md) 10 | 11 | **NOTE**: Definitely not ready for any production usage. 12 | My goal is to be somewhat close to production ready by the time that 13 | Google Photos goes non-free (currently set for July 2021) but 14 | that might be missed. 15 | 16 | `photomanager` is an effort to clone as many features of Google Photos as possible 17 | in a high-quality manner, while still remaining free, open-source software 18 | accessible to all. 19 | 20 | The goals include: 21 | 22 | * Per-user authentication 23 | * Nextcloud integration, perhaps with a simple read-only mount 24 | * Easier to auto-upload - app already exists 25 | * [Tensorflow NASNet](https://www.tensorflow.org/api_docs/python/tf/keras/applications/NASNetMobile) 26 | for autotagging images 27 | * [`face-recognition`](https://github.com/ageitgey/face_recognition) for 28 | face recognition 29 | 30 | For further information on deployment and development, see the Wiki 31 | ([here](https://github.com/etnguyen03/photomanager/wiki)). 32 | 33 | --- 34 | 35 | This project is definitely not endorsed by Google, Alphabet Inc., 36 | or anyone else affiliated with Google Photos. 37 | 38 | Copyright (c) 2020 Ethan Nguyen and contributors. All rights reserved. 39 | 40 | This program is free software: you can redistribute it and/or modify 41 | it under the terms of the GNU Affero General Public License as published by 42 | the Free Software Foundation, either version 3 of the License, or 43 | (at your option) any later version. 44 | 45 | This program is distributed in the hope that it will be useful, 46 | but WITHOUT ANY WARRANTY; without even the implied warranty of 47 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 48 | GNU Affero General Public License for more details. 49 | 50 | You should have received a copy of the GNU Affero General Public License 51 | along with this program. If not, see . -------------------------------------------------------------------------------- /photomanager/apps/faces/migrations/0001_squashed_0003_auto_20201227_2248.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-27 22:49 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name="Face", 21 | fields=[ 22 | ( 23 | "id", 24 | models.UUIDField( 25 | default=uuid.uuid4, 26 | editable=False, 27 | primary_key=True, 28 | serialize=False, 29 | ), 30 | ), 31 | ( 32 | "defined_name", 33 | models.CharField( 34 | blank=True, 35 | help_text="Name for this face. Ignored if a user is defined", 36 | max_length=250, 37 | ), 38 | ), 39 | ( 40 | "modify_time", 41 | models.DateTimeField( 42 | auto_now=True, verbose_name="Last modified time" 43 | ), 44 | ), 45 | ( 46 | "create_time", 47 | models.DateTimeField( 48 | auto_now_add=True, verbose_name="Creation time" 49 | ), 50 | ), 51 | ( 52 | "creator", 53 | models.ForeignKey( 54 | help_text="The user that created this object.", 55 | null=True, 56 | on_delete=django.db.models.deletion.SET_NULL, 57 | related_name="creator", 58 | to=settings.AUTH_USER_MODEL, 59 | ), 60 | ), 61 | ( 62 | "user", 63 | models.ForeignKey( 64 | help_text="The user that whose face is represented by this object.", 65 | null=True, 66 | on_delete=django.db.models.deletion.SET_NULL, 67 | to=settings.AUTH_USER_MODEL, 68 | ), 69 | ), 70 | ( 71 | "face_data", 72 | models.TextField( 73 | help_text="Internal data used to recognize this face.", 74 | max_length=5000, 75 | ), 76 | ), 77 | ], 78 | ), 79 | ] 80 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Read the Wiki on Github for configuration instructions 2 | # before running `docker-compose up -d`. 3 | 4 | version: "3.8" 5 | services: 6 | redis: 7 | image: redis:alpine 8 | expose: 9 | - "6379" 10 | 11 | postgres: 12 | image: postgres:13-alpine 13 | volumes: 14 | - photomanager-db:/var/lib/postgresql/data 15 | environment: 16 | POSTGRES_USER: photomanager 17 | # Supply a password before you deploy 18 | POSTGRES_PASSWORD: "" 19 | expose: 20 | - "5432" 21 | 22 | photomanager-daphne: 23 | #image: etnguyen03/photomanager 24 | build: 25 | context: . 26 | dockerfile: Dockerfile 27 | command: daphne 28 | volumes: 29 | - ./photomanager/settings/secret.py:/app/photomanager/settings/secret.py 30 | # Change the source of the mount below to your Nextcloud data folder. 31 | # This is typically /var/www/nextcloud/data 32 | # For instance, change the line below to "- /var/www/nextcloud/data:/data 33 | - photomanager-photos:/data 34 | # Change the source of the mount below to a place to store thumbnails. 35 | # You don't have to, though. 36 | - photomanager-thumbs:/thumbs 37 | depends_on: 38 | - redis 39 | - postgres 40 | ports: 41 | - "8000:8000" 42 | 43 | photomanager-celery: 44 | #image: etnguyen03/photomanager 45 | build: 46 | context: . 47 | dockerfile: Dockerfile 48 | command: celery 49 | volumes: 50 | - ./photomanager/settings/secret.py:/app/photomanager/settings/secret.py 51 | # Change the source of the mount below to your Nextcloud data folder. 52 | # This is typically /var/www/nextcloud/data 53 | # For instance, change the line below to "- /var/www/nextcloud/data:/data 54 | - photomanager-photos:/data 55 | # Change the source of the mount below to a place to store thumbnails. 56 | # You don't have to, though. 57 | - photomanager-thumbs:/thumbs 58 | depends_on: 59 | - redis 60 | - postgres 61 | 62 | photomanager-celerybeat: 63 | #image: etnguyen03/photomanager 64 | build: 65 | context: . 66 | dockerfile: Dockerfile 67 | command: celerybeat 68 | volumes: 69 | - ./photomanager/settings/secret.py:/app/photomanager/settings/secret.py 70 | # Change the source of the mount below to your Nextcloud data folder. 71 | # This is typically /var/www/nextcloud/data 72 | # For instance, change the line below to "- /var/www/nextcloud/data:/data 73 | - photomanager-photos:/data 74 | # Change the source of the mount below to a place to store thumbnails. 75 | # You don't have to, though. 76 | - photomanager-thumbs:/thumbs 77 | depends_on: 78 | - redis 79 | - postgres 80 | 81 | volumes: 82 | photomanager-db: 83 | photomanager-photos: 84 | photomanager-thumbs: 85 | 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | db.sqlite3 3 | password.txt 4 | secret.py 5 | 6 | # Collected static files 7 | photomanager/serve 8 | 9 | .vagrant 10 | 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | share/python-wheels/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | MANIFEST 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .nox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | *.py,cover 60 | .hypothesis/ 61 | .pytest_cache/ 62 | cover/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Django stuff: 69 | *.log 70 | local_settings.py 71 | db.sqlite3 72 | db.sqlite3-journal 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | .pybuilder/ 86 | target/ 87 | 88 | # Jupyter Notebook 89 | .ipynb_checkpoints 90 | 91 | # IPython 92 | profile_default/ 93 | ipython_config.py 94 | 95 | # pyenv 96 | # For a library or package, you might want to ignore these files since the code is 97 | # intended to run in multiple environments; otherwise, check them in: 98 | # .python-version 99 | 100 | # pipenv 101 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 102 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 103 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 104 | # install all needed dependencies. 105 | #Pipfile.lock 106 | 107 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 108 | __pypackages__/ 109 | 110 | # Celery stuff 111 | celerybeat-schedule 112 | celerybeat.pid 113 | 114 | # SageMath parsed files 115 | *.sage.py 116 | 117 | # Environments 118 | .env 119 | .venv 120 | env/ 121 | venv/ 122 | ENV/ 123 | env.bak/ 124 | venv.bak/ 125 | 126 | # Spyder project settings 127 | .spyderproject 128 | .spyproject 129 | 130 | # Rope project settings 131 | .ropeproject 132 | 133 | # mkdocs documentation 134 | /site 135 | 136 | # mypy 137 | .mypy_cache/ 138 | .dmypy.json 139 | dmypy.json 140 | 141 | # Pyre type checker 142 | .pyre/ 143 | 144 | # pytype static type analyzer 145 | .pytype/ 146 | 147 | # Cython debug symbols 148 | cython_debug/ -------------------------------------------------------------------------------- /photomanager/templates/albums/albumsharelink_list.html: -------------------------------------------------------------------------------- 1 | {% extends "albums/base.html" %} 2 | 3 | {% load bootstrap_pagination %} 4 | 5 | {% block titleprefix %}List Share Links - {{ album.name }}{% endblock %} 6 | 7 | {% block album_content %} 8 |

Share Links

9 |

Share these links to grant public access to albums and their photos.

10 | 11 |
12 |
13 |
14 | {% csrf_token %} 15 | 16 |
17 |
18 |
19 | 20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {% for link in object_list %} 32 | 33 | 38 | 39 | 49 | 50 | {% empty %} 51 | 52 | 53 | 54 | 55 | 56 | {% endfor %} 57 | 58 |
Share LinkDescription
34 | 35 | {{ request.get_host }}{% url "albums:share_display" album.id link.id %} 36 | 37 | {{ link.description }} 40 | {# Edit link #} 41 | 42 | 43 | 44 | {# Delete link #} 45 | 46 | 47 | 48 |
There are no share links.
59 |
60 |
61 | 62 |
63 |
64 | {% bootstrap_paginate page_obj range=10 extra_pagination_classes="justify-content-center" %} 65 |
66 |
67 | 68 | {% endblock %} -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0008_auto_20201128_2344.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-28 23:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("photos", "0007_photo_user"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="photo", 15 | name="aperture_value", 16 | field=models.FloatField(help_text="Aperture in the APEX system", null=True), 17 | ), 18 | migrations.AddField( 19 | model_name="photo", 20 | name="camera_make", 21 | field=models.CharField(blank=True, max_length=150), 22 | ), 23 | migrations.AddField( 24 | model_name="photo", 25 | name="camera_model", 26 | field=models.CharField(blank=True, max_length=150), 27 | ), 28 | migrations.AddField( 29 | model_name="photo", 30 | name="flash_fired", 31 | field=models.BooleanField(help_text="Did the flash fire?", null=True), 32 | ), 33 | migrations.AddField( 34 | model_name="photo", 35 | name="flash_mode", 36 | field=models.IntegerField( 37 | choices=[ 38 | (0, "Unknown"), 39 | (1, "Compulsory Flash Firing"), 40 | (2, "Compulsory Flash Suppression"), 41 | (3, "Automatic"), 42 | ], 43 | help_text="Flash firing mode", 44 | null=True, 45 | ), 46 | ), 47 | migrations.AddField( 48 | model_name="photo", 49 | name="focal_length", 50 | field=models.FloatField(help_text="Focal length in millimeters", null=True), 51 | ), 52 | migrations.AddField( 53 | model_name="photo", 54 | name="image_height", 55 | field=models.PositiveIntegerField( 56 | help_text="Height, in pixels, of the image", null=True 57 | ), 58 | ), 59 | migrations.AddField( 60 | model_name="photo", 61 | name="image_size", 62 | field=models.PositiveIntegerField( 63 | help_text="File size (on disk) of the image", null=True 64 | ), 65 | ), 66 | migrations.AddField( 67 | model_name="photo", 68 | name="image_width", 69 | field=models.PositiveIntegerField( 70 | help_text="Width, in pixels, of the image", null=True 71 | ), 72 | ), 73 | migrations.AddField( 74 | model_name="photo", 75 | name="iso", 76 | field=models.PositiveIntegerField( 77 | help_text="Sensor sensitivity in ISO", null=True 78 | ), 79 | ), 80 | migrations.AddField( 81 | model_name="photo", 82 | name="shutter_speed_value", 83 | field=models.FloatField( 84 | help_text="Shutter speed in the APEX system", null=True 85 | ), 86 | ), 87 | ] 88 | -------------------------------------------------------------------------------- /photomanager/test/photomanger_test.py: -------------------------------------------------------------------------------- 1 | import io 2 | from pathlib import Path, PurePosixPath 3 | 4 | import requests 5 | from django.contrib.auth import get_user_model 6 | from django.test import TestCase 7 | from PIL import Image 8 | 9 | from photomanager.apps.users.models import User 10 | 11 | 12 | class PhotomanagerTestCase(TestCase): 13 | def login(self, username: str = "jdoe", is_admin: bool = False) -> User: 14 | """ 15 | Log the test client in. 16 | 17 | :param username: Username to log in as. If this user doesn't exist, it will be created. 18 | :param is_admin: Whether this user should be an admin (is_superuser, is_staff). 19 | :return: The user object. 20 | """ 21 | user = get_user_model().objects.update_or_create( 22 | username=username, defaults={"is_superuser": is_admin, "is_staff": is_admin} 23 | )[0] 24 | self.client.force_login(user) 25 | return user 26 | 27 | def write_photo(self, path: PurePosixPath, raise_exception: bool = False) -> None: 28 | """ 29 | Write a photo to use for testing purposes to /data. 30 | 31 | :param path: Path under `/data` to write the photo to. For instance, `/ethan/image.jpg`. 32 | Subdirectories are created automatically if they do not exist. 33 | Must end in ".jpg". 34 | :param raise_exception: Network access is required to download a photo. If the photo is not downloadable, then 35 | if raise_exception is True, a ConnectionError is raised. If raise_exception is False, then 36 | a blank file is created. 37 | :raises ConnectionError if raise_exception is True and no image could be downloaded. 38 | :return: None 39 | """ 40 | assert path.suffix == ".jpg", "Path must have a .jpg file extension" 41 | path = PurePosixPath(f"/data/{str(path).lstrip('/')}") 42 | 43 | # Make directories if they don't exist 44 | Path(path.parent).mkdir(parents=True, exist_ok=True) 45 | 46 | # A list of image URLs that can be downloaded from. 47 | # URLs are tried in succession until the first 48 | # one succeeds, and is written to the path given. 49 | IMAGE_URLS = [ 50 | # Puppy by Lisa L Wiedmeier, CC-BY-SA 2.0 51 | "https://live.staticflickr.com/8068/8165497065_f4ae999991_o_d.jpg", 52 | # Puppy by David J, CC-BY 2.0 53 | "https://live.staticflickr.com/7217/7335671690_48d31181bc_o_d.jpg", 54 | # Chesapeake Bay Retriever puppy (6weeks old) by Benbas 55 | # CC-BY-SA 3.0 or GFDL-1.2-no-invariants-or-later 56 | "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Bas_20060903_021.JPG/800px-Bas_20060903_021.JPG", 57 | # Chiemsee2016 (Pixabay) 58 | # Pixabay License 59 | "https://cdn.pixabay.com/photo/2016/02/18/18/37/puppy-1207816_960_720.jpg", 60 | ] 61 | downloaded_image = False 62 | for url in IMAGE_URLS: 63 | try: 64 | response = requests.get(url, timeout=1) 65 | response.raise_for_status() 66 | image = Image.open(io.BytesIO(response.content)) 67 | image.verify() 68 | with Path(path).open(mode="wb") as file: 69 | file.write(response.content) 70 | downloaded_image = True 71 | except Exception: 72 | pass 73 | 74 | if not downloaded_image: 75 | if raise_exception: 76 | raise ConnectionError() 77 | else: 78 | # Write an empty file 79 | with Path(path).open(mode="wb") as file: 80 | pass 81 | -------------------------------------------------------------------------------- /photomanager/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {% block titleprefix %}{% endblock %} - Photo Manager 28 | 29 | 30 | 48 | 49 |
50 | {% if messages %} 51 |
52 |
53 | {% for message in messages %} 54 | 57 | {% endfor %} 58 |
59 |
60 | {% endif %} 61 | {% block content %}{% endblock %} 62 |
63 | 64 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: [3.8] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Install dependencies 23 | run: | 24 | pip install pipenv 25 | pipenv install --dev --deploy 26 | 27 | - name: check Black formatting 28 | run: | 29 | pipenv run black --check . 30 | 31 | test: 32 | runs-on: ubuntu-latest 33 | strategy: 34 | matrix: 35 | python-version: [ 3.8 ] 36 | 37 | steps: 38 | - uses: actions/checkout@v2 39 | 40 | - name: Set up Python ${{ matrix.python-version }} 41 | uses: actions/setup-python@v2 42 | with: 43 | python-version: ${{ matrix.python-version }} 44 | 45 | - name: Install dependencies 46 | run: | 47 | pip install pipenv 48 | pipenv install --dev --deploy 49 | 50 | - name: make /data directory 51 | run: | 52 | sudo mkdir -p /data 53 | sudo chown $USER:$(id -gn $USER) /data 54 | 55 | - name: Migrate database 56 | run: pipenv run python3 manage.py migrate 57 | 58 | - name: Collect static files 59 | run: pipenv run python3 manage.py collectstatic --noinput 60 | 61 | - name: Run tests 62 | run: pipenv run coverage run --source photomanager manage.py test 63 | 64 | - name: generate coverage XML report 65 | run: pipenv run coverage xml 66 | 67 | - name: Run codacy-coverage-reporter 68 | uses: codacy/codacy-coverage-reporter-action@master 69 | with: 70 | project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 71 | coverage-reports: coverage.xml 72 | 73 | # Docker 74 | - name: Set up QEMU 75 | uses: docker/setup-qemu-action@v1 76 | with: 77 | platforms: all 78 | 79 | - name: Set up Docker Buildx 80 | id: buildx 81 | uses: docker/setup-buildx-action@v1 82 | with: 83 | version: latest 84 | install: true 85 | 86 | - name: Cache Docker layers 87 | uses: actions/cache@v2 88 | with: 89 | path: /tmp/.buildx-cache 90 | key: ${{ runner.os }}-buildx-${{ github.sha }} 91 | restore-keys: | 92 | ${{ runner.os }}-buildx- 93 | 94 | - name: Build Docker image 95 | uses: docker/build-push-action@v2 96 | with: 97 | context: . 98 | file: ./Dockerfile 99 | platforms: linux/amd64 100 | cache-from: type=local,src=/tmp/.buildx-cache 101 | cache-to: type=local,dest=/tmp/.buildx-cache,mode=max 102 | 103 | push: 104 | needs: 105 | - test 106 | - lint 107 | runs-on: ubuntu-latest 108 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' 109 | 110 | steps: 111 | - uses: actions/checkout@v2 112 | 113 | - name: Set up QEMU 114 | uses: docker/setup-qemu-action@v1 115 | with: 116 | platforms: all 117 | 118 | - name: Set up Docker Buildx 119 | id: buildx 120 | uses: docker/setup-buildx-action@v1 121 | with: 122 | version: latest 123 | install: true 124 | 125 | - name: Cache Docker layers 126 | uses: actions/cache@v2 127 | with: 128 | path: /tmp/.buildx-cache 129 | key: ${{ runner.os }}-buildx-${{ github.sha }} 130 | restore-keys: | 131 | ${{ runner.os }}-buildx- 132 | 133 | - name: Login to DockerHub 134 | uses: docker/login-action@v1 135 | with: 136 | username: etnguyen03 137 | password: ${{ secrets.DOCKER_PASSWORD }} 138 | 139 | - name: Build and push to Dockerhub 140 | uses: docker/build-push-action@v2 141 | with: 142 | context: . 143 | file: ./Dockerfile 144 | platforms: linux/amd64 145 | pull: true 146 | push: true 147 | cache-from: type=local,src=/tmp/.buildx-cache 148 | cache-to: type=local,dest=/tmp/.buildx-cache,mode=max 149 | tags: etnguyen03/photomanager:latest 150 | 151 | - name: Login to Github Container Registry 152 | uses: docker/login-action@v1 153 | with: 154 | registry: ghcr.io 155 | username: ${{ github.repository_owner }} 156 | password: ${{ secrets.CR_PAT }} 157 | 158 | - name: Build and push to Github Container Registry 159 | uses: docker/build-push-action@v2 160 | with: 161 | context: . 162 | file: ./Dockerfile 163 | platforms: linux/amd64 164 | pull: true 165 | push: true 166 | cache-from: type=local,src=/tmp/.buildx-cache 167 | cache-to: type=local,dest=/tmp/.buildx-cache,mode=max 168 | tags: ghcr.io/etnguyen03/photomanager:latest 169 | -------------------------------------------------------------------------------- /photomanager/apps/photos/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from fractions import Fraction 3 | from math import sqrt 4 | 5 | from django.db import models 6 | 7 | from photomanager.apps.faces.models import Face 8 | from photomanager.apps.tags.models import PhotoTag 9 | from photomanager.apps.users.models import User 10 | 11 | 12 | class Photo(models.Model): 13 | """ 14 | Represents a single photo. 15 | """ 16 | 17 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 18 | file = models.FilePathField( 19 | path="/data", 20 | null=False, 21 | blank=False, 22 | help_text="Path to the photo file.", 23 | allow_files=True, 24 | allow_folders=False, 25 | recursive=True, 26 | ) 27 | user = models.ForeignKey( 28 | User, help_text="The user that this photo belongs to.", on_delete=models.CASCADE 29 | ) 30 | description = models.TextField(help_text="Description for this photo.", blank=True) 31 | 32 | class FileTypes(models.IntegerChoices): 33 | IMAGE = 1 34 | VIDEO = 2 35 | 36 | file_type = models.IntegerField(choices=FileTypes.choices, null=False, default=1) 37 | 38 | creation_time = models.DateTimeField( 39 | auto_now_add=True, help_text="Photo creation time.", null=True 40 | ) 41 | last_modified_time = models.DateTimeField( 42 | auto_now=True, help_text="Photo modification time.", null=True 43 | ) 44 | 45 | # EXIF Metadata 46 | photo_taken_time = models.DateTimeField( 47 | help_text="Time the photo was taken.", null=True, blank=True 48 | ) 49 | image_height = models.PositiveIntegerField( 50 | help_text="Height, in pixels, of the image", null=True 51 | ) 52 | image_width = models.PositiveIntegerField( 53 | help_text="Width, in pixels, of the image", null=True 54 | ) 55 | image_size = models.PositiveIntegerField( 56 | help_text="File size (on disk, in bytes) of the image", null=True 57 | ) 58 | 59 | camera_make = models.CharField(max_length=150, blank=True) 60 | camera_model = models.CharField(max_length=150, blank=True) 61 | 62 | aperture_value = models.FloatField( 63 | help_text="Aperture in the APEX system", null=True 64 | ) 65 | 66 | @property 67 | def aperture_value_f_stop(self) -> float: 68 | """ 69 | Returns the aperture value as an f-stop. 70 | 71 | :return: a float. 72 | """ 73 | # Source: http://www.fifi.org/doc/jhead/exif-e.html 74 | return round(sqrt(2) ** self.aperture_value, 1) 75 | 76 | shutter_speed_value = models.FloatField( 77 | help_text="Shutter speed in the APEX system", null=True 78 | ) 79 | 80 | @property 81 | def shutter_speed_seconds(self) -> Fraction: 82 | """ 83 | Returns the shutter speed value as a fraction of a second. 84 | 85 | :return: a fraction of a second 86 | """ 87 | return Fraction(1, int(round(2 ** self.shutter_speed_value, 0))) 88 | 89 | focal_length = models.FloatField(help_text="Focal length in millimeters", null=True) 90 | iso = models.PositiveIntegerField( 91 | help_text="Sensor sensitivity in ISO", null=True, verbose_name="ISO" 92 | ) 93 | 94 | flash_fired = models.BooleanField(help_text="Did the flash fire?", null=True) 95 | 96 | class FlashMode(models.IntegerChoices): 97 | """ 98 | Enum for flash_mode field; describes possible flash modes 99 | """ 100 | 101 | UNKNOWN = 0 102 | COMPULSORY_FLASH_FIRING = 1 103 | COMPULSORY_FLASH_SUPPRESSION = 2 104 | AUTOMATIC = 3 105 | 106 | flash_mode = models.IntegerField( 107 | choices=FlashMode.choices, help_text="Flash firing mode", null=True 108 | ) 109 | 110 | # TODO: GPS information 111 | 112 | # User modifiable metadata 113 | class License(models.TextChoices): 114 | """ 115 | Enum of license choices that the user can choose from. 116 | """ 117 | 118 | ARR = ("ARR", "All rights reserved") 119 | PDM = ("PDM", "Public Domain Mark") 120 | CC0 = ("CC0", "CC0") 121 | CCBY = ("CCBY", "Creative Commons Attribution") 122 | CCBYSA = ("CCBYSA", "Creative Commons Attribution Share-Alike") 123 | CCBYND = ("CCBYND", "Creative Commons Attribution-NoDerivs") 124 | CCBYNC = ("CCBYNC", "Creative Commons Attribution-NonCommercial") 125 | CCBYNCSA = ("CCBYNCSA", "Creative Commons Attribution-NonCommercial-ShareAlike") 126 | CCBYNCND = ("CCBYNCND", "Creative Commons Attribution-NonCommercial-NoDerivs") 127 | 128 | license = models.CharField( 129 | max_length=50, 130 | choices=License.choices, 131 | default=License.ARR, 132 | blank=False, 133 | null=False, 134 | ) 135 | 136 | publicly_accessible = models.BooleanField( 137 | default=False, 138 | null=False, 139 | help_text="Whether this photo is publicly accessible. If checked, this photo is " 140 | "listed on the front page and accessible without authentication.", 141 | ) 142 | 143 | # Tags; both automatically generated tags and user modifiable 144 | tags = models.ManyToManyField(PhotoTag, blank=True) 145 | 146 | # Faces; both automatically generated and user modifiable 147 | faces = models.ManyToManyField(Face, blank=True) 148 | -------------------------------------------------------------------------------- /photomanager/apps/users/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-12 20:39 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | import django.utils.timezone 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ("auth", "0012_alter_user_first_name_max_length"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="User", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ("password", models.CharField(max_length=128, verbose_name="password")), 31 | ( 32 | "last_login", 33 | models.DateTimeField( 34 | blank=True, null=True, verbose_name="last login" 35 | ), 36 | ), 37 | ( 38 | "is_superuser", 39 | models.BooleanField( 40 | default=False, 41 | help_text="Designates that this user has all permissions without explicitly assigning them.", 42 | verbose_name="superuser status", 43 | ), 44 | ), 45 | ( 46 | "username", 47 | models.CharField( 48 | error_messages={ 49 | "unique": "A user with that username already exists." 50 | }, 51 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 52 | max_length=150, 53 | unique=True, 54 | validators=[ 55 | django.contrib.auth.validators.UnicodeUsernameValidator() 56 | ], 57 | verbose_name="username", 58 | ), 59 | ), 60 | ( 61 | "first_name", 62 | models.CharField( 63 | blank=True, max_length=150, verbose_name="first name" 64 | ), 65 | ), 66 | ( 67 | "last_name", 68 | models.CharField( 69 | blank=True, max_length=150, verbose_name="last name" 70 | ), 71 | ), 72 | ( 73 | "email", 74 | models.EmailField( 75 | blank=True, max_length=254, verbose_name="email address" 76 | ), 77 | ), 78 | ( 79 | "is_staff", 80 | models.BooleanField( 81 | default=False, 82 | help_text="Designates whether the user can log into this admin site.", 83 | verbose_name="staff status", 84 | ), 85 | ), 86 | ( 87 | "is_active", 88 | models.BooleanField( 89 | default=True, 90 | help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", 91 | verbose_name="active", 92 | ), 93 | ), 94 | ( 95 | "date_joined", 96 | models.DateTimeField( 97 | default=django.utils.timezone.now, verbose_name="date joined" 98 | ), 99 | ), 100 | ("subdirectory", models.FilePathField(default="/data/", path="/data")), 101 | ( 102 | "groups", 103 | models.ManyToManyField( 104 | blank=True, 105 | help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", 106 | related_name="user_set", 107 | related_query_name="user", 108 | to="auth.Group", 109 | verbose_name="groups", 110 | ), 111 | ), 112 | ( 113 | "user_permissions", 114 | models.ManyToManyField( 115 | blank=True, 116 | help_text="Specific permissions for this user.", 117 | related_name="user_set", 118 | related_query_name="user", 119 | to="auth.Permission", 120 | verbose_name="user permissions", 121 | ), 122 | ), 123 | ], 124 | options={ 125 | "verbose_name": "user", 126 | "verbose_name_plural": "users", 127 | "abstract": False, 128 | }, 129 | managers=[ 130 | ("objects", django.contrib.auth.models.UserManager()), 131 | ], 132 | ), 133 | ] 134 | -------------------------------------------------------------------------------- /photomanager/apps/faces/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import uuid 4 | from pathlib import PurePosixPath 5 | 6 | import numpy as np 7 | from django.urls import reverse_lazy 8 | 9 | from photomanager.test.photomanger_test import PhotomanagerTestCase 10 | 11 | from ..photos.models import Photo 12 | from .models import Face 13 | 14 | 15 | class FacesTestCase(PhotomanagerTestCase): 16 | def test_list_face_view(self): 17 | """ 18 | Tests the list face view. 19 | 20 | :return: None 21 | """ 22 | 23 | # First, clear out all the faces that already exist 24 | Face.objects.all().delete() 25 | 26 | # Add some faces 27 | faces = [] 28 | for i in range(10): 29 | faces.append( 30 | Face.objects.create( 31 | face_data=json.dumps(np.random.rand(128, 1).tolist()[0]) 32 | ) 33 | ) 34 | 35 | # Since we are not logged in, this should be empty 36 | response = self.client.get(reverse_lazy("faces:list")) 37 | self.assertEqual(200, response.status_code) 38 | self.assertEqual(0, len(response.context["object_list"])) 39 | 40 | # Log us in, the list should be empty because the user 41 | # doesn't have any photos associated with them yet 42 | user = self.login() 43 | response = self.client.get(reverse_lazy("faces:list")) 44 | self.assertEqual(200, response.status_code) 45 | self.assertEqual(0, len(response.context["object_list"])) 46 | 47 | # While I'm at it, take the first Face in the list and make 48 | # its user the user that we are logged in as 49 | faces[0].user = user 50 | faces[0].save() 51 | 52 | # Also create a second user and set the second in the list to that user 53 | user2 = self.login("user2") 54 | user2.first_name = "hello" 55 | user2.last_name = "there" 56 | user2.save() 57 | faces[1].user = user2 58 | faces[1].save() 59 | 60 | # Add a photo for testing purposes 61 | path = f"/{uuid.uuid4()}/{uuid.uuid4()}.jpg" 62 | self.write_photo(PurePosixPath(path), raise_exception=True) 63 | 64 | photo = Photo.objects.create(file=path, user=user, publicly_accessible=False) 65 | photo.faces.add(faces[0]) 66 | photo.save() 67 | 68 | # Remove the file from disk now, we no longer need it 69 | os.remove(f"/data/{path.lstrip('/')}") 70 | os.removedirs(f"/data/{str(PurePosixPath(path).parent).lstrip('/')}") 71 | 72 | # We should now see no faces because the first doesn't belong to user2, 73 | # which is the currently logged in user 74 | response = self.client.get(reverse_lazy("faces:list")) 75 | self.assertEqual(200, response.status_code) 76 | self.assertEqual(0, len(response.context["object_list"])) 77 | 78 | # Log in as user 79 | self.login() 80 | 81 | # Try again, we should see one now 82 | response = self.client.get(reverse_lazy("faces:list")) 83 | self.assertEqual(200, response.status_code) 84 | self.assertEqual(1, len(response.context["object_list"])) 85 | 86 | # Set that photo to publicly visible then logout 87 | photo.publicly_accessible = True 88 | photo.save() 89 | 90 | self.client.logout() 91 | 92 | # Try again, we should now see one face 93 | response = self.client.get(reverse_lazy("faces:list")) 94 | self.assertEqual(200, response.status_code) 95 | self.assertEqual(1, len(response.context["object_list"])) 96 | 97 | def test_face_detail_view(self): 98 | """ 99 | Test the face detail view (.views.FaceDetailView). 100 | 101 | :return: None 102 | """ 103 | face = Face.objects.create( 104 | face_data=json.dumps(np.random.rand(128, 1).tolist()[0]) 105 | ) 106 | 107 | # Since we are not logged in, and there are no images 108 | # associated with this face, a request should 404 109 | response = self.client.get( 110 | reverse_lazy("faces:display", kwargs={"pk": face.id}) 111 | ) 112 | self.assertEqual(404, response.status_code) 113 | 114 | # Log in 115 | user1 = self.login() 116 | user2 = self.login(username="user2") 117 | 118 | # We are logged in as user2 119 | 120 | # Add a photo for this face 121 | path = f"/{uuid.uuid4()}/{uuid.uuid4()}.jpg" 122 | self.write_photo(PurePosixPath(path), raise_exception=True) 123 | 124 | # Add the photo, but belonging to user1 125 | photo = Photo.objects.create(file=path, user=user1, publicly_accessible=False) 126 | photo.faces.add(face) 127 | photo.save() 128 | 129 | # Remove the file from disk now, we no longer need it 130 | os.remove(f"/data/{path.lstrip('/')}") 131 | os.removedirs(f"/data/{str(PurePosixPath(path).parent).lstrip('/')}") 132 | 133 | # This request should 404 since user2 doesn't own any photos 134 | response = self.client.get( 135 | reverse_lazy("faces:display", kwargs={"pk": face.id}) 136 | ) 137 | self.assertEqual(404, response.status_code) 138 | 139 | # Change this photo to user2 140 | photo.user = user2 141 | photo.save() 142 | 143 | # This request should 200 since user2 now owns this photo 144 | response = self.client.get( 145 | reverse_lazy("faces:display", kwargs={"pk": face.id}) 146 | ) 147 | self.assertEqual(200, response.status_code) 148 | 149 | # Now, log out and make this photo publicly accessible 150 | self.client.logout() 151 | photo.publicly_accessible = True 152 | photo.save() 153 | 154 | # A request to the face page should 200 because the photo is public 155 | response = self.client.get( 156 | reverse_lazy("faces:display", kwargs={"pk": face.id}) 157 | ) 158 | self.assertEqual(200, response.status_code) 159 | -------------------------------------------------------------------------------- /photomanager/settings/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for photomanager project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | import os 13 | import sys 14 | from pathlib import Path 15 | 16 | from django.urls import reverse_lazy 17 | 18 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 19 | 20 | BASE_DIR = Path(__file__).resolve().parent.parent 21 | 22 | 23 | # Quick-start development settings - unsuitable for production 24 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 25 | 26 | # SECURITY WARNING: keep the secret key used in production secret! 27 | SECRET_KEY = "q5$$*t3r6c_di(6@xj$%zxy#1ne%m=8plo8o(jma*gofp5^^#n" 28 | 29 | # SECURITY WARNING: don't run with debug turned on in production! 30 | DEBUG = True 31 | 32 | ALLOWED_HOSTS = [] 33 | 34 | 35 | # Application definition 36 | 37 | INSTALLED_APPS = [ 38 | # Django 39 | "django.contrib.admin", 40 | "django.contrib.auth", 41 | "django.contrib.contenttypes", 42 | "django.contrib.sessions", 43 | "django.contrib.messages", 44 | "django.contrib.staticfiles", 45 | "django_extensions", 46 | "channels", 47 | "bootstrap_pagination", 48 | "crispy_forms", 49 | # Celery 50 | "django_celery_results", 51 | "django_celery_beat", 52 | # Social auth 53 | "social_django", 54 | # photomanager apps 55 | "photomanager.apps", 56 | "photomanager.apps.users", 57 | "photomanager.apps.photos", 58 | "photomanager.apps.albums", 59 | "photomanager.apps.tags", 60 | "photomanager.apps.faces", 61 | ] 62 | 63 | MIDDLEWARE = [ 64 | "django.middleware.security.SecurityMiddleware", 65 | "whitenoise.middleware.WhiteNoiseMiddleware", 66 | "django.contrib.sessions.middleware.SessionMiddleware", 67 | "django.middleware.common.CommonMiddleware", 68 | "django.middleware.csrf.CsrfViewMiddleware", 69 | "django.contrib.auth.middleware.AuthenticationMiddleware", 70 | "django.contrib.messages.middleware.MessageMiddleware", 71 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 72 | ] 73 | 74 | ROOT_URLCONF = "photomanager.urls" 75 | 76 | TEMPLATES = [ 77 | { 78 | "BACKEND": "django.template.backends.django.DjangoTemplates", 79 | "DIRS": [BASE_DIR / "templates"], 80 | "APP_DIRS": True, 81 | "OPTIONS": { 82 | "context_processors": [ 83 | "django.template.context_processors.debug", 84 | "django.template.context_processors.request", 85 | "django.contrib.auth.context_processors.auth", 86 | "django.contrib.messages.context_processors.messages", 87 | ], 88 | }, 89 | }, 90 | ] 91 | 92 | # WSGI_APPLICATION = "photomanager.wsgi.application" 93 | ASGI_APPLICATION = "photomanager.asgi.application" 94 | 95 | # Database 96 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 97 | 98 | DATABASES = { 99 | "default": { 100 | "ENGINE": "django.db.backends.sqlite3", 101 | "NAME": BASE_DIR / "db.sqlite3", 102 | } 103 | } 104 | 105 | 106 | # Password validation 107 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 108 | 109 | AUTH_PASSWORD_VALIDATORS = [ 110 | { 111 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 112 | }, 113 | { 114 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 115 | }, 116 | { 117 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 118 | }, 119 | { 120 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 121 | }, 122 | ] 123 | 124 | 125 | # Internationalization 126 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 127 | 128 | LANGUAGE_CODE = "en-us" 129 | 130 | TIME_ZONE = "UTC" 131 | 132 | USE_I18N = True 133 | 134 | USE_L10N = True 135 | 136 | USE_TZ = True 137 | 138 | 139 | # Static files (CSS, JavaScript, Images) 140 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 141 | 142 | STATIC_URL = "/static/" 143 | STATIC_ROOT = os.path.join(BASE_DIR, "serve/") 144 | STATICFILES_DIRS = (os.path.join(BASE_DIR, "static/"),) 145 | 146 | LOGIN_URL = reverse_lazy("social:begin", args=["nextcloud"]) 147 | LOGIN_REDIRECT_URL = reverse_lazy("index") 148 | LOGOUT_REDIRECT_URL = reverse_lazy("index") 149 | 150 | AUTH_USER_MODEL = "users.User" 151 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 152 | 153 | # Celery 154 | CELERY_RESULT_BACKEND = "django-db" 155 | CELERY_CACHE_BACKEND = "default" 156 | CELERY_BROKER_URL = "redis://localhost:6379/1" 157 | CELERY_TIMEZONE = "America/New_York" # Change maybe? 158 | CELERY_TASK_RESULT_EXPIRES = 86400 # Clear after one day 159 | 160 | CELERY_BEAT_SCHEDULE = { 161 | "rescan-directory": { 162 | "task": "photomanager.apps.photos.tasks.scan_all_dirs_for_changes", 163 | "schedule": 60, 164 | } 165 | } 166 | 167 | CACHES = { 168 | "default": { 169 | "BACKEND": "django_redis.cache.RedisCache", 170 | "LOCATION": "redis://localhost:6379/1", 171 | "OPTIONS": { 172 | "CLIENT_CLASS": "django_redis.client.DefaultClient", 173 | }, 174 | } 175 | } 176 | 177 | AUTHENTICATION_BACKENDS = [ 178 | "photomanager.apps.users.oauth.NextcloudOAuth2", 179 | ] 180 | SOCIAL_AUTH_URL_NAMESPACE = "social" 181 | SOCIAL_AUTH_USER_FIELDS = [ 182 | "username", 183 | "email", 184 | ] 185 | SOCIAL_AUTH_PIPELINE = ( 186 | "social_core.pipeline.social_auth.social_details", 187 | "social_core.pipeline.social_auth.social_uid", 188 | "social_core.pipeline.social_auth.auth_allowed", 189 | "social_core.pipeline.social_auth.social_user", 190 | "social_core.pipeline.social_auth.associate_by_email", 191 | "social_core.pipeline.user.create_user", 192 | "social_core.pipeline.social_auth.associate_user", 193 | "social_core.pipeline.social_auth.load_extra_data", 194 | ) 195 | SOCIAL_AUTH_ALWAYS_ASSOCIATE = True 196 | SOCIAL_AUTH_REDIRECT_IS_HTTPS = True 197 | 198 | CRISPY_TEMPLATE_PACK = "bootstrap4" 199 | 200 | TESTING = "test" in sys.argv 201 | if TESTING: 202 | DATABASES["default"]["ENGINE"] = "django.db.backends.sqlite3" 203 | DATABASES["default"]["NAME"] = ":memory:" 204 | 205 | IMAGE_THUMBS_DIR = "/thumbs" 206 | 207 | ########################################## 208 | # These values are defined in secret.py # 209 | ########################################## 210 | SOCIAL_AUTH_NEXTCLOUD_KEY = "" 211 | SOCIAL_AUTH_NEXTCLOUD_SECRET = "" 212 | NEXTCLOUD_URI = "" # Hostname of your Nextcloud instance, like "nextcloud.example.com" 213 | 214 | ENABLE_TENSORFLOW_TAGGING = True 215 | ENABLE_FACE_RECOGNITION = True 216 | 217 | try: 218 | from .secret import * 219 | except ImportError: 220 | pass 221 | -------------------------------------------------------------------------------- /photomanager/apps/albums/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import messages 3 | from django.contrib.auth.decorators import login_required 4 | from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin 5 | from django.http import HttpResponse, HttpResponseForbidden 6 | from django.shortcuts import get_object_or_404, redirect, render 7 | from django.urls import reverse_lazy 8 | from django.views.decorators.http import require_POST 9 | from django.views.generic import CreateView, DeleteView, ListView, UpdateView 10 | 11 | from photomanager.apps.albums.models import Album, AlbumShareLink 12 | 13 | 14 | def view_album(request, album_id: str) -> HttpResponse: 15 | """ 16 | View for viewing an album. 17 | 18 | :param request: Request object 19 | :param album_id: ID of an album (a UUID) 20 | :return: an HTTP response 21 | """ 22 | album = get_object_or_404(Album, id=album_id) 23 | 24 | if not album.publicly_accessible: 25 | if not request.user.is_authenticated: 26 | return redirect(settings.LOGIN_URL) 27 | if album.owner != request.user: 28 | return HttpResponseForbidden() 29 | 30 | context = { 31 | "album": album, 32 | "photos": album.photos.all().order_by("photo_taken_time"), 33 | } 34 | 35 | return render(request, "albums/view_single_album.html", context=context) 36 | 37 | 38 | def view_album_share(request, album_id: str, share_album_id: str) -> HttpResponse: 39 | """ 40 | View for viewing a (private) album, but with a share link 41 | 42 | :param request: Request object 43 | :param album_id: Album ID (UUID) 44 | :param share_album_id: Share link ID (UUID) 45 | :return: HttpResponse or 404 if share link invalid 46 | """ 47 | album_share_link = get_object_or_404( 48 | AlbumShareLink, id=share_album_id, album__id=album_id 49 | ) 50 | 51 | album = album_share_link.album 52 | 53 | context = { 54 | "album": album, 55 | "photos": album.photos.all().order_by("photo_taken_time"), 56 | "album_share_id": share_album_id, 57 | } 58 | 59 | return render(request, "albums/view_single_album.html", context=context) 60 | 61 | 62 | class AlbumEditView(LoginRequiredMixin, UserPassesTestMixin, UpdateView): 63 | """View for editing the metadata on an album.""" 64 | 65 | model = Album 66 | fields = ["name", "description", "publicly_accessible", "photos"] 67 | template_name_suffix = "_update" 68 | 69 | def get_success_url(self): 70 | return reverse_lazy("albums:display", kwargs={"album_id": self.kwargs["pk"]}) 71 | 72 | def test_func(self) -> bool: 73 | """ 74 | Used to ensure that only the owner of this album can modify it. 75 | 76 | :return: True if the owner of this album is the logged in user, false otherwise 77 | """ 78 | return get_object_or_404(Album, id=self.kwargs["pk"]).owner == self.request.user 79 | 80 | 81 | class AlbumCreateView(LoginRequiredMixin, CreateView): 82 | """View to create an album.""" 83 | 84 | model = Album 85 | fields = ["name", "description", "publicly_accessible", "photos"] 86 | 87 | def get_success_url(self): 88 | return reverse_lazy("albums:display", kwargs={"album_id": self.object.id}) 89 | 90 | def form_valid(self, form): 91 | form.instance.owner = self.request.user 92 | return super(AlbumCreateView, self).form_valid(form) 93 | 94 | 95 | class AlbumListView(ListView): 96 | """View to list albums.""" 97 | 98 | model = Album 99 | paginate_by = 25 100 | 101 | def get_queryset(self): 102 | # If the user isn't authenticated, then only the albums that are publicly accessible are visible 103 | if not self.request.user.is_authenticated: 104 | return Album.objects.filter(publicly_accessible=True).order_by( 105 | "-creation_time" 106 | ) 107 | else: 108 | # Otherwise, only show the albums where the owner is the logged in user 109 | return Album.objects.filter(owner=self.request.user).order_by( 110 | "-creation_time" 111 | ) 112 | 113 | 114 | class AlbumDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView): 115 | """View to handle deletion of an album, but not the photos within it.""" 116 | 117 | model = Album 118 | success_url = reverse_lazy("albums:list") 119 | 120 | def test_func(self) -> bool: 121 | """ 122 | Used to ensure that only the owner of this album can modify it. 123 | 124 | :return: True if the owner of this album is the logged in user, false otherwise 125 | """ 126 | return get_object_or_404(Album, id=self.kwargs["pk"]).owner == self.request.user 127 | 128 | 129 | @login_required 130 | @require_POST 131 | def album_share_link_create(request, album_id: str) -> HttpResponse: 132 | """ 133 | View to handle the creation of share links for albums. 134 | 135 | :param request: Request object 136 | :param album_id: The album ID to create a link for 137 | :return: HttpResponse 138 | """ 139 | album = get_object_or_404(Album, id=album_id) 140 | 141 | if request.user != album.owner: 142 | return HttpResponseForbidden() 143 | 144 | AlbumShareLink.objects.create(album=album, creator=request.user) 145 | 146 | messages.success(request, "Share link successfully created.") 147 | 148 | return redirect(reverse_lazy("albums:share_links", kwargs={"album_id": album_id})) 149 | 150 | 151 | class AlbumShareLinkList(LoginRequiredMixin, UserPassesTestMixin, ListView): 152 | """View to list AlbumShareLinks.""" 153 | 154 | model = AlbumShareLink 155 | paginate_by = 25 156 | 157 | def get_context_data(self, *, object_list=None, **kwargs): 158 | context = super().get_context_data(**kwargs) 159 | context["album"] = get_object_or_404(Album, id=self.kwargs["album_id"]) 160 | return context 161 | 162 | def test_func(self): 163 | return ( 164 | get_object_or_404(Album, id=self.kwargs["album_id"]).owner 165 | == self.request.user 166 | ) 167 | 168 | def get_queryset(self): 169 | return AlbumShareLink.objects.filter( 170 | album=get_object_or_404(Album, id=self.kwargs["album_id"]) 171 | ).order_by("-creation_time") 172 | 173 | 174 | class AlbumShareLinkDelete(LoginRequiredMixin, UserPassesTestMixin, DeleteView): 175 | """View to handle the deletion of AlbumShareLinks.""" 176 | 177 | model = AlbumShareLink 178 | 179 | def get_success_url(self): 180 | return reverse_lazy( 181 | "albums:share_links", kwargs={"album_id": self.kwargs["album_id"]} 182 | ) 183 | 184 | def test_func(self): 185 | return ( 186 | get_object_or_404(AlbumShareLink, id=self.kwargs["pk"]).album.owner 187 | == self.request.user 188 | ) 189 | 190 | 191 | class AlbumShareLinkEdit(LoginRequiredMixin, UserPassesTestMixin, UpdateView): 192 | """View to handle editing the metadata of AlbumShareLinks.""" 193 | 194 | model = AlbumShareLink 195 | fields = ["description"] 196 | template_name_suffix = "_update" 197 | 198 | def get_success_url(self): 199 | return reverse_lazy( 200 | "albums:share_links", kwargs={"album_id": self.kwargs["album_id"]} 201 | ) 202 | 203 | def test_func(self): 204 | return ( 205 | get_object_or_404(AlbumShareLink, id=self.kwargs["pk"]).album.owner 206 | == self.request.user 207 | ) 208 | -------------------------------------------------------------------------------- /photomanager/apps/tags/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | from pathlib import PurePosixPath 4 | 5 | from django.urls import reverse_lazy 6 | 7 | from photomanager.apps.photos.models import Photo 8 | from photomanager.apps.tags.models import PhotoTag 9 | from photomanager.test.photomanger_test import PhotomanagerTestCase 10 | 11 | 12 | class TagsTestCase(PhotomanagerTestCase): 13 | """Tests the tags app.""" 14 | 15 | def test_create_tag_view(self): 16 | """ 17 | Tests CreateTagView. 18 | 19 | :return: None 20 | """ 21 | response = self.client.get(reverse_lazy("tags:create")) 22 | self.assertEqual(302, response.status_code) # to login page 23 | 24 | user = self.login() 25 | 26 | response = self.client.get(reverse_lazy("tags:create")) 27 | self.assertEqual(200, response.status_code) 28 | 29 | name_of_tag = uuid.uuid4() 30 | response = self.client.post( 31 | reverse_lazy("tags:create"), 32 | data={"tag": name_of_tag}, # UUID as an arbitrary name 33 | follow=True, 34 | ) 35 | self.assertEqual(200, response.status_code) 36 | 37 | self.assertEqual(1, PhotoTag.objects.filter(tag=name_of_tag).count()) 38 | 39 | def test_list_tag_view(self): 40 | """ 41 | Tests the list tag view. 42 | 43 | :return: None 44 | """ 45 | 46 | # First, we need to clear out all PhotoTag objects that already exist. 47 | PhotoTag.objects.all().delete() 48 | 49 | # Now, we can add PhotoTag objects 50 | names = [] 51 | for i in range(10): 52 | name = uuid.uuid4() 53 | PhotoTag.objects.create(tag=name, creator=None, is_auto_generated=True) 54 | names.append(name) 55 | 56 | # Since we are not logged in, this list should be empty 57 | response = self.client.get(reverse_lazy("tags:list"), follow=True) 58 | self.assertEqual(200, response.status_code) 59 | self.assertEqual(0, len(response.context["object_list"])) 60 | 61 | # Add a publicly accessible photo 62 | path = f"/{uuid.uuid4()}/{uuid.uuid4()}.jpg" 63 | self.write_photo(PurePosixPath(path), raise_exception=True) 64 | 65 | # Login to get the user, then logout 66 | user = self.login() 67 | self.client.logout() 68 | 69 | photo = Photo.objects.create(file=path, user=user, publicly_accessible=True) 70 | 71 | # Remove the file from disk now, we no longer need it 72 | os.remove(f"/data/{path.lstrip('/')}") 73 | os.removedirs(f"/data/{str(PurePosixPath(path).parent).lstrip('/')}") 74 | 75 | # This list should still be empty 76 | response = self.client.get(reverse_lazy("tags:list"), follow=True) 77 | self.assertEqual(200, response.status_code) 78 | self.assertEqual(0, len(response.context["object_list"])) 79 | 80 | # Add a tag to that image 81 | photo.tags.add(PhotoTag.objects.get(tag=names[0])) 82 | photo.save() 83 | 84 | # The list should now have one tag 85 | response = self.client.get(reverse_lazy("tags:list"), follow=True) 86 | self.assertEqual(200, response.status_code) 87 | self.assertEqual(1, len(response.context["object_list"])) 88 | self.assertIn( 89 | PhotoTag.objects.get(tag=names[0]), response.context["object_list"] 90 | ) 91 | 92 | # Make the photo not publicly visible, and this list should be zero 93 | photo.publicly_accessible = False 94 | photo.save() 95 | 96 | response = self.client.get(reverse_lazy("tags:list"), follow=True) 97 | self.assertEqual(200, response.status_code) 98 | self.assertEqual(0, len(response.context["object_list"])) 99 | 100 | # Log in, and the list should now have ten tags 101 | self.login() 102 | 103 | response = self.client.get(reverse_lazy("tags:list"), follow=True) 104 | self.assertEqual(200, response.status_code) 105 | self.assertEqual(10, len(response.context["object_list"])) 106 | self.assertSetEqual( 107 | set([PhotoTag.objects.get(tag=n) for n in names]), 108 | set(response.context["object_list"]), 109 | ) 110 | 111 | # Clean up 112 | for tag in names: 113 | PhotoTag.objects.get(tag=tag).delete() 114 | 115 | def test_detail_tag_view(self): 116 | # First, we need to clear out all PhotoTag objects that already exist. 117 | PhotoTag.objects.all().delete() 118 | 119 | # Now, create a tag 120 | name = uuid.uuid4() 121 | 122 | response = self.client.get( 123 | reverse_lazy("tags:display", kwargs={"pk": name}), follow=True 124 | ) 125 | self.assertEqual(404, response.status_code) 126 | 127 | tag = PhotoTag.objects.create(tag=name) 128 | 129 | # There are no publicly accessible photos so this should 404 130 | response = self.client.get( 131 | reverse_lazy("tags:display", kwargs={"pk": name}), follow=True 132 | ) 133 | self.assertEqual(404, response.status_code) 134 | 135 | user = self.login() 136 | 137 | # Now, try again, and this should 200 138 | response = self.client.get( 139 | reverse_lazy("tags:display", kwargs={"pk": name}), follow=True 140 | ) 141 | self.assertEqual(200, response.status_code) 142 | self.assertEqual(0, len(response.context["photos"])) 143 | 144 | # Add a photo so we can test tags 145 | path = f"/{uuid.uuid4()}/{uuid.uuid4()}.jpg" 146 | self.write_photo(PurePosixPath(path), raise_exception=True) 147 | 148 | photo = Photo.objects.create(file=path, user=user, publicly_accessible=False) 149 | photo.tags.add(tag) 150 | photo.save() 151 | 152 | # Remove the file from disk now, we no longer need it 153 | os.remove(f"/data/{path.lstrip('/')}") 154 | os.removedirs(f"/data/{str(PurePosixPath(path).parent).lstrip('/')}") 155 | 156 | # Log us out, and since the photo is not publicly accessible, this should 404 157 | self.client.logout() 158 | response = self.client.get( 159 | reverse_lazy("tags:display", kwargs={"pk": name}), follow=True 160 | ) 161 | self.assertEqual(404, response.status_code) 162 | 163 | # Now, change the photo to publicly accessible and try again 164 | photo.publicly_accessible = True 165 | photo.save() 166 | 167 | response = self.client.get( 168 | reverse_lazy("tags:display", kwargs={"pk": name}), follow=True 169 | ) 170 | self.assertEqual(200, response.status_code) 171 | self.assertEqual(1, len(response.context["photos"])) 172 | self.assertIn(photo, response.context["photos"]) 173 | 174 | # Log myself in 175 | self.login() 176 | 177 | response = self.client.get( 178 | reverse_lazy("tags:display", kwargs={"pk": name}), follow=True 179 | ) 180 | self.assertEqual(200, response.status_code) 181 | self.assertEqual(1, len(response.context["photos"])) 182 | self.assertIn(photo, response.context["photos"]) 183 | 184 | # Set the photo to not publicly accessible and try again 185 | photo.publicly_accessible = False 186 | photo.save() 187 | response = self.client.get( 188 | reverse_lazy("tags:display", kwargs={"pk": name}), follow=True 189 | ) 190 | self.assertEqual(200, response.status_code) 191 | self.assertEqual(1, len(response.context["photos"])) 192 | self.assertIn(photo, response.context["photos"]) 193 | -------------------------------------------------------------------------------- /photomanager/apps/photos/migrations/0001_squashed_0020_auto_20201209_0438.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-09 04:57 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | replaces = [ 13 | ("photos", "0001_initial"), 14 | ("photos", "0002_auto_20201128_2159"), 15 | ("photos", "0003_auto_20201128_2210"), 16 | ("photos", "0004_auto_20201128_2211"), 17 | ("photos", "0005_auto_20201128_2211"), 18 | ("photos", "0006_auto_20201128_2213"), 19 | ("photos", "0007_photo_user"), 20 | ("photos", "0008_auto_20201128_2344"), 21 | ("photos", "0009_auto_20201128_2351"), 22 | ("photos", "0010_auto_20201129_0228"), 23 | ("photos", "0011_auto_20201129_2034"), 24 | ("photos", "0012_photo_license"), 25 | ("photos", "0013_photo_publicly_accessible"), 26 | ("photos", "0014_auto_20201201_0114"), 27 | ("photos", "0015_auto_20201204_2241"), 28 | ("photos", "0016_auto_20201204_2244"), 29 | ("photos", "0017_auto_20201204_2246"), 30 | ("photos", "0018_auto_20201204_2246"), 31 | ("photos", "0019_phototag_is_auto_generated"), 32 | ("photos", "0020_auto_20201209_0438"), 33 | ] 34 | 35 | initial = True 36 | 37 | dependencies = [ 38 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 39 | ("tags", "0001_initial"), 40 | ] 41 | 42 | operations = [ 43 | migrations.CreateModel( 44 | name="PhotoTag", 45 | fields=[ 46 | ("tag", models.SlugField(primary_key=True, serialize=False)), 47 | ( 48 | "create_time", 49 | models.DateTimeField( 50 | auto_now_add=True, verbose_name="Creation time" 51 | ), 52 | ), 53 | ( 54 | "creator", 55 | models.ForeignKey( 56 | null=True, 57 | on_delete=django.db.models.deletion.SET_NULL, 58 | to=settings.AUTH_USER_MODEL, 59 | ), 60 | ), 61 | ( 62 | "is_auto_generated", 63 | models.BooleanField( 64 | default=False, 65 | help_text="Whether this tag was automatically generated.", 66 | verbose_name="Automatically generated", 67 | ), 68 | ), 69 | ], 70 | ), 71 | migrations.CreateModel( 72 | name="Photo", 73 | fields=[ 74 | ( 75 | "id", 76 | models.UUIDField( 77 | default=uuid.uuid4, 78 | editable=False, 79 | primary_key=True, 80 | serialize=False, 81 | ), 82 | ), 83 | ( 84 | "file", 85 | models.FilePathField( 86 | help_text="Path to the photo file.", 87 | path="/data", 88 | recursive=True, 89 | ), 90 | ), 91 | ( 92 | "description", 93 | models.TextField( 94 | blank=True, help_text="Description for this photo." 95 | ), 96 | ), 97 | ( 98 | "creation_time", 99 | models.DateTimeField( 100 | auto_now_add=True, help_text="Photo creation time.", null=True 101 | ), 102 | ), 103 | ( 104 | "last_modified_time", 105 | models.DateTimeField( 106 | auto_now=True, help_text="Photo modification time.", null=True 107 | ), 108 | ), 109 | ( 110 | "photo_taken_time", 111 | models.DateTimeField( 112 | blank=True, help_text="Time the photo was taken.", null=True 113 | ), 114 | ), 115 | ( 116 | "user", 117 | models.ForeignKey( 118 | default=None, 119 | help_text="The user that this photo belongs to.", 120 | on_delete=django.db.models.deletion.CASCADE, 121 | to="users.user", 122 | ), 123 | ), 124 | ( 125 | "aperture_value", 126 | models.FloatField( 127 | help_text="Aperture in the APEX system", null=True 128 | ), 129 | ), 130 | ("camera_make", models.CharField(blank=True, max_length=150)), 131 | ("camera_model", models.CharField(blank=True, max_length=150)), 132 | ( 133 | "flash_fired", 134 | models.BooleanField(help_text="Did the flash fire?", null=True), 135 | ), 136 | ( 137 | "flash_mode", 138 | models.IntegerField( 139 | choices=[ 140 | (0, "Unknown"), 141 | (1, "Compulsory Flash Firing"), 142 | (2, "Compulsory Flash Suppression"), 143 | (3, "Automatic"), 144 | ], 145 | help_text="Flash firing mode", 146 | null=True, 147 | ), 148 | ), 149 | ( 150 | "focal_length", 151 | models.FloatField( 152 | help_text="Focal length in millimeters", null=True 153 | ), 154 | ), 155 | ( 156 | "image_height", 157 | models.PositiveIntegerField( 158 | help_text="Height, in pixels, of the image", null=True 159 | ), 160 | ), 161 | ( 162 | "image_size", 163 | models.PositiveIntegerField( 164 | help_text="File size (on disk, in bytes) of the image", 165 | null=True, 166 | ), 167 | ), 168 | ( 169 | "image_width", 170 | models.PositiveIntegerField( 171 | help_text="Width, in pixels, of the image", null=True 172 | ), 173 | ), 174 | ( 175 | "iso", 176 | models.PositiveIntegerField( 177 | help_text="Sensor sensitivity in ISO", 178 | null=True, 179 | verbose_name="ISO", 180 | ), 181 | ), 182 | ( 183 | "shutter_speed_value", 184 | models.FloatField( 185 | help_text="Shutter speed in the APEX system", null=True 186 | ), 187 | ), 188 | ( 189 | "license", 190 | models.CharField( 191 | choices=[ 192 | ("ARR", "All rights reserved"), 193 | ("PDM", "Public Domain Mark"), 194 | ("CC0", "CC0"), 195 | ("CCBY", "Creative Commons Attribution"), 196 | ("CCBYSA", "Creative Commons Attribution Share-Alike"), 197 | ("CCBYND", "Creative Commons Attribution-NoDerivs"), 198 | ("CCBYNC", "Creative Commons Attribution-NonCommercial"), 199 | ( 200 | "CCBYNCSA", 201 | "Creative Commons Attribution-NonCommercial-ShareAlike", 202 | ), 203 | ( 204 | "CCBYNCND", 205 | "Creative Commons Attribution-NonCommercial-NoDerivs", 206 | ), 207 | ], 208 | default="ARR", 209 | max_length=50, 210 | ), 211 | ), 212 | ( 213 | "publicly_accessible", 214 | models.BooleanField( 215 | default=False, 216 | help_text="Whether this photo is publicly accessible. If checked, this photo is listed on the front page and accessible without authentication.", 217 | ), 218 | ), 219 | ("tags", models.ManyToManyField(blank=True, to="tags.PhotoTag")), 220 | ], 221 | ), 222 | migrations.SeparateDatabaseAndState( 223 | database_operations=[ 224 | migrations.AlterModelTable( 225 | name="PhotoTag", 226 | table="tags_phototag", 227 | ), 228 | migrations.AlterField( 229 | model_name="photo", 230 | name="tags", 231 | field=models.ManyToManyField(blank=True, to="tags.PhotoTag"), 232 | ), 233 | ], 234 | state_operations=[ 235 | migrations.DeleteModel( 236 | name="PhotoTag", 237 | ), 238 | ], 239 | ), 240 | ] 241 | -------------------------------------------------------------------------------- /photomanager/apps/photos/views.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import io 3 | import json 4 | import os 5 | import subprocess 6 | from pathlib import Path 7 | 8 | from django.conf import settings 9 | from django.contrib import messages 10 | from django.contrib.auth.decorators import login_required 11 | from django.contrib.auth.mixins import UserPassesTestMixin 12 | from django.http import ( 13 | FileResponse, 14 | HttpResponse, 15 | HttpResponseForbidden, 16 | HttpResponseNotFound, 17 | HttpResponseServerError, 18 | ) 19 | from django.shortcuts import get_object_or_404, redirect, render 20 | from django.urls import reverse_lazy 21 | from django.views.decorators.http import require_POST 22 | from django.views.generic import ListView 23 | from django.views.generic.edit import UpdateView 24 | from hurry.filesize import size 25 | 26 | from ..albums.models import Album, AlbumShareLink 27 | from .models import Photo 28 | from .tasks import process_image, scan_dir_for_changes 29 | 30 | 31 | @login_required 32 | def rescan_directory(request) -> HttpResponse: 33 | """ 34 | Rescans the directory of the logged in user for new files. 35 | 36 | :param request: Request object 37 | :return: HttpResponse 38 | """ 39 | scan_dir_for_changes.delay(request.user.subdirectory, request.user.username) 40 | 41 | # TODO: return a redirect 42 | return HttpResponse("OK") 43 | 44 | 45 | @login_required 46 | @require_POST 47 | def reprocess_file(request, image_id): 48 | """ 49 | Reprocess a file, or regenerate an image's metadata. 50 | Reprocessing is done asynchronously. 51 | 52 | :param request: Request object 53 | :param image_id: The ID of the photo to regenerate 54 | :return: HttpResponse - a redirect 55 | """ 56 | photo = get_object_or_404(Photo, id=image_id) 57 | 58 | if photo.user != request.user: 59 | return HttpResponseForbidden() 60 | 61 | process_image.delay(photo.id) 62 | messages.success(request, "EXIF metadata refresh queued.") 63 | return redirect( 64 | reverse_lazy("photos:view_single_photo", kwargs={"image_id": photo.id}) 65 | ) 66 | 67 | 68 | def _get_raw_image(request, photo: Photo) -> FileResponse: 69 | """ 70 | Backend method for getting a raw image. 71 | 72 | :param request: Request object 73 | :param photo: Photo object 74 | :return: FileResponse 75 | """ 76 | # Read file 77 | READ_FILE_PATH = os.path.join( 78 | os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 79 | "utils/files/read_file.py", 80 | ) 81 | 82 | # If this is a thumbnail we are reading, we will want to use a 83 | # different file path than if we are reading the actual file 84 | 85 | if request.GET.get("thumbnail"): 86 | file_to_read = os.path.join( 87 | settings.IMAGE_THUMBS_DIR, 88 | str(photo.id)[0], 89 | str(photo.id)[1], 90 | f"{str(photo.id)}.thumb.jpeg", 91 | ) 92 | else: 93 | # TODO: find a better way to do this 94 | file_to_read = "/data/" + str(photo.file).lstrip("/") 95 | 96 | file_read: dict = json.loads( 97 | subprocess.run( 98 | (["sudo"] if os.getuid() != 0 else []) 99 | + [ 100 | "pipenv", 101 | "run", 102 | "python3", 103 | READ_FILE_PATH, 104 | file_to_read, 105 | ], # sudo required for chroot 106 | capture_output=True, 107 | text=True, 108 | ).stdout 109 | ) 110 | 111 | if "error" in file_read.keys(): 112 | if file_read["error"] == 404: 113 | return HttpResponseNotFound() 114 | elif file_read["error"] == 500: 115 | return HttpResponseServerError() 116 | 117 | return FileResponse( 118 | io.BytesIO(base64.b64decode(file_read[file_to_read]["data"])), 119 | filename=Path(file_to_read).name, 120 | content_type=file_read[file_to_read]["mime"], 121 | ) 122 | 123 | 124 | def get_raw_image(request, image_id) -> HttpResponse: 125 | """ 126 | Returns the image specified by image_id 127 | 128 | :param request: Django request 129 | :param image_id: Image ID to request 130 | :return: FileResponse, or HttpResponse for 403s 131 | """ 132 | 133 | photo = get_object_or_404(Photo, id=image_id) 134 | 135 | if not photo.publicly_accessible: 136 | # Check if this photo is a part of any publicly visible albums. 137 | if Album.objects.filter(photos=photo, publicly_accessible=True).count() == 0: 138 | if not request.user.is_authenticated: 139 | return redirect(settings.LOGIN_URL) 140 | 141 | if photo.user != request.user: 142 | return HttpResponseForbidden() 143 | 144 | return _get_raw_image(request, photo) 145 | 146 | 147 | def get_raw_image_album_share(request, image_id, album_share_id) -> HttpResponse: 148 | """ 149 | Returns the image specified, but authenticated with an album share ID 150 | 151 | :param request: Request object 152 | :param image_id: ID (UUID) for an image 153 | :param album_share_id: Album share ID (UUID) 154 | :return: FileResponse, or HttpResponse for 403s 155 | """ 156 | photo = get_object_or_404(Photo, id=image_id) 157 | album_share_link = get_object_or_404(AlbumShareLink, id=album_share_id) 158 | 159 | if photo not in album_share_link.album.photos.all(): 160 | return HttpResponseForbidden() 161 | 162 | return _get_raw_image(request, photo) 163 | 164 | 165 | def _view_single_photo( 166 | request, photo: Photo, album_share_id: str = None 167 | ) -> HttpResponse: 168 | """ 169 | Backend method to render single photo page 170 | 171 | :param request: Request object 172 | :param photo: Photo object 173 | :param album_share_id: Album share link, if it is to be included 174 | :return: HttpResponse 175 | """ 176 | 177 | # Find the albums that this photo is a part of 178 | album_queryset_list = [] 179 | if request.user.is_authenticated: 180 | album_queryset_list.append( 181 | Album.objects.filter(photos=photo, owner=request.user) 182 | ) 183 | 184 | if album_share_id: 185 | album_queryset_list.append( 186 | Album.objects.filter( 187 | photos=photo, 188 | id=get_object_or_404(AlbumShareLink, id=album_share_id).album.id, 189 | ) 190 | ) 191 | 192 | albums = Album.objects.filter(photos=photo, publicly_accessible=True).union( 193 | *album_queryset_list 194 | ) 195 | 196 | context = { 197 | "photo": photo, 198 | "size_hurry": None, 199 | "album_share_id": album_share_id, 200 | "license": Photo.License(photo.license).label, 201 | "albums": albums, 202 | } 203 | 204 | try: 205 | context["size_hurry"] = size(photo.image_size) 206 | except Exception: 207 | pass 208 | 209 | return render(request, "photos/view_single_photo.html", context=context) 210 | 211 | 212 | def view_single_photo(request, image_id: str) -> HttpResponse: 213 | """ 214 | View for a single photo 215 | 216 | :param request: Request object 217 | :param image_id: ID (UUID) for an image 218 | :return: HttpResponse 219 | """ 220 | photo = get_object_or_404(Photo, id=image_id) 221 | 222 | if not photo.publicly_accessible: 223 | # Check if this photo is a part of any publicly visible albums. 224 | if Album.objects.filter(photos=photo, publicly_accessible=True).count() == 0: 225 | if not request.user.is_authenticated: 226 | return redirect(settings.LOGIN_URL) 227 | 228 | if photo.user != request.user: 229 | return HttpResponseForbidden() 230 | 231 | return _view_single_photo(request, photo) 232 | 233 | 234 | def view_single_photo_album_share(request, image_id, album_share_id): 235 | """ 236 | View for a single photo, but authenticated with an album share ID 237 | 238 | :param request: Request object 239 | :param image_id: ID (UUID) for an image 240 | :param album_share_id: Album share ID (UUID) 241 | :return: HttpResponse 242 | """ 243 | photo = get_object_or_404(Photo, id=image_id) 244 | album_share_link = get_object_or_404(AlbumShareLink, id=album_share_id) 245 | 246 | if photo not in album_share_link.album.photos.all(): 247 | return HttpResponseForbidden() 248 | 249 | return _view_single_photo(request, photo, album_share_id=album_share_id) 250 | 251 | 252 | class PhotoUpdate(UserPassesTestMixin, UpdateView): 253 | """View for updating the metadata on a photo.""" 254 | 255 | model = Photo 256 | fields = ["description", "tags", "faces", "license", "publicly_accessible"] 257 | template_name = "photos/photo_update.html" 258 | 259 | def test_func(self): 260 | """ 261 | Used to ensure that only the owner of the photo can make changes to it. 262 | 263 | :return: True if the logged in user is the owner of the photo, false otherwise. 264 | """ 265 | return self.request.user == self.get_object().user 266 | 267 | def get_success_url(self): 268 | # https://stackoverflow.com/a/64108595/2034128 269 | pk = self.kwargs["pk"] 270 | return reverse_lazy("photos:view_single_photo", kwargs={"image_id": pk}) 271 | 272 | 273 | class IndexView(ListView): 274 | """ 275 | View for listing all the photos that the user has access to. 276 | Used for the index page. 277 | """ 278 | 279 | model = Photo 280 | paginate_by = 100 281 | 282 | def get_queryset(self): 283 | # If the user isn't authenticated, return all the publicly accessible images 284 | if not self.request.user.is_authenticated: 285 | return Photo.objects.filter(publicly_accessible=True).order_by( 286 | "-photo_taken_time" 287 | ) 288 | else: 289 | # If the user is authenticated, return only that user's photos 290 | return Photo.objects.filter(user=self.request.user).order_by( 291 | "-photo_taken_time" 292 | ) 293 | -------------------------------------------------------------------------------- /photomanager/apps/photos/tasks.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import io 3 | import json 4 | import multiprocessing 5 | import os 6 | import subprocess 7 | import time 8 | from contextlib import contextmanager 9 | from datetime import datetime 10 | from pathlib import Path 11 | 12 | import billiard 13 | import face_recognition 14 | import magic 15 | import pytz 16 | from celery import shared_task 17 | from django.conf import settings 18 | from django.contrib.auth import get_user_model 19 | from django.core.cache import cache 20 | from exif import Image as exif_Image 21 | from PIL import Image as PIL_Image 22 | from PIL import ImageOps 23 | from timezonefinder import TimezoneFinder 24 | 25 | from ..faces.models import Face 26 | 27 | if settings.ENABLE_TENSORFLOW_TAGGING: 28 | import numpy as np 29 | from tensorflow.keras.applications import NASNetLarge 30 | from tensorflow.keras.applications.imagenet_utils import ( 31 | decode_predictions, 32 | preprocess_input, 33 | ) 34 | from tensorflow.keras.preprocessing import image as keras_image 35 | 36 | from ..tags.models import PhotoTag 37 | from .models import Photo 38 | 39 | LOCK_EXPIRE = 60 * 10 40 | 41 | 42 | @contextmanager 43 | def redis_lock(lock_id): 44 | """ 45 | Helper function to manage locks in the Redis database 46 | From https://docs.celeryproject.org/en/latest/tutorials/task-cookbook.html 47 | 48 | :param lock_id: An ID for the lock 49 | """ 50 | timeout_at = time.monotonic() + LOCK_EXPIRE - 3 51 | # cache.add fails if the key already exists 52 | # Second value is arbitrary 53 | status = cache.add(lock_id, "lock", timeout=LOCK_EXPIRE) 54 | try: 55 | yield status 56 | finally: 57 | # memcache delete is very slow, but we have to use it to take 58 | # advantage of using add() for atomic locking 59 | if time.monotonic() < timeout_at and status: 60 | # don't release the lock if we exceeded the timeout 61 | # to lessen the chance of releasing an expired lock 62 | # owned by someone else 63 | # also don't release the lock if we didn't acquire it 64 | cache.delete(lock_id) 65 | 66 | 67 | @shared_task 68 | def scan_dir_for_changes(directory: Path, username: str) -> None: 69 | """ 70 | Scans the directory given for new files. 71 | Queues new tasks for any new files found. 72 | 73 | :param directory: The directory to scan. 74 | :param username: Username that this directory corresponds to 75 | :return: None 76 | """ 77 | 78 | LIST_DIR_PATH = os.path.join( 79 | os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 80 | "utils/files/list_dir.py", 81 | ) 82 | 83 | # First, list the directory 84 | # sudo required for chroot 85 | contents = subprocess.run( 86 | ["sudo", "pipenv", "run", "python3", LIST_DIR_PATH, str(directory)], 87 | capture_output=True, 88 | text=True, 89 | ) 90 | 91 | if contents.returncode != 0: 92 | raise Exception() 93 | 94 | user = get_user_model().objects.get(username=username) 95 | 96 | for file, mime in json.loads(contents.stdout).items(): 97 | if "image" in mime: 98 | # file must be prepended with user.subdirectory 99 | actual_path = os.path.join("/data/", str(user.subdirectory), file) 100 | photo = Photo.objects.get_or_create( 101 | file=actual_path, file_type=Photo.FileTypes.IMAGE, user=user 102 | ) 103 | if photo[1]: # If this was just created by get_or_create 104 | process_image.delay(photo[0].id) 105 | elif "video" in mime: 106 | pass 107 | 108 | 109 | @shared_task 110 | def scan_all_dirs_for_changes() -> None: 111 | """ 112 | Scan all directories held by all users for changes. 113 | Queues new tasks for any new files found. 114 | 115 | :return: None 116 | """ 117 | for user in get_user_model().objects.all(): 118 | scan_dir_for_changes.delay(user.subdirectory, user.username) 119 | 120 | 121 | @shared_task 122 | def process_image(photo_id: str) -> None: 123 | """ 124 | Process an image. 125 | 126 | :param photo_id: The UUID of a photo 127 | :return: None 128 | """ 129 | photo = Photo.objects.get(id=photo_id) 130 | 131 | # Read this file 132 | READ_FILE_PATH = os.path.join( 133 | os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 134 | "utils/files/read_file.py", 135 | ) 136 | # TODO: find a better way to do this 137 | file_path = "/data/" + str(photo.file).lstrip("/") 138 | file_read: dict = json.loads( 139 | subprocess.run( 140 | [ 141 | "sudo", 142 | "pipenv", 143 | "run", 144 | "python3", 145 | READ_FILE_PATH, 146 | file_path, 147 | ], # sudo required for chroot 148 | capture_output=True, 149 | text=True, 150 | ).stdout 151 | ) 152 | 153 | if "error" in file_read.keys(): 154 | if file_read["error"] == 404: 155 | raise Exception() 156 | elif file_read["error"] == 500: 157 | raise Exception() 158 | 159 | assert "image" in file_read[file_path]["mime"], "Not an image" 160 | 161 | image_data: bytes = base64.b64decode(file_read[file_path]["data"]) 162 | 163 | m = magic.Magic(mime=True) 164 | assert "image" in m.from_buffer(image_data), "Not an image file" 165 | 166 | exif_image = exif_Image(image_data) 167 | 168 | # Update the photo's metadata 169 | 170 | if "datetime" in dir(exif_image): 171 | # EXIF does not include timezones (WHY?!?) in timestamps. 172 | # Therefore, we use the GPS location to find the timezone of the image, 173 | # and where there is no GPS location, we use server time (likely UTC). 174 | tf = TimezoneFinder() 175 | 176 | if "gps_longitude" in dir(exif_image) and "gps_latitude" in dir(exif_image): 177 | gps_point = [] 178 | for pt, direction in [ 179 | (exif_image.gps_latitude, exif_image.gps_latitude_ref), 180 | (exif_image.gps_longitude, exif_image.gps_longitude_ref), 181 | ]: 182 | deg, minutes, seconds = pt 183 | # https://stackoverflow.com/a/54294962 184 | point = ( 185 | float(deg) + float(minutes) / 60 + float(seconds) / (60 * 60) 186 | ) * (-1 if direction in ["W", "S"] else 1) 187 | gps_point.append(point) 188 | tz = tf.timezone_at(lat=gps_point[0], lng=gps_point[1]) 189 | else: 190 | tz = settings.TIME_ZONE 191 | 192 | photo.photo_taken_time = datetime.strptime( 193 | exif_image.datetime, "%Y:%m:%d %H:%M:%S" 194 | ).replace(tzinfo=pytz.timezone(tz)) 195 | 196 | # Height and width are a property of every image 197 | image_pillow = PIL_Image.open(io.BytesIO(image_data)) 198 | image_pillow = ImageOps.exif_transpose(image_pillow) 199 | width, height = image_pillow.size 200 | photo.image_width = width 201 | photo.image_height = height 202 | photo.image_size = file_read[file_path]["size"] 203 | 204 | if "make" in dir(exif_image): 205 | photo.camera_make = exif_image.make 206 | if "model" in dir(exif_image): 207 | photo.camera_model = exif_image.model 208 | if "aperture_value" in dir(exif_image): 209 | photo.aperture_value = exif_image.aperture_value 210 | if "shutter_speed_value" in dir(exif_image): 211 | photo.shutter_speed_value = exif_image.shutter_speed_value 212 | if "focal_length" in dir(exif_image): 213 | photo.focal_length = exif_image.focal_length 214 | if "photographic_sensitivity" in dir(exif_image): 215 | photo.iso = exif_image.photographic_sensitivity 216 | if "flash" in dir(exif_image): 217 | photo.flash_fired = exif_image.flash.flash_fired 218 | photo.flash_mode = exif_image.flash.flash_mode 219 | 220 | if settings.ENABLE_TENSORFLOW_TAGGING: 221 | # We only want to run one tagging operation at a time since it is memory intensive 222 | with redis_lock("tensorflow-tag"): 223 | # We define get_predictions to allow for a timeout 224 | # The queue is created to grab the return value 225 | queue = multiprocessing.Queue() 226 | 227 | def get_predictions(img_pillow: PIL_Image): 228 | """ 229 | Helper function to determine tags of an image 230 | :param img_pillow: Pillow.Image 231 | :return: None (results appended to queue) 232 | """ 233 | model = NASNetLarge(weights="imagenet") 234 | image_tags = keras_image.img_to_array( 235 | img_pillow.resize((331, 331), PIL_Image.NEAREST) 236 | ) 237 | image_tags = np.expand_dims(image_tags, axis=0) 238 | image_tags = preprocess_input(image_tags) 239 | predictions = model.predict(image_tags) 240 | queue.put(decode_predictions(predictions)[0]) 241 | 242 | process = billiard.context.Process( 243 | target=get_predictions, kwargs={"img_pillow": image_pillow} 244 | ) 245 | process.daemon = True 246 | process.start() 247 | 248 | process.join(60) # 60 second (arbitrary) timeout 249 | if process.is_alive(): 250 | process.terminate() 251 | raise TimeoutError 252 | 253 | decoded_predictions = queue.get() 254 | 255 | for prediction in decoded_predictions: 256 | if ( 257 | prediction[2] > 0.20 258 | ): # If score greater than 0.20 (chosen arbitrarily) 259 | tag = PhotoTag.objects.get_or_create(tag=prediction[1]) 260 | if tag[1]: # If an object was created 261 | tag[0].is_auto_generated = True 262 | tag[0].save() 263 | photo.tags.add(tag[0]) 264 | 265 | if settings.ENABLE_FACE_RECOGNITION: 266 | # We can only do one at a time to prevent duplicates 267 | with redis_lock("face-recognition"): 268 | # We now need to convert the image to a NumPy array in order to do anything with it 269 | np_array = np.array(image_pillow) 270 | 271 | # Now, get the face encodings for the face(s) in this image 272 | encodings = face_recognition.face_encodings(np_array, model="cnn") 273 | 274 | # Loop over all the encodings detected 275 | 276 | # First, we have to query the database 277 | faces = list(Face.objects.all()) 278 | faces_data = [json.loads(s.face_data) for s in faces] 279 | for encoding in encodings: 280 | # Now, we compare to the list of faces in the database 281 | # TODO: This needs to be more efficient 282 | compare = face_recognition.compare_faces(faces_data, encoding) 283 | 284 | # If a face already exists, then it should have a True value in that array, 285 | # corresponding to the faces list 286 | # TODO: Deduplicate somehow? 287 | if any(compare): 288 | # Find the indices that are True, and take the same index in the faces list 289 | for i in range(len(compare)): 290 | if compare[i]: 291 | photo.faces.add(faces[i]) 292 | 293 | # If no face exists, then add one 294 | face = Face.objects.create(face_data=json.dumps(encoding.tolist())) 295 | photo.faces.add(face) 296 | 297 | # We will need to make a thumbnail of this image 298 | image_pillow.thumbnail((1024, 1024)) 299 | 300 | # Save the thumbnail in a directory 301 | # This directory is under settings.IMAGE_THUMBS_DIR, and 302 | # we use the first two characters of the photo's UUID as subdirectories 303 | # to save images under 304 | 305 | path = os.path.join(settings.IMAGE_THUMBS_DIR, photo_id[0], photo_id[1]) 306 | Path(os.path.join(settings.IMAGE_THUMBS_DIR, photo_id[0], photo_id[1])).mkdir( 307 | parents=True, exist_ok=True 308 | ) 309 | 310 | image_pillow.save(os.path.join(path, f"{photo_id}.thumb.jpeg")) 311 | 312 | photo.save() 313 | -------------------------------------------------------------------------------- /ThirdPartyNotices.md: -------------------------------------------------------------------------------- 1 | Third Party Notices 2 | === 3 | 4 | This project incorporates components from the projects listed below. 5 | The original copyright notices and the licenses under which this 6 | project's authors received such components are set forth below. 7 | This project's authors reserves all rights not expressly granted herein, 8 | whether by implication, estoppel or otherwise. 9 | 10 | ## celery 11 | 12 | https://github.com/celery/celery 13 | 14 | ```text 15 | Copyright (c) 2015-2016 Ask Solem & contributors. All rights reserved. 16 | Copyright (c) 2012-2014 GoPivotal, Inc. All rights reserved. 17 | Copyright (c) 2009, 2010, 2011, 2012 Ask Solem, and individual contributors. All rights reserved. 18 | 19 | Celery is licensed under The BSD License (3 Clause, also known as 20 | the new BSD license). The license is an OSI approved Open Source 21 | license and is GPL-compatible(1). 22 | 23 | The license text can also be found here: 24 | http://www.opensource.org/licenses/BSD-3-Clause 25 | 26 | License 27 | ======= 28 | 29 | Redistribution and use in source and binary forms, with or without 30 | modification, are permitted provided that the following conditions are met: 31 | * Redistributions of source code must retain the above copyright 32 | notice, this list of conditions and the following disclaimer. 33 | * Redistributions in binary form must reproduce the above copyright 34 | notice, this list of conditions and the following disclaimer in the 35 | documentation and/or other materials provided with the distribution. 36 | * Neither the name of Ask Solem, nor the 37 | names of its contributors may be used to endorse or promote products 38 | derived from this software without specific prior written permission. 39 | 40 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 41 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 42 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 43 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Ask Solem OR CONTRIBUTORS 44 | BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 45 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 46 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 47 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 48 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 49 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 50 | POSSIBILITY OF SUCH DAMAGE. 51 | 52 | Documentation License 53 | ===================== 54 | 55 | The documentation portion of Celery (the rendered contents of the 56 | "docs" directory of a software distribution or checkout) is supplied 57 | under the "Creative Commons Attribution-ShareAlike 4.0 58 | International" (CC BY-SA 4.0) License as described by 59 | https://creativecommons.org/licenses/by-sa/4.0/ 60 | 61 | Footnotes 62 | ========= 63 | (1) A GPL-compatible license makes it possible to 64 | combine Celery with other software that is released 65 | under the GPL, it does not mean that we're distributing 66 | Celery under the GPL license. The BSD license, unlike the GPL, 67 | let you distribute a modified version without making your 68 | changes open source. 69 | ``` 70 | 71 | ## django 72 | 73 | https://github.com/django/django 74 | 75 | ```text 76 | Copyright (c) Django Software Foundation and individual contributors. 77 | All rights reserved. 78 | 79 | Redistribution and use in source and binary forms, with or without modification, 80 | are permitted provided that the following conditions are met: 81 | 82 | 1. Redistributions of source code must retain the above copyright notice, 83 | this list of conditions and the following disclaimer. 84 | 85 | 2. Redistributions in binary form must reproduce the above copyright 86 | notice, this list of conditions and the following disclaimer in the 87 | documentation and/or other materials provided with the distribution. 88 | 89 | 3. Neither the name of Django nor the names of its contributors may be used 90 | to endorse or promote products derived from this software without 91 | specific prior written permission. 92 | 93 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 94 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 95 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 96 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 97 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 98 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 99 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 100 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 101 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 102 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 103 | ``` 104 | 105 | ## exif 106 | 107 | https://gitlab.com/TNThieding/exif 108 | 109 | ```text 110 | Copyright 2020 Tyler N. Thieding 111 | 112 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 113 | software and associated documentation files (the "Software"), to deal in the Software 114 | without restriction, including without limitation the rights to use, copy, modify, merge, 115 | publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons 116 | to whom the Software is furnished to do so, subject to the following conditions: 117 | 118 | The above copyright notice and this permission notice shall be included in all copies or 119 | substantial portions of the Software. 120 | 121 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 122 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 123 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 124 | FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 125 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 126 | DEALINGS IN THE SOFTWARE. 127 | ``` 128 | 129 | ## gunicorn 130 | 131 | https://github.com/benoitc/gunicorn 132 | 133 | ```text 134 | 2009-2018 (c) Benoît Chesneau 135 | 2009-2015 (c) Paul J. Davis 136 | 137 | Permission is hereby granted, free of charge, to any person 138 | obtaining a copy of this software and associated documentation 139 | files (the "Software"), to deal in the Software without 140 | restriction, including without limitation the rights to use, 141 | copy, modify, merge, publish, distribute, sublicense, and/or sell 142 | copies of the Software, and to permit persons to whom the 143 | Software is furnished to do so, subject to the following 144 | conditions: 145 | 146 | The above copyright notice and this permission notice shall be 147 | included in all copies or substantial portions of the Software. 148 | 149 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 150 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 151 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 152 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 153 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 154 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 155 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 156 | OTHER DEALINGS IN THE SOFTWARE. 157 | ``` 158 | 159 | ## pillow 160 | 161 | https://github.com/python-pillow/Pillow 162 | 163 | ```text 164 | The Python Imaging Library (PIL) is 165 | 166 | Copyright © 1997-2011 by Secret Labs AB 167 | Copyright © 1995-2011 by Fredrik Lundh 168 | 169 | Pillow is the friendly PIL fork. It is 170 | 171 | Copyright © 2010-2020 by Alex Clark and contributors 172 | 173 | Like PIL, Pillow is licensed under the open source HPND License: 174 | 175 | By obtaining, using, and/or copying this software and/or its associated 176 | documentation, you agree that you have read, understood, and will comply 177 | with the following terms and conditions: 178 | 179 | Permission to use, copy, modify, and distribute this software and its 180 | associated documentation for any purpose and without fee is hereby granted, 181 | provided that the above copyright notice appears in all copies, and that 182 | both that copyright notice and this permission notice appear in supporting 183 | documentation, and that the name of Secret Labs AB or the author not be 184 | used in advertising or publicity pertaining to distribution of the software 185 | without specific, written prior permission. 186 | 187 | SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS 188 | SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. 189 | IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR ANY SPECIAL, 190 | INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 191 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE 192 | OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 193 | PERFORMANCE OF THIS SOFTWARE. 194 | ``` 195 | 196 | ## Python 3.8 197 | 198 | ```text 199 | 1. This LICENSE AGREEMENT is between the Python Software Foundation ("PSF"), and 200 | the Individual or Organization ("Licensee") accessing and otherwise using Python 201 | 3.8.6 software in source or binary form and its associated documentation. 202 | 203 | 2. Subject to the terms and conditions of this License Agreement, PSF hereby 204 | grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, 205 | analyze, test, perform and/or display publicly, prepare derivative works, 206 | distribute, and otherwise use Python 3.8.6 alone or in any derivative 207 | version, provided, however, that PSF's License Agreement and PSF's notice of 208 | copyright, i.e., "Copyright © 2001-2020 Python Software Foundation; All Rights 209 | Reserved" are retained in Python 3.8.6 alone or in any derivative version 210 | prepared by Licensee. 211 | 212 | 3. In the event Licensee prepares a derivative work that is based on or 213 | incorporates Python 3.8.6 or any part thereof, and wants to make the 214 | derivative work available to others as provided herein, then Licensee hereby 215 | agrees to include in any such work a brief summary of the changes made to Python 216 | 3.8.6. 217 | 218 | 4. PSF is making Python 3.8.6 available to Licensee on an "AS IS" basis. 219 | PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF 220 | EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND DISCLAIMS ANY REPRESENTATION OR 221 | WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE 222 | USE OF PYTHON 3.8.6 WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 223 | 224 | 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 3.8.6 225 | FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF 226 | MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 3.8.6, OR ANY DERIVATIVE 227 | THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 228 | 229 | 6. This License Agreement will automatically terminate upon a material breach of 230 | its terms and conditions. 231 | 232 | 7. Nothing in this License Agreement shall be deemed to create any relationship 233 | of agency, partnership, or joint venture between PSF and Licensee. This License 234 | Agreement does not grant permission to use PSF trademarks or trade name in a 235 | trademark sense to endorse or promote products or services of Licensee, or any 236 | third party. 237 | 238 | 8. By copying, installing or otherwise using Python 3.8.6, Licensee agrees 239 | to be bound by the terms and conditions of this License Agreement. 240 | ``` 241 | 242 | ## timezonefinder 243 | 244 | https://github.com/MrMinimal64/timezonefinder 245 | 246 | ```text 247 | The MIT License (MIT) 248 | 249 | Copyright (c) 2016 MrMinimal64 250 | 251 | Permission is hereby granted, free of charge, to any person obtaining a copy 252 | of this software and associated documentation files (the "Software"), to deal 253 | in the Software without restriction, including without limitation the rights 254 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 255 | copies of the Software, and to permit persons to whom the Software is 256 | furnished to do so, subject to the following conditions: 257 | 258 | The above copyright notice and this permission notice shall be included in all 259 | copies or substantial portions of the Software. 260 | 261 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 262 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 263 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 264 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 265 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 266 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 267 | SOFTWARE. 268 | ``` 269 | 270 | ## whitenoise 271 | 272 | https://github.com/evansd/whitenoise 273 | 274 | ```text 275 | The MIT License (MIT) 276 | 277 | Copyright (c) 2013 David Evans 278 | 279 | Permission is hereby granted, free of charge, to any person obtaining a copy of 280 | this software and associated documentation files (the "Software"), to deal in 281 | the Software without restriction, including without limitation the rights to 282 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 283 | the Software, and to permit persons to whom the Software is furnished to do so, 284 | subject to the following conditions: 285 | 286 | The above copyright notice and this permission notice shall be included in all 287 | copies or substantial portions of the Software. 288 | 289 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 290 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 291 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 292 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 293 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 294 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 295 | ``` -------------------------------------------------------------------------------- /photomanager/templates/photos/view_single_photo.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load tz %} 4 | 5 | {% block titleprefix %}Photo{% endblock %} 6 | 7 | {% block content %} 8 |
9 | 30 |
31 | 32 |
33 |
34 |
35 | 36 | 37 | {# Description #} 38 | 39 | 40 | 49 | 50 | {# Tags #} 51 | 52 | 53 | 62 | 63 | {# Albums #} 64 | 65 | 66 | 85 | 86 | {# Public accessibility #} 87 | 88 | 89 | 98 | 99 | {% if user == photo.user %} 100 | {# Edit link #} 101 | 102 | 103 | 108 | 109 | {% endif %} 110 | {# Uploaded by #} 111 | 112 | 113 | 118 | 119 | {# License info #} 120 | 121 | 122 | 127 | 128 | 129 |
41 |

42 | {% if photo.description|length > 0 %} 43 | {{ photo.description }} 44 | {% else %} 45 | No description was provided. 46 | {% endif %} 47 |

48 |
54 |

55 | {% for tag in photo.tags.all %} 56 | {{ tag }} 57 | {% empty %} 58 | There are no tags for this image. 59 | {% endfor %} 60 |

61 |
67 |
68 | {% if albums %} 69 |

This photo is a part of the following albums:

70 | 80 | {% else %} 81 |

This image is not a part of any album.

82 | {% endif %} 83 |
84 |
90 |

91 | {% if photo.publicly_accessible %} 92 | This photo is publicly accessible. 93 | {% else %} 94 | This photo is not publicly accessible. 95 | {% endif %} 96 |

97 |
104 | 107 |
114 |

115 | Uploaded by {{ photo.user.username }} 116 |

117 |
123 | 126 |
130 |
131 |
132 | 133 | 134 | {% if photo.photo_taken_time %} 135 | 136 | 137 | 144 | 145 | {% endif %} 146 | {% if photo.camera_make or photo.camera_model %} 147 | 148 | 149 | 150 | 151 | {% endif %} 152 | {% if photo.aperture_value or photo.shutter_speed_value or photo.focal_length or photo.iso %} 153 | 154 | 158 | 174 | 175 | {% endif %} 176 | {# Flash mode compulsory on all EXIF data #} 177 | 178 | 179 | 203 | 204 | {% if photo.image_width and photo.image_height and size_hurry %} {# if image has been processed #} 205 | 206 | 207 | 208 | 209 | {% endif %} 210 | 211 | 212 |
138 |
139 |

Taken on {{ photo.photo_taken_time }}

140 |

Uploaded on {{ photo.creation_time }}

141 |

Last modified {{ photo.last_modified_time }}

142 |
143 |

{{ photo.camera_make|title }} {{ photo.camera_model }}

155 | 157 | 159 |

160 | {% if photo.shutter_speed_seconds %} 161 | {{ photo.shutter_speed_seconds }}" 162 | {% endif %} 163 | {% if photo.aperture_value %} 164 | f/{{ photo.aperture_value_f_stop }} 165 | {% endif %} 166 | {% if photo.focal_length %} 167 | {{ photo.focal_length }}mm 168 | {% endif %} 169 | {% if photo.iso %} 170 | ISO {{ photo.iso }} 171 | {% endif %} 172 |

173 |
180 |

181 | 182 | {% if photo.flash_fired %} 183 | Flash fired 184 | {% elif photo.flash_fired == None %} 185 | Flash status unknown 186 | {% else %} 187 | Flash did not fire 188 | {% endif %} 189 | 190 | 191 | {% if photo.flash_mode == 1 %} 192 | Flash forced 193 | {% elif photo.flash_mode == 2 %} 194 | Flash disabled 195 | {% elif photo.flash_mode == 3 %} 196 | Flash on automatic mode 197 | {% else %} 198 | Flash mode unknown 199 | {% endif %} 200 | 201 |

202 |

{{ photo.image_width }} × {{ photo.image_height }}, {{ size_hurry }}

213 |
214 |
215 |
216 | 217 | 248 | {% endblock %} --------------------------------------------------------------------------------