diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index d3b8746..1c2c400 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -3,94 +3,155 @@ name: cicd on: push: branches: - - development - - master + - main + pull_request: + branches: + - main jobs: + test: + runs-on: ubuntu-latest + env: + DOCKER_BUILDKIT: "1" + COMPOSE_DOCKER_CLI_BUILD: "1" + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - run: docker compose -f docker-compose.test.yml up --exit-code-from test + build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest + if: ${{ github.event_name == 'push' }} env: SKAFFOLD_DEFAULT_REPO: ghcr.io/con2 steps: - - uses: actions/checkout@v1 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: '3.x' - - uses: pairity/setup-cd-tools@30acb848f9ff747aff4810dac40c5cc0971f485d + + - id: cache-bin + uses: actions/cache@v3 with: - skaffold: '1.20.0' - - uses: docker/login-action@v1 + path: bin + key: ${{ runner.os }}-bin-2 + - if: steps.cache-bin.outputs.cache-hit != 'true' + run: | + mkdir bin + curl -Lo bin/skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64 + curl -Lo bin/kubectl https://dl.k8s.io/release/v1.22.0/bin/linux/amd64/kubectl + chmod +x bin/skaffold bin/kubectl + - run: echo "$PWD/bin" >> $GITHUB_PATH + + - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ secrets.GHCR_USERNAME }} password: ${{ secrets.GHCR_PASSWORD }} - - uses: docker/setup-buildx-action@v1 + - uses: docker/setup-buildx-action@v3 - id: build run: | python3 -m pip install -U pip setuptools wheel python3 -m pip install emskaffolden emskaffolden -E staging -- build --file-output build.json - echo "::set-output name=build_json::$(base64 -w 0 < build.json)" - outputs: - build_json: ${{ steps.build.outputs.build_json }} + - uses: actions/upload-artifact@v3 + with: + name: build-json + path: build.json # TODO DRY deploy_staging: runs-on: self-hosted needs: build - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/development' }} + environment: staging + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: - - uses: actions/checkout@v1 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v3 + with: + name: build-json + - uses: actions/setup-python@v5 with: python-version: '3.x' - - uses: pairity/setup-cd-tools@30acb848f9ff747aff4810dac40c5cc0971f485d + + - id: cache-bin + uses: actions/cache@v3 with: - kubectl: '1.20.4' - skaffold: '1.20.0' - - uses: docker/setup-buildx-action@v1 + path: bin + key: ${{ runner.os }}-bin-2 + - if: steps.cache-bin.outputs.cache-hit != 'true' + run: | + mkdir bin + curl -Lo bin/skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64 + curl -Lo bin/kubectl https://dl.k8s.io/release/v1.22.0/bin/linux/amd64/kubectl + chmod +x bin/skaffold bin/kubectl + - run: echo "$PWD/bin" >> $GITHUB_PATH + - run: | python3 -m pip install -U pip setuptools wheel python3 -m pip install emskaffolden - base64 -d <<< "${{ needs.build.outputs.build_json }}" > build.json emskaffolden -E staging -- deploy -n conikuvat-staging -a build.json deploy_conikuvat: runs-on: self-hosted needs: build - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} + environment: production + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: - - uses: actions/checkout@v1 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v3 + with: + name: build-json + - uses: actions/setup-python@v5 with: python-version: '3.x' - - uses: pairity/setup-cd-tools@30acb848f9ff747aff4810dac40c5cc0971f485d + + - id: cache-bin + uses: actions/cache@v3 with: - kubectl: '1.20.4' - skaffold: '1.20.0' - - uses: docker/setup-buildx-action@v1 + path: bin + key: ${{ runner.os }}-bin-2 + - if: steps.cache-bin.outputs.cache-hit != 'true' + run: | + mkdir bin + curl -Lo bin/skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64 + curl -Lo bin/kubectl https://dl.k8s.io/release/v1.22.0/bin/linux/amd64/kubectl + chmod +x bin/skaffold bin/kubectl + - run: echo "$PWD/bin" >> $GITHUB_PATH + - run: | python3 -m pip install -U pip setuptools wheel python3 -m pip install emskaffolden - base64 -d <<< "${{ needs.build.outputs.build_json }}" > build.json emskaffolden -E production -- deploy -n conikuvat-production -a build.json deploy_larppikuvat: runs-on: self-hosted needs: build - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} + environment: production + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: - - uses: actions/checkout@v1 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v3 + with: + name: build-json + - uses: actions/setup-python@v5 with: python-version: '3.x' - - uses: pairity/setup-cd-tools@30acb848f9ff747aff4810dac40c5cc0971f485d + + - id: cache-bin + uses: actions/cache@v3 with: - kubectl: '1.20.4' - skaffold: '1.20.0' - - uses: docker/setup-buildx-action@v1 + path: bin + key: ${{ runner.os }}-bin-2 + - if: steps.cache-bin.outputs.cache-hit != 'true' + run: | + mkdir bin + curl -Lo bin/skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64 + curl -Lo bin/kubectl https://dl.k8s.io/release/v1.22.0/bin/linux/amd64/kubectl + chmod +x bin/skaffold bin/kubectl + - run: echo "$PWD/bin" >> $GITHUB_PATH + - run: | python3 -m pip install -U pip setuptools wheel python3 -m pip install emskaffolden - base64 -d <<< "${{ needs.build.outputs.build_json }}" > build.json emskaffolden -E larppikuvat -- deploy -n larppikuvat -a build.json diff --git a/backend/Dockerfile b/backend/Dockerfile index 1abbca4..ebe728b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11 +FROM python:3.12 WORKDIR /usr/src/app COPY requirements.txt /usr/src/app/ RUN groupadd -g 1082 -r conikuvat && useradd -r -u 1082 -g conikuvat -G users conikuvat && \ diff --git a/backend/README.md b/backend/README.md index 43298c7..a99e6a1 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,7 +4,7 @@ This is the REST API backend for Edegal. Technology choices include the following: -* Python (3.11+) +* Python (3.12+) * Django (4.2+) * PostgreSQL (15+) * Redis diff --git a/backend/edegal/admin.py b/backend/edegal/admin.py index d905fa2..3ddc135 100644 --- a/backend/edegal/admin.py +++ b/backend/edegal/admin.py @@ -68,27 +68,34 @@ class Meta: model = Album +@admin.action( + description="Make not public, not visible" +) def make_not_public_not_visible(modeladmin, request, queryset): return queryset.update(is_public=False, is_visible=False) -make_not_public_not_visible.short_description = "Make not public, not visible" +@admin.action( + description="Make public but not visible" +) def make_public_but_not_visible(modeladmin, request, queryset): return queryset.update(is_public=True, is_visible=False) -make_public_but_not_visible.short_description = "Make public but not visible" +@admin.action( + description="Make public and visible" +) def make_public_and_visible(modeladmin, request, queryset): return queryset.update(is_public=True, is_visible=True) -make_public_and_visible.short_description = "Make public and visible" +@admin.register(Album) class AlbumAdmin(MultiUploadAdmin): model = Album form = AlbumAdminForm @@ -198,10 +205,12 @@ def get_queryset(self, request): qs = super().get_queryset(request) return qs.annotate(num_pictures=Count("pictures")) + @admin.display( + description="Pictures" + ) def admin_get_num_pictures(self, obj): return obj.num_pictures - admin_get_num_pictures.short_description = "Pictures" class MediaInline(admin.TabularInline): @@ -214,6 +223,7 @@ class MediaInline(admin.TabularInline): show_change_link = False +@admin.register(Picture) class PictureAdmin(admin.ModelAdmin): model = Picture readonly_fields = ("path", "taken_at", "created_at", "updated_at", "created_by") @@ -223,20 +233,25 @@ class PictureAdmin(admin.ModelAdmin): inlines = (MediaInline,) +@admin.action( + description="Activate selected media specs" +) def activate_media_specs(modeladmin, request, queryset): queryset.update(active=True) -activate_media_specs.short_description = "Activate selected media specs" +@admin.action( + description="Deactivate selected media specs" +) def deactivate_media_specs(modeladmin, request, queryset): queryset.update(active=False) -deactivate_media_specs.short_description = "Deactivate selected media specs" +@admin.register(MediaSpec) class MediaSpecAdmin(admin.ModelAdmin): model = MediaSpec list_display = ("role", "max_width", "max_height", "quality", "format", "active") @@ -277,6 +292,7 @@ class Meta: photographer_inlines.append(LarppikuvatPhotographerProfileInlineAdmin) +@admin.register(Photographer) class PhotographerAdmin(admin.ModelAdmin): model = Photographer form = PhotographerAdminForm @@ -308,6 +324,7 @@ class Meta: model = Series +@admin.register(Series) class SeriesAdmin(admin.ModelAdmin): model = Series form = SeriesAdminForm @@ -321,6 +338,7 @@ def save_model(self, request, obj, form, change): return super().save_model(request, obj, form, change) +@admin.register(TermsAndConditions) class TermsAndConditionsAdmin(admin.ModelAdmin): model = TermsAndConditions list_display = ("admin_get_abridged_text", "url", "user", "is_public") @@ -346,6 +364,7 @@ def has_delete_permission(self, request, obj): return False +@admin.register(ImportJob) class ImportJobAdmin(admin.ModelAdmin): model = ImportJob inlines = (ImportItemInline,) @@ -374,13 +393,6 @@ def has_change_permission(self, request, obj=None): return False -admin.site.register(Album, AlbumAdmin) -admin.site.register(MediaSpec, MediaSpecAdmin) -admin.site.register(Photographer, PhotographerAdmin) -admin.site.register(Picture, PictureAdmin) -admin.site.register(Series, SeriesAdmin) -admin.site.register(TermsAndConditions, TermsAndConditionsAdmin) -admin.site.register(ImportJob, ImportJobAdmin) admin.site.site_header = "Edegal Admin" admin.site.site_title = "Edegal Admin" diff --git a/backend/edegal/migrations/0001_initial.py b/backend/edegal/migrations/0001_initial.py index 7a356e1..1851ace 100644 --- a/backend/edegal/migrations/0001_initial.py +++ b/backend/edegal/migrations/0001_initial.py @@ -2,16 +2,16 @@ # Generated by Django 1.11 on 2017-09-30 10:05 from __future__ import unicode_literals -from django.conf import settings import django.core.validators -from django.db import migrations, models import django.db.models.deletion -import edegal.models.terms_and_conditions import mptt.fields +from django.conf import settings +from django.db import migrations, models +import edegal.models.terms_and_conditions -class Migration(migrations.Migration): +class Migration(migrations.Migration): initial = True dependencies = [ @@ -20,117 +20,349 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Album', + name="Album", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('slug', models.CharField(blank=True, help_text='Tekninen nimi eli "slug" näkyy URL-osoitteissa. Sallittuja merkkejä ovat pienet kirjaimet, numerot ja väliviiva. Jos jätät teknisen nimen tyhjäksi, se generoidaan automaattisesti otsikosta. Jos muutat teknistä nimeä julkaisun jälkeen, muista luoda tarvittavat uudelleenohjaukset.', max_length=63, validators=[django.core.validators.RegexValidator(message='Tekninen nimi saa sisältää vain pieniä kirjaimia, numeroita sekä väliviivoja.', regex='[a-z0-9-]+')], verbose_name='Tekninen nimi')), - ('path', models.CharField(help_text='Polku määritetään automaattisesti teknisen nimen perusteella.', max_length=1023, unique=True, validators=[django.core.validators.RegexValidator(message='Polku saa sisältää vain pieniä kirjaimia, numeroita, väliviivoja sekä kauttaviivoja.', regex='[a-z0-9-/]+')], verbose_name='Polku')), - ('title', models.CharField(help_text='Otsikko näytetään automaattisesti sivun ylälaidassa sekä valikossa.', max_length=1023, verbose_name='Otsikko')), - ('description', models.TextField(blank=True, default='', help_text='Näkyy mm. hakukoneille sekä RSS-asiakasohjelmille.', verbose_name='Kuvaus')), - ('body', models.TextField(blank=True, default='', help_text='Albumilla voi olla tekstisisältöä, jolloin se näytetään albuminäkymän yläosassa ennen ala-albumeja ja kuvia.', verbose_name='Tekstisisältö')), - ('is_public', models.BooleanField(default=True, help_text='Ei-julkiset albumit näkyvät vain ylläpitokäyttäjille.', verbose_name='Julkinen')), - ('lft', models.PositiveIntegerField(db_index=True, editable=False)), - ('rght', models.PositiveIntegerField(db_index=True, editable=False)), - ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), - ('level', models.PositiveIntegerField(db_index=True, editable=False)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "slug", + models.CharField( + blank=True, + help_text='Tekninen nimi eli "slug" näkyy URL-osoitteissa. Sallittuja merkkejä ovat pienet kirjaimet, numerot ja väliviiva. Jos jätät teknisen nimen tyhjäksi, se generoidaan automaattisesti otsikosta. Jos muutat teknistä nimeä julkaisun jälkeen, muista luoda tarvittavat uudelleenohjaukset.', + max_length=63, + validators=[ + django.core.validators.RegexValidator( + message="Tekninen nimi saa sisältää vain pieniä kirjaimia, numeroita sekä väliviivoja.", + regex="[a-z0-9-]+", + ) + ], + verbose_name="Tekninen nimi", + ), + ), + ( + "path", + models.CharField( + help_text="Polku määritetään automaattisesti teknisen nimen perusteella.", + max_length=1023, + unique=True, + validators=[ + django.core.validators.RegexValidator( + message="Polku saa sisältää vain pieniä kirjaimia, numeroita, väliviivoja sekä kauttaviivoja.", + regex="[a-z0-9-/]+", + ) + ], + verbose_name="Polku", + ), + ), + ( + "title", + models.CharField( + help_text="Otsikko näytetään automaattisesti sivun ylälaidassa sekä valikossa.", + max_length=1023, + verbose_name="Otsikko", + ), + ), + ( + "description", + models.TextField( + blank=True, + default="", + help_text="Näkyy mm. hakukoneille sekä RSS-asiakasohjelmille.", + verbose_name="Kuvaus", + ), + ), + ( + "body", + models.TextField( + blank=True, + default="", + help_text="Albumilla voi olla tekstisisältöä, jolloin se näytetään albuminäkymän yläosassa ennen ala-albumeja ja kuvia.", + verbose_name="Tekstisisältö", + ), + ), + ( + "is_public", + models.BooleanField( + default=True, + help_text="Ei-julkiset albumit näkyvät vain ylläpitokäyttäjille.", + verbose_name="Julkinen", + ), + ), + ("lft", models.PositiveIntegerField(db_index=True, editable=False)), + ("rght", models.PositiveIntegerField(db_index=True, editable=False)), + ("tree_id", models.PositiveIntegerField(db_index=True, editable=False)), + ("level", models.PositiveIntegerField(db_index=True, editable=False)), ], options={ - 'verbose_name': 'Albumi', - 'verbose_name_plural': 'Albumit', + "verbose_name": "Albumi", + "verbose_name_plural": "Albumit", }, ), migrations.CreateModel( - name='Media', + name="Media", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('width', models.PositiveIntegerField(default=0)), - ('height', models.PositiveIntegerField(default=0)), - ('src', models.ImageField(height_field='height', max_length=255, null=True, upload_to='', width_field='width')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("width", models.PositiveIntegerField(default=0)), + ("height", models.PositiveIntegerField(default=0)), + ( + "src", + models.ImageField( + height_field="height", + max_length=255, + null=True, + upload_to="", + width_field="width", + ), + ), ], options={ - 'verbose_name': 'Media', - 'verbose_name_plural': 'Media', + "verbose_name": "Media", + "verbose_name_plural": "Media", }, ), migrations.CreateModel( - name='MediaSpec', + name="MediaSpec", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('max_width', models.PositiveIntegerField()), - ('max_height', models.PositiveIntegerField()), - ('quality', models.PositiveIntegerField()), - ('is_default_thumbnail', models.BooleanField(default=False)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("max_width", models.PositiveIntegerField()), + ("max_height", models.PositiveIntegerField()), + ("quality", models.PositiveIntegerField()), + ("is_default_thumbnail", models.BooleanField(default=False)), ], ), migrations.CreateModel( - name='Picture', + name="Picture", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('slug', models.CharField(blank=True, help_text='Tekninen nimi eli "slug" näkyy URL-osoitteissa. Sallittuja merkkejä ovat pienet kirjaimet, numerot ja väliviiva. Jos jätät teknisen nimen tyhjäksi, se generoidaan automaattisesti otsikosta. Jos muutat teknistä nimeä julkaisun jälkeen, muista luoda tarvittavat uudelleenohjaukset.', max_length=63, validators=[django.core.validators.RegexValidator(message='Tekninen nimi saa sisältää vain pieniä kirjaimia, numeroita sekä väliviivoja.', regex='[a-z0-9-]+')], verbose_name='Tekninen nimi')), - ('order', models.IntegerField(default=0, help_text='Saman yläsivun alaiset sivut järjestetään valikossa tämän luvun mukaan nousevaan järjestykseen (pienin ensin).', verbose_name='Järjestys')), - ('path', models.CharField(help_text='Polku määritetään automaattisesti teknisen nimen perusteella.', max_length=1023, unique=True, validators=[django.core.validators.RegexValidator(message='Polku saa sisältää vain pieniä kirjaimia, numeroita, väliviivoja sekä kauttaviivoja.', regex='[a-z0-9-/]+')], verbose_name='Polku')), - ('title', models.CharField(help_text='Otsikko näytetään automaattisesti sivun ylälaidassa sekä valikossa.', max_length=1023, verbose_name='Otsikko')), - ('description', models.TextField(blank=True, default='', help_text='Näkyy mm. hakukoneille sekä RSS-asiakasohjelmille.', verbose_name='Kuvaus')), - ('is_public', models.BooleanField(default=True)), - ('album', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pictures', to='edegal.Album')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "slug", + models.CharField( + blank=True, + help_text='Tekninen nimi eli "slug" näkyy URL-osoitteissa. Sallittuja merkkejä ovat pienet kirjaimet, numerot ja väliviiva. Jos jätät teknisen nimen tyhjäksi, se generoidaan automaattisesti otsikosta. Jos muutat teknistä nimeä julkaisun jälkeen, muista luoda tarvittavat uudelleenohjaukset.', + max_length=63, + validators=[ + django.core.validators.RegexValidator( + message="Tekninen nimi saa sisältää vain pieniä kirjaimia, numeroita sekä väliviivoja.", + regex="[a-z0-9-]+", + ) + ], + verbose_name="Tekninen nimi", + ), + ), + ( + "order", + models.IntegerField( + default=0, + help_text="Saman yläsivun alaiset sivut järjestetään valikossa tämän luvun mukaan nousevaan järjestykseen (pienin ensin).", + verbose_name="Järjestys", + ), + ), + ( + "path", + models.CharField( + help_text="Polku määritetään automaattisesti teknisen nimen perusteella.", + max_length=1023, + unique=True, + validators=[ + django.core.validators.RegexValidator( + message="Polku saa sisältää vain pieniä kirjaimia, numeroita, väliviivoja sekä kauttaviivoja.", + regex="[a-z0-9-/]+", + ) + ], + verbose_name="Polku", + ), + ), + ( + "title", + models.CharField( + help_text="Otsikko näytetään automaattisesti sivun ylälaidassa sekä valikossa.", + max_length=1023, + verbose_name="Otsikko", + ), + ), + ( + "description", + models.TextField( + blank=True, + default="", + help_text="Näkyy mm. hakukoneille sekä RSS-asiakasohjelmille.", + verbose_name="Kuvaus", + ), + ), + ("is_public", models.BooleanField(default=True)), + ( + "album", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pictures", + to="edegal.Album", + ), + ), ], options={ - 'verbose_name': 'Picture', - 'verbose_name_plural': 'Pictures', - 'ordering': ('album', 'order', 'slug'), + "verbose_name": "Picture", + "verbose_name_plural": "Pictures", + "ordering": ("album", "order", "slug"), }, ), migrations.CreateModel( - name='TermsAndConditions', + name="TermsAndConditions", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('digest', models.CharField(help_text='Used for de-duplication. Kindly please do not change.', max_length=64, verbose_name='Digest')), - ('text', models.TextField(blank=True, default='', help_text='Keep this short enough to fit in a small modal dialog and use the URL field for full license text.', verbose_name='Terms and conditions text')), - ('is_public', models.BooleanField(default=True, help_text='Public T&Cs can be selected by any user at upload time. Use public T&Cs for eg. Creative Commons licenses, "All Rights Reserved" and other common situations. Private T&Cs can only be selected by the owner.', verbose_name='Public')), - ('url', models.CharField(blank=True, default='', help_text='If these terms and conditions refer to a publicly known content license, such as Creative Commons, please link to it here.', max_length=255, verbose_name='License URL')), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "digest", + models.CharField( + help_text="Used for de-duplication. Kindly please do not change.", + max_length=64, + verbose_name="Digest", + ), + ), + ( + "text", + models.TextField( + blank=True, + default="", + help_text="Keep this short enough to fit in a small modal dialog and use the URL field for full license text.", + verbose_name="Terms and conditions text", + ), + ), + ( + "is_public", + models.BooleanField( + default=True, + help_text='Public T&Cs can be selected by any user at upload time. Use public T&Cs for eg. Creative Commons licenses, "All Rights Reserved" and other common situations. Private T&Cs can only be selected by the owner.', + verbose_name="Public", + ), + ), + ( + "url", + models.CharField( + blank=True, + default="", + help_text="If these terms and conditions refer to a publicly known content license, such as Creative Commons, please link to it here.", + max_length=255, + verbose_name="License URL", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'Terms and conditions', - 'verbose_name_plural': 'Terms and conditions', + "verbose_name": "Terms and conditions", + "verbose_name_plural": "Terms and conditions", }, bases=(models.Model, edegal.models.terms_and_conditions.DedupMixin), ), migrations.AddField( - model_name='media', - name='picture', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media', to='edegal.Picture'), + model_name="media", + name="picture", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="media", + to="edegal.Picture", + ), ), migrations.AddField( - model_name='media', - name='spec', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='edegal.MediaSpec'), + model_name="media", + name="spec", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="edegal.MediaSpec", + ), ), migrations.AddField( - model_name='album', - name='cover_picture', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='edegal.Picture'), + model_name="album", + name="cover_picture", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="edegal.Picture", + ), ), migrations.AddField( - model_name='album', - name='parent', - field=mptt.fields.TreeForeignKey(blank=True, help_text='Tämä albumi luodaan valitun albumin alaisuuteen. Juurialbumilla ei ole yläalbumia.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subalbums', to='edegal.Album', verbose_name='Yläalbumi'), + model_name="album", + name="parent", + field=mptt.fields.TreeForeignKey( + blank=True, + help_text="Tämä albumi luodaan valitun albumin alaisuuteen. Juurialbumilla ei ole yläalbumia.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="subalbums", + to="edegal.Album", + verbose_name="Yläalbumi", + ), ), migrations.AddField( - model_name='album', - name='terms_and_conditions', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='edegal.TermsAndConditions'), + model_name="album", + name="terms_and_conditions", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="edegal.TermsAndConditions", + ), ), migrations.AlterUniqueTogether( - name='picture', - unique_together=set([('album', 'slug')]), - ), - migrations.AlterIndexTogether( - name='picture', - index_together=set([('album', 'order', 'slug')]), + name="picture", + unique_together=set([("album", "slug")]), ), + # XXX(django-5.1): breaks with "TypeError: 'class Meta' got invalid attribute(s): index_together", dropped manually in 0027_* + # migrations.AlterIndexTogether( + # name="picture", + # index_together=set([("album", "order", "slug")]), + # ), migrations.AlterUniqueTogether( - name='album', - unique_together=set([('parent', 'slug')]), + name="album", + unique_together=set([("parent", "slug")]), ), ] diff --git a/backend/edegal/migrations/0027_alter_picture_album_and_more.py b/backend/edegal/migrations/0027_alter_picture_album_and_more.py new file mode 100644 index 0000000..41c123b --- /dev/null +++ b/backend/edegal/migrations/0027_alter_picture_album_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.3 on 2024-08-11 13:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("edegal", "0026_alter_album_path_alter_picture_path_and_more"), + ] + + operations = [ + # XXX(django-5.1): corresponding AlterIndexTogether commented out in 0001_initial because it breaks in Django 5.1 + migrations.RunSQL( + sql="drop index if exists edegal_picture_album_id_order_slug_117e4cf9_idx", + ), + migrations.AlterField( + model_name="picture", + name="album", + field=models.ForeignKey( + db_index=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="pictures", + to="edegal.album", + ), + ), + migrations.AddIndex( + model_name="picture", + index=models.Index( + fields=["album", "order", "taken_at", "slug"], + name="edegal_pict_album_i_145fe8_idx", + ), + ), + ] diff --git a/backend/edegal/models/picture.py b/backend/edegal/models/picture.py index 9503d15..328cd20 100644 --- a/backend/edegal/models/picture.py +++ b/backend/edegal/models/picture.py @@ -7,11 +7,10 @@ from django.db.utils import ProgrammingError from django.utils.translation import gettext_lazy as _ -from ..utils import slugify, pick_attrs +from ..utils import pick_attrs, slugify from .common import CommonFields from .media_spec import DEFAULT_FORMAT, MediaSpec - logger = logging.getLogger(__name__) @@ -20,7 +19,12 @@ class Picture(models.Model): media: Any slug = models.CharField(**CommonFields.slug) - album = models.ForeignKey("edegal.Album", related_name="pictures", on_delete=models.CASCADE) + album = models.ForeignKey( + "edegal.Album", + related_name="pictures", + on_delete=models.CASCADE, + db_index=False, # have "fat" indexes on album, slug etc. + ) order = models.IntegerField(**CommonFields.order) path = models.CharField(**CommonFields.path) @@ -37,7 +41,9 @@ class Picture(models.Model): help_text="EXIF original date time of the original media", ) - created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL + ) def as_dict(self, include_credits=False): result = pick_attrs( @@ -84,7 +90,9 @@ def get_media_dict(self, role: str): for media_item in all_media if media_item.role == role and media_item.format != base_media_item.format ] - additional_formats = list({media_item.format for media_item in additional_media}) + additional_formats = list( + {media_item.format for media_item in additional_media} + ) # hack: avif precedes webp in alphabetical order additional_formats.sort() @@ -94,7 +102,9 @@ def get_media_dict(self, role: str): def refresh_media(self, dry_run=False): current_specs = MediaSpec.objects.filter(active=True) - media_to_remove = self.media.all().exclude(role="original").exclude(spec__in=current_specs) + media_to_remove = ( + self.media.all().exclude(role="original").exclude(spec__in=current_specs) + ) assert dry_run, "actually doing this not implemented yet :)" @@ -122,7 +132,9 @@ def get_random_picture(cls): @property def original(self): if not hasattr(self, "_original"): - self._original = next((media for media in self.media.all() if media.spec is None), None) + self._original = next( + (media for media in self.media.all() if media.spec is None), None + ) return self._original @@ -130,7 +142,11 @@ def original(self): def thumbnail(self): if not hasattr(self, "_thumbnail"): self._thumbnail = next( - (media for media in self.media.all() if media.spec and media.spec.is_default_thumbnail), + ( + media + for media in self.media.all() + if media.spec and media.spec.is_default_thumbnail + ), None, ) @@ -153,4 +169,6 @@ class Meta: verbose_name_plural = _("Pictures") unique_together = [("album", "slug")] ordering = ("album", "order", "taken_at", "slug") - index_together = [("album", "order", "slug")] + indexes = [ + models.Index(fields=["album", "order", "taken_at", "slug"]), + ] diff --git a/backend/edegal_site/urls.py b/backend/edegal_site/urls.py index bd5fe37..ae797e2 100644 --- a/backend/edegal_site/urls.py +++ b/backend/edegal_site/urls.py @@ -1,6 +1,6 @@ from django.conf import settings -from django.conf.urls import include from django.contrib import admin +from django.urls import include from django.urls import re_path, path from django.views.static import serve as serve_static diff --git a/backend/kompassi_oauth2/urls.py b/backend/kompassi_oauth2/urls.py index 828c4c9..a2848ac 100644 --- a/backend/kompassi_oauth2/urls.py +++ b/backend/kompassi_oauth2/urls.py @@ -1,4 +1,3 @@ -from django.conf.urls import include from .views import LoginView, CallbackView from django.urls import re_path diff --git a/backend/requirements.txt b/backend/requirements.txt index 652a65d..2c31331 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,118 +1,114 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# pip-compile -# -admin-multiupload @ git+https://github.com/con2/django-admin-multiupload.git@django3 +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.in +admin-multiupload @ git+https://github.com/con2/django-admin-multiupload.git@b70bc1515ce8d260f3580365f4e1b34762872bae#egg=admin-multiupload # via -r requirements.in -amqp==5.1.1 +amqp==5.2.0 # via kombu -asgiref==3.7.2 +asgiref==3.8.1 # via django -beautifulsoup4==4.12.2 +beautifulsoup4==4.12.3 # via -r requirements.in -billiard==4.1.0 +billiard==4.2.0 # via celery -build==0.10.0 +build==1.2.1 # via pip-tools -celery==5.3.1 +celery==5.4.0 # via -r requirements.in -certifi==2023.7.22 +certifi==2024.7.4 # via requests -charset-normalizer==3.2.0 +charset-normalizer==3.3.2 # via requests -click==8.1.6 +click==8.1.7 # via # celery # click-didyoumean # click-plugins # click-repl # pip-tools -click-didyoumean==0.3.0 +click-didyoumean==0.3.1 # via celery click-plugins==1.1.1 # via celery click-repl==0.3.0 # via celery -django==4.2.3 +django==5.1 # via # -r requirements.in # django-ckeditor # django-js-asset # django-redis -django-ckeditor==6.6.1 +django-ckeditor==6.7.1 # via -r requirements.in -django-environ==0.10.0 +django-environ==0.11.2 # via -r requirements.in -django-js-asset==2.1.0 +django-js-asset==2.2.0 # via # django-ckeditor # django-mptt -django-mptt==0.14.0 +django-mptt==0.16.0 # via -r requirements.in -django-redis==5.3.0 +django-redis==5.4.0 # via -r requirements.in -gunicorn==21.2.0 +gunicorn==23.0.0 # via -r requirements.in -idna==3.4 +idna==3.7 # via requests -kombu==5.3.1 +kombu==5.4.0 # via celery oauthlib==3.2.2 # via requests-oauthlib -packaging==23.1 +packaging==24.1 # via # build # gunicorn -pillow==10.0.0 +pillow==10.4.0 # via -r requirements.in -pillow-avif-plugin==1.3.1 +pillow-avif-plugin==1.4.6 # via -r requirements.in -pip-tools==7.1.0 +pip==24.2 + # via pip-tools +pip-tools==7.4.1 # via -r requirements.in -prompt-toolkit==3.0.39 +prompt-toolkit==3.0.47 # via click-repl -psycopg2==2.9.6 +psycopg2==2.9.9 # via -r requirements.in -pyproject-hooks==1.0.0 - # via build -python-dateutil==2.8.2 +pyproject-hooks==1.1.0 + # via + # build + # pip-tools +python-dateutil==2.9.0.post0 # via celery -python-memcached==1.59 +python-memcached==1.62 # via -r requirements.in -redis==4.6.0 +redis==5.0.8 # via # -r requirements.in # django-redis -requests==2.31.0 +requests==2.32.3 # via # -r requirements.in # requests-oauthlib -requests-oauthlib==1.3.1 +requests-oauthlib==2.0.0 # via -r requirements.in +setuptools==72.1.0 + # via pip-tools six==1.16.0 - # via - # python-dateutil - # python-memcached -soupsieve==2.4.1 + # via python-dateutil +soupsieve==2.5 # via beautifulsoup4 -sqlparse==0.4.4 +sqlparse==0.5.1 # via django -tzdata==2023.3 +tzdata==2024.1 # via celery -urllib3==2.0.4 +urllib3==2.2.2 # via requests -vine==5.0.0 +vine==5.1.0 # via # amqp # celery # kombu -wcwidth==0.2.6 +wcwidth==0.2.13 # via prompt-toolkit -wheel==0.41.0 +wheel==0.44.0 # via pip-tools - -# The following packages are considered to be unsafe in a requirements file: -# pip -# setuptools diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ed53ddb..1ee4181 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2611,14 +2611,25 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001278", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001278.tgz", - "integrity": "sha512-mpF9KeH8u5cMoEmIic/cr7PNS+F5LWBk0t2ekGT60lFf0Wq+n9LspAj0g3P+o7DQhD3sUdlMln4YFAWhFYn9jg==", + "version": "1.0.30001651", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", + "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - } + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "2.4.2", @@ -10091,9 +10102,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001278", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001278.tgz", - "integrity": "sha512-mpF9KeH8u5cMoEmIic/cr7PNS+F5LWBk0t2ekGT60lFf0Wq+n9LspAj0g3P+o7DQhD3sUdlMln4YFAWhFYn9jg==", + "version": "1.0.30001651", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", + "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", "dev": true }, "chalk": {