23 Commits

Author SHA1 Message Date
4d492ded4e Not logging correct IP yet
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 26s
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-28 09:33:42 +01:00
3b53967c40 Not logging correct IP yet 2026-01-28 09:33:15 +01:00
65868c043e Not logging yet
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 27s
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-28 09:06:39 +01:00
5c9b45715b Deploy last commit
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 2m40s
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-28 08:58:07 +01:00
bbed20813a Forward proxying and suppression of health checks in access logs
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 11s
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-28 08:55:19 +01:00
22f5b87a20 Search menu entry changed to inventory
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 33s
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-20 01:03:04 +01:00
1dede761e3 ...and deploy
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 28s
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 6s
2026-01-20 00:18:39 +01:00
860e80a552 Box sorting works 2026-01-20 00:18:05 +01:00
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
23 changed files with 594 additions and 93 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

22
argocd/configmap.yaml Normal file
View File

@@ -0,0 +1,22 @@
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"
TRUSTED_PROXIES: "192.168.17.44,192.168.17.53"
GUNICORN_OPTS: "--access-logfile -"
IMAGE_TAG: "0.071"

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.071
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8000 - containerPort: 8000
@@ -37,6 +37,86 @@ 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: TRUSTED_PROXIES
valueFrom:
configMapKeyRef:
name: django-config
key: TRUSTED_PROXIES
- 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,7 +1,12 @@
import json
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
from django.http import JsonResponse
from django.urls import path
from django.utils.html import format_html from django.utils.html import format_html
from django.views.decorators.http import require_POST
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
@@ -13,7 +18,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):
@@ -50,9 +55,34 @@ class BoxTypeAdmin(admin.ModelAdmin):
class BoxAdmin(admin.ModelAdmin): class BoxAdmin(admin.ModelAdmin):
"""Admin configuration for Box model.""" """Admin configuration for Box model."""
list_display = ('id', 'box_type') ordering = ['sort_order']
list_display = ('id', 'box_type', 'sort_order')
list_filter = ('box_type',) list_filter = ('box_type',)
search_fields = ('id',) search_fields = ('id',)
change_list_template = 'admin/boxes/box/change_list.html'
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('reorder/', self.admin_site.admin_view(self.reorder_view), name='boxes_box_reorder'),
]
return custom_urls + urls
def reorder_view(self, request):
"""Handle AJAX reorder requests."""
if request.method != 'POST':
return JsonResponse({'error': 'POST required'}, status=405)
try:
data = json.loads(request.body)
order = data.get('order', [])
for index, pk in enumerate(order):
Box.objects.filter(pk=pk).update(sort_order=index)
return JsonResponse({'status': 'ok'})
except (json.JSONDecodeError, KeyError) as e:
return JsonResponse({'error': str(e)}, status=400)
class ThingFileInline(admin.TabularInline): class ThingFileInline(admin.TabularInline):

View File

@@ -4,65 +4,83 @@ 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
@@ -79,26 +97,30 @@ class Command(BaseCommand):
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):
@@ -109,39 +131,69 @@ class Command(BaseCommand):
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

@@ -0,0 +1,22 @@
# Generated by Django 5.2.9 on 2026-01-19 23:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boxes', '0010_remove_thingtype'),
]
operations = [
migrations.AlterModelOptions(
name='box',
options={'ordering': ['sort_order'], 'verbose_name_plural': 'boxes'},
),
migrations.AddField(
model_name='box',
name='sort_order',
field=models.PositiveIntegerField(db_index=True, default=0, help_text='Order in which boxes are displayed'),
),
]

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

@@ -0,0 +1,126 @@
{% extends "admin/change_list.html" %}
{% block extrahead %}
{{ block.super }}
<style>
#result_list tbody tr {
cursor: move;
}
#result_list tbody tr.dragging {
opacity: 0.5;
background: #ffffd0;
}
#result_list tbody tr.drag-over {
border-top: 2px solid #417690;
}
</style>
{% endblock %}
{% block result_list %}
{{ block.super }}
<script>
document.addEventListener('DOMContentLoaded', function() {
const tbody = document.querySelector('#result_list tbody');
if (!tbody) return;
let draggedRow = null;
tbody.querySelectorAll('tr').forEach(row => {
row.draggable = true;
row.addEventListener('dragstart', function(e) {
draggedRow = this;
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragend', function() {
this.classList.remove('dragging');
tbody.querySelectorAll('tr').forEach(r => r.classList.remove('drag-over'));
draggedRow = null;
});
row.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (this !== draggedRow) {
this.classList.add('drag-over');
}
});
row.addEventListener('dragleave', function() {
this.classList.remove('drag-over');
});
row.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('drag-over');
if (draggedRow && this !== draggedRow) {
const allRows = Array.from(tbody.querySelectorAll('tr'));
const draggedIndex = allRows.indexOf(draggedRow);
const targetIndex = allRows.indexOf(this);
if (draggedIndex < targetIndex) {
this.parentNode.insertBefore(draggedRow, this.nextSibling);
} else {
this.parentNode.insertBefore(draggedRow, this);
}
saveOrder();
}
});
});
function saveOrder() {
const rows = tbody.querySelectorAll('tr');
const order = [];
rows.forEach(row => {
// Use the action checkbox which contains the PK
const checkbox = row.querySelector('input[name="_selected_action"]');
if (checkbox) {
order.push(checkbox.value);
}
});
fetch('{% url "admin:boxes_box_reorder" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || getCookie('csrftoken')
},
body: JSON.stringify({ order: order })
})
.then(response => response.json())
.then(data => {
if (data.status === 'ok') {
window.location.reload();
} else {
console.error('Reorder failed:', data.error);
alert('Failed to save order');
}
})
.catch(error => {
console.error('Reorder error:', error);
alert('Failed to save order');
});
}
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
});
</script>
{% endblock %}

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,
}) })

45
gunicorn.conf.py Normal file
View File

@@ -0,0 +1,45 @@
import logging
import os
from gunicorn.glogging import Logger
TRUSTED_PROXIES = {
ip.strip()
for ip in os.environ.get("TRUSTED_PROXIES", "").split(",")
if ip.strip()
}
class HealthCheckFilter(logging.Filter):
def filter(self, record):
message = record.getMessage()
return "kube-probe" not in message
class CustomLogger(Logger):
def setup(self, cfg):
super().setup(cfg)
self.access_log.addFilter(HealthCheckFilter())
def atoms(self, resp, req, environ, request_time):
atoms = super().atoms(resp, req, environ, request_time)
atoms["{client-ip}e"] = self._get_client_ip(environ)
return atoms
@staticmethod
def _get_client_ip(environ):
remote_addr = environ.get("REMOTE_ADDR", "-")
xff = environ.get("HTTP_X_FORWARDED_FOR", "")
if not xff:
return remote_addr
# Walk the chain from right to left, skipping trusted proxies
ips = [ip.strip() for ip in xff.split(",")]
for ip in reversed(ips):
if ip not in TRUSTED_PROXIES:
return ip
# All IPs in the chain are trusted; fall back to the leftmost
return ips[0]
logger_class = CustomLogger
access_log_format = '%({client-ip}e)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'

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
@@ -47,6 +47,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 +68,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 +110,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 +137,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

@@ -402,7 +402,7 @@
</button> </button>
<div class="navbar-nav" id="navbar-nav"> <div class="navbar-nav" id="navbar-nav">
<a href="/"><i class="fas fa-home"></i> Home</a> <a href="/"><i class="fas fa-home"></i> Home</a>
<a href="/search/"><i class="fas fa-search"></i> Search</a> <a href="{% url 'inventory' %}"><i class="fas fa-boxes-stacked"></i> Inventory</a>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="dropdown"> <div class="dropdown">
<button class="dropdown-btn"> <button class="dropdown-btn">
@@ -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

@@ -55,12 +55,24 @@ urlpatterns = [
path('thing/<int:thing_id>/edit/', edit_thing, name='edit_thing'), path('thing/<int:thing_id>/edit/', edit_thing, name='edit_thing'),
path('box/<str:box_id>/add/', add_things, name='add_things'), path('box/<str:box_id>/add/', add_things, name='add_things'),
path('boxes/', boxes_list, name='boxes_list'), path('boxes/', boxes_list, name='boxes_list'),
path('search/', boxes_list, name='search'), path('inventory/', boxes_list, name='inventory'),
path('search/api/', search_api, name='search_api'), path('search/api/', search_api, name='search_api'),
path('resources/', resources_list, name='resources_list'), path('resources/', resources_list, name='resources_list'),
path('fixme/', fixme, name='fixme'), path('fixme/', fixme, name='fixme'),
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"