+ {% for file, next_file, prev_file in file_orders %}
+
{{ file.caption }}
+ {% endfor %}
+
+
+{% endblock content %}
diff --git a/gallery/tests.py b/gallery/tests.py
new file mode 100644
index 0000000..791fb3e
--- /dev/null
+++ b/gallery/tests.py
@@ -0,0 +1,100 @@
+import tempfile
+
+from django.core.exceptions import ValidationError
+from django.core.files.base import ContentFile
+from django.test import TestCase, override_settings
+from django.urls import reverse
+from PIL import Image as PILImage
+
+from gallery import views
+from gallery.models import Album, Image
+
+
+class SimpleAlbumTest(TestCase):
+ def test_empty_album(self):
+ self.assertFalse(Album.objects.exists())
+ instance = Album.objects.create(title="Album Title", slug="album-title")
+ instance.full_clean()
+ self.assertTrue(Album.objects.exists())
+
+
+@override_settings(MEDIA_ROOT=tempfile.gettempdir())
+class SimpleMediaTest(TestCase):
+ def setUp(self):
+ album = Album.objects.create(title="Album Title", slug="album-title")
+ album.full_clean()
+
+ def generate_image(self, suffix):
+ album = Album.objects.all()[0]
+
+ temp_file = tempfile.NamedTemporaryFile(suffix=suffix)
+ PILImage.new("RGB", (100, 100)).save(temp_file)
+
+ Image(file=temp_file.name, album=album).save()
+
+ try:
+ Image.objects.all()[0].file.crop["200x200"]
+ except ValidationError:
+ self.fail(
+ "VersatileImageField Raised ValidationError on a good %s image" % suffix
+ )
+ self.assertEqual(len(Image.objects.all()), 1)
+ self.assertIsInstance(album.basemedia.select_subclasses()[0], Image)
+
+ def test_simple_jpg_album(self):
+ self.generate_image(".jpg")
+
+ def test_simple_png_album(self):
+ self.generate_image(".png")
+
+ def test_simple_gif_album(self):
+ self.generate_image(".gif")
+
+
+@override_settings(MEDIA_ROOT=tempfile.gettempdir())
+class CorruptedMediaTest(TestCase):
+ def setUp(self):
+ album = Album.objects.create(
+ title="Corrupt Album Title", slug="corrupt-album-title"
+ )
+ album.full_clean()
+
+ def test_truncated_jpg_album(self):
+ album = Album.objects.all()[0]
+
+ temp_file = tempfile.NamedTemporaryFile(suffix=".jpg")
+ PILImage.new("RGB", (100, 100)).save(temp_file, "jpeg")
+ temp_file.truncate(800)
+
+ instance = Image(file=temp_file.name, album=album)
+ instance.full_clean()
+ with self.assertLogs(level="ERROR"):
+ with self.assertRaises(ValidationError):
+ instance.save()
+
+ self.assertEqual(len(Image.objects.all()), 0)
+
+
+class GalleryViewTest(TestCase):
+ def setUp(self):
+ super().setUp()
+ self.album = Album.objects.create(gfyear=2018, title="Test", slug="test")
+ # Create two images
+ self.create_image("image1")
+ self.create_image("image2")
+
+ def create_image(self, slug):
+ with tempfile.NamedTemporaryFile(suffix=".png") as im:
+ PILImage.new("RGB", (100, 100)).save(im)
+ im.seek(0)
+ contents = ContentFile(im.read(), "im.png")
+ image = Image(
+ file=contents, album=self.album, slug=slug, visibility=Image.PUBLIC
+ )
+ image.full_clean()
+ image.save()
+ return image
+
+ def test_album_count(self):
+ response = self.client.get(reverse("gallery_index"))
+ self.assertEqual(1, len(list(response.context["albumSets"])))
diff --git a/gallery/urls.py b/gallery/urls.py
new file mode 100644
index 0000000..d949181
--- /dev/null
+++ b/gallery/urls.py
@@ -0,0 +1,24 @@
+from django.conf.urls import url
+from django.urls import path, re_path
+
+import gallery.views as views
+
+urlpatterns = [
+ # Gallery overview
+ path("", views.gallery, name="gallery_index"),
+ re_path(r"^(?P\d+)$", views.gallery, name="gfyear"),
+ # Album overview
+ re_path(r"^(?P\d+)/(?P[^/]+)$", views.album, name="album"),
+ # Single images
+ re_path(
+ r"^(?P\d+)/(?P[^/]+)/(?P[^/]+)$",
+ views.image,
+ name="image",
+ ),
+ # Bulk-update BaseMedia.visibility
+ re_path(r"^set_visibility/$", views.set_visibility, name="set_image_visibility"),
+ # JFU upload
+ re_path(r"^upload/", views.upload, name="jfu_upload"),
+ # RSS feed
+ re_path(r"^album\.rss$", views.AlbumFeed(), name="album_rss"),
+]
diff --git a/gallery/utils.py b/gallery/utils.py
new file mode 100644
index 0000000..00554e5
--- /dev/null
+++ b/gallery/utils.py
@@ -0,0 +1,98 @@
+import logging
+import os
+from datetime import datetime
+
+from constance import config
+from django.utils.text import slugify as dslugify
+from django.utils.timezone import get_current_timezone
+from PIL import Image as PilImage
+from PIL.ExifTags import TAGS
+from unidecode import unidecode
+
+from fredagscafeen.settings.local import YEAR
+
+logger = logging.getLogger(__name__)
+
+
+def slugify(string):
+ return dslugify(unidecode(string))
+
+
+def file_name(instance, path):
+ filename = os.path.basename(path)
+ sepFilename = os.path.splitext(filename)
+ newFilename = slugify(sepFilename[0]) + sepFilename[1]
+ gfyear = str(instance.album.gfyear)
+ album_slug = instance.album.slug
+
+ return "/".join([gfyear, album_slug, newFilename])
+
+
+def get_gfyear():
+ return YEAR
+
+
+def get_exif_date(filename):
+ logger.debug("get_exif_date: called with filename: %s" % filename)
+ try:
+ image = PilImage.open(filename)
+ info = image._getexif()
+ if info is not None:
+ exif = {}
+ for tag, value in info.items():
+ decoded = TAGS.get(tag, tag)
+ exif[decoded] = value
+
+ for t in ["Original", "Digitized", ""]:
+ """
+ Check the exif fields DateTimeOriginal, DateTimeDigitized and
+ DateTime in that order. Return when the first is found.
+ """
+ if "DateTime" + t in exif:
+ s = exif["DateTime" + t]
+ if type(s) is tuple:
+ s = str(s[0])
+ logger.debug(
+ "get_exif_date: found EXIF field DateTime%s. Parsed it as %s",
+ t,
+ s,
+ )
+
+ if "SubsecTime" + t in exif:
+ ms = exif["SubsecTime" + t]
+ if type(ms) is tuple:
+ ms = str(ms[0])
+ logger.debug(
+ "get_exif_date: found EXIF field SubsecTime. Parsed it as %s",
+ ms,
+ )
+ else:
+ ms = "0"
+
+ s += "." + ms
+
+ if any(str(n) in s for n in range(1, 10)):
+ dt = datetime.strptime(s, "%Y:%m:%d %H:%M:%S.%f")
+ dt = dt.replace(tzinfo=get_current_timezone())
+
+ return dt
+
+ logger.debug(
+ "get_exif_date: the DateTime%s field only contained zeros. Trying next field",
+ t,
+ )
+
+ except AttributeError as e:
+ logger.info(
+ "get_exif_date: could not get exif data. This file is properly not a jpg or tif. Returning None"
+ )
+ return None
+
+ except Exception as e:
+ logger.warning(
+ "get_exif_date: An exception occurred in this slightly volatile function.",
+ exc_info=True,
+ )
+
+ logger.info("get_exif_date: could not get exif date. Returning None")
+ return None
diff --git a/gallery/views.py b/gallery/views.py
new file mode 100644
index 0000000..53f8401
--- /dev/null
+++ b/gallery/views.py
@@ -0,0 +1,273 @@
+import os
+
+from django.contrib.auth.decorators import permission_required
+from django.contrib.syndication.views import Feed
+from django.core.exceptions import ValidationError
+from django.db.models import Count
+from django.http import (
+ Http404,
+ HttpResponse,
+ HttpResponseBadRequest,
+ HttpResponseRedirect,
+)
+from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse
+from django.views.decorators.http import require_POST
+from jfu.http import JFUResponse, UploadResponse, upload_receive
+
+from gallery.forms import EditVisibilityForm
+from gallery.models import Album, BaseMedia, GenericFile, Image
+
+GALLERY_PERMISSION = "gallery.change_image"
+
+
+def get_basemedia_count_by_album():
+ """
+ Return C such that::
+
+ C[v][i] == BaseMedia.objects.filter(visibility=v, album_id=i).count()
+ """
+
+ qs = BaseMedia.objects.all()
+ qs = qs.order_by().values("album_id", "visibility")
+ qs = qs.annotate(count=Count("id"))
+ by_visibility = {}
+ for record in qs:
+ by_album = by_visibility.setdefault(record["visibility"], {})
+ by_album[record["album_id"]] = record["count"]
+ return by_visibility
+
+
+def gallery(request, **kwargs):
+ count_by_visibility = get_basemedia_count_by_album()
+ public_count = count_by_visibility.get(BaseMedia.PUBLIC, {})
+ new_count = count_by_visibility.get(BaseMedia.NEW, {})
+ edit_visibility = request.user.has_perm(GALLERY_PERMISSION)
+
+ try:
+ requested_year = int(kwargs["gfyear"])
+ except (KeyError, ValueError):
+ requested_year = None
+
+ albums = list(Album.objects.all())
+ for album in albums:
+ album.count = public_count.get(album.id, 0)
+ album.new_count = new_count.get(album.id, 0) if edit_visibility else 0
+
+ # Hide albums with no images
+ albums = [a for a in albums if a.count + a.new_count > 0]
+ years = set(a.gfyear for a in albums)
+ years = sorted(years, reverse=True)
+
+ if requested_year is None:
+ show_year = max(years)
+ else:
+ show_year = requested_year
+
+ # Hide albums not in show_year
+ albums = [a for a in albums if a.gfyear == show_year]
+
+ if not albums:
+ raise Http404("No albums exist")
+
+ firstImages = BaseMedia.objects.filter(album__in=albums, isCoverFile=True)
+ firstImages = firstImages.select_subclasses()
+ firstImages = {fi.album_id: fi for fi in firstImages}
+ albumSets = [(a, firstImages.get(a.id)) for a in albums]
+
+ context = {
+ "years": years,
+ "show_year": show_year,
+ "albumSets": albumSets,
+ }
+
+ return render(request, "gallery.html", context)
+
+
+def album(request, gfyear, album_slug):
+ album = get_object_or_404(Album, gfyear=gfyear, slug=album_slug)
+ files = album.basemedia.filter(visibility=BaseMedia.PUBLIC).select_subclasses()
+ context = {"album": album, "files": files}
+
+ edit_visibility = request.user.has_perm(GALLERY_PERMISSION)
+ new_file = album.basemedia.filter(visibility=BaseMedia.NEW).first()
+ if edit_visibility and new_file:
+ if request.POST.get("set_all_new_visible"):
+ qs = album.basemedia.filter(visibility=BaseMedia.NEW)
+ qs.update(visibility=BaseMedia.PUBLIC)
+
+ # Update isCoverFile
+ album.clean()
+
+ return redirect("album", gfyear=gfyear, album_slug=album_slug)
+
+ kwargs = dict(
+ gfyear=album.gfyear, album_slug=album.slug, image_slug=new_file.slug
+ )
+ context["edit_visibility_link"] = reverse("image", kwargs=kwargs) + "?v=1"
+
+ qs = album.basemedia.all().order_by()
+ qs_visibility = qs.values_list("visibility")
+ visibility_counts = dict(qs_visibility.annotate(count=Count("pk")))
+ c_public = visibility_counts.pop(BaseMedia.PUBLIC, 0)
+ c_discarded = visibility_counts.pop(BaseMedia.DISCARDED, 0)
+ c_sensitive = visibility_counts.pop(BaseMedia.SENSITIVE, 0)
+ c_delete = visibility_counts.pop(BaseMedia.DELETE, 0)
+ c_new = visibility_counts.pop(BaseMedia.NEW, 0)
+ if visibility_counts:
+ raise ValueError(visibility_counts)
+ context["visible_count"] = c_public
+ context["hidden_count"] = c_discarded + c_sensitive + c_delete + c_new
+ context["new_count"] = c_new
+ return render(request, "album.html", context)
+
+
+def image(request, gfyear, album_slug, image_slug, **kwargs):
+ album = get_object_or_404(Album, gfyear=gfyear, slug=album_slug)
+
+ edit_visibility = bool(request.GET.get("v")) and request.user.has_perm(
+ GALLERY_PERMISSION
+ )
+
+ qs = album.basemedia.all()
+ if not edit_visibility:
+ qs = qs.filter(visibility=BaseMedia.PUBLIC)
+ qs = qs.select_subclasses()
+ # list() will force evaluation of the QuerySet. It is now iterable.
+ files = list(qs)
+ start_file = (
+ album.basemedia.filter(album=album, slug=image_slug).select_subclasses().first()
+ )
+ if edit_visibility:
+ form = EditVisibilityForm(files)
+ for file, (pk, key) in zip(files, form.basemedias):
+ file.visibility_field = form[key]
+
+ if not start_file:
+ raise Http404("Billedet kan ikke findes")
+
+ if start_file.notPublic and not edit_visibility:
+ raise Http404("Billedet kan ikke findes")
+
+ prev_files = files[1:] + files[:1]
+ next_files = files[-1:] + files[:-1]
+ file_orders = zip(files, prev_files, next_files)
+ file_count = len(files)
+
+ context = {
+ "album": album,
+ "file_orders": file_orders,
+ "start_file": start_file,
+ "file_count": file_count,
+ "edit_visibility": edit_visibility,
+ }
+ return render(request, "image.html", context)
+
+
+@require_POST
+@permission_required("gallery.add_image", raise_exception=True)
+def upload(request):
+ file = upload_receive(request)
+ album = Album.objects.get(id=int(request.POST["object_id"]))
+ ext = os.path.splitext(file.name)[1].lower()
+
+ if ext in (".png", ".gif", ".jpg", ".jpeg"):
+ instance = Image(file=file, album=album)
+ elif ext in (".mp3"):
+ instance = GenericFile(file=file, album=album)
+ instance.type = BaseMedia.AUDIO
+ elif ext in (".mp4"):
+ instance = GenericFile(file=file, album=album)
+ instance.type = BaseMedia.VIDEO
+ else:
+ instance = GenericFile(file=file, album=album)
+ instance.type = BaseMedia.OTHER
+
+ try:
+ instance.full_clean()
+ instance.save()
+ except ValidationError as exn:
+ try:
+ error = " ".join(
+ "%s: %s" % (k, v) for k, vs in exn.message_dict.items() for v in vs
+ )
+ except AttributeError:
+ error = " ".join(exn.messages)
+
+ jfu_msg = {
+ "name": file.name,
+ "size": file.size,
+ "error": error,
+ }
+ return UploadResponse(request, jfu_msg)
+
+ jfu_msg = {
+ "name": os.path.basename(instance.file.path),
+ "size": file.size,
+ "url": instance.file.url,
+ }
+ return UploadResponse(request, jfu_msg)
+
+
+@require_POST
+@permission_required("gallery.delete_image", raise_exception=True)
+def upload_delete(request, pk):
+ success = True
+ try:
+ instance = Image.objects.get(pk=pk)
+ instance.image.delete(save=False)
+ instance.delete()
+ except Image.DoesNotExist:
+ success = False
+
+ return JFUResponse(request, success)
+
+
+@require_POST
+@permission_required(GALLERY_PERMISSION, raise_exception=True)
+def set_visibility(request):
+ try:
+ form = EditVisibilityForm.from_POST(request.POST)
+ except EditVisibilityForm.Missing as exn:
+ return HttpResponseBadRequest(str(exn))
+ if not form.is_valid():
+ return HttpResponseBadRequest(str(form.errors))
+ for pk, key in form.basemedias:
+ initial = form.fields[key].initial
+ value = form.cleaned_data[key]
+ if initial != value:
+ BaseMedia.objects.filter(pk=pk).update(visibility=value)
+
+ albums = list(Album.objects.filter(pk__in=form.album_pks))
+ for a in albums:
+ # Update isCoverFile
+ a.clean()
+
+ # Redirect to album
+ if albums:
+ album = albums[0]
+ kwargs = dict(gfyear=album.gfyear, album_slug=album.slug)
+ return HttpResponseRedirect(reverse("album", kwargs=kwargs))
+ else:
+ return HttpResponse("Synlighed på givne billeder er blevet opdateret")
+
+
+class AlbumFeed(Feed):
+ title = "Fredagscaféens billedalbummer"
+
+ def link(self):
+ return reverse("gallery_index")
+
+ description = "Feed med nye billedalbummer fra Fredagscaféens begivenheder."
+
+ def items(self):
+ nonempties = Album.objects.filter(
+ basemedia__visibility=BaseMedia.PUBLIC
+ ).distinct()
+ return nonempties.order_by("-publish_date")
+
+ def item_title(self, item):
+ return item.title
+
+ def item_description(self, item):
+ return item.description
diff --git a/locale/da/LC_MESSAGES/django.po b/locale/da/LC_MESSAGES/django.po
index 8a60c7f..4ba8ae5 100644
--- a/locale/da/LC_MESSAGES/django.po
+++ b/locale/da/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-11-03 16:17+0100\n"
+"POT-Creation-Date: 2024-11-03 23:42+0100\n"
"PO-Revision-Date: 2024-10-17 23:46+0200\n"
"Last-Translator: \n"
"Language-Team: LANGUAGE \n"
@@ -182,7 +182,8 @@ msgid "Bartender's first shift"
msgstr "Bartenderens første vagt"
#: bartenders/templates/barplan.html:38 bartenders/templates/barplan.html:78
-#: bartenders/templates/board.html:34 items/templates/items.html:79
+#: bartenders/templates/board.html:34 gallery/templates/gallery.html:21
+#: items/templates/items.html:79
msgid "Edit"
msgstr "Rediger"
@@ -491,11 +492,11 @@ msgstr "Ja"
msgid "No"
msgstr "Nej"
-#: fredagscafeen/settings/base.py:267
+#: fredagscafeen/settings/base.py:272
msgid "Danish"
msgstr "Dansk"
-#: fredagscafeen/settings/base.py:268
+#: fredagscafeen/settings/base.py:273
msgid "English"
msgstr "Engelsk"
@@ -1117,34 +1118,38 @@ msgid "Events"
msgstr ""
#: web/templates/base.html:93
-msgid "Sortiment"
+msgid "Gallery"
msgstr ""
#: web/templates/base.html:94
+msgid "Sortiment"
+msgstr ""
+
+#: web/templates/base.html:95
msgid "Om baren"
msgstr ""
-#: web/templates/base.html:96
+#: web/templates/base.html:97
msgid "Profil"
msgstr ""
-#: web/templates/base.html:98
+#: web/templates/base.html:99
msgid "Krydlistestatus"
msgstr ""
-#: web/templates/base.html:100
+#: web/templates/base.html:101
msgid "Bartenderprofil"
msgstr ""
-#: web/templates/base.html:103
+#: web/templates/base.html:104
msgid "Admin"
msgstr ""
-#: web/templates/base.html:108
+#: web/templates/base.html:109
msgid "Log ud"
msgstr ""
-#: web/templates/base.html:147
+#: web/templates/base.html:148
msgid "er en fredagsbar for datalogi og IT på Aarhus Universitet."
msgstr ""
diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po
index ccab841..acf2b02 100644
--- a/locale/en/LC_MESSAGES/django.po
+++ b/locale/en/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-11-03 16:17+0100\n"
+"POT-Creation-Date: 2024-11-03 23:42+0100\n"
"PO-Revision-Date: 2024-11-03 16:17+0050\n"
"Last-Translator: \n"
"Language-Team: LANGUAGE \n"
@@ -182,7 +182,8 @@ msgid "Bartender's first shift"
msgstr "Bartender's first shift"
#: bartenders/templates/barplan.html:38 bartenders/templates/barplan.html:78
-#: bartenders/templates/board.html:34 items/templates/items.html:79
+#: bartenders/templates/board.html:34 gallery/templates/gallery.html:21
+#: items/templates/items.html:79
msgid "Edit"
msgstr "Edit"
@@ -217,8 +218,7 @@ msgstr "Are you sure?"
#: bartenders/templates/bartender_info.html:49
#: bartenders/templates/bartender_info.html:56
msgid ""
-"Husk at du som inaktiv bartender ikke kan komme til "
-"bartenderarrangementerne."
+"Husk at du som inaktiv bartender ikke kan komme til bartenderarrangementerne."
msgstr ""
"Remember that as an inactive bartender you cannot come to the bartender "
"events."
@@ -326,8 +326,8 @@ msgid ""
"begivenheder, såsom ølsmagning og lign. Alt sammen gratis for bartendere."
msgstr ""
"In addition to this, you are invited to our annual events for bartenders, "
-"such as the Summer Party and the Christmas Lunch. In addition, there will be"
-" ongoing cool events, such as beer tasting and the like. All free for "
+"such as the Summer Party and the Christmas Lunch. In addition, there will be "
+"ongoing cool events, such as beer tasting and the like. All free for "
"bartenders."
#: bartenders/templates/index.html:16
@@ -516,11 +516,11 @@ msgstr "Yes"
msgid "No"
msgstr "No"
-#: fredagscafeen/settings/base.py:267
+#: fredagscafeen/settings/base.py:272
msgid "Danish"
msgstr "Danish"
-#: fredagscafeen/settings/base.py:268
+#: fredagscafeen/settings/base.py:273
msgid "English"
msgstr "English"
@@ -543,8 +543,8 @@ msgstr "Guides"
#: guides/templates/guides.html:9
msgctxt "Fredagscaféen"
msgid ""
-"Her ligger alle de lækre guides, som er rare at have, når man involverer sig"
-" i driften af"
+"Her ligger alle de lækre guides, som er rare at have, når man involverer sig "
+"i driften af"
msgstr ""
"Here are all the delicious guides that are nice to have when you get "
"involved in the operation of"
@@ -560,16 +560,22 @@ msgstr ""
#: items/templates/items.html:11
msgid ""
"\n"
-"\tHer kan du se Fredagscaféens faste sortiment, og mange af de ting vi er kendte for at sælge.\n"
-"\tMen derudover finder du altid et stort sortiment af spændende og nye specialøl, for enhver smag, i baren.\n"
-"\tVi har i Fredagscaféen et stort fokus på at have noget for alle, så derfor finder du også flere\n"
+"\tHer kan du se Fredagscaféens faste sortiment, og mange af de ting vi er "
+"kendte for at sælge.\n"
+"\tMen derudover finder du altid et stort sortiment af spændende og nye "
+"specialøl, for enhver smag, i baren.\n"
+"\tVi har i Fredagscaféen et stort fokus på at have noget for alle, så derfor "
+"finder du også flere\n"
"\tglutenfrie og alkoholfrie øl.\n"
"\t"
msgstr ""
"\n"
-"Here you can see Fredagscaféen's fixed selection and many of the things we are known for selling.\n"
-"But in addition, you will always find a large range of exciting and new specialty beers, for every taste, in the bar.\n"
-"At Fredagscaféen, we have a big focus on having something for everyone, so you will also find more\n"
+"Here you can see Fredagscaféen's fixed selection and many of the things we "
+"are known for selling.\n"
+"But in addition, you will always find a large range of exciting and new "
+"specialty beers, for every taste, in the bar.\n"
+"At Fredagscaféen, we have a big focus on having something for everyone, so "
+"you will also find more\n"
"gluten-free and alcohol-free beers."
#: items/templates/items.html:22
@@ -702,8 +708,8 @@ msgstr "Expected consumption"
#: udlejning/models.py:64
msgid ""
-"Hvilke slags øl eller andre drikkevarer ønskes der og hvor mange fustager af"
-" hver type?"
+"Hvilke slags øl eller andre drikkevarer ønskes der og hvor mange fustager af "
+"hver type?"
msgstr ""
"What types of beer or other beverages are desired and how many kegs of each "
"type?"
@@ -805,8 +811,8 @@ msgid ""
"Hvis vores anlæg ikke allerede er udlånt, kan du låne det ved at udfylde "
"ansøgninsformularen nederst på denne side."
msgstr ""
-"If our equipment is not already on loan, you can borrow it by filling in the"
-" application form at the bottom of this page."
+"If our equipment is not already on loan, you can borrow it by filling in the "
+"application form at the bottom of this page."
#: udlejning/templates/udlejning.html:38
msgid "Anlæg udlånes kun til"
@@ -830,8 +836,7 @@ msgstr "Internal events"
#: udlejning/templates/udlejning.html:42
msgid ""
-"Udelukkende arrangementer der finder sted på universitetet. Dette "
-"inkluderer:"
+"Udelukkende arrangementer der finder sted på universitetet. Dette inkluderer:"
msgstr "Exclusive events that take place at the university. This includes:"
#: udlejning/templates/udlejning.html:44
@@ -848,8 +853,8 @@ msgstr "Events with association"
#: udlejning/templates/udlejning.html:50
msgid ""
-"Arrangementer der finder sted på universitetet eller andetsteds, kan være af"
-" privat natur. Dette inkluderer:"
+"Arrangementer der finder sted på universitetet eller andetsteds, kan være af "
+"privat natur. Dette inkluderer:"
msgstr ""
"Events that take place at the university or elsewhere may be of a private "
"nature. This includes:"
@@ -879,9 +884,9 @@ msgid ""
"Forløber som det har gjort indtil nu, udlån inkluderer opstilling, kulsyre "
"og skylning af anlægget, 50 glas (1 rulle) tilkøbes for 150,- pr. stk."
msgstr ""
-"Precursor as it has been until now, lending includes setting up, carbonation"
-" and rinsing of the system, 50 cups (1 roll) can be purchased for DKK 150 "
-"per PCS."
+"Precursor as it has been until now, lending includes setting up, carbonation "
+"and rinsing of the system, 50 cups (1 roll) can be purchased for DKK 150 per "
+"PCS."
#: udlejning/templates/udlejning.html:66
msgid ""
@@ -890,9 +895,9 @@ msgid ""
"Afleveringstid af anlægget skal aftales i dialog med den ansvarlige for det "
"praktiske, og er ikke nødvendigvis samme dag."
msgstr ""
-"Here, loans only include unlocking and unlocking of the draft beer facility."
-" This means that the plant must be picked up in Ada-0, and delivered cleaned"
-" in Ada-0. The delivery time of the installation must be agreed in dialogue "
+"Here, loans only include unlocking and unlocking of the draft beer facility. "
+"This means that the plant must be picked up in Ada-0, and delivered cleaned "
+"in Ada-0. The delivery time of the installation must be agreed in dialogue "
"with the person responsible for the practical, and is not necessarily the "
"same day."
@@ -902,10 +907,10 @@ msgstr "Request rental in good time"
#: udlejning/templates/udlejning.html:72
msgid ""
-"Når du låner et anlæg, skal der på udlåningsdagen samt tilbagelevering, være"
-" et bestyrelsesmedlem der står for det praktiske. Vi er alle frivillige og "
-"har studier og jobs ved siden af vorest bestyrelsesarbejde, og derfor kan vi"
-" ikke være sikre på at kunne stille et besyterelsesmedlem til rådighed."
+"Når du låner et anlæg, skal der på udlåningsdagen samt tilbagelevering, være "
+"et bestyrelsesmedlem der står for det praktiske. Vi er alle frivillige og "
+"har studier og jobs ved siden af vorest bestyrelsesarbejde, og derfor kan vi "
+"ikke være sikre på at kunne stille et besyterelsesmedlem til rådighed."
msgstr ""
"When you borrow a facility, there must be a board member responsible for "
"practical matters on the day of lending and return. We are all volunteers "
@@ -926,15 +931,15 @@ msgid ""
"mulighed for at være der i en af de mulige tidsrum. Hvis ikke du skriver "
"mindst 7 dage i forvejen, kan vi ikke garantere at vi kan melde ud hvorvidt "
"udlejning er muligt i god tid, dog er det stadig muligt at kunne låne "
-"anlægget selv hvis man melder ud i dårlig tid, vi kan blot ikke garantere at"
-" der kommer et svar i god tid."
+"anlægget selv hvis man melder ud i dårlig tid, vi kan blot ikke garantere at "
+"der kommer et svar i god tid."
msgstr ""
"We will therefore, no later than 5 days before, announce whether someone on "
"the board has the opportunity to be there during one of the possible times. "
"If you do not write at least 7 days in advance, we cannot guarantee that we "
"can announce whether or not renting is possible in good time, however it is "
-"still possible to borrow the facility even if you announce at a bad time, we"
-" just cannot guarantee that there will be an answer in good time."
+"still possible to borrow the facility even if you announce at a bad time, we "
+"just cannot guarantee that there will be an answer in good time."
#: udlejning/templates/udlejning.html:79
msgid "Generelle informationer"
@@ -950,8 +955,7 @@ msgstr "Our primary draft beer is"
#: udlejning/templates/udlejning.html:91
msgid ""
-", som kommer i 25 liters fustager. Prisen er 750 kr. pr. fustage (incl. "
-"moms)"
+", som kommer i 25 liters fustager. Prisen er 750 kr. pr. fustage (incl. moms)"
msgstr ""
", which comes in 25 liter kegs. The price is DKK 750 per keg (incl. VAT)"
@@ -976,16 +980,15 @@ msgid ""
"Vi sælger også Magners Cider på fustage, der koster 1250 kr. pr. fustage "
"(incl. moms)."
msgstr ""
-"We also sell Magners Cider in kegs, which costs DKK 1250 per keg (incl. "
-"VAT)."
+"We also sell Magners Cider in kegs, which costs DKK 1250 per keg (incl. VAT)."
#: udlejning/templates/udlejning.html:106
msgid ""
"Vi har et sortiment af special-øl tilgængeligt på: (det er ikke tilgængelig "
"pt. men man kan skrive en mail til"
msgstr ""
-"We have a range of specialty beers available at: (it is not available at the"
-" moment, but you can write an email to"
+"We have a range of specialty beers available at: (it is not available at the "
+"moment, but you can write an email to"
#: udlejning/templates/udlejning.html:109
msgid ""
@@ -1190,34 +1193,41 @@ msgid "Events"
msgstr "Events"
#: web/templates/base.html:93
+#, fuzzy
+#| msgctxt "Baren har åbent alle fredage..."
+#| msgid "alle"
+msgid "Gallery"
+msgstr "every"
+
+#: web/templates/base.html:94
msgid "Sortiment"
msgstr "Selection"
-#: web/templates/base.html:94
+#: web/templates/base.html:95
msgid "Om baren"
msgstr "About"
-#: web/templates/base.html:96
+#: web/templates/base.html:97
msgid "Profil"
msgstr "Profile"
-#: web/templates/base.html:98
+#: web/templates/base.html:99
msgid "Krydlistestatus"
msgstr "Tab status"
-#: web/templates/base.html:100
+#: web/templates/base.html:101
msgid "Bartenderprofil"
msgstr "Bartender profile"
-#: web/templates/base.html:103
+#: web/templates/base.html:104
msgid "Admin"
msgstr "Admin"
-#: web/templates/base.html:108
+#: web/templates/base.html:109
msgid "Log ud"
msgstr "Log out"
-#: web/templates/base.html:147
+#: web/templates/base.html:148
msgid "er en fredagsbar for datalogi og IT på Aarhus Universitet."
msgstr "is a friday bar for computer science and IT at Aarhus University."
@@ -1230,8 +1240,7 @@ msgid "Missing from environment"
msgstr "Missing from environment"
#: web/views.py:48
-msgid ""
-"Login mail sendt: Tryk på linket i din modtagede mail for at logge ind."
+msgid "Login mail sendt: Tryk på linket i din modtagede mail for at logge ind."
msgstr "Login email sent: Press the link in your received email to log in."
#~ msgid "bartenders"
diff --git a/requirements.in b/requirements.in
index c9926e8..2f0eba4 100644
--- a/requirements.in
+++ b/requirements.in
@@ -23,3 +23,7 @@ django-celery-beat
django-rosetta==0.9.9
lxml_html_clean
django_bootstrap_icons==0.8.7
+sorl-thumbnail==12.10
+django-jfu
+django-model-utils
+Unidecode
diff --git a/requirements.txt b/requirements.txt
index ed6cd18..e4e783b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -65,6 +65,7 @@ django==3.2.4
# django-extensions
# django-ical
# django-logentry-admin
+ # django-model-utils
# django-picklefield
# django-recaptcha
# django-recurrence
@@ -87,8 +88,12 @@ django-extensions==3.1.3
# via -r requirements.in
django-ical==1.8.0
# via -r requirements.in
+django-jfu==2.0.9
+ # via -r requirements.in
django-logentry-admin==1.1.0
# via -r requirements.in
+django-model-utils==5.0.0
+ # via -r requirements.in
django-object-actions==3.0.2
# via -r requirements.in
django-picklefield==3.0.1
@@ -193,6 +198,8 @@ six==1.16.0
# click-repl
# python-dateutil
# w3lib
+sorl-thumbnail==12.10.0
+ # via -r requirements.in
soupsieve==2.2.1
# via beautifulsoup4
sqlparse==0.4.1
@@ -211,6 +218,8 @@ typing-extensions==4.12.2
# pyee
tzdata==2024.1
# via celery
+unidecode==1.3.8
+ # via -r requirements.in
urllib3==1.26.5
# via
# pyppeteer
diff --git a/web/static/css/stylesheet.css b/web/static/css/stylesheet.css
index 3e163b3..79be932 100755
--- a/web/static/css/stylesheet.css
+++ b/web/static/css/stylesheet.css
@@ -246,6 +246,91 @@ html.light {
.slider.round:before {
border-radius: 50%;
}
+
+ .row {
+ margin: 0px;
+ }
+
+ .panel-body {
+ width: 100%;
+ }
+
+ .gallery-elem {
+ margin: 10px;
+ float: left;
+ width: 165px;
+ height: 185px;
+ }
+
+ .thumbnail, .mediawrapper {
+ position: relative;
+ }
+
+ .thumbcap img {
+ width: 155px;
+ height: 155px;
+ object-fit: cover;
+ }
+
+ .mediawrapper {
+ position: relative;
+ width: 100%;
+ text-align: center;
+ }
+
+ .mediawrapper img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .caption {
+ text-align: center;
+ position: absolute;
+ bottom: 4px;
+ left: 0px;
+ right: 0px;
+ background: rgba(255, 255, 255, 0.7);
+ }
+
+ .overlay-top {
+ text-align: left;
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ right: 0px;
+ background: rgba(255, 255, 255, 0.7);
+ }
+
+ .overlay-bottom {
+ position: absolute;
+ bottom: 0px;
+ left: 0px;
+ right: 0px;
+ background: rgba(255, 255, 255, 0.7);
+ padding: 8px;
+ }
+
+ .text-left {
+ float: left;
+ }
+
+ .text-right {
+ float: right;
+ }
+
+ .imagetitle {
+ padding: 8px;
+ }
+
+ .breadcrumb {
+ background-color: transparent;
+ margin: 0px;
+ }
+
+ .breadcrumb :before {
+ color: black;
+ }
}
/* dark theme */
diff --git a/web/templates/base.html b/web/templates/base.html
index 940370a..52efcb4 100644
--- a/web/templates/base.html
+++ b/web/templates/base.html
@@ -90,6 +90,7 @@