15 Commits

Author SHA1 Message Date
a1bc7967c5 Box sorting field added and put into effect
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 1m41s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 7s
2026-01-19 23:56:26 +01:00
2a825646a3 Kubernetes PVC changed to shared NFS 2026-01-19 22:16:48 +01:00
7c990998a4 Revert 2026-01-19 09:04:24 +01:00
bf62ddcbd7 NFS test 2026-01-19 08:58:14 +01:00
985460ff84 Fixed management command to remove orphan files
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 2m13s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 8s
2026-01-17 14:46:45 +01:00
2705f6c16e Statif media files now served in non-DEBUG-context
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 15s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 3s
2026-01-16 13:41:52 +01:00
887247028a Static file serving out of DEBUG mode addressed
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 55s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 4s
2026-01-16 13:15:40 +01:00
4cbd3e2f87 Gunicorn options in config map; Image tag displays on page
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 1m3s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 4s
2026-01-16 12:22:50 +01:00
935392d27d Variables now in config map
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 6s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 4s
2026-01-16 11:35:01 +01:00
30657be6c2 Secret now properly deployable 2026-01-16 11:28:03 +01:00
4bca3ae403 Stupid mistake in argocd secret... 2026-01-16 11:24:34 +01:00
db80ddf069 Main image now also "Lightboxes"
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 15s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 3s
2026-01-06 15:14:44 +01:00
0602347539 Images on detail page now show up as thumbnails with lightbox functionality 2026-01-06 14:54:56 +01:00
384c0d58e6 Merge pull request 'Deployment' (#7) from improvement/redesign into master
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 4s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 3s
Reviewed-on: #7
2026-01-06 12:12:51 +00:00
7e96fcef8b Merge pull request 'Restructured pages' (#6) from improvement/redesign into master
Reviewed-on: #6
2026-01-06 11:55:54 +00:00
20 changed files with 365 additions and 91 deletions

View File

@@ -34,6 +34,7 @@ WORKDIR /app
COPY --chown=appuser:appuser . . COPY --chown=appuser:appuser . .
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV IMAGE_TAG=build
USER appuser USER appuser
EXPOSE 8000 EXPOSE 8000
RUN rm -rvf /app/Dockerfile* \ RUN rm -rvf /app/Dockerfile* \
@@ -47,5 +48,5 @@ RUN rm -rvf /app/Dockerfile* \
/app/*.json \ /app/*.json \
/app/test_*.py && \ /app/test_*.py && \
python3 /app/manage.py collectstatic --noinput python3 /app/manage.py collectstatic --noinput
CMD ["sh", "-c", "python manage.py thumbnail clear && gunicorn --bind 0.0.0.0:8000 --workers 3 labhelper.wsgi:application"] CMD ["sh", "-c", "python manage.py thumbnail clear && gunicorn --bind 0.0.0.0:8000 --workers 3 $GUNICORN_OPTS labhelper.wsgi:application"]

View File

@@ -6,7 +6,7 @@ metadata:
spec: spec:
accessModes: accessModes:
- ReadWriteMany - ReadWriteMany
storageClassName: nfs-labhelper storageClassName: nfs
resources: resources:
requests: requests:
storage: 2Gi storage: 2Gi

21
argocd/configmap.yaml Normal file
View File

@@ -0,0 +1,21 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: django-config
namespace: labhelper
data:
DEBUG: "False"
ALLOWED_HOSTS: "labhelper.adebaumann.com,*"
ALLOWED_CIDR_NETS: "10.0.0.0/16"
LANGUAGE_CODE: "en-us"
TIME_ZONE: "UTC"
USE_I18N: "True"
USE_TZ: "True"
STATIC_URL: "/static/"
MEDIA_URL: "/media/"
CSRF_TRUSTED_ORIGINS: "https://labhelper.adebaumann.com"
LOGIN_URL: "login"
LOGIN_REDIRECT_URL: "index"
LOGOUT_REDIRECT_URL: "login"
GUNICORN_OPTS: "--access-logfile -"
IMAGE_TAG: "0.064"

View File

@@ -27,7 +27,7 @@ spec:
mountPath: /data mountPath: /data
containers: containers:
- name: web - name: web
image: git.baumann.gr/adebaumann/labhelper:0.058 image: git.baumann.gr/adebaumann/labhelper:0.065
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8000 - containerPort: 8000
@@ -37,6 +37,81 @@ spec:
secretKeyRef: secretKeyRef:
name: django-secret name: django-secret
key: secret-key key: secret-key
- name: DEBUG
valueFrom:
configMapKeyRef:
name: django-config
key: DEBUG
- name: ALLOWED_HOSTS
valueFrom:
configMapKeyRef:
name: django-config
key: ALLOWED_HOSTS
- name: ALLOWED_CIDR_NETS
valueFrom:
configMapKeyRef:
name: django-config
key: ALLOWED_CIDR_NETS
- name: LANGUAGE_CODE
valueFrom:
configMapKeyRef:
name: django-config
key: LANGUAGE_CODE
- name: TIME_ZONE
valueFrom:
configMapKeyRef:
name: django-config
key: TIME_ZONE
- name: USE_I18N
valueFrom:
configMapKeyRef:
name: django-config
key: USE_I18N
- name: USE_TZ
valueFrom:
configMapKeyRef:
name: django-config
key: USE_TZ
- name: STATIC_URL
valueFrom:
configMapKeyRef:
name: django-config
key: STATIC_URL
- name: MEDIA_URL
valueFrom:
configMapKeyRef:
name: django-config
key: MEDIA_URL
- name: CSRF_TRUSTED_ORIGINS
valueFrom:
configMapKeyRef:
name: django-config
key: CSRF_TRUSTED_ORIGINS
- name: LOGIN_URL
valueFrom:
configMapKeyRef:
name: django-config
key: LOGIN_URL
- name: LOGIN_REDIRECT_URL
valueFrom:
configMapKeyRef:
name: django-config
key: LOGIN_REDIRECT_URL
- name: LOGOUT_REDIRECT_URL
valueFrom:
configMapKeyRef:
name: django-config
key: LOGOUT_REDIRECT_URL
- name: GUNICORN_OPTS
valueFrom:
configMapKeyRef:
name: django-config
key: GUNICORN_OPTS
- name: IMAGE_TAG
valueFrom:
configMapKeyRef:
name: django-config
key: IMAGE_TAG
volumeMounts: volumeMounts:
- name: data - name: data
mountPath: /app/data mountPath: /app/data

View File

@@ -4,12 +4,15 @@ metadata:
name: labhelper-data-pv name: labhelper-data-pv
namespace: labhelper namespace: labhelper
spec: spec:
claimRef:
name: labhelper-data-pvc
namespace: labhelper
capacity: capacity:
storage: 2Gi storage: 2Gi
accessModes: accessModes:
- ReadWriteMany - ReadWriteMany
persistentVolumeReclaimPolicy: Retain persistentVolumeReclaimPolicy: Retain
storageClassName: nfs-labhelper storageClassName: nfs
nfs: nfs:
server: 192.168.17.199 server: 192.168.17.199
path: /mnt/user/labhelper path: /mnt/user/labhelper

View File

@@ -1,8 +0,0 @@
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs-labhelper
provisioner: kubernetes.io/no-provisioner
allowVolumeExpansion: true
reclaimPolicy: Retain
volumeBindingMode: Immediate

View File

@@ -1,3 +1,4 @@
from adminsortable2.admin import SortableAdminMixin
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.contrib.admin import SimpleListFilter from django.contrib.admin import SimpleListFilter
@@ -13,7 +14,7 @@ class BoxFilter(SimpleListFilter):
parameter_name = 'box__pk' parameter_name = 'box__pk'
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
boxes = Box.objects.select_related('box_type').order_by('id') boxes = Box.objects.select_related('box_type').order_by('sort_order')
return [(box.pk, str(box)) for box in boxes] return [(box.pk, str(box)) for box in boxes]
def queryset(self, request, queryset): def queryset(self, request, queryset):
@@ -47,7 +48,7 @@ class BoxTypeAdmin(admin.ModelAdmin):
@admin.register(Box) @admin.register(Box)
class BoxAdmin(admin.ModelAdmin): class BoxAdmin(SortableAdminMixin, admin.ModelAdmin):
"""Admin configuration for Box model.""" """Admin configuration for Box model."""
list_display = ('id', 'box_type') list_display = ('id', 'box_type')

View File

@@ -4,144 +4,196 @@ from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db.models import F from django.db.models import F
from sorl.thumbnail.models import KVStore from sorl.thumbnail.models import KVStore
from boxes.models import Thing from boxes.models import Thing, ThingFile
class Command(BaseCommand): class Command(BaseCommand):
help = 'Clean up orphaned images and thumbnails from deleted things' help = "Clean up orphaned images, files, and thumbnails from deleted things"
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument( parser.add_argument(
'--dry-run', "--dry-run",
action='store_true', action="store_true",
dest='dry_run', dest="dry_run",
help='Show what would be deleted without actually deleting', help="Show what would be deleted without actually deleting",
) )
def handle(self, *args, **options): def handle(self, *args, **options):
dry_run = options.get('dry_run', False) dry_run = options.get("dry_run", False)
if dry_run: if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN - No files will be deleted')) self.stdout.write(self.style.WARNING("DRY RUN - No files will be deleted"))
self.stdout.write('Finding orphaned images and thumbnails...') self.stdout.write("Finding orphaned images and thumbnails...")
media_root = settings.MEDIA_ROOT media_root = settings.MEDIA_ROOT
cache_root = os.path.join(media_root, 'cache') cache_root = os.path.join(media_root, "cache")
things_root = os.path.join(media_root, 'things') things_root = os.path.join(media_root, "things")
if not os.path.exists(things_root): if not os.path.exists(things_root):
self.stdout.write(self.style.WARNING('No things directory found')) self.stdout.write(self.style.WARNING("No things directory found"))
return return
valid_paths = set() valid_paths = set()
for thing in Thing.objects.exclude(picture__exact='').exclude(picture__isnull=True): for thing in Thing.objects.exclude(picture__exact="").exclude(
picture__isnull=True
):
if thing.picture: if thing.picture:
valid_paths.add(os.path.basename(thing.picture.name)) valid_paths.add(os.path.basename(thing.picture.name))
self.stdout.write(f'Found {len(valid_paths)} valid images in database') for thing_file in ThingFile.objects.all():
if thing_file.file:
if thing_file.file.name.startswith("things/"):
relative_path = thing_file.file.name[7:]
valid_paths.add(relative_path)
self.stdout.write(
f"Found {len(valid_paths)} valid images and files in database"
)
orphaned_thumbnail_paths = set() orphaned_thumbnail_paths = set()
db_cache_paths = set() db_cache_paths = set()
for kvstore in KVStore.objects.filter(key__startswith='sorl-thumbnail||image||'): for kvstore in KVStore.objects.filter(
key__startswith="sorl-thumbnail||image||"
):
try: try:
data = json.loads(kvstore.value) data = json.loads(kvstore.value)
name = data.get('name', '') name = data.get("name", "")
if name.startswith('things/'): if name.startswith("things/"):
filename = os.path.basename(name) filename = os.path.basename(name)
if filename not in valid_paths: if filename not in valid_paths:
image_hash = kvstore.key.split('||')[-1] image_hash = kvstore.key.split("||")[-1]
thumbnail_kvstore = KVStore.objects.filter(key=f'sorl-thumbnail||thumbnails||{image_hash}').first() thumbnail_kvstore = KVStore.objects.filter(
key=f"sorl-thumbnail||thumbnails||{image_hash}"
).first()
if thumbnail_kvstore: if thumbnail_kvstore:
thumbnail_list = json.loads(thumbnail_kvstore.value) thumbnail_list = json.loads(thumbnail_kvstore.value)
for thumbnail_hash in thumbnail_list: for thumbnail_hash in thumbnail_list:
thumbnail_image_kvstore = KVStore.objects.filter(key=f'sorl-thumbnail||image||{thumbnail_hash}').first() thumbnail_image_kvstore = KVStore.objects.filter(
key=f"sorl-thumbnail||image||{thumbnail_hash}"
).first()
if thumbnail_image_kvstore: if thumbnail_image_kvstore:
thumbnail_data = json.loads(thumbnail_image_kvstore.value) thumbnail_data = json.loads(
thumbnail_path = thumbnail_data.get('name', '') thumbnail_image_kvstore.value
if thumbnail_path.startswith('cache/'): )
thumbnail_path = thumbnail_data.get("name", "")
if thumbnail_path.startswith("cache/"):
orphaned_thumbnail_paths.add(thumbnail_path) orphaned_thumbnail_paths.add(thumbnail_path)
elif name.startswith('cache/'): elif name.startswith("cache/"):
db_cache_paths.add(name) db_cache_paths.add(name)
except (json.JSONDecodeError, KeyError, AttributeError): except (json.JSONDecodeError, KeyError, AttributeError):
pass pass
deleted_count = 0 deleted_count = 0
thumbnail_deleted_count = 0 thumbnail_deleted_count = 0
empty_dirs_removed = 0 empty_dirs_removed = 0
for root, dirs, files in os.walk(things_root, topdown=False): for root, dirs, files in os.walk(things_root, topdown=False):
for filename in files: for filename in files:
file_path = os.path.join(root, filename) file_path = os.path.join(root, filename)
relative_path = os.path.relpath(file_path, things_root) relative_path = os.path.relpath(file_path, things_root)
if relative_path not in valid_paths: if relative_path not in valid_paths:
deleted_count += 1 deleted_count += 1
if dry_run: if dry_run:
self.stdout.write(f'Would delete: {file_path}') self.stdout.write(f"Would delete: {file_path}")
else: else:
try: try:
os.remove(file_path) os.remove(file_path)
self.stdout.write(f'Deleted: {file_path}') self.stdout.write(f"Deleted: {file_path}")
except OSError as e: except OSError as e:
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}')) self.stdout.write(
self.style.ERROR(f"Failed to delete {file_path}: {e}")
)
for dirname in dirs: for dirname in dirs:
dir_path = os.path.join(root, dirname) dir_path = os.path.join(root, dirname)
if not os.listdir(dir_path): if not os.listdir(dir_path):
if dry_run: if dry_run:
self.stdout.write(f'Would remove empty directory: {dir_path}') self.stdout.write(f"Would remove empty directory: {dir_path}")
else: else:
try: try:
os.rmdir(dir_path) os.rmdir(dir_path)
self.stdout.write(f'Removed empty directory: {dir_path}') self.stdout.write(f"Removed empty directory: {dir_path}")
empty_dirs_removed += 1 empty_dirs_removed += 1
except OSError as e: except OSError as e:
self.stdout.write(self.style.ERROR(f'Failed to remove {dir_path}: {e}')) self.stdout.write(
self.style.ERROR(f"Failed to remove {dir_path}: {e}")
)
if os.path.exists(cache_root): if os.path.exists(cache_root):
for root, dirs, files in os.walk(cache_root, topdown=False): for root, dirs, files in os.walk(cache_root, topdown=False):
for filename in files: for filename in files:
file_path = os.path.join(root, filename) file_path = os.path.join(root, filename)
relative_path = os.path.relpath(file_path, media_root) relative_path = os.path.relpath(file_path, media_root)
if relative_path in orphaned_thumbnail_paths: if relative_path in orphaned_thumbnail_paths:
thumbnail_deleted_count += 1 thumbnail_deleted_count += 1
if dry_run: if dry_run:
self.stdout.write(f'Would delete thumbnail (orphaned image): {file_path}') self.stdout.write(
f"Would delete thumbnail (orphaned image): {file_path}"
)
else: else:
try: try:
os.remove(file_path) os.remove(file_path)
self.stdout.write(f'Deleted thumbnail (orphaned image): {file_path}') self.stdout.write(
f"Deleted thumbnail (orphaned image): {file_path}"
)
except OSError as e: except OSError as e:
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}')) self.stdout.write(
self.style.ERROR(
f"Failed to delete {file_path}: {e}"
)
)
elif relative_path not in db_cache_paths: elif relative_path not in db_cache_paths:
thumbnail_deleted_count += 1 thumbnail_deleted_count += 1
if dry_run: if dry_run:
self.stdout.write(f'Would delete thumbnail (no db entry): {file_path}') self.stdout.write(
f"Would delete thumbnail (no db entry): {file_path}"
)
else: else:
try: try:
os.remove(file_path) os.remove(file_path)
self.stdout.write(f'Deleted thumbnail (no db entry): {file_path}') self.stdout.write(
f"Deleted thumbnail (no db entry): {file_path}"
)
except OSError as e: except OSError as e:
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}')) self.stdout.write(
self.style.ERROR(
f"Failed to delete {file_path}: {e}"
)
)
for dirname in dirs: for dirname in dirs:
dir_path = os.path.join(root, dirname) dir_path = os.path.join(root, dirname)
if not os.listdir(dir_path): if not os.listdir(dir_path):
if dry_run: if dry_run:
self.stdout.write(f'Would remove empty cache directory: {dir_path}') self.stdout.write(
f"Would remove empty cache directory: {dir_path}"
)
else: else:
try: try:
os.rmdir(dir_path) os.rmdir(dir_path)
empty_dirs_removed += 1 empty_dirs_removed += 1
except OSError as e: except OSError as e:
self.stdout.write(self.style.ERROR(f'Failed to remove {dir_path}: {e}')) self.stdout.write(
self.style.ERROR(
f"Failed to remove {dir_path}: {e}"
)
)
if dry_run: if dry_run:
self.stdout.write(self.style.WARNING(f'\nDry run complete. Would delete {deleted_count} images and {thumbnail_deleted_count} thumbnails')) self.stdout.write(
self.stdout.write(f'Would remove {empty_dirs_removed} empty directories') self.style.WARNING(
f"\nDry run complete. Would delete {deleted_count} files and {thumbnail_deleted_count} thumbnails"
)
)
self.stdout.write(f"Would remove {empty_dirs_removed} empty directories")
else: else:
self.stdout.write(self.style.SUCCESS(f'\nCleanup complete! Deleted {deleted_count} images and {thumbnail_deleted_count} thumbnails')) self.stdout.write(
self.stdout.write(f'Removed {empty_dirs_removed} empty directories') self.style.SUCCESS(
f"\nCleanup complete! Deleted {deleted_count} files and {thumbnail_deleted_count} thumbnails"
)
)
self.stdout.write(f"Removed {empty_dirs_removed} empty directories")

View File

@@ -41,9 +41,15 @@ class Box(models.Model):
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='boxes' related_name='boxes'
) )
sort_order = models.PositiveIntegerField(
default=0,
db_index=True,
help_text='Order in which boxes are displayed'
)
class Meta: class Meta:
verbose_name_plural = 'boxes' verbose_name_plural = 'boxes'
ordering = ['sort_order']
def __str__(self): def __str__(self):
return self.id return self.id

View File

@@ -26,7 +26,7 @@
<div class="thing-image" style="flex-shrink: 0; width: 100%; max-width: 400px;"> <div class="thing-image" style="flex-shrink: 0; width: 100%; max-width: 400px;">
{% if thing.picture %} {% if thing.picture %}
{% thumbnail thing.picture "400x400" crop="center" as thumb %} {% thumbnail thing.picture "400x400" crop="center" as thumb %}
<img src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 100%; height: auto; max-height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);"> <img class="lightbox-trigger" data-url="{{ thing.picture.url }}" src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 100%; height: auto; max-height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); cursor: pointer; transition: transform 0.2s;" onmouseover="this.style.transform='scale(1.02)'" onmouseout="this.style.transform='scale(1)'">
{% endthumbnail %} {% endthumbnail %}
{% else %} {% else %}
<div style="width: 100%; aspect-ratio: 1; max-width: 400px; max-height: 400px; background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%); display: flex; align-items: center; justify-content: center; color: #999; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);"> <div style="width: 100%; aspect-ratio: 1; max-width: 400px; max-height: 400px; background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%); display: flex; align-items: center; justify-content: center; color: #999; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
@@ -92,11 +92,24 @@
</div> </div>
<div style="display: flex; flex-direction: column; gap: 10px;"> <div style="display: flex; flex-direction: column; gap: 10px;">
{% for file in thing.files.all %} {% for file in thing.files.all %}
{% if file.filename|lower|slice:"-4:" == '.jpg' or file.filename|lower|slice:"-5:" == '.jpeg' or file.filename|lower|slice:"-4:" == '.png' or file.filename|lower|slice:"-5:" == '.webp' or file.filename|lower|slice:"-4:" == '.gif' or file.filename|lower|slice:"-4:" == '.svg' or file.filename|lower|slice:"-4:" == '.bmp' or file.filename|lower|slice:"-5:" == '.tiff' or file.filename|lower|slice:"-4:" == '.ico' %}
{% thumbnail file.file "200x200" crop="center" as thumb %}
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border:1px solid #e9ecef;">
<img class="lightbox-trigger" data-url="{{ file.file.url }}" src="{{ thumb.url }}" alt="{{ file.title }}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: transform 0.2s;" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">
<div style="flex-grow: 1;">
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
</div>
<i class="fas fa-expand lightbox-trigger" data-url="{{ file.file.url }}" style="color: #999; font-size: 14px; cursor: pointer;"></i>
</div>
{% endthumbnail %}
{% else %}
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;"> <div style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
<i class="fas fa-paperclip" style="color: #667eea; font-size: 16px;"></i> <i class="fas fa-paperclip" style="color: #667eea; font-size: 16px;"></i>
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a> <a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span> <span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -120,6 +133,39 @@
</div> </div>
</div> </div>
</div> </div>
<div class="lightbox" id="lightbox">
<span class="lightbox-close">&times;</span>
<img id="lightbox-image" src="" alt="">
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
$('.lightbox-trigger').on('click', function(e) {
e.preventDefault();
const imageUrl = $(this).data('url');
$('#lightbox-image').attr('src', imageUrl);
$('#lightbox').addClass('active');
$('body').css('overflow', 'hidden');
});
$('#lightbox').on('click', function(e) {
if (e.target === this || e.target.classList.contains('lightbox-close')) {
$('#lightbox').removeClass('active');
$('body').css('overflow', 'auto');
}
});
$(document).on('keydown', function(e) {
if (e.key === 'Escape') {
$('#lightbox').removeClass('active');
$('body').css('overflow', 'auto');
}
});
});
</script>
{% endblock %} {% endblock %}
{% block extra_css %} {% block extra_css %}
@@ -198,5 +244,46 @@
border-top: 2px solid #e0e0e0; border-top: 2px solid #e0e0e0;
margin: 1.5em 0; margin: 1.5em 0;
} }
.lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 9999;
justify-content: center;
align-items: center;
cursor: zoom-out;
}
.lightbox.active {
display: flex;
}
.lightbox img {
max-width: 90%;
max-height: 90%;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
}
.lightbox-close {
position: absolute;
top: 20px;
right: 30px;
color: white;
font-size: 40px;
cursor: pointer;
z-index: 10000;
transition: opacity 0.2s;
}
.lightbox-close:hover {
opacity: 0.7;
}
</style> </style>
{% endblock %} {% endblock %}

View File

@@ -79,7 +79,7 @@ def edit_thing(request, thing_id):
pk=thing_id pk=thing_id
) )
boxes = Box.objects.select_related('box_type').all().order_by('id') boxes = Box.objects.select_related('box_type').all()
facets = Facet.objects.all().prefetch_related('tags') facets = Facet.objects.all().prefetch_related('tags')
picture_form = ThingPictureForm(instance=thing) picture_form = ThingPictureForm(instance=thing)
file_form = ThingFileForm() file_form = ThingFileForm()
@@ -192,7 +192,7 @@ def edit_thing(request, thing_id):
@login_required @login_required
def boxes_list(request): def boxes_list(request):
"""Boxes list page showing all boxes with contents.""" """Boxes list page showing all boxes with contents."""
boxes = Box.objects.select_related('box_type').prefetch_related('things').all().order_by('id') boxes = Box.objects.select_related('box_type').prefetch_related('things').all()
return render(request, 'boxes/boxes_list.html', { return render(request, 'boxes/boxes_list.html', {
'boxes': boxes, 'boxes': boxes,
}) })

View File

@@ -0,0 +1,8 @@
import os
def image_tag(request):
"""Add the image tag to all template contexts."""
return {
'image_tag': os.environ.get('IMAGE_TAG', 'dev')
}

View File

@@ -24,10 +24,10 @@ BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'f0arjg8q3ut4iuqrguqfjaruf0eripIZZN3t1kymy8ugqnj$li2knhha0@gc5v8f3bge=$+gbybj2$jt28uqm') SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'f0arjg8q3ut4iuqrguqfjaruf0eripIZZN3t1kymy8ugqnj$li2knhha0@gc5v8f3bge=$+gbybj2$jt28uqm')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = os.environ.get('DEBUG', 'True').lower() == 'true'
ALLOWED_HOSTS = ["*","labhelper.adebaumann.com"] ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
ALLOWED_CIDR_NETS = ['10.0.0.0/16'] ALLOWED_CIDR_NETS = os.environ.get('ALLOWED_CIDR_NETS', '10.0.0.0/16').split(',')
# Application definition # Application definition
@@ -39,6 +39,7 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'adminsortable2',
'mptt', 'mptt',
'django_mptt_admin', 'django_mptt_admin',
'sorl.thumbnail', 'sorl.thumbnail',
@@ -47,6 +48,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
@@ -67,6 +69,7 @@ TEMPLATES = [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'labhelper.context_processors.image_tag',
], ],
}, },
}, },
@@ -108,23 +111,26 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/ # https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = os.environ.get('LANGUAGE_CODE', 'en-us')
TIME_ZONE = 'UTC' TIME_ZONE = os.environ.get('TIME_ZONE', 'UTC')
USE_I18N = True USE_I18N = os.environ.get('USE_I18N', 'True').lower() == 'true'
USE_TZ = True USE_TZ = os.environ.get('USE_TZ', 'True').lower() == 'true'
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/ # https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = '/static/' STATIC_URL = os.environ.get('STATIC_URL', '/static/')
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
# WhiteNoise static file serving configuration
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
# Media files (user uploads) # Media files (user uploads)
MEDIA_URL = '/media/' MEDIA_URL = os.environ.get('MEDIA_URL', '/media/')
MEDIA_ROOT = BASE_DIR / 'data' / 'media' MEDIA_ROOT = BASE_DIR / 'data' / 'media'
# Default primary key field type # Default primary key field type
@@ -132,8 +138,8 @@ MEDIA_ROOT = BASE_DIR / 'data' / 'media'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
CSRF_TRUSTED_ORIGINS=["https://labhelper.adebaumann.com"] CSRF_TRUSTED_ORIGINS=os.environ.get('CSRF_TRUSTED_ORIGINS', 'https://labhelper.adebaumann.com').split(',')
LOGIN_URL = 'login' LOGIN_URL = os.environ.get('LOGIN_URL', 'login')
LOGIN_REDIRECT_URL = 'index' LOGIN_REDIRECT_URL = os.environ.get('LOGIN_REDIRECT_URL', 'index')
LOGOUT_REDIRECT_URL = 'login' LOGOUT_REDIRECT_URL = os.environ.get('LOGOUT_REDIRECT_URL', 'login')

View File

@@ -435,6 +435,7 @@
<footer class="footer"> <footer class="footer">
<p>&copy; 2025 LabHelper. Built with <i class="fas fa-heart"></i> for science.</p> <p>&copy; 2025 LabHelper. Built with <i class="fas fa-heart"></i> for science.</p>
<p style="font-size: 12px; opacity: 0.7; margin-top: 10px;">Image: {{ image_tag }}</p>
</footer> </footer>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}

View File

@@ -62,5 +62,17 @@ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
] ]
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # Static files are served by WhiteNoise middleware in production
# Media files need to be served in all environments
from django.views.static import serve
from django.urls import re_path
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Add explicit media serving for production
if not settings.DEBUG:
urlpatterns += [
re_path(r'^media/(?P<path>.*)$', serve, {
'document_root': settings.MEDIA_ROOT,
}),
]

View File

@@ -36,3 +36,4 @@ Pillow==11.1.0
sorl-thumbnail==12.11.0 sorl-thumbnail==12.11.0
bleach==6.1.0 bleach==6.1.0
coverage==7.6.1 coverage==7.6.1
whitenoise==6.8.2

View File

@@ -3,7 +3,7 @@
NAMESPACE="labhelper" NAMESPACE="labhelper"
SECRET_NAME="django-secret" SECRET_NAME="django-secret"
SECRET_FILE="argocd/secret.yaml" SECRET_FILE="k8s-templates/secret.yaml"
# Check if secret file exists # Check if secret file exists
if [ ! -f "$SECRET_FILE" ]; then if [ ! -f "$SECRET_FILE" ]; then

View File

@@ -36,10 +36,14 @@ NEW_MAIN_VERSION=$(echo "$MAIN_VERSION + 0.001" | bc | sed 's/^\./0./')
sed -i "s|image: git.baumann.gr/adebaumann/labhelper-data-loader:$LOADER_VERSION|image: git.baumann.gr/adebaumann/labhelper-data-loader:$NEW_LOADER_VERSION|" "$DEPLOYMENT_FILE" sed -i "s|image: git.baumann.gr/adebaumann/labhelper-data-loader:$LOADER_VERSION|image: git.baumann.gr/adebaumann/labhelper-data-loader:$NEW_LOADER_VERSION|" "$DEPLOYMENT_FILE"
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$MAIN_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_MAIN_VERSION|" "$DEPLOYMENT_FILE" sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$MAIN_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_MAIN_VERSION|" "$DEPLOYMENT_FILE"
# Update ConfigMap with new main container version
sed -i "s| IMAGE_TAG: \"$MAIN_VERSION\"| IMAGE_TAG: \"$NEW_MAIN_VERSION\"|" "argocd/configmap.yaml"
# Copy database # Copy database
cp "$DB_SOURCE" "$DB_DEST" cp "$DB_SOURCE" "$DB_DEST"
echo "Full deployment prepared:" echo "Full deployment prepared:"
echo " Data loader: $LOADER_VERSION -> $NEW_LOADER_VERSION" echo " Data loader: $LOADER_VERSION -> $NEW_LOADER_VERSION"
echo " Main container: $MAIN_VERSION -> $NEW_MAIN_VERSION" echo " Main container: $MAIN_VERSION -> $NEW_MAIN_VERSION"
echo " ConfigMap IMAGE_TAG: $MAIN_VERSION -> $NEW_MAIN_VERSION"
echo " Database copied to $DB_DEST" echo " Database copied to $DB_DEST"

View File

@@ -23,5 +23,9 @@ NEW_VERSION=$(echo "$CURRENT_VERSION + 0.001" | bc | sed 's/^\./0./')
# Update the deployment file (only the main container, not the data-loader) # Update the deployment file (only the main container, not the data-loader)
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$CURRENT_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_VERSION|" "$DEPLOYMENT_FILE" sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$CURRENT_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_VERSION|" "$DEPLOYMENT_FILE"
# Update ConfigMap with new main container version
sed -i "s| IMAGE_TAG: \"$CURRENT_VERSION\"| IMAGE_TAG: \"$NEW_VERSION\"|" "argocd/configmap.yaml"
echo "Partial deployment prepared:" echo "Partial deployment prepared:"
echo " Main container: $CURRENT_VERSION -> $NEW_VERSION" echo " Main container: $CURRENT_VERSION -> $NEW_VERSION"
echo " ConfigMap IMAGE_TAG: $CURRENT_VERSION -> $NEW_VERSION"