Compare commits
10 commits
f221740228
...
8dd3c8701c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd3c8701c | ||
|
|
faf880a236 | ||
|
|
7fbc81b9e1 | ||
|
|
3a73bb8887 | ||
|
|
74609215e0 | ||
|
|
1a5f1d5e81 | ||
|
|
d44b31f024 | ||
|
|
3d985e473d | ||
|
|
687445e414 | ||
|
|
3efa217716 |
30 changed files with 522 additions and 123 deletions
55
.env.example
Normal file
55
.env.example
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Copy this file to .env and fill in the values
|
||||
# .env is gitignored and must never be committed
|
||||
|
||||
SECRET_KEY=change-me-to-a-long-random-string
|
||||
|
||||
# Set to True only for development
|
||||
DEBUG=False
|
||||
|
||||
# Comma-separated list of additional allowed hosts (beyond 127.0.0.1 and localhost)
|
||||
EXTRA_HOSTS=photos.crans.org,photos-dev.crans.org
|
||||
|
||||
# Comma-separated list of admins in "Name:email" format
|
||||
ADMINS=admin:photos-admin@lists.crans.org
|
||||
|
||||
# Email address used as sender for server emails
|
||||
SERVER_EMAIL=photos@crans.org
|
||||
|
||||
# Email verification: 'mandatory', 'optional', or 'none'
|
||||
EMAIL_VERIFICATION=mandatory
|
||||
|
||||
# Mail server settings
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=25
|
||||
#SMTP_USER=
|
||||
#SMTP_PASSWORD=
|
||||
SMTP_USE_TLS=False
|
||||
|
||||
# OAuth2 settings
|
||||
# Enable OAuth2 login
|
||||
OAUTH_ENABLED=False
|
||||
# Disable normal username/password login (requires OAUTH_ENABLED=True)
|
||||
OAUTH_ONLY=False
|
||||
# OAuth2 server base URL (e.g. auth.example.com)
|
||||
#OAUTH_SERVER_URL=
|
||||
# OAuth2 app credentials
|
||||
#OAUTH_CLIENT_ID=
|
||||
#OAUTH_CLIENT_SECRET=
|
||||
# Button appearance on the login page
|
||||
#OAUTH_BUTTON_TEXT=Login with OAuth
|
||||
#OAUTH_BUTTON_IMAGE=
|
||||
# Space-separated OAuth2 scopes
|
||||
#OAUTH_SCOPE=openid profile email
|
||||
|
||||
# Database engine: 'sqlite' or 'postgres'
|
||||
DB_ENGINE=sqlite
|
||||
|
||||
# PostgreSQL settings (only used when DB_ENGINE=postgres)
|
||||
#DB_NAME=photo21
|
||||
#DB_USER=photo21
|
||||
#DB_PASSWORD=
|
||||
#DB_HOST=localhost
|
||||
#DB_PORT=5432
|
||||
|
||||
# SQLite settings (only used when DB_ENGINE=sqlite)
|
||||
#DB_PATH=/app/data/db.sqlite3
|
||||
44
.forgejo/workflows/docker.yml
Normal file
44
.forgejo/workflows/docker.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Codeberg registry
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: codeberg.org
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract version tag
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
id: meta
|
||||
run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build (verify only, no push)
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
|
||||
- name: Build and push (tagged release)
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
codeberg.org/${{ github.repository }}:${{ steps.meta.outputs.tag }}
|
||||
codeberg.org/${{ github.repository }}:latest
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Create volume mount points
|
||||
RUN mkdir -p /app/media /app/static /app/data
|
||||
|
||||
# Collect static files at build time (uses a dummy key, no DB needed)
|
||||
RUN SECRET_KEY=build-time-placeholder DB_ENGINE=sqlite python manage.py collectstatic --noinput
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
RUN chmod +x entrypoint.sh
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
19
README.md
19
README.md
|
|
@ -1,5 +1,7 @@
|
|||
# Photo server 2021-2023
|
||||
|
||||
This project is a fork of [Photo21](https://gitlab.crans.org/bde/photo21/) developped at ENS Paris-Saclay.
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
This is the source code for the webserver hosting pictures from the
|
||||
|
|
@ -15,9 +17,6 @@ run and to maintain.
|
|||
|
||||
```bash
|
||||
sudo apt install git gettext python3-django python3-django-allauth python3-django-crispy-forms python3-docutils python3-exifread python3-pil
|
||||
|
||||
# Only for production
|
||||
sudo apt install nginx uwsgi uwsgi-plugin-python3 python3-certbot-nginx
|
||||
```
|
||||
|
||||
2. **Cloning.**
|
||||
|
|
@ -25,25 +24,14 @@ run and to maintain.
|
|||
In production, we usually use `/var/www/photos/` as the `root` user.
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.crans.org/bde/photo21.git && cd photo21
|
||||
git clone https://codeberg.org/krek0/photo21.git && cd photo21
|
||||
```
|
||||
|
||||
3. **Configuration (production only).**
|
||||
|
||||
```bash
|
||||
# Only for production
|
||||
sudo mkdir static media
|
||||
sudo cp docs/maintenance.html static/maintenance.html
|
||||
sudo chown www-data:www-data -R static media
|
||||
sudo chmod g+rwx -R static media
|
||||
sudo chmod +x maintenance_tool.sh
|
||||
sudo cp docs/uwsgi_photos.ini /etc/uwsgi/apps-available/uwsgi_photos.ini
|
||||
sudo ln -s /etc/uwsgi/apps-available/uwsgi_photos.ini /etc/uwsgi/apps-enabled/
|
||||
sudo cp docs/nginx_photos_maintenance /etc/nginx/sites-available/photos.crans.org
|
||||
sudo ln -s /etc/nginx/sites-available/photos.crans.org /etc/nginx/sites-enabled/
|
||||
sudo cp docs/letsencrypt_photos.crans.org /etc/letsencrypt/conf.d/photos.crans.org
|
||||
sudo cp docs/renewal-hooks_post_nginx /etc/letsencrypt/renewal-hooks/post/nginx
|
||||
sudo certbot --config /etc/letsencrypt/conf.d/photos.crans.org.ini certonly
|
||||
```
|
||||
|
||||
4. **Database (production only).**
|
||||
|
|
@ -80,7 +68,6 @@ run and to maintain.
|
|||
|
||||
6. *Enjoy \o/*
|
||||
|
||||
In production, the NGINX site should now work.
|
||||
In development, you can launch the development server using:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# This file is part of photo21
|
||||
# Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
default_app_config = "allauth_oauth.apps.AllauthOAuthConfig"
|
||||
12
allauth_oauth/apps.py
Normal file
12
allauth_oauth/apps.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# This file is part of photo21
|
||||
# Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AllauthOAuthConfig(AppConfig):
|
||||
name = "allauth_oauth"
|
||||
|
||||
def ready(self):
|
||||
import allauth_oauth.signals # noqa: F401
|
||||
|
|
@ -7,15 +7,15 @@ from allauth.socialaccount.providers.base import ProviderAccount
|
|||
from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider
|
||||
|
||||
|
||||
class NoteKfetAccount(ProviderAccount):
|
||||
class OAuthAccount(ProviderAccount):
|
||||
def to_str(self):
|
||||
return self.account.extra_data.get("username")
|
||||
|
||||
|
||||
class NoteKfetProvider(OAuth2Provider):
|
||||
id = "notekfet"
|
||||
name = "Note Kfet"
|
||||
account_class = NoteKfetAccount
|
||||
class OAuthProvider(OAuth2Provider):
|
||||
id = "oauth"
|
||||
name = "OAuth"
|
||||
account_class = OAuthAccount
|
||||
|
||||
def extract_uid(self, data):
|
||||
return str(data["username"])
|
||||
|
|
@ -39,4 +39,4 @@ class NoteKfetProvider(OAuth2Provider):
|
|||
return ret
|
||||
|
||||
|
||||
provider_classes = [NoteKfetProvider]
|
||||
provider_classes = [OAuthProvider]
|
||||
29
allauth_oauth/signals.py
Normal file
29
allauth_oauth/signals.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# This file is part of photo21
|
||||
# Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from allauth.socialaccount.signals import pre_social_login
|
||||
from django.dispatch import receiver
|
||||
|
||||
|
||||
@receiver(pre_social_login)
|
||||
def sync_user_fields(sender, request, sociallogin, **kwargs):
|
||||
if not sociallogin.is_existing:
|
||||
return
|
||||
|
||||
user = sociallogin.user
|
||||
data = sociallogin.account.extra_data
|
||||
changed = False
|
||||
|
||||
email = data.get("email")
|
||||
if email and user.email != email:
|
||||
user.email = email
|
||||
changed = True
|
||||
|
||||
username = data.get("username")
|
||||
if username and user.username != username:
|
||||
user.username = username
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
user.save()
|
||||
|
|
@ -4,6 +4,6 @@
|
|||
|
||||
from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns
|
||||
|
||||
from .provider import NoteKfetProvider
|
||||
from .provider import OAuthProvider
|
||||
|
||||
urlpatterns = default_urlpatterns(NoteKfetProvider)
|
||||
urlpatterns = default_urlpatterns(OAuthProvider)
|
||||
|
|
@ -10,11 +10,11 @@ from allauth.socialaccount.providers.oauth2.views import (
|
|||
OAuth2LoginView,
|
||||
)
|
||||
|
||||
from .provider import NoteKfetProvider
|
||||
from .provider import OAuthProvider
|
||||
|
||||
|
||||
class NoteKfetOAuth2Adapter(OAuth2Adapter):
|
||||
provider_id = NoteKfetProvider.id
|
||||
class OAuthAdapter(OAuth2Adapter):
|
||||
provider_id = OAuthProvider.id
|
||||
|
||||
def complete_login(self, request, app, token, **kwargs):
|
||||
headers = {
|
||||
|
|
@ -31,7 +31,7 @@ class NoteKfetOAuth2Adapter(OAuth2Adapter):
|
|||
|
||||
@property
|
||||
def domain(self):
|
||||
return self.settings.get("DOMAIN", "note.crans.org")
|
||||
return self.settings.get("DOMAIN", "")
|
||||
|
||||
@property
|
||||
def access_token_url(self):
|
||||
|
|
@ -46,5 +46,5 @@ class NoteKfetOAuth2Adapter(OAuth2Adapter):
|
|||
return f"https://{self.domain}/api/me/"
|
||||
|
||||
|
||||
oauth2_login = OAuth2LoginView.adapter_view(NoteKfetOAuth2Adapter)
|
||||
oauth2_callback = OAuth2CallbackView.adapter_view(NoteKfetOAuth2Adapter)
|
||||
oauth2_login = OAuth2LoginView.adapter_view(OAuthAdapter)
|
||||
oauth2_callback = OAuth2CallbackView.adapter_view(OAuthAdapter)
|
||||
5
entrypoint.sh
Normal file
5
entrypoint.sh
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
python manage.py migrate --noinput
|
||||
exec gunicorn photo21.wsgi:application --bind 0.0.0.0:8000 --workers 3
|
||||
|
|
@ -13,8 +13,7 @@ class CustomSignupForm(SignupForm):
|
|||
|
||||
# Add description on email field
|
||||
self.fields["email"].help_text = _(
|
||||
"Please enter a valid email address ending with `@crans.org` or "
|
||||
"`@ens-paris-saclay.fr`."
|
||||
"Please enter a valid email address ending with `@ens-rennes.fr`"
|
||||
)
|
||||
|
||||
def clean_email(self):
|
||||
|
|
@ -22,10 +21,8 @@ class CustomSignupForm(SignupForm):
|
|||
Check that the email address ends with a trusted domain.
|
||||
"""
|
||||
email = super().clean_email()
|
||||
if not email.endswith("@crans.org") and not email.endswith(
|
||||
"@ens-paris-saclay.fr"
|
||||
):
|
||||
if not email.endswith("@ens-rennes.fr"):
|
||||
raise forms.ValidationError(
|
||||
_("Must end with `@crans.org` or `@ens-paris-saclay.fr`.")
|
||||
_("Must end with `@ens-rennes.fr`.")
|
||||
)
|
||||
return email
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ https://docs.djangoproject.com/en/2.2/ref/settings/
|
|||
|
||||
import os
|
||||
|
||||
from decouple import Csv, config
|
||||
from django.contrib.messages import constants as messages
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
|
@ -25,17 +26,12 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "CHANGE ME"
|
||||
SECRET_KEY = config("SECRET_KEY")
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = False
|
||||
DEBUG = config("DEBUG", default=False, cast=bool)
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
"photos.crans.org",
|
||||
"photos-dev.crans.org",
|
||||
]
|
||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost"] + config("EXTRA_HOSTS", default="", cast=Csv())
|
||||
|
||||
INTERNAL_IPS = [
|
||||
"127.0.0.1",
|
||||
|
|
@ -43,14 +39,16 @@ INTERNAL_IPS = [
|
|||
]
|
||||
|
||||
# Admins receive server errors, this is useful to be notified of potential bugs
|
||||
ADMINS = [
|
||||
("admin", "photos-admin@lists.crans.org"),
|
||||
]
|
||||
# Format: "Name:email,Name2:email2"
|
||||
ADMINS = [tuple(a.split(":")) for a in config("ADMINS", default="", cast=Csv()) if a]
|
||||
|
||||
# Use secure cookies in production
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
|
||||
# Trust Caddy's forwarded proto header
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
# Remember HTTPS for 1 year
|
||||
SECURE_HSTS_SECONDS = 31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
|
|
@ -58,6 +56,15 @@ SECURE_HSTS_PRELOAD = True
|
|||
|
||||
# Application definition
|
||||
|
||||
OAUTH_ENABLED = config("OAUTH_ENABLED", default=False, cast=bool)
|
||||
OAUTH_ONLY = config("OAUTH_ONLY", default=False, cast=bool)
|
||||
OAUTH_CLIENT_ID = config("OAUTH_CLIENT_ID", default="")
|
||||
OAUTH_CLIENT_SECRET = config("OAUTH_CLIENT_SECRET", default="")
|
||||
OAUTH_SERVER_URL = config("OAUTH_SERVER_URL", default="")
|
||||
OAUTH_BUTTON_TEXT = config("OAUTH_BUTTON_TEXT", default="Login with OAuth")
|
||||
OAUTH_BUTTON_IMAGE = config("OAUTH_BUTTON_IMAGE", default="")
|
||||
OAUTH_SCOPE = config("OAUTH_SCOPE", default="openid profile email", cast=Csv(delimiter=" "))
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.admindocs",
|
||||
|
|
@ -71,17 +78,20 @@ INSTALLED_APPS = [
|
|||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"allauth_note_kfet",
|
||||
"crispy_forms",
|
||||
"photologue",
|
||||
"photo21",
|
||||
]
|
||||
|
||||
if OAUTH_ENABLED:
|
||||
INSTALLED_APPS += ["allauth_oauth"]
|
||||
|
||||
if DEBUG:
|
||||
INSTALLED_APPS += ["debug_toolbar",] # For debug and optimisations
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
|
|
@ -126,12 +136,31 @@ WSGI_APPLICATION = "photo21.wsgi.application"
|
|||
# Database
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
||||
|
||||
_db_engine = config("DB_ENGINE", default="sqlite").strip().lower()
|
||||
|
||||
if _db_engine == "postgres":
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": config("DB_NAME", default="photo21"),
|
||||
"USER": config("DB_USER", default="photo21"),
|
||||
"PASSWORD": config("DB_PASSWORD", default=""),
|
||||
"HOST": config("DB_HOST", default="localhost"),
|
||||
"PORT": config("DB_PORT", default="5432"),
|
||||
}
|
||||
}
|
||||
elif _db_engine == "sqlite":
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
|
||||
"NAME": config("DB_PATH", default=os.path.join(BASE_DIR, "db.sqlite3")),
|
||||
"OPTIONS": {
|
||||
"timeout": 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unknown DB_ENGINE '{_db_engine}'. Must be 'sqlite' or 'postgres'.")
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
|
|
@ -194,6 +223,18 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static/")
|
|||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||
MEDIA_URL = "/media/"
|
||||
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "photo21.storage.CompressedManifestStorage",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
WHITENOISE_MANIFEST_STRICT = False
|
||||
|
||||
LOCALE_PATHS = [os.path.join(BASE_DIR, "photo21/locale")]
|
||||
|
||||
FIXTURE_DIRS = [os.path.join(BASE_DIR, "photo21/fixtures")]
|
||||
|
|
@ -204,9 +245,14 @@ if DEBUG:
|
|||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
# Mail will be sent from this address
|
||||
SERVER_EMAIL = "photos@crans.org"
|
||||
SERVER_EMAIL = config("SERVER_EMAIL", default="photos@crans.org")
|
||||
DEFAULT_FROM_EMAIL = f"Serveur photos <{SERVER_EMAIL}>"
|
||||
EMAIL_SUBJECT_PREFIX = "[Serveur photos] "
|
||||
EMAIL_HOST = config("SMTP_HOST", default="localhost")
|
||||
EMAIL_PORT = config("SMTP_PORT", default=25, cast=int)
|
||||
EMAIL_HOST_USER = config("SMTP_USER", default="")
|
||||
EMAIL_HOST_PASSWORD = config("SMTP_PASSWORD", default="")
|
||||
EMAIL_USE_TLS = config("SMTP_USE_TLS", default=False, cast=bool)
|
||||
|
||||
# After login redirect user to transfer page
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
|
|
@ -226,14 +272,21 @@ MESSAGE_TAGS = {
|
|||
# Allauth configuration ## For the django =< 5.0
|
||||
ACCOUNT_EMAIL_REQUIRED = True
|
||||
# ACCOUNT_SIGNUP_FIELDS = ['email*', 'username*', 'password1*', 'password2*'] ## For the django =< 5.0
|
||||
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
||||
ACCOUNT_EMAIL_VERIFICATION = config("EMAIL_VERIFICATION", default="mandatory")
|
||||
ACCOUNT_AUTHENTICATION_METHOD = "username_email"
|
||||
# ACCOUNT_LOGIN_METHODS = {'username', 'email'}
|
||||
ACCOUNT_FORMS = {"signup": "photo21.forms.CustomSignupForm"}
|
||||
|
||||
if OAUTH_ENABLED:
|
||||
SOCIALACCOUNT_ONLY = OAUTH_ONLY
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
"notekfet": {
|
||||
# Fetch user profile
|
||||
"SCOPE": ["1_1"],
|
||||
"oauth": {
|
||||
"SCOPE": OAUTH_SCOPE,
|
||||
"DOMAIN": OAUTH_SERVER_URL,
|
||||
"APP": {
|
||||
"client_id": OAUTH_CLIENT_ID,
|
||||
"secret": OAUTH_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,29 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||
background-color: rgba(163, 163, 163, 0.274);
|
||||
}
|
||||
|
||||
/* Gallery - Google Photos style justified grid */
|
||||
#lightgallery {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.photo-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-item img.photo-private {
|
||||
outline: 5px solid var(--bs-danger);
|
||||
outline-offset: -5px;
|
||||
}
|
||||
|
||||
/* Language selector */
|
||||
.lang-select {
|
||||
border: none;
|
||||
|
|
|
|||
12
photo21/storage.py
Normal file
12
photo21/storage.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from whitenoise.storage import CompressedManifestStaticFilesStorage
|
||||
|
||||
|
||||
class CompressedManifestStorage(CompressedManifestStaticFilesStorage):
|
||||
"""Like CompressedManifestStaticFilesStorage but silently skips missing
|
||||
referenced files (e.g. source maps not included in the package)."""
|
||||
|
||||
def hashed_name(self, name, content=None, filename=None):
|
||||
try:
|
||||
return super().hashed_name(name, content, filename)
|
||||
except ValueError:
|
||||
return name
|
||||
|
|
@ -39,5 +39,5 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||
<a class="link-secondary" href="{% url 'account_reset_password' %}">{% trans "Forgot Password?" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small text-center mt-1">{% trans "If any problem, please contact the server owners at" %} <code>photos[at]crans.org</code>.</p>
|
||||
<!-- <p class="small text-center mt-1">{% trans "If any problem, please contact the server owners at" %} <code>photos[at]crans.org</code>.</p> -->
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'">
|
||||
<meta http-equiv="Referrer-Policy" content="no-referrer">
|
||||
<title>{% block title %}{{ title }}{% endblock title %} - {{ request.site.name }}</title>
|
||||
<meta name="description" content="{% trans "The ENS Paris-Saclay pictures server." %}">
|
||||
<meta name="description" content="{% trans "The ENS Rennes pictures server." %}">
|
||||
<script src="{% static "theme.js" %}"></script>
|
||||
<link rel="stylesheet" href="{% static "bootstrap5/css/bootstrap.min.css" %}">
|
||||
<link rel="stylesheet" href="{% static "layout.css" %}">
|
||||
|
|
@ -118,16 +118,16 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||
{% if request.user.is_authenticated %}
|
||||
{% trans "Connected as" %} <code>{{ request.user.username }}</code> ·
|
||||
{% endif %}
|
||||
<a class="text-reset" href="https://gitlab.crans.org/bde/photo21/">{% trans "Source code" %}</a> ·
|
||||
<select title="language" name="language" class="lang-select">
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
{% get_available_languages as LANGUAGES %}
|
||||
{% for lang_code, lang_name in LANGUAGES %}
|
||||
<option value="{{ lang_code }}" {% if lang_code == LANGUAGE_CODE %}selected{% endif %}>
|
||||
{{ lang_name }} ({{ lang_code }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<a class="text-reset" href="https://github.com/krek0/photo21">{% trans "Source code" %}</a> ·
|
||||
<!-- <select title="language" name="language" class="lang-select"> -->
|
||||
<!-- {% get_current_language as LANGUAGE_CODE %} -->
|
||||
<!-- {% get_available_languages as LANGUAGES %} -->
|
||||
<!-- {% for lang_code, lang_name in LANGUAGES %} -->
|
||||
<!-- <option value="{{ lang_code }}" {% if lang_code == LANGUAGE_CODE %}selected{% endif %}> -->
|
||||
<!-- {{ lang_name }} ({{ lang_code }}) -->
|
||||
<!-- </option> -->
|
||||
<!-- {% endfor %} -->
|
||||
<!-- </select> -->
|
||||
<noscript><input type="submit"></noscript>
|
||||
</p>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
This website aims to collect the pictures and movies taken in the student
|
||||
life of ENS Paris-Saclay or involving its students.
|
||||
life of ENS Rennes or involving its students.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>
|
||||
|
|
@ -25,21 +25,16 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
If you want a photo to be deleted, please let us know:
|
||||
<a href="mailto:photos@crans.org?subject=[ABUS] Nouvelle requête" class="btn btn-secondary btn-sm">Abuse request</a>
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% if not perms.photologue.add_photo %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
If you want to obtain the right to upload pictures, please let us know:
|
||||
<a href="mailto:photos@crans.org?subject=[Photographe] Demande de droits photographe" class="btn btn-secondary btn-sm">Become a photograph</a>
|
||||
<a href="mailto:sinfonie@ens-rennes.fr?subject=[Photographe] Demande de droits photographe" class="btn btn-secondary btn-sm">Become a photograph</a>
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<h3>{% trans "Last galleries" %}</h3>
|
||||
<div class="row mb-2">
|
||||
{% for gallery in object_list %}
|
||||
|
|
@ -52,15 +47,15 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||
<h3 class="mt-4">{% trans "Behind the scene" %}</h3>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
This project if a fork of <a href="https://gitlab.crans.org/bde/photo21/">Photo21</a>.
|
||||
Because we value your privacy, we do not sell the data on this site,
|
||||
unlike many free online platforms.
|
||||
The dedicated server running this website is kindly hosted by the
|
||||
<a href="https://www.crans.org/">Crans</a> at the ENS Paris-Saclay
|
||||
basement.
|
||||
It is not managed by the Crans. Current active administrators are:
|
||||
{% endblocktrans %}
|
||||
{% for user in superusers %} <code>{{ user.username }}</code>{% endfor %}.
|
||||
{% trans "They should be contacted at" %}
|
||||
<a href="mailto:photos@crans.org">photos@crans.org</a>.
|
||||
<!-- The dedicated server running this website is kindly hosted by -->
|
||||
<!-- <a href="https://sinfonie.org/">Sinfonie</a> at the ENS Rennes. -->
|
||||
<!-- Current active administrators are: -->
|
||||
<!-- {% endblocktrans %} -->
|
||||
<!-- {% for user in superusers %} <code>{{ user.username }}</code>{% endfor %}. -->
|
||||
<!-- {% trans "They should be contacted at" %} -->
|
||||
<!-- <a href="mailto:photos@crans.org">photos@crans.org</a>. -->
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,29 @@
|
|||
# Copyright (C) 2021-2022 Amicale des élèves de l'ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpResponse
|
||||
from django.http import FileResponse, Http404
|
||||
from django.views.generic import ListView, View
|
||||
from photologue.models import Gallery
|
||||
|
||||
|
||||
|
||||
class MediaAccess(LoginRequiredMixin, View):
|
||||
class MediaAccess(View):
|
||||
def get(self, request, path):
|
||||
response = HttpResponse()
|
||||
# Content-type will be detected by nginx
|
||||
del response["Content-Type"]
|
||||
response["X-Accel-Redirect"] = "/protected/media/" + path
|
||||
response["Cache-Control"] = 'max-age=2678400'
|
||||
if not request.user.is_authenticated and not request.session.get('public_gallery_access'):
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
return redirect_to_login(request.get_full_path())
|
||||
media_root = os.path.realpath(settings.MEDIA_ROOT)
|
||||
file_path = os.path.realpath(os.path.join(media_root, path))
|
||||
if not file_path.startswith(media_root + os.sep):
|
||||
raise Http404
|
||||
if not os.path.isfile(file_path):
|
||||
raise Http404
|
||||
response = FileResponse(open(file_path, 'rb'))
|
||||
response['Cache-Control'] = 'max-age=2678400'
|
||||
return response
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin import EmptyFieldListFilter
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Gallery, Photo, Tag
|
||||
|
|
@ -10,7 +11,7 @@ from .models import Gallery, Photo, Tag
|
|||
|
||||
class GalleryAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "date_start", "photo_count", "get_tags")
|
||||
list_filter = ["date_start", "tags"]
|
||||
list_filter = ["date_start", "tags", ("public_token", EmptyFieldListFilter)]
|
||||
date_hierarchy = "date_start"
|
||||
prepopulated_fields = {"slug": ("title",)}
|
||||
model = Gallery
|
||||
|
|
|
|||
|
|
@ -3,8 +3,25 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db.backends.signals import connection_created
|
||||
|
||||
|
||||
class PhotologueConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.AutoField"
|
||||
name = "photologue"
|
||||
|
||||
def ready(self):
|
||||
from django.db import connection
|
||||
|
||||
def enable_sqlite_wal(sender, connection, **kwargs):
|
||||
if connection.vendor == "sqlite":
|
||||
cursor = connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL;")
|
||||
cursor.execute("PRAGMA synchronous=OFF;")
|
||||
cursor.execute("PRAGMA journal_size_limit=67108864;")
|
||||
cursor.execute("PRAGMA wal_autocheckpoint=1000;")
|
||||
cursor.execute("PRAGMA cache_size=-65536;")
|
||||
cursor.execute("PRAGMA temp_store=MEMORY;")
|
||||
cursor.execute("PRAGMA mmap_size=268435456;")
|
||||
|
||||
connection_created.connect(enable_sqlite_wal)
|
||||
|
|
|
|||
45
photologue/migrations/0008_photo_dimensions.py
Normal file
45
photologue/migrations/0008_photo_dimensions.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import photologue.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def fill_dimensions(apps, schema_editor):
|
||||
Photo = apps.get_model('photologue', 'Photo')
|
||||
for photo in Photo.objects.filter(image_width__isnull=True).iterator():
|
||||
try:
|
||||
photo.image_width = photo.image.width
|
||||
photo.image_height = photo.image.height
|
||||
photo.save(update_fields=['image_width', 'image_height'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('photologue', '0007_allow_duplicate_photo_titles'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='photo',
|
||||
name='image_width',
|
||||
field=models.PositiveIntegerField(editable=False, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='photo',
|
||||
name='image_height',
|
||||
field=models.PositiveIntegerField(editable=False, null=True),
|
||||
),
|
||||
migrations.RunPython(fill_dimensions, migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name='photo',
|
||||
name='image',
|
||||
field=models.ImageField(
|
||||
max_length=100,
|
||||
upload_to=photologue.models.get_storage_path,
|
||||
verbose_name='image',
|
||||
width_field='image_width',
|
||||
height_field='image_height',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -224,8 +224,14 @@ class Gallery(models.Model):
|
|||
|
||||
class ImageModel(models.Model):
|
||||
image = models.ImageField(
|
||||
_("image"), max_length=IMAGE_FIELD_MAX_LENGTH, upload_to=get_storage_path
|
||||
_("image"),
|
||||
max_length=IMAGE_FIELD_MAX_LENGTH,
|
||||
upload_to=get_storage_path,
|
||||
width_field="image_width",
|
||||
height_field="image_height",
|
||||
)
|
||||
image_width = models.PositiveIntegerField(editable=False, null=True)
|
||||
image_height = models.PositiveIntegerField(editable=False, null=True)
|
||||
date_taken = models.DateTimeField(
|
||||
_("date taken"),
|
||||
null=True,
|
||||
|
|
@ -480,7 +486,7 @@ class ImageModel(models.Model):
|
|||
self._old_image.storage.delete(
|
||||
self._old_image.name
|
||||
) # Delete (old) base image.
|
||||
if self.date_taken is None or image_has_changed:
|
||||
if (self.date_taken is None or image_has_changed) and self.image:
|
||||
# Attempt to get the date the photo was taken from the EXIF data.
|
||||
try:
|
||||
exif_date = self.exif(self.image.file).get(
|
||||
|
|
|
|||
|
|
@ -34,20 +34,20 @@ lgContainer.addEventListener('lgAfterOpen', () => {
|
|||
|
||||
const downloadUrl = this.getAttribute('href');
|
||||
|
||||
// Affichage de la modale stylisée
|
||||
Swal.fire({
|
||||
title: gettext('Download'),
|
||||
text: gettext("This image is free to download, but permission from the photographer and the people in the photo is required before republishing it on another website. Furthermore, it is good practice to credit L[ENS] and the photographers in any republications."),
|
||||
icon: 'info',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#3085d6',
|
||||
cancelButtonColor: '#d33',
|
||||
confirmButtonText: gettext('Download'),
|
||||
cancelButtonText: gettext('Cancel'),
|
||||
background: '#1a1a1a', // Optionnel : pour matcher le thème sombre de LightGallery
|
||||
color: '#fff'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
// // Affichage de la modale stylisée
|
||||
// Swal.fire({
|
||||
// title: gettext('Download'),
|
||||
// text: gettext("This image is free to download, but permission from the photographer and the people in the photo is required before republishing it on another website. Furthermore, it is good practice to credit L[ENS] and the photographers in any republications."),
|
||||
// icon: 'info',
|
||||
// showCancelButton: true,
|
||||
// confirmButtonColor: '#3085d6',
|
||||
// cancelButtonColor: '#d33',
|
||||
// confirmButtonText: gettext('Download'),
|
||||
// cancelButtonText: gettext('Cancel'),
|
||||
// background: '#1a1a1a', // Optionnel : pour matcher le thème sombre de LightGallery
|
||||
// color: '#fff'
|
||||
// }).then((result) => {
|
||||
// if (result.isConfirmed) {
|
||||
// Si validé, on déclenche le téléchargement
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
|
|
@ -55,8 +55,8 @@ lgContainer.addEventListener('lgAfterOpen', () => {
|
|||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
});
|
||||
// }
|
||||
// });
|
||||
}, true); // Utilisation du mode capture pour intercepter avant le script interne
|
||||
}
|
||||
});
|
||||
|
|
|
|||
75
photologue/static/gallery_justified.js
Normal file
75
photologue/static/gallery_justified.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// Justified photo grid layout (Google Photos style)
|
||||
// Inspired by https://github.com/flickr/justified-layout
|
||||
(function () {
|
||||
const SPACING = 3;
|
||||
const PADDING = 3;
|
||||
|
||||
function applyLayout() {
|
||||
const container = document.getElementById('lightgallery');
|
||||
if (!container) return;
|
||||
|
||||
const items = [...container.querySelectorAll('.photo-item')];
|
||||
if (!items.length) return;
|
||||
|
||||
const containerWidth = container.offsetWidth - PADDING * 2;
|
||||
const TARGET_HEIGHT = Math.min(220, Math.floor(containerWidth * 0.4));
|
||||
const ratios = items.map(a => {
|
||||
const w = parseInt(a.dataset.width) || 1;
|
||||
const h = parseInt(a.dataset.height) || 1;
|
||||
return w / h;
|
||||
});
|
||||
|
||||
// Group items into rows
|
||||
const rows = [];
|
||||
let rowStart = 0;
|
||||
while (rowStart < ratios.length) {
|
||||
let sum = 0;
|
||||
let end = rowStart;
|
||||
while (end < ratios.length) {
|
||||
sum += ratios[end];
|
||||
const rowWidth = sum * TARGET_HEIGHT + SPACING * (end - rowStart);
|
||||
end++;
|
||||
if (rowWidth >= containerWidth) break;
|
||||
}
|
||||
rows.push({ start: rowStart, end });
|
||||
rowStart = end;
|
||||
}
|
||||
|
||||
// Position each item
|
||||
let top = PADDING;
|
||||
rows.forEach(({ start, end }, rowIndex) => {
|
||||
const isLastRow = rowIndex === rows.length - 1;
|
||||
const count = end - start;
|
||||
const sumRatios = ratios.slice(start, end).reduce((a, b) => a + b, 0);
|
||||
const totalSpacing = SPACING * (count - 1);
|
||||
|
||||
// Don't stretch the last row if it's not full
|
||||
const rowHeight = isLastRow && count < 3
|
||||
? TARGET_HEIGHT
|
||||
: (containerWidth - totalSpacing) / sumRatios;
|
||||
|
||||
let left = PADDING;
|
||||
for (let i = start; i < end; i++) {
|
||||
const width = Math.round(ratios[i] * rowHeight);
|
||||
const height = Math.round(rowHeight);
|
||||
const item = items[i];
|
||||
item.style.top = top + 'px';
|
||||
item.style.left = left + 'px';
|
||||
item.style.width = width + 'px';
|
||||
item.style.height = height + 'px';
|
||||
left += width + SPACING;
|
||||
}
|
||||
top += Math.round(rowHeight) + SPACING;
|
||||
});
|
||||
|
||||
container.style.height = (top - SPACING + PADDING) + 'px';
|
||||
items.forEach(item => {
|
||||
item.style.visibility = 'visible';
|
||||
const img = item.querySelector('img[data-lazy]');
|
||||
if (img) img.src = img.dataset.lazy;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', applyLayout);
|
||||
window.addEventListener('resize', applyLayout);
|
||||
})();
|
||||
|
|
@ -13,6 +13,7 @@ class lgAdmin {
|
|||
this.isStaff = document.querySelector('[name=is_staff]').value === "true";
|
||||
this.userId = document.querySelector('[name=user_id]').value;
|
||||
this.canResolveCensorship = document.querySelector('[name=can_resolve_censorship]').value === "true";
|
||||
this.guestMode = document.querySelector('[name=guest_mode]').value === "true";
|
||||
this.csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
this.photoId = 0;
|
||||
return this;
|
||||
|
|
@ -33,7 +34,8 @@ class lgAdmin {
|
|||
document.getElementById("lg-delete").addEventListener('click', this.onDelete.bind(this));
|
||||
|
||||
// Add button to report photo
|
||||
this.core.$toolbar.append(`<a href="#" id="lg-report" title="Notify abuse" class="lg-icon lg-bi-icon">${reportIcon}</a>`);
|
||||
this.core.$toolbar.append(`<a href="#" id="lg-report" title="Request removal" class="lg-icon lg-bi-icon">${deleteIcon}</a>`);
|
||||
document.getElementById("lg-report").style.display = 'none';
|
||||
document.getElementById("lg-report").addEventListener('click', this.onReport.bind(this));
|
||||
|
||||
// Add button to restore a censored photo
|
||||
|
|
@ -53,6 +55,7 @@ class lgAdmin {
|
|||
const ownerId = el ? el.dataset.ownerId : null;
|
||||
const canDelete = this.isStaff || (ownerId && ownerId === this.userId);
|
||||
document.getElementById("lg-delete").style.display = canDelete ? 'block' : 'none';
|
||||
document.getElementById("lg-report").style.display = canDelete ? 'none' : 'block';
|
||||
const isCensored = el ? el.dataset.isPublic === 'false' : false;
|
||||
document.getElementById("lg-restore").style.display = (this.canResolveCensorship && isCensored) ? 'block' : 'none';
|
||||
}
|
||||
|
|
@ -135,7 +138,7 @@ class lgAdmin {
|
|||
// Event called when user click on report button
|
||||
onReport(event) {
|
||||
event.preventDefault();
|
||||
if(confirm("Are you sure to report this photo?")) {
|
||||
if(confirm("Are you sure to ask removal for this photo?")) {
|
||||
// Build form request
|
||||
const photoId = this.photoId;
|
||||
const currentIndex = this.core.index;
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||
<input type="hidden" name="is_staff" value="{{ request.user.is_staff|yesno:'true,false' }}">
|
||||
<input type="hidden" name="user_id" value="{{ request.user.id }}">
|
||||
<input type="hidden" name="can_resolve_censorship" value="{{ can_resolve_censorship|yesno:'true,false' }}">
|
||||
<input type="hidden" name="guest_mode" value="{{ guest_mode|yesno:'true,false' }}">
|
||||
<script src="{% static 'lightgallery/lightgallery.min.js' %}"></script>
|
||||
<script src="{% static 'lightgallery/plugins/admin/lg-admin.js' %}"></script>
|
||||
<script src="{% static 'lightgallery/plugins/hash/lg-hash.min.js' %}"></script>
|
||||
<script src="{% static 'lightgallery/plugins/thumbnail/lg-thumbnail.min.js' %}"></script>
|
||||
<script src="{% static 'lightgallery/plugins/zoom/lg-zoom.min.js' %}"></script>
|
||||
<script src="{% static 'gallery_justified.js' %}"></script>
|
||||
<script src="{% static 'gallery_detail.js' %}"></script>
|
||||
<script src="{% static 'sweetalert.js' %}"></script>
|
||||
<script src="{% static 'copy-button.js' %}"></script>
|
||||
|
|
@ -87,10 +89,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body row" id="lightgallery">
|
||||
<div class="card-body p-0" id="lightgallery">
|
||||
{% for photo in photos %}
|
||||
<a class="col-6 col-md-3 mb-2 text-center" href="{{ photo.get_absolute_url }}" data-src="{{ photo.get_display_url}}" data-download-url="{{ photo.image.url }}" data-slide-name="{{ photo.id }}" data-owner-id="{{ photo.owner.id }}" data-is-public="{{ photo.is_public|yesno:'true,false' }}">
|
||||
<img src="{{ photo.get_thumbnail_url }}" loading="lazy" class="img-thumbnail p-0{% if not photo.is_public %} border-danger border-5{% endif %}" alt="{{ photo.title }}{% if photo.date_taken %} - {{ photo.date_taken|date }} {{ photo.date_taken|time }}{% endif %}{% if photo.owner.get_full_name %} - {{ photo.owner.get_full_name }}{% else %} - {{ photo.owner.username }}{% endif %}{% if photo.license %} - {{ photo.license }}{% endif %}{% if not photo.is_public %} - !PRIVATE!{% endif %}">
|
||||
<a class="photo-item" href="{{ photo.get_absolute_url }}" data-src="{{ photo.get_display_url}}" data-download-url="{{ photo.image.url }}" data-slide-name="{{ photo.id }}" data-owner-id="{{ photo.owner.id }}" data-is-public="{{ photo.is_public|yesno:'true,false' }}" data-width="{{ photo.image_width|default:1 }}" data-height="{{ photo.image_height|default:1 }}">
|
||||
<img src="{{ photo.get_thumbnail_url }}" data-lazy="{{ photo.get_thumbnail_url }}" class="{% if not photo.is_public %}photo-private{% endif %}" alt="{{ photo.title }}{% if photo.date_taken %} - {{ photo.date_taken|date }} {{ photo.date_taken|time }}{% endif %}{% if photo.owner.get_full_name %} - {{ photo.owner.get_full_name }}{% else %} - {{ photo.owner.username }}{% endif %}{% if photo.license %} - {{ photo.license }}{% endif %}{% if not photo.is_public %} - !PRIVATE!{% endif %}">
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ class GalleryPublicView(DetailView):
|
|||
if request.user.is_authenticated:
|
||||
gallery = self.get_object()
|
||||
return redirect("photologue:pl-gallery", slug=gallery.slug)
|
||||
request.session['public_gallery_access'] = True
|
||||
request.guest_mode = True
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,3 +5,8 @@ django-select2>=4.8
|
|||
ExifRead>=2.1.2
|
||||
Pillow>=6.0.0
|
||||
django-debug-toolbar>=3.2.0
|
||||
python-decouple>=3.6
|
||||
whitenoise>=6.0
|
||||
psycopg2-binary>=2.9
|
||||
requests>=2.25
|
||||
gunicorn>=21.0
|
||||
|
|
|
|||
2
tox.ini
2
tox.ini
|
|
@ -27,7 +27,7 @@ deps =
|
|||
pep8-naming
|
||||
pyflakes
|
||||
commands =
|
||||
flake8 allauth_note_kfet photo21 photologue
|
||||
flake8 allauth_oauth photo21 photologue
|
||||
|
||||
[flake8]
|
||||
ignore = W503, I100, I101
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue