From d427ff992fbda08fa5388d22c09c3c474f3c849d Mon Sep 17 00:00:00 2001 From: Anders Bruun Severinsen <202204885@post.au.dk> Date: Sun, 3 Nov 2024 23:53:35 +0100 Subject: [PATCH 1/9] TK gallery port --- fredagscafeen/settings/base.py | 11 + fredagscafeen/settings/local.py | 2 + gallery/__init__.py | 0 gallery/admin.py | 93 ++++++ gallery/forms.py | 48 +++ gallery/management/__init__.py | 0 gallery/management/commands/__init__.py | 0 .../commands/delete_marked_images.py | 19 ++ gallery/models.py | 205 +++++++++++++ .../static/gallery/jquery.touchswipe.min.js | 1 + gallery/static/gallery/tkgal-visibility.js | 21 ++ gallery/static/gallery/tkgal.js | 118 ++++++++ gallery/templates/admin/gallery/add_form.html | 4 + .../templates/admin/gallery/change_form.html | 11 + .../templates/admin/gallery/upload_form.html | 45 +++ gallery/templates/album.html | 52 ++++ gallery/templates/gallery.html | 70 +++++ gallery/templates/image.html | 80 +++++ gallery/tests.py | 100 +++++++ gallery/urls.py | 24 ++ gallery/utils.py | 98 +++++++ gallery/views.py | 273 ++++++++++++++++++ locale/da/LC_MESSAGES/django.po | 27 +- locale/en/LC_MESSAGES/django.po | 115 ++++---- requirements.in | 4 + requirements.txt | 9 + web/static/css/stylesheet.css | 85 ++++++ web/templates/base.html | 1 + web/urls.py | 2 + 29 files changed, 1454 insertions(+), 64 deletions(-) create mode 100644 gallery/__init__.py create mode 100644 gallery/admin.py create mode 100644 gallery/forms.py create mode 100644 gallery/management/__init__.py create mode 100644 gallery/management/commands/__init__.py create mode 100644 gallery/management/commands/delete_marked_images.py create mode 100644 gallery/models.py create mode 100644 gallery/static/gallery/jquery.touchswipe.min.js create mode 100644 gallery/static/gallery/tkgal-visibility.js create mode 100644 gallery/static/gallery/tkgal.js create mode 100644 gallery/templates/admin/gallery/add_form.html create mode 100644 gallery/templates/admin/gallery/change_form.html create mode 100644 gallery/templates/admin/gallery/upload_form.html create mode 100644 gallery/templates/album.html create mode 100644 gallery/templates/gallery.html create mode 100644 gallery/templates/image.html create mode 100644 gallery/tests.py create mode 100644 gallery/urls.py create mode 100644 gallery/utils.py create mode 100644 gallery/views.py diff --git a/fredagscafeen/settings/base.py b/fredagscafeen/settings/base.py index 955ac69..e9ffbca 100644 --- a/fredagscafeen/settings/base.py +++ b/fredagscafeen/settings/base.py @@ -181,6 +181,9 @@ "events", "printer", "rosetta", + "sorl.thumbnail", + "jfu", + "gallery", ) MIDDLEWARE = ( @@ -227,6 +230,8 @@ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "django.template.context_processors.request", + "django.template.context_processors.static", ], }, }, @@ -317,3 +322,9 @@ X_FRAME_OPTIONS = "SAMEORIGIN" DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +THUMBNAIL_KVSTORE = "sorl.thumbnail.kvstores.dbm_kvstore.KVStore" +# THUMBNAIL_DBM_FILE = '/home/mftutor/web/thumbnails/thumbnail_kvstore' +THUMBNAIL_DBM_FILE = "/Users/andersseverinsen/Library/CloudStorage/OneDrive-Aarhusuniversitet/Uni/tutor/thumbnails/thumbnail_kvstore" + +THUMBNAIL_DEBUG = True diff --git a/fredagscafeen/settings/local.py b/fredagscafeen/settings/local.py index 9c32685..fb4c8ba 100644 --- a/fredagscafeen/settings/local.py +++ b/fredagscafeen/settings/local.py @@ -19,3 +19,5 @@ if sys.argv[1:2] != ["test"]: MIDDLEWARE += ("fredagscafeen.autologin.AutologinMiddleware",) + +YEAR = 2024 diff --git a/gallery/__init__.py b/gallery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gallery/admin.py b/gallery/admin.py new file mode 100644 index 0000000..02be282 --- /dev/null +++ b/gallery/admin.py @@ -0,0 +1,93 @@ +from django import forms +from django.contrib import admin +from django.db import models +from django.urls import reverse +from django.utils.html import format_html + +from gallery.models import Album, BaseMedia + + +class InlineBaseMediaAdmin(admin.TabularInline): + model = BaseMedia + extra = 0 + fields = ( + "admin_thumbnail", + "date", + "caption", + "visibility", + "slug", + "forcedOrder", + "isCoverFile", + ) + readonly_fields = ( + "admin_thumbnail", + "slug", + "isCoverFile", + ) + + def has_add_permission(self, request, obj=None): + return False + + +class AlbumAdminForm(forms.ModelForm): + class Meta: + model = Album + fields = [ + "title", + "publish_date", + "gfyear", + "eventalbum", + "description", + "slug", + ] + + +class AlbumAdmin(admin.ModelAdmin): + # List display of multiple albums + list_display = ("title", "gfyear", "publish_date", "get_visibility_link") + ordering = [ + "-gfyear", + "eventalbum", + "-oldFolder", + "-publish_date", + ] # Reverse of models.Album.ordering + list_filter = ("gfyear", "eventalbum") + + # Form display of single album + inlines = [InlineBaseMediaAdmin] + form = AlbumAdminForm + prepopulated_fields = { + "slug": ("title",), + } + formfield_overrides = { + models.SlugField: {"widget": forms.TextInput(attrs={"readOnly": "True"})} + } + + add_form_template = "admin/gallery/add_form.html" + + def get_inline_instances(self, request, obj=None): + if obj is None: + # When creating Album, don't display the BaseMedia inlines + return [] + return super(AlbumAdmin, self).get_inline_instances(request, obj) + + def get_visibility_link(self, album): + file = album.basemedia.first() + if file: + kwargs = dict( + gfyear=album.gfyear, album_slug=album.slug, image_slug=file.slug + ) + return format_html( + 'Udvælg billeder', reverse("image", kwargs=kwargs) + ) + + get_visibility_link.short_description = "Udvælg billeder" + + def save_related(self, request, form, formsets, change): + super().save_related(request, form, formsets, change) + # Update isCoverFile on all images in album + # now that images have been saved to the database. + form.instance.clean() + + +admin.site.register(Album, AlbumAdmin) diff --git a/gallery/forms.py b/gallery/forms.py new file mode 100644 index 0000000..61509cf --- /dev/null +++ b/gallery/forms.py @@ -0,0 +1,48 @@ +import re + +from django import forms + +from gallery.models import BaseMedia + + +class EditVisibilityForm(forms.Form): + class Missing(Exception): + pass + + def __init__(self, file_visibility, **kwargs): + super(EditVisibilityForm, self).__init__(**kwargs) + self.basemedias = [] + self.album_pks = set() + for file in file_visibility: + if isinstance(file, BaseMedia): + pk = file.pk + visibility = file.visibility + album = file.album_id + elif isinstance(file, tuple): + pk, visibility, album = file + else: + raise TypeError(type(file)) + k = "i%s" % pk + self.album_pks.add(album) + self.fields[k] = forms.ChoiceField( + choices=BaseMedia.VISIBILITY, + initial=visibility, + widget=forms.RadioSelect, + ) + self.basemedias.append((pk, k)) + + @classmethod + def from_POST(cls, post_data): + pattern = r"^i(\d+)$" + pks = [] + for k, v in post_data.items(): + mo = re.match(pattern, k) + if mo: + pks.append(int(mo.group(1))) + files = BaseMedia.objects.filter(pk__in=pks) + files = list(files.values_list("pk", "visibility", "album_id")) + found_pks = [f[0] for f in files] + missing = set(pks) - set(found_pks) + if missing: + raise cls.Missing("Not found: %r" % (sorted(missing),)) + return cls(files, data=post_data) diff --git a/gallery/management/__init__.py b/gallery/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gallery/management/commands/__init__.py b/gallery/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gallery/management/commands/delete_marked_images.py b/gallery/management/commands/delete_marked_images.py new file mode 100644 index 0000000..78e8d11 --- /dev/null +++ b/gallery/management/commands/delete_marked_images.py @@ -0,0 +1,19 @@ +import logging + +from django.core.management.base import BaseCommand + +from gallery.models import BaseMedia + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Slet billeder som er markeret "Slet"' + + def handle(self, *args, **options): + qs = BaseMedia.objects.filter(visibility=BaseMedia.DELETE) + n = qs.count() + if n: + ids = sorted(qs.values_list("id", flat=True)) + logger.info("Deleting %s BaseMedia objects with IDs %s", n, ids) + BaseMedia.objects.filter(id__in=ids).delete() diff --git a/gallery/models.py b/gallery/models.py new file mode 100644 index 0000000..7532262 --- /dev/null +++ b/gallery/models.py @@ -0,0 +1,205 @@ +import logging +import os +from datetime import date + +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.dispatch import receiver +from django.urls import reverse +from django.utils.html import format_html +from model_utils.managers import InheritanceManager +from six import python_2_unicode_compatible +from sorl.thumbnail import get_thumbnail + +from fredagscafeen.settings.base import MEDIA_ROOT +from gallery.utils import file_name, get_exif_date, get_gfyear, slugify + +FORCEDORDERMAX = 10000 + +logger = logging.getLogger(__name__) + + +@python_2_unicode_compatible +class Album(models.Model): + class Meta: + ordering = ["gfyear", "-eventalbum", "oldFolder", "publish_date"] + unique_together = (("gfyear", "slug"),) + + title = models.CharField(max_length=200, verbose_name="Titel") + publish_date = models.DateField( + blank=True, null=True, default=date.today, verbose_name="Udgivelsesdato" + ) + eventalbum = models.BooleanField(default=True, verbose_name="Arrangement") + gfyear = models.PositiveSmallIntegerField(default=get_gfyear, verbose_name="Årgang") + slug = models.SlugField(verbose_name="Kort titel") + description = models.TextField(blank=True, verbose_name="Beskrivelse") + + oldFolder = models.CharField(max_length=200, blank=True) + + def __str__(self): + return "%s: %s" % (self.gfyear, self.title) + + def clean(self): + for m in self.basemedia.all(): + m.isCoverFile = False + m.save() + + f = self.basemedia.filter(visibility=BaseMedia.PUBLIC).first() + if f: + f.isCoverFile = True + f.save() + + def get_absolute_url(self): + return reverse("album", kwargs={"gfyear": self.gfyear, "album_slug": self.slug}) + + +@python_2_unicode_compatible +class BaseMedia(models.Model): + class Meta: + ordering = ["forcedOrder", "date", "slug"] + unique_together = (("album", "slug"),) + + # Use the pre-1.6 save(). This is a workaround for + # https://github.com/TK-IT/web/issues/72 This can be removed when the + # upstream bug https://code.djangoproject.com/ticket/21670 is closed + select_on_save = True + + IMAGE = "I" + VIDEO = "V" + AUDIO = "A" + OTHER = "O" + TYPE_CHOICES = ( + (IMAGE, "Image"), + (VIDEO, "Video"), + (AUDIO, "Audio"), + (OTHER, "Other"), + ) + + PUBLIC = "public" + DISCARDED = "discarded" + SENSITIVE = "sensitive" + DELETE = "delete" + NEW = "new" + VISIBILITY = ( + (PUBLIC, "Synligt"), + (DISCARDED, "Frasorteret"), + (SENSITIVE, "Skjult"), + (DELETE, "Slet"), + (NEW, "Ubesluttet"), + ) + + type = models.CharField(max_length=1, choices=TYPE_CHOICES, default=OTHER) + + objects = InheritanceManager() + album = models.ForeignKey(Album, on_delete=models.CASCADE, related_name="basemedia") + + date = models.DateTimeField(null=True, blank=True, verbose_name="Dato") + visibility = models.CharField( + max_length=10, choices=VISIBILITY, verbose_name="Synlighed", default=NEW + ) + caption = models.CharField(max_length=200, blank=True, verbose_name="Overskrift") + + slug = models.SlugField(null=True, blank=True, verbose_name="Kort titel") + + forcedOrder = models.SmallIntegerField( + default=0, + validators=[ + MinValueValidator(-FORCEDORDERMAX), + MaxValueValidator(FORCEDORDERMAX), + ], + verbose_name="Rækkefølge", + ) + isCoverFile = models.NullBooleanField(null=True, verbose_name="Vis på forsiden") + + def admin_thumbnail(self): + if self.type == BaseMedia.IMAGE: + return self.image.admin_thumbnail() + + admin_thumbnail.short_description = "Thumbnail" + + @property + def notPublic(self): + return self.visibility != self.PUBLIC + + def __str__(self): + return "%s" % (self.slug) + + def get_absolute_url(self): + return reverse( + "image", + kwargs={ + "gfyear": self.album.gfyear, + "album_slug": self.album.slug, + "image_slug": self.slug, + }, + ) + + +class Image(BaseMedia): + class Meta: + # Use the pre-1.6 save(). This is a workaround for + # https://github.com/TK-IT/web/issues/72 This can be removed when the + # upstream bug https://code.djangoproject.com/ticket/21670 is closed + select_on_save = True + + objects = models.Manager() + file = models.ImageField( + upload_to=file_name, + blank=True, + ) + + def admin_thumbnail(self): + # print("Test:") + # img_path = os.path.join(MEDIA_ROOT, str(self.file)) + # print(str(img_path)) + return format_html('', self.file.url) + + admin_thumbnail.short_description = "Thumbnail" + + def clean(self): + self.type = BaseMedia.IMAGE + + if self.date == None: + self.date = get_exif_date(self.file) + + if self.slug == None: + if self.date == None: + self.slug = slugify( + os.path.splitext(os.path.basename(self.file.name))[0] + ) + else: + self.slug = self.date.strftime("%Y%m%d%H%M%S_%f")[ + : len("YYYYmmddHHMMSS_ff") + ] + + +class GenericFile(BaseMedia): + class Meta: + # Use the pre-1.6 save(). This is a workaround for + # https://github.com/TK-IT/web/issues/72 This can be removed when the + # upstream bug https://code.djangoproject.com/ticket/21670 is closed + select_on_save = True + + objects = models.Manager() + originalFile = models.FileField(upload_to=file_name, blank=True) + file = models.FileField(upload_to=file_name) + + def clean(self): + if self.slug == None: + if self.date == None: + sep = os.path.splitext(os.path.basename(self.file.name)) + self.slug = slugify(sep[0]) + sep[1] + self.forcedOrder = FORCEDORDERMAX + else: + self.slug = self.date.strftime("%Y%m%d%H%M%S_%f")[ + : len("YYYYmmddHHMMSS_ff") + ] + + +@receiver(models.signals.post_save, sender=BaseMedia) +@receiver(models.signals.post_save, sender=Image) +@receiver(models.signals.post_save, sender=GenericFile) +def cleanAlbum(sender, instance, **kwargs): + if instance.isCoverFile is None: + instance.album.full_clean() diff --git a/gallery/static/gallery/jquery.touchswipe.min.js b/gallery/static/gallery/jquery.touchswipe.min.js new file mode 100644 index 0000000..a76acaf --- /dev/null +++ b/gallery/static/gallery/jquery.touchswipe.min.js @@ -0,0 +1 @@ +(function(a){if(typeof define==="function"&&define.amd&&define.amd.jQuery){define(["jquery"],a)}else{a(jQuery)}}(function(f){var y="1.6.12",p="left",o="right",e="up",x="down",c="in",A="out",m="none",s="auto",l="swipe",t="pinch",B="tap",j="doubletap",b="longtap",z="hold",E="horizontal",u="vertical",i="all",r=10,g="start",k="move",h="end",q="cancel",a="ontouchstart" in window,v=window.navigator.msPointerEnabled&&!window.navigator.pointerEnabled,d=window.navigator.pointerEnabled||window.navigator.msPointerEnabled,C="TouchSwipe";var n={fingers:1,threshold:75,cancelThreshold:null,pinchThreshold:20,maxTimeThreshold:null,fingerReleaseThreshold:250,longTapThreshold:500,doubleTapThreshold:200,swipe:null,swipeLeft:null,swipeRight:null,swipeUp:null,swipeDown:null,swipeStatus:null,pinchIn:null,pinchOut:null,pinchStatus:null,click:null,tap:null,doubleTap:null,longTap:null,hold:null,triggerOnTouchEnd:true,triggerOnTouchLeave:false,allowPageScroll:"auto",fallbackToMouseEvents:true,excludedElements:"label, button, input, select, textarea, a, .noSwipe",preventDefaultEvents:true};f.fn.swipe=function(H){var G=f(this),F=G.data(C);if(F&&typeof H==="string"){if(F[H]){return F[H].apply(this,Array.prototype.slice.call(arguments,1))}else{f.error("Method "+H+" does not exist on jQuery.swipe")}}else{if(F&&typeof H==="object"){F.option.apply(this,arguments)}else{if(!F&&(typeof H==="object"||!H)){return w.apply(this,arguments)}}}return G};f.fn.swipe.version=y;f.fn.swipe.defaults=n;f.fn.swipe.phases={PHASE_START:g,PHASE_MOVE:k,PHASE_END:h,PHASE_CANCEL:q};f.fn.swipe.directions={LEFT:p,RIGHT:o,UP:e,DOWN:x,IN:c,OUT:A};f.fn.swipe.pageScroll={NONE:m,HORIZONTAL:E,VERTICAL:u,AUTO:s};f.fn.swipe.fingers={ONE:1,TWO:2,THREE:3,FOUR:4,FIVE:5,ALL:i};function w(F){if(F&&(F.allowPageScroll===undefined&&(F.swipe!==undefined||F.swipeStatus!==undefined))){F.allowPageScroll=m}if(F.click!==undefined&&F.tap===undefined){F.tap=F.click}if(!F){F={}}F=f.extend({},f.fn.swipe.defaults,F);return this.each(function(){var H=f(this);var G=H.data(C);if(!G){G=new D(this,F);H.data(C,G)}})}function D(a4,au){var au=f.extend({},au);var az=(a||d||!au.fallbackToMouseEvents),K=az?(d?(v?"MSPointerDown":"pointerdown"):"touchstart"):"mousedown",ax=az?(d?(v?"MSPointerMove":"pointermove"):"touchmove"):"mousemove",V=az?(d?(v?"MSPointerUp":"pointerup"):"touchend"):"mouseup",T=az?null:"mouseleave",aD=(d?(v?"MSPointerCancel":"pointercancel"):"touchcancel");var ag=0,aP=null,ac=0,a1=0,aZ=0,H=1,ap=0,aJ=0,N=null;var aR=f(a4);var aa="start";var X=0;var aQ={};var U=0,a2=0,a5=0,ay=0,O=0;var aW=null,af=null;try{aR.bind(K,aN);aR.bind(aD,a9)}catch(aj){f.error("events not supported "+K+","+aD+" on jQuery.swipe")}this.enable=function(){aR.bind(K,aN);aR.bind(aD,a9);return aR};this.disable=function(){aK();return aR};this.destroy=function(){aK();aR.data(C,null);aR=null};this.option=function(bc,bb){if(typeof bc==="object"){au=f.extend(au,bc)}else{if(au[bc]!==undefined){if(bb===undefined){return au[bc]}else{au[bc]=bb}}else{if(!bc){return au}else{f.error("Option "+bc+" does not exist on jQuery.swipe.options")}}}return null};function aN(bd){if(aB()){return}if(f(bd.target).closest(au.excludedElements,aR).length>0){return}var be=bd.originalEvent?bd.originalEvent:bd;var bc,bf=be.touches,bb=bf?bf[0]:be;aa=g;if(bf){X=bf.length}else{if(au.preventDefaultEvents!==false){bd.preventDefault()}}ag=0;aP=null;aJ=null;ac=0;a1=0;aZ=0;H=1;ap=0;N=ab();S();ai(0,bb);if(!bf||(X===au.fingers||au.fingers===i)||aX()){U=ar();if(X==2){ai(1,bf[1]);a1=aZ=at(aQ[0].start,aQ[1].start)}if(au.swipeStatus||au.pinchStatus){bc=P(be,aa)}}else{bc=false}if(bc===false){aa=q;P(be,aa);return bc}else{if(au.hold){af=setTimeout(f.proxy(function(){aR.trigger("hold",[be.target]);if(au.hold){bc=au.hold.call(aR,be,be.target)}},this),au.longTapThreshold)}an(true)}return null}function a3(be){var bh=be.originalEvent?be.originalEvent:be;if(aa===h||aa===q||al()){return}var bd,bi=bh.touches,bc=bi?bi[0]:bh;var bf=aH(bc);a2=ar();if(bi){X=bi.length}if(au.hold){clearTimeout(af)}aa=k;if(X==2){if(a1==0){ai(1,bi[1]);a1=aZ=at(aQ[0].start,aQ[1].start)}else{aH(bi[1]);aZ=at(aQ[0].end,aQ[1].end);aJ=aq(aQ[0].end,aQ[1].end)}H=a7(a1,aZ);ap=Math.abs(a1-aZ)}if((X===au.fingers||au.fingers===i)||!bi||aX()){aP=aL(bf.start,bf.end);ak(be,aP);ag=aS(bf.start,bf.end);ac=aM();aI(aP,ag);if(au.swipeStatus||au.pinchStatus){bd=P(bh,aa)}if(!au.triggerOnTouchEnd||au.triggerOnTouchLeave){var bb=true;if(au.triggerOnTouchLeave){var bg=aY(this);bb=F(bf.end,bg)}if(!au.triggerOnTouchEnd&&bb){aa=aC(k)}else{if(au.triggerOnTouchLeave&&!bb){aa=aC(h)}}if(aa==q||aa==h){P(bh,aa)}}}else{aa=q;P(bh,aa)}if(bd===false){aa=q;P(bh,aa)}}function M(bb){var bc=bb.originalEvent?bb.originalEvent:bb,bd=bc.touches;if(bd){if(bd.length&&!al()){G();return true}else{if(bd.length&&al()){return true}}}if(al()){X=ay}a2=ar();ac=aM();if(ba()||!am()){aa=q;P(bc,aa)}else{if(au.triggerOnTouchEnd||(au.triggerOnTouchEnd==false&&aa===k)){if(au.preventDefaultEvents!==false){bb.preventDefault()}aa=h;P(bc,aa)}else{if(!au.triggerOnTouchEnd&&a6()){aa=h;aF(bc,aa,B)}else{if(aa===k){aa=q;P(bc,aa)}}}}an(false);return null}function a9(){X=0;a2=0;U=0;a1=0;aZ=0;H=1;S();an(false)}function L(bb){var bc=bb.originalEvent?bb.originalEvent:bb;if(au.triggerOnTouchLeave){aa=aC(h);P(bc,aa)}}function aK(){aR.unbind(K,aN);aR.unbind(aD,a9);aR.unbind(ax,a3);aR.unbind(V,M);if(T){aR.unbind(T,L)}an(false)}function aC(bf){var be=bf;var bd=aA();var bc=am();var bb=ba();if(!bd||bb){be=q}else{if(bc&&bf==k&&(!au.triggerOnTouchEnd||au.triggerOnTouchLeave)){be=h}else{if(!bc&&bf==h&&au.triggerOnTouchLeave){be=q}}}return be}function P(bd,bb){var bc,be=bd.touches;if((J()&&W())||(Q()&&aX())){if(J()&&W()){bc=aF(bd,bb,l)}if((Q()&&aX())&&bc!==false){bc=aF(bd,bb,t)}}else{if(aG()&&bc!==false){bc=aF(bd,bb,j)}else{if(ao()&&bc!==false){bc=aF(bd,bb,b)}else{if(ah()&&bc!==false){bc=aF(bd,bb,B)}}}}if(bb===q){if(W()){bc=aF(bd,bb,l)}if(aX()){bc=aF(bd,bb,t)}a9(bd)}if(bb===h){if(be){if(!be.length){a9(bd)}}else{a9(bd)}}return bc}function aF(be,bb,bd){var bc;if(bd==l){aR.trigger("swipeStatus",[bb,aP||null,ag||0,ac||0,X,aQ]);if(au.swipeStatus){bc=au.swipeStatus.call(aR,be,bb,aP||null,ag||0,ac||0,X,aQ);if(bc===false){return false}}if(bb==h&&aV()){aR.trigger("swipe",[aP,ag,ac,X,aQ]);if(au.swipe){bc=au.swipe.call(aR,be,aP,ag,ac,X,aQ);if(bc===false){return false}}switch(aP){case p:aR.trigger("swipeLeft",[aP,ag,ac,X,aQ]);if(au.swipeLeft){bc=au.swipeLeft.call(aR,be,aP,ag,ac,X,aQ)}break;case o:aR.trigger("swipeRight",[aP,ag,ac,X,aQ]);if(au.swipeRight){bc=au.swipeRight.call(aR,be,aP,ag,ac,X,aQ)}break;case e:aR.trigger("swipeUp",[aP,ag,ac,X,aQ]);if(au.swipeUp){bc=au.swipeUp.call(aR,be,aP,ag,ac,X,aQ)}break;case x:aR.trigger("swipeDown",[aP,ag,ac,X,aQ]);if(au.swipeDown){bc=au.swipeDown.call(aR,be,aP,ag,ac,X,aQ)}break}}}if(bd==t){aR.trigger("pinchStatus",[bb,aJ||null,ap||0,ac||0,X,H,aQ]);if(au.pinchStatus){bc=au.pinchStatus.call(aR,be,bb,aJ||null,ap||0,ac||0,X,H,aQ);if(bc===false){return false}}if(bb==h&&a8()){switch(aJ){case c:aR.trigger("pinchIn",[aJ||null,ap||0,ac||0,X,H,aQ]);if(au.pinchIn){bc=au.pinchIn.call(aR,be,aJ||null,ap||0,ac||0,X,H,aQ)}break;case A:aR.trigger("pinchOut",[aJ||null,ap||0,ac||0,X,H,aQ]);if(au.pinchOut){bc=au.pinchOut.call(aR,be,aJ||null,ap||0,ac||0,X,H,aQ)}break}}}if(bd==B){if(bb===q||bb===h){clearTimeout(aW);clearTimeout(af);if(Z()&&!I()){O=ar();aW=setTimeout(f.proxy(function(){O=null;aR.trigger("tap",[be.target]);if(au.tap){bc=au.tap.call(aR,be,be.target)}},this),au.doubleTapThreshold)}else{O=null;aR.trigger("tap",[be.target]);if(au.tap){bc=au.tap.call(aR,be,be.target)}}}}else{if(bd==j){if(bb===q||bb===h){clearTimeout(aW);O=null;aR.trigger("doubletap",[be.target]);if(au.doubleTap){bc=au.doubleTap.call(aR,be,be.target)}}}else{if(bd==b){if(bb===q||bb===h){clearTimeout(aW);O=null;aR.trigger("longtap",[be.target]);if(au.longTap){bc=au.longTap.call(aR,be,be.target)}}}}}return bc}function am(){var bb=true;if(au.threshold!==null){bb=ag>=au.threshold}return bb}function ba(){var bb=false;if(au.cancelThreshold!==null&&aP!==null){bb=(aT(aP)-ag)>=au.cancelThreshold}return bb}function ae(){if(au.pinchThreshold!==null){return ap>=au.pinchThreshold}return true}function aA(){var bb;if(au.maxTimeThreshold){if(ac>=au.maxTimeThreshold){bb=false}else{bb=true}}else{bb=true}return bb}function ak(bb,bc){if(au.preventDefaultEvents===false){return}if(au.allowPageScroll===m){bb.preventDefault()}else{var bd=au.allowPageScroll===s;switch(bc){case p:if((au.swipeLeft&&bd)||(!bd&&au.allowPageScroll!=E)){bb.preventDefault()}break;case o:if((au.swipeRight&&bd)||(!bd&&au.allowPageScroll!=E)){bb.preventDefault()}break;case e:if((au.swipeUp&&bd)||(!bd&&au.allowPageScroll!=u)){bb.preventDefault()}break;case x:if((au.swipeDown&&bd)||(!bd&&au.allowPageScroll!=u)){bb.preventDefault()}break}}}function a8(){var bc=aO();var bb=Y();var bd=ae();return bc&&bb&&bd}function aX(){return !!(au.pinchStatus||au.pinchIn||au.pinchOut)}function Q(){return !!(a8()&&aX())}function aV(){var be=aA();var bg=am();var bd=aO();var bb=Y();var bc=ba();var bf=!bc&&bb&&bd&&bg&&be;return bf}function W(){return !!(au.swipe||au.swipeStatus||au.swipeLeft||au.swipeRight||au.swipeUp||au.swipeDown)}function J(){return !!(aV()&&W())}function aO(){return((X===au.fingers||au.fingers===i)||!a)}function Y(){return aQ[0].end.x!==0}function a6(){return !!(au.tap)}function Z(){return !!(au.doubleTap)}function aU(){return !!(au.longTap)}function R(){if(O==null){return false}var bb=ar();return(Z()&&((bb-O)<=au.doubleTapThreshold))}function I(){return R()}function aw(){return((X===1||!a)&&(isNaN(ag)||agau.longTapThreshold)&&(ag=0)){return p}else{if((bd<=360)&&(bd>=315)){return p}else{if((bd>=135)&&(bd<=225)){return o}else{if((bd>45)&&(bd<135)){return x}else{return e}}}}}function ar(){var bb=new Date();return bb.getTime()}function aY(bb){bb=f(bb);var bd=bb.offset();var bc={left:bd.left,right:bd.left+bb.outerWidth(),top:bd.top,bottom:bd.top+bb.outerHeight()};return bc}function F(bb,bc){return(bb.x>bc.left&&bb.xbc.top&&bb.y *:not(.hidden) input[type=radio]'); + var target = radios.eq(optionIndex - 1).val(); + radios.val([target]); + } +}); + +function any_visibility_changed() { + var radios = $('#tkgal-container input[type=radio]').toArray(); + for (var i = 0; i < radios.length; ++i) + if (radios[i].checked !== radios[i].defaultChecked) + return true; + return false; +} + +$(window).on('beforeunload', function () { + if (any_visibility_changed()) + return 'Du mangler at gemme dine ændringer til billedernes synlighed'; +}); diff --git a/gallery/static/gallery/tkgal.js b/gallery/static/gallery/tkgal.js new file mode 100644 index 0000000..238762f --- /dev/null +++ b/gallery/static/gallery/tkgal.js @@ -0,0 +1,118 @@ +$(document).ready(function() { + // Set the navbar position as absolute when viewing images. This prevents + // the navbar from obscuring the image when using pinch to zoom. + $(".navbar-fixed-top").css("position", "absolute"); + + // Get array of all slugs + var slugs = $("#tkgal-container > *").map(function() { + return $(this).attr("data-permlink"); + }).get(); + + // Call changeCurrent on click on the controls + $(".tkgal-control").click(function(e) { + e.preventDefault(); + pauseMedia(); + changeCurrent($(this).attr("href")); + }); + + + function changeCurrent(newimage) { + // Calculate prev and next images + var l = slugs.length; + var i = slugs.indexOf(newimage); + var prev = slugs[(((i-1)%l)+l)%l]; // mod is broken for negative numbers + var next = slugs[(i+1)%l]; + + // Update visibility of current picture + $("#tkgal-container>*").addClass("hidden"); + $("#tkgal-caption-container>*").addClass("hidden"); + $("[data-permlink='"+newimage+"']").removeClass("hidden"); + + function deferMedia(file) { + // This removes the data- prefix from 'file' causing the browser to + // request the files. + var img = $("[data-permlink='"+file+"'] [data-src]"); + + if(img.attr('data-srcset')){ + img.attr('srcset', img.attr('data-srcset')); + img.removeAttr('data-srcset'); + } + if(img.attr('data-sizes')){ + img.attr('sizes', img.attr('data-sizes')); + img.removeAttr('data-sizes'); + } + if(img.attr('data-src')){ + img.attr('src', img.attr('data-src')); + img.removeAttr('data-src'); + } + } + deferMedia(prev); + deferMedia(next); + + // Rewrite history + window.history.replaceState(null, null, newimage + location.search); + } + + // Call swipehandler on swipe + // This requires jquery touchswipe + $("body").swipe( { // register swipe anywhere in body + swipeLeft:swipehandler, + swipeRight:swipehandler, + allowPageScroll:"auto", + fingers:1 + }); + + function swipehandler(event, direction) { + switch(direction) { + case "left": + $(".tkgal-next:visible").eq(0).click(); + break; + case "right": + $(".tkgal-prev:visible").eq(0).click(); + break; + default: + } + } + + //Load neighbors on pageload + loadfirst = $("#tkgal-container > :not(.hidden)").attr("data-permlink"); + changeCurrent(loadfirst); +}); + +function pauseMedia() { + $("video, audio").each(function(){ + $(this).get(0).pause(); + }); +} + +function togglePlay() { + $(":not(.hidden) > * > audio, :not(.hidden) > * > video").each(function(){ + if (this.paused ? this.play() : this.pause()); + }); +} + +// simulate link press when arrow keys are pressed +$(document).keydown(function(e) { + switch(e.which) { + case 37: // left + $(".tkgal-prev:visible").eq(0).click(); + break; + case 39: // right + $(".tkgal-next:visible").eq(0).click(); + break; + case 32: // space + togglePlay(); + if(e.target == document.body) { + e.preventDefault(); + } + break; + case 27: // ESC + $("#albumlink").eq(0).click(); + if(e.target == document.body) { + e.preventDefault(); + } + break; + } +}); + +$('video').click(function(){ togglePlay(); }); diff --git a/gallery/templates/admin/gallery/add_form.html b/gallery/templates/admin/gallery/add_form.html new file mode 100644 index 0000000..397f59f --- /dev/null +++ b/gallery/templates/admin/gallery/add_form.html @@ -0,0 +1,4 @@ +{% extends "admin/change_form.html" %} +{% block form_top %} +

Du kan tilføje billeder efter du har trykket på "Gem og forsæt med at redigere".

+{% endblock %} diff --git a/gallery/templates/admin/gallery/change_form.html b/gallery/templates/admin/gallery/change_form.html new file mode 100644 index 0000000..4f8aae1 --- /dev/null +++ b/gallery/templates/admin/gallery/change_form.html @@ -0,0 +1,11 @@ +{% extends "admin/change_form.html" %} +{% load jfutags %} +{% block content %} +{{ block.super }} +
+{% if object_id %} +{% jfu 'admin/gallery/upload_form.html' %} +{% else %} +

Du kan tilføje billeder efter du har trykket på "Gem og forsæt med at redigere".

+{% endif %} +{% endblock %} diff --git a/gallery/templates/admin/gallery/upload_form.html b/gallery/templates/admin/gallery/upload_form.html new file mode 100644 index 0000000..5271ed0 --- /dev/null +++ b/gallery/templates/admin/gallery/upload_form.html @@ -0,0 +1,45 @@ +{% extends 'jfu/upload_form.html' %} + +{% block CSS_BOOTSTRAP %} + +{% endblock %} + +{% block UPLOAD_FORM_BUTTON_BAR_CONTROL %} + +{% endblock %} + +{% block JS_INIT %} + + $('#fileupload').fileupload({ + + formData: [ + { name: "csrfmiddlewaretoken", value: "{{ csrf_token }}" }, + { name: "object_id", value: "{{ object_id }}" }, + ], + + {% block JS_OPTS %} + autoUpload: true, + {% endblock %} + + }); +{% endblock JS_INIT %} + + +{% comment %} + Remove all blueimp gallery related stuf. +{% endcomment %} + +{% block CSS_BLUEIMP_GALLERY %} +{% endblock %} + +{% block MODAL_GALLERY %} +{% endblock %} + +{% block JS_BLUEIMP_GALLERY %} +{% endblock %} diff --git a/gallery/templates/album.html b/gallery/templates/album.html new file mode 100644 index 0000000..6cb65de --- /dev/null +++ b/gallery/templates/album.html @@ -0,0 +1,52 @@ +{% extends 'base.html' %} + +{% block title %}{{ album.gfyear }} / {{ album.title }}{% endblock title %} + +{% block canonical_url %}{% url 'album' gfyear=album.gfyear album_slug=album.slug %}{% endblock canonical_url %} + +{% block content %} + +{% if edit_visibility_link %} +

BEST: Udvælg billeder

+

{{ visible_count }} synlig{{ visible_count|pluralize:'e' }} +og {{ hidden_count }} skjult{{ hidden_count|pluralize:'e' }} +hvoraf {{ new_count }} er ny{{ new_count|pluralize:'e' }}. +

+
+ {% csrf_token %} + +
+{% endif %} +
+
+ +
+
+

+ {{ files|length }} billede{{ files|length|pluralize:"r" }}{% if album.publish_date %} fra d. {{ album.publish_date }}{% endif %} +

+
+
+
+ {% for file in files %} + + {% empty %} +

Albummet '{{ album.title }}' har ingen billeder endnu.

+ {% endfor %} +
+
+ Tilbage til {{ album.gfyear }} +
+{% endblock content%} diff --git a/gallery/templates/gallery.html b/gallery/templates/gallery.html new file mode 100644 index 0000000..53ea247 --- /dev/null +++ b/gallery/templates/gallery.html @@ -0,0 +1,70 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block title %}{{ show_year }}{% endblock title %} + +{% block canonical_url %}{% url 'gfyear' gfyear=show_year %}{% endblock canonical_url %} + +{% block content %} +
+
+ {% for year in years %} +
+
+

+ + {{ year }} + + {% if user.is_staff %} + + {% translate "Edit" %} + + {% endif %} +

+
+
+ {% if year == show_year %} +
+
+
+ {% for album, firstFile in albumSets %} + {% ifchanged album.eventalbum %}{% if not album.eventalbum %} +
+
+
+

Årets gang:

+
+ +
+
+ {% endif %} +
+
+ {% empty %} +
+

+ {% translate "Ingen albums med billeder fundet." %} +

+
+ {% endfor %} +
+
+{% endblock content%} diff --git a/gallery/templates/image.html b/gallery/templates/image.html new file mode 100644 index 0000000..f28ac45 --- /dev/null +++ b/gallery/templates/image.html @@ -0,0 +1,80 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}{{ album.title }}{% endblock title %} +{% block navbar_class %}navbar-static-top{% endblock navbar_class %} + +{% block js %} + + + {% if edit_visibility %} + + + {% endif %} +{% endblock js %} + +{% block canonical_url %}{% url 'image' gfyear=album.gfyear album_slug=album.slug image_slug=start_file.slug %}{% endblock canonical_url %} + +{% block opengraph %} + + + +{% endblock opengraph %} + +{% block content %} +{% if edit_visibility %} +
{% csrf_token %} +{% endif %} +
+ {% for file, next_file, prev_file in file_orders %} +
+
+ {{ album.slug }}/{{ file.slug }} +
+ + {% if edit_visibility %} +
{{ file.visibility_field }}
+ + {% endif %} +
+
+
+
{{ file.date|default_if_none:"" }}
+ +
+ {% if file_count > 1 %} + | + + {% endif %} +
+
+
+
+
+ {% endfor %} +
+{% if edit_visibility %}
{% endif %} + +
+
+ {% for file, next_file, prev_file in file_orders %} + + {% 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 @@

  • {% translate "Bestyrelsen" %}
  • {% translate "Events" %}
  • +
  • {% translate "Gallery" %}
  • {% translate "Sortiment" %}
  • {% translate "Om baren" %}