mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-11-04 02:53:06 +00:00 
			
		
		
		
	Compare commits
	
		
			4 Commits
		
	
	
		
			fix-poster
			...
			photos
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					e5b5568d51 | ||
| 
						 | 
					e1331c7cb6 | ||
| 
						 | 
					6f7167c5b4 | ||
| 
						 | 
					078ee3a016 | 
@@ -88,9 +88,9 @@ class PageAdmin(admin.ModelAdmin):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
@admin.register(SithFile)
 | 
					@admin.register(SithFile)
 | 
				
			||||||
class SithFileAdmin(admin.ModelAdmin):
 | 
					class SithFileAdmin(admin.ModelAdmin):
 | 
				
			||||||
    list_display = ("name", "owner", "size", "date", "is_in_sas")
 | 
					    list_display = ("name", "owner", "size", "date")
 | 
				
			||||||
    autocomplete_fields = ("parent", "owner", "moderator")
 | 
					    autocomplete_fields = ("parent", "owner", "moderator")
 | 
				
			||||||
    search_fields = ("name", "parent__name")
 | 
					    search_fields = ("name",)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@admin.register(OperationLog)
 | 
					@admin.register(OperationLog)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -97,7 +97,7 @@ class SithFileController(ControllerBase):
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
    @paginate(PageNumberPaginationExtra, page_size=50)
 | 
					    @paginate(PageNumberPaginationExtra, page_size=50)
 | 
				
			||||||
    def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
 | 
					    def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
 | 
				
			||||||
        return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
 | 
					        return SithFile.objects.filter(name__icontains=search)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@api_controller("/group")
 | 
					@api_controller("/group")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -110,7 +110,6 @@ class Command(BaseCommand):
 | 
				
			|||||||
        p.save(force_lock=True)
 | 
					        p.save(force_lock=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        club_root = SithFile.objects.create(name="clubs", owner=root)
 | 
					        club_root = SithFile.objects.create(name="clubs", owner=root)
 | 
				
			||||||
        sas = SithFile.objects.create(name="SAS", owner=root)
 | 
					 | 
				
			||||||
        main_club = Club.objects.create(
 | 
					        main_club = Club.objects.create(
 | 
				
			||||||
            id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
 | 
					            id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
@@ -693,33 +692,21 @@ class Command(BaseCommand):
 | 
				
			|||||||
        # SAS
 | 
					        # SAS
 | 
				
			||||||
        for f in self.SAS_FIXTURE_PATH.glob("*"):
 | 
					        for f in self.SAS_FIXTURE_PATH.glob("*"):
 | 
				
			||||||
            if f.is_dir():
 | 
					            if f.is_dir():
 | 
				
			||||||
                album = Album(
 | 
					                album = Album.objects.create(name=f.name, is_moderated=True)
 | 
				
			||||||
                    parent=sas,
 | 
					 | 
				
			||||||
                    name=f.name,
 | 
					 | 
				
			||||||
                    owner=root,
 | 
					 | 
				
			||||||
                    is_folder=True,
 | 
					 | 
				
			||||||
                    is_in_sas=True,
 | 
					 | 
				
			||||||
                    is_moderated=True,
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
                album.clean()
 | 
					 | 
				
			||||||
                album.save()
 | 
					 | 
				
			||||||
                for p in f.iterdir():
 | 
					                for p in f.iterdir():
 | 
				
			||||||
                    file = resize_image(Image.open(p), 1000, "WEBP")
 | 
					                    file = resize_image(Image.open(p), 1000, "WEBP")
 | 
				
			||||||
                    pict = Picture(
 | 
					                    pict = Picture(
 | 
				
			||||||
                        parent=album,
 | 
					                        parent=album,
 | 
				
			||||||
                        name=p.name,
 | 
					                        name=p.name,
 | 
				
			||||||
                        file=file,
 | 
					                        original=file,
 | 
				
			||||||
                        owner=root,
 | 
					                        owner=root,
 | 
				
			||||||
                        is_folder=False,
 | 
					 | 
				
			||||||
                        is_in_sas=True,
 | 
					 | 
				
			||||||
                        is_moderated=True,
 | 
					                        is_moderated=True,
 | 
				
			||||||
                        mime_type="image/webp",
 | 
					 | 
				
			||||||
                        size=file.size,
 | 
					 | 
				
			||||||
                    )
 | 
					                    )
 | 
				
			||||||
                    pict.file.name = p.name
 | 
					                    pict.original.name = pict.name
 | 
				
			||||||
                    pict.full_clean()
 | 
					 | 
				
			||||||
                    pict.generate_thumbnails()
 | 
					                    pict.generate_thumbnails()
 | 
				
			||||||
 | 
					                    pict.full_clean()
 | 
				
			||||||
                    pict.save()
 | 
					                    pict.save()
 | 
				
			||||||
 | 
					                album.generate_thumbnail()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        img_skia = Picture.objects.get(name="skia.jpg")
 | 
					        img_skia = Picture.objects.get(name="skia.jpg")
 | 
				
			||||||
        img_sli = Picture.objects.get(name="sli.jpg")
 | 
					        img_sli = Picture.objects.get(name="sli.jpg")
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										27
									
								
								core/migrations/0048_remove_sithfiles.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								core/migrations/0048_remove_sithfiles.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					# Generated by Django 4.2.17 on 2025-01-26 15:01
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from typing import TYPE_CHECKING
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations
 | 
				
			||||||
 | 
					from django.db.migrations.state import StateApps
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if TYPE_CHECKING:
 | 
				
			||||||
 | 
					    import core.models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def remove_sas_sithfiles(apps: StateApps, schema_editor):
 | 
				
			||||||
 | 
					    SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
 | 
				
			||||||
 | 
					    SithFile.objects.filter(is_in_sas=True).delete()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("core", "0047_alter_notification_date_alter_notification_type"),
 | 
				
			||||||
 | 
					        ("sas", "0007_alter_peoplepicturerelation_picture_and_more"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.RunPython(
 | 
				
			||||||
 | 
					            remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										9
									
								
								core/migrations/0049_remove_sithfile_is_in_sas.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								core/migrations/0049_remove_sithfile_is_in_sas.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					# Generated by Django 4.2.17 on 2025-02-14 11:58
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					    dependencies = [("core", "0048_remove_sithfiles")]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]
 | 
				
			||||||
@@ -863,9 +863,6 @@ class SithFile(models.Model):
 | 
				
			|||||||
        on_delete=models.CASCADE,
 | 
					        on_delete=models.CASCADE,
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
 | 
					    asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
 | 
				
			||||||
    is_in_sas = models.BooleanField(
 | 
					 | 
				
			||||||
        _("is in the SAS"), default=False, db_index=True
 | 
					 | 
				
			||||||
    )  # Allows to query this flag, updated at each call to save()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        verbose_name = _("file")
 | 
					        verbose_name = _("file")
 | 
				
			||||||
@@ -874,22 +871,10 @@ class SithFile(models.Model):
 | 
				
			|||||||
        return self.get_parent_path() + "/" + self.name
 | 
					        return self.get_parent_path() + "/" + self.name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def save(self, *args, **kwargs):
 | 
					    def save(self, *args, **kwargs):
 | 
				
			||||||
        sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
 | 
					 | 
				
			||||||
        self.is_in_sas = sas in self.get_parent_list() or self == sas
 | 
					 | 
				
			||||||
        adding = self._state.adding
 | 
					        adding = self._state.adding
 | 
				
			||||||
        super().save(*args, **kwargs)
 | 
					        super().save(*args, **kwargs)
 | 
				
			||||||
        if adding:
 | 
					        if adding:
 | 
				
			||||||
            self.copy_rights()
 | 
					            self.copy_rights()
 | 
				
			||||||
        if self.is_in_sas:
 | 
					 | 
				
			||||||
            for user in User.objects.filter(
 | 
					 | 
				
			||||||
                groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
 | 
					 | 
				
			||||||
            ):
 | 
					 | 
				
			||||||
                Notification(
 | 
					 | 
				
			||||||
                    user=user,
 | 
					 | 
				
			||||||
                    url=reverse("sas:moderation"),
 | 
					 | 
				
			||||||
                    type="SAS_MODERATION",
 | 
					 | 
				
			||||||
                    param="1",
 | 
					 | 
				
			||||||
                ).save()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def is_owned_by(self, user: User) -> bool:
 | 
					    def is_owned_by(self, user: User) -> bool:
 | 
				
			||||||
        if user.is_anonymous:
 | 
					        if user.is_anonymous:
 | 
				
			||||||
@@ -902,8 +887,6 @@ class SithFile(models.Model):
 | 
				
			|||||||
            return user.is_board_member
 | 
					            return user.is_board_member
 | 
				
			||||||
        if user.is_com_admin:
 | 
					        if user.is_com_admin:
 | 
				
			||||||
            return True
 | 
					            return True
 | 
				
			||||||
        if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
 | 
					 | 
				
			||||||
            return True
 | 
					 | 
				
			||||||
        return user.id == self.owner_id
 | 
					        return user.id == self.owner_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def can_be_viewed_by(self, user: User) -> bool:
 | 
					    def can_be_viewed_by(self, user: User) -> bool:
 | 
				
			||||||
@@ -930,8 +913,6 @@ class SithFile(models.Model):
 | 
				
			|||||||
        super().clean()
 | 
					        super().clean()
 | 
				
			||||||
        if "/" in self.name:
 | 
					        if "/" in self.name:
 | 
				
			||||||
            raise ValidationError(_("Character '/' not authorized in name"))
 | 
					            raise ValidationError(_("Character '/' not authorized in name"))
 | 
				
			||||||
        if self == self.parent:
 | 
					 | 
				
			||||||
            raise ValidationError(_("Loop in folder tree"), code="loop")
 | 
					 | 
				
			||||||
        if self == self.parent or (
 | 
					        if self == self.parent or (
 | 
				
			||||||
            self.parent is not None and self in self.get_parent_list()
 | 
					            self.parent is not None and self in self.get_parent_list()
 | 
				
			||||||
        ):
 | 
					        ):
 | 
				
			||||||
@@ -1069,18 +1050,6 @@ class SithFile(models.Model):
 | 
				
			|||||||
    def is_file(self):
 | 
					    def is_file(self):
 | 
				
			||||||
        return not self.is_folder
 | 
					        return not self.is_folder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @cached_property
 | 
					 | 
				
			||||||
    def as_picture(self):
 | 
					 | 
				
			||||||
        from sas.models import Picture
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return Picture.objects.filter(id=self.id).first()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @cached_property
 | 
					 | 
				
			||||||
    def as_album(self):
 | 
					 | 
				
			||||||
        from sas.models import Album
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return Album.objects.filter(id=self.id).first()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_parent_list(self):
 | 
					    def get_parent_list(self):
 | 
				
			||||||
        parents = []
 | 
					        parents = []
 | 
				
			||||||
        current = self.parent
 | 
					        current = self.parent
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,6 +5,7 @@ from typing import Callable
 | 
				
			|||||||
from uuid import uuid4
 | 
					from uuid import uuid4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import pytest
 | 
					import pytest
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
 | 
					from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
 | 
				
			||||||
from django.test import Client, TestCase
 | 
					from django.test import Client, TestCase
 | 
				
			||||||
@@ -17,8 +18,8 @@ from pytest_django.asserts import assertNumQueries
 | 
				
			|||||||
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
 | 
					from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
 | 
				
			||||||
from core.models import Group, QuickUploadImage, SithFile, User
 | 
					from core.models import Group, QuickUploadImage, SithFile, User
 | 
				
			||||||
from core.utils import RED_PIXEL_PNG
 | 
					from core.utils import RED_PIXEL_PNG
 | 
				
			||||||
 | 
					from sas.baker_recipes import picture_recipe
 | 
				
			||||||
from sas.models import Picture
 | 
					from sas.models import Picture
 | 
				
			||||||
from sith import settings
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@pytest.mark.django_db
 | 
					@pytest.mark.django_db
 | 
				
			||||||
@@ -30,24 +31,19 @@ class TestImageAccess:
 | 
				
			|||||||
            lambda: baker.make(
 | 
					            lambda: baker.make(
 | 
				
			||||||
                User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
 | 
					                User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
            lambda: baker.make(
 | 
					 | 
				
			||||||
                User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    def test_sas_image_access(self, user_factory: Callable[[], User]):
 | 
					    def test_sas_image_access(self, user_factory: Callable[[], User]):
 | 
				
			||||||
        """Test that only authorized users can access the sas image."""
 | 
					        """Test that only authorized users can access the sas image."""
 | 
				
			||||||
        user = user_factory()
 | 
					        user = user_factory()
 | 
				
			||||||
        picture: SithFile = baker.make(
 | 
					        picture = picture_recipe.make()
 | 
				
			||||||
            Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
 | 
					        assert user.can_edit(picture)
 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        assert picture.is_owned_by(user)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_sas_image_access_owner(self):
 | 
					    def test_sas_image_access_owner(self):
 | 
				
			||||||
        """Test that the owner of the image can access it."""
 | 
					        """Test that the owner of the image can access it."""
 | 
				
			||||||
        user = baker.make(User)
 | 
					        user = baker.make(User)
 | 
				
			||||||
        picture: Picture = baker.make(Picture, owner=user)
 | 
					        picture = picture_recipe.make(owner=user)
 | 
				
			||||||
        assert picture.is_owned_by(user)
 | 
					        assert user.can_edit(picture)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @pytest.mark.parametrize(
 | 
					    @pytest.mark.parametrize(
 | 
				
			||||||
        "user_factory",
 | 
					        "user_factory",
 | 
				
			||||||
@@ -63,7 +59,41 @@ class TestImageAccess:
 | 
				
			|||||||
        user = user_factory()
 | 
					        user = user_factory()
 | 
				
			||||||
        owner = baker.make(User)
 | 
					        owner = baker.make(User)
 | 
				
			||||||
        picture: Picture = baker.make(Picture, owner=owner)
 | 
					        picture: Picture = baker.make(Picture, owner=owner)
 | 
				
			||||||
        assert not picture.is_owned_by(user)
 | 
					        assert not user.can_edit(picture)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.mark.django_db
 | 
				
			||||||
 | 
					class TestUserPicture:
 | 
				
			||||||
 | 
					    def test_anonymous_user_unauthorized(self, client):
 | 
				
			||||||
 | 
					        """An anonymous user shouldn't have access to an user's photo page."""
 | 
				
			||||||
 | 
					        response = client.get(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "core:user_pictures",
 | 
				
			||||||
 | 
					                kwargs={"user_id": User.objects.get(username="sli").pk},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert response.status_code == 403
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.parametrize(
 | 
				
			||||||
 | 
					        ("username", "status"),
 | 
				
			||||||
 | 
					        [
 | 
				
			||||||
 | 
					            ("guy", 403),
 | 
				
			||||||
 | 
					            ("root", 200),
 | 
				
			||||||
 | 
					            ("skia", 200),
 | 
				
			||||||
 | 
					            ("sli", 200),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    def test_page_is_working(self, client, username, status):
 | 
				
			||||||
 | 
					        """Only user that subscribed (or admins) should be able to see the page."""
 | 
				
			||||||
 | 
					        # Test for simple user
 | 
				
			||||||
 | 
					        client.force_login(User.objects.get(username=username))
 | 
				
			||||||
 | 
					        response = client.get(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "core:user_pictures",
 | 
				
			||||||
 | 
					                kwargs={"user_id": User.objects.get(username="sli").pk},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert response.status_code == status
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# TODO: many tests on the pages:
 | 
					# TODO: many tests on the pages:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,6 +22,7 @@ from core.models import Group, User
 | 
				
			|||||||
from core.views import UserTabsMixin
 | 
					from core.views import UserTabsMixin
 | 
				
			||||||
from counter.models import Counter, Refilling, Selling
 | 
					from counter.models import Counter, Refilling, Selling
 | 
				
			||||||
from eboutic.models import Invoice, InvoiceItem
 | 
					from eboutic.models import Invoice, InvoiceItem
 | 
				
			||||||
 | 
					from sas.models import Picture
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestSearchUsers(TestCase):
 | 
					class TestSearchUsers(TestCase):
 | 
				
			||||||
@@ -29,6 +30,7 @@ class TestSearchUsers(TestCase):
 | 
				
			|||||||
    def setUpTestData(cls):
 | 
					    def setUpTestData(cls):
 | 
				
			||||||
        # News.author has on_delete=PROTECT, so news must be deleted beforehand
 | 
					        # News.author has on_delete=PROTECT, so news must be deleted beforehand
 | 
				
			||||||
        News.objects.all().delete()
 | 
					        News.objects.all().delete()
 | 
				
			||||||
 | 
					        Picture.objects.all().delete()  # same for pictures
 | 
				
			||||||
        User.objects.all().delete()
 | 
					        User.objects.all().delete()
 | 
				
			||||||
        user_recipe = Recipe(
 | 
					        user_recipe = Recipe(
 | 
				
			||||||
            User,
 | 
					            User,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,18 +12,23 @@
 | 
				
			|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
					# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
 | 
					from dataclasses import dataclass
 | 
				
			||||||
from datetime import date, timedelta
 | 
					from datetime import date, timedelta
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Image utils
 | 
					# Image utils
 | 
				
			||||||
from io import BytesIO
 | 
					from io import BytesIO
 | 
				
			||||||
from typing import Final
 | 
					from typing import Any, Final, Unpack
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import PIL
 | 
					import PIL
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core.files.base import ContentFile
 | 
					from django.core.files.base import ContentFile
 | 
				
			||||||
from django.core.files.uploadedfile import UploadedFile
 | 
					from django.core.files.uploadedfile import UploadedFile
 | 
				
			||||||
from django.http import HttpRequest
 | 
					from django.db import models
 | 
				
			||||||
 | 
					from django.forms import BaseForm
 | 
				
			||||||
 | 
					from django.http import Http404, HttpRequest
 | 
				
			||||||
 | 
					from django.shortcuts import get_list_or_404
 | 
				
			||||||
 | 
					from django.template.loader import render_to_string
 | 
				
			||||||
 | 
					from django.utils.safestring import SafeString
 | 
				
			||||||
from django.utils.timezone import localdate
 | 
					from django.utils.timezone import localdate
 | 
				
			||||||
from PIL import ExifTags
 | 
					from PIL import ExifTags
 | 
				
			||||||
from PIL.Image import Image, Resampling
 | 
					from PIL.Image import Image, Resampling
 | 
				
			||||||
@@ -42,6 +47,21 @@ to generate a dummy image that is considered valid nonetheless
 | 
				
			|||||||
"""
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclass
 | 
				
			||||||
 | 
					class FormFragmentTemplateData[T: BaseForm]:
 | 
				
			||||||
 | 
					    """Dataclass used to pre-render form fragments"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    form: T
 | 
				
			||||||
 | 
					    template: str
 | 
				
			||||||
 | 
					    context: dict[str, Any]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def render(self, request: HttpRequest) -> SafeString:
 | 
				
			||||||
 | 
					        # Request is needed for csrf_tokens
 | 
				
			||||||
 | 
					        return render_to_string(
 | 
				
			||||||
 | 
					            self.template, context={"form": self.form, **self.context}, request=request
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_start_of_semester(today: date | None = None) -> date:
 | 
					def get_start_of_semester(today: date | None = None) -> date:
 | 
				
			||||||
    """Return the date of the start of the semester of the given date.
 | 
					    """Return the date of the start of the semester of the given date.
 | 
				
			||||||
    If no date is given, return the start date of the current semester.
 | 
					    If no date is given, return the start date of the current semester.
 | 
				
			||||||
@@ -195,3 +215,56 @@ def get_client_ip(request: HttpRequest) -> str | None:
 | 
				
			|||||||
            return ip
 | 
					            return ip
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return None
 | 
					    return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Filterable = models.Model | models.QuerySet | models.Manager
 | 
				
			||||||
 | 
					ListFilter = dict[str, list | tuple | set]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_list_exact_or_404(klass: Filterable, **kwargs: Unpack[ListFilter]) -> list:
 | 
				
			||||||
 | 
					    """Use filter() to return a list of objects from a list of unique keys (like ids)
 | 
				
			||||||
 | 
					    or raises Http404 if the list has not the same length as the given one.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Work like `get_object_or_404()` but for lists of objects, with some caveats :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - The filter must be a list, a tuple or a set.
 | 
				
			||||||
 | 
					    - There can't be more than exactly one filter.
 | 
				
			||||||
 | 
					    - There must be no duplicate in the filter.
 | 
				
			||||||
 | 
					    - The filter should consist in unique keys (like ids), or it could fail randomly.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    klass may be a Model, Manager, or QuerySet object. All other passed
 | 
				
			||||||
 | 
					    arguments and keyword arguments are used in the filter() query.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Raises:
 | 
				
			||||||
 | 
					        Http404: If the list is empty or doesn't have as many elements as the keys list.
 | 
				
			||||||
 | 
					        ValueError: If the first argument is not a Model, Manager, or QuerySet object.
 | 
				
			||||||
 | 
					        ValueError: If more than one filter is passed.
 | 
				
			||||||
 | 
					        TypeError: If the given filter is not a list, a tuple or a set.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Examples:
 | 
				
			||||||
 | 
					        Get all the products with ids 1, 2, 3: ::
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            products = get_list_exact_or_404(Product, id__in=[1, 2, 3])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Don't work with duplicate ids: ::
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            products = get_list_exact_or_404(Product, id__in=[1, 2, 3, 3])
 | 
				
			||||||
 | 
					            # Raises Http404: "The list of keys must contain no duplicates."
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    if len(kwargs) > 1:
 | 
				
			||||||
 | 
					        raise ValueError("get_list_exact_or_404() only accepts one filter.")
 | 
				
			||||||
 | 
					    key, list_filter = next(iter(kwargs.items()))
 | 
				
			||||||
 | 
					    if not isinstance(list_filter, (list, tuple, set)):
 | 
				
			||||||
 | 
					        raise TypeError(
 | 
				
			||||||
 | 
					            f"The given filter must be a list, a tuple or a set, not {type(list_filter)}"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    if len(list_filter) != len(set(list_filter)):
 | 
				
			||||||
 | 
					        raise ValueError("The list of keys must contain no duplicates.")
 | 
				
			||||||
 | 
					    kwargs = {key: list_filter}
 | 
				
			||||||
 | 
					    obj_list = get_list_or_404(klass, **kwargs)
 | 
				
			||||||
 | 
					    if len(obj_list) != len(list_filter):
 | 
				
			||||||
 | 
					        raise Http404(
 | 
				
			||||||
 | 
					            "The given list of keys doesn't match the number of objects found."
 | 
				
			||||||
 | 
					            f"Expected {len(list_filter)} items, got {len(obj_list)}."
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    return obj_list
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -374,7 +374,7 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
 | 
				
			|||||||
class FileModerationView(AllowFragment, ListView):
 | 
					class FileModerationView(AllowFragment, ListView):
 | 
				
			||||||
    model = SithFile
 | 
					    model = SithFile
 | 
				
			||||||
    template_name = "core/file_moderation.jinja"
 | 
					    template_name = "core/file_moderation.jinja"
 | 
				
			||||||
    queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
 | 
					    queryset = SithFile.objects.filter(is_moderated=False)
 | 
				
			||||||
    ordering = "id"
 | 
					    ordering = "id"
 | 
				
			||||||
    paginate_by = 100
 | 
					    paginate_by = 100
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -263,3 +263,35 @@ avec un unique champ permettant de sélectionner des groupes.
 | 
				
			|||||||
Par défaut, seuls les utilisateurs avec la permission
 | 
					Par défaut, seuls les utilisateurs avec la permission
 | 
				
			||||||
`auth.change_permission` auront accès à ce formulaire
 | 
					`auth.change_permission` auront accès à ce formulaire
 | 
				
			||||||
(donc, normalement, uniquement les utilisateurs Root).
 | 
					(donc, normalement, uniquement les utilisateurs Root).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```mermaid
 | 
				
			||||||
 | 
					sequenceDiagram
 | 
				
			||||||
 | 
					    participant A as Utilisateur
 | 
				
			||||||
 | 
					    participant B as ReverseProxy
 | 
				
			||||||
 | 
					    participant C as MarkdownImage
 | 
				
			||||||
 | 
					    participant D as Model
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    A->>B: GET /page/foo
 | 
				
			||||||
 | 
					    B->>C: GET /page/foo
 | 
				
			||||||
 | 
					    C-->>B: La page, avec les urls
 | 
				
			||||||
 | 
					    B-->>A: La page, avec les urls
 | 
				
			||||||
 | 
					    alt image publique 
 | 
				
			||||||
 | 
					        A->>B: GET markdown/public/2025/img.webp
 | 
				
			||||||
 | 
					        B-->>A: img.webp
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					    alt image privée 
 | 
				
			||||||
 | 
					        A->>B: GET markdown_image/{id}
 | 
				
			||||||
 | 
					        B->>C: GET markdown_image/{id}
 | 
				
			||||||
 | 
					        C->>D: user.can_view(image)
 | 
				
			||||||
 | 
					        alt l'utilisateur a le droit de voir l'image
 | 
				
			||||||
 | 
					            D-->>C: True
 | 
				
			||||||
 | 
					            C-->>B: 200 (avec le X-Accel-Redirect)
 | 
				
			||||||
 | 
					            B-->>A: img.webp
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					        alt l'utilisateur n'a pas le droit de l'image
 | 
				
			||||||
 | 
					            D-->>C: False
 | 
				
			||||||
 | 
					            C-->>B: 403
 | 
				
			||||||
 | 
					            B-->>A: 403
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,13 +25,12 @@ import warnings
 | 
				
			|||||||
from datetime import timedelta
 | 
					from datetime import timedelta
 | 
				
			||||||
from typing import Final, Optional
 | 
					from typing import Final, Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					 | 
				
			||||||
from django.core.files.base import ContentFile
 | 
					from django.core.files.base import ContentFile
 | 
				
			||||||
from django.core.management.base import BaseCommand
 | 
					from django.core.management.base import BaseCommand
 | 
				
			||||||
from django.utils import timezone
 | 
					from django.utils import timezone
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from club.models import Club, Membership
 | 
					from club.models import Club, Membership
 | 
				
			||||||
from core.models import Group, Page, SithFile, User
 | 
					from core.models import Group, Page, User
 | 
				
			||||||
from core.utils import RED_PIXEL_PNG
 | 
					from core.utils import RED_PIXEL_PNG
 | 
				
			||||||
from sas.models import Album, PeoplePictureRelation, Picture
 | 
					from sas.models import Album, PeoplePictureRelation, Picture
 | 
				
			||||||
from subscription.models import Subscription
 | 
					from subscription.models import Subscription
 | 
				
			||||||
@@ -91,13 +90,8 @@ class Command(BaseCommand):
 | 
				
			|||||||
        self.NB_CLUBS = options["club_count"]
 | 
					        self.NB_CLUBS = options["club_count"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        root = User.objects.filter(username="root").first()
 | 
					        root = User.objects.filter(username="root").first()
 | 
				
			||||||
        sas = SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
 | 
					 | 
				
			||||||
        self.galaxy_album = Album.objects.create(
 | 
					        self.galaxy_album = Album.objects.create(
 | 
				
			||||||
            name="galaxy-register-file",
 | 
					            name="galaxy-register-file", owner=root, is_moderated=True
 | 
				
			||||||
            owner=root,
 | 
					 | 
				
			||||||
            is_moderated=True,
 | 
					 | 
				
			||||||
            is_in_sas=True,
 | 
					 | 
				
			||||||
            parent=sas,
 | 
					 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.make_clubs()
 | 
					        self.make_clubs()
 | 
				
			||||||
@@ -285,14 +279,10 @@ class Command(BaseCommand):
 | 
				
			|||||||
                    owner=u,
 | 
					                    owner=u,
 | 
				
			||||||
                    name=f"galaxy-picture {u} {i // self.NB_USERS}",
 | 
					                    name=f"galaxy-picture {u} {i // self.NB_USERS}",
 | 
				
			||||||
                    is_moderated=True,
 | 
					                    is_moderated=True,
 | 
				
			||||||
                    is_folder=False,
 | 
					 | 
				
			||||||
                    parent=self.galaxy_album,
 | 
					                    parent=self.galaxy_album,
 | 
				
			||||||
                    is_in_sas=True,
 | 
					                    original=ContentFile(RED_PIXEL_PNG),
 | 
				
			||||||
                    file=ContentFile(RED_PIXEL_PNG),
 | 
					 | 
				
			||||||
                    compressed=ContentFile(RED_PIXEL_PNG),
 | 
					                    compressed=ContentFile(RED_PIXEL_PNG),
 | 
				
			||||||
                    thumbnail=ContentFile(RED_PIXEL_PNG),
 | 
					                    thumbnail=ContentFile(RED_PIXEL_PNG),
 | 
				
			||||||
                    mime_type="image/png",
 | 
					 | 
				
			||||||
                    size=len(RED_PIXEL_PNG),
 | 
					 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            self.picts[i].file.name = self.picts[i].name
 | 
					            self.picts[i].file.name = self.picts[i].name
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,6 +16,767 @@ msgstr ""
 | 
				
			|||||||
"Content-Transfer-Encoding: 8bit\n"
 | 
					"Content-Transfer-Encoding: 8bit\n"
 | 
				
			||||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
 | 
					"Plural-Forms: nplurals=2; plural=(n > 1);\n"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py club/models.py com/models.py counter/models.py
 | 
				
			||||||
 | 
					#: forum/models.py launderette/models.py sas/models.py
 | 
				
			||||||
 | 
					msgid "name"
 | 
				
			||||||
 | 
					msgstr "nom"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "street"
 | 
				
			||||||
 | 
					msgstr "rue"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "city"
 | 
				
			||||||
 | 
					msgstr "ville"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "postcode"
 | 
				
			||||||
 | 
					msgstr "code postal"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "country"
 | 
				
			||||||
 | 
					msgstr "pays"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py core/models.py
 | 
				
			||||||
 | 
					msgid "phone"
 | 
				
			||||||
 | 
					msgstr "téléphone"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "email"
 | 
				
			||||||
 | 
					msgstr "email"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "website"
 | 
				
			||||||
 | 
					msgstr "site internet"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "company"
 | 
				
			||||||
 | 
					msgstr "entreprise"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "iban"
 | 
				
			||||||
 | 
					msgstr "IBAN"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "account number"
 | 
				
			||||||
 | 
					msgstr "numéro de compte"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py club/models.py com/models.py counter/models.py
 | 
				
			||||||
 | 
					#: trombi/models.py
 | 
				
			||||||
 | 
					msgid "club"
 | 
				
			||||||
 | 
					msgstr "club"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "Bank account"
 | 
				
			||||||
 | 
					msgstr "Compte en banque"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "bank account"
 | 
				
			||||||
 | 
					msgstr "compte en banque"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "Club account"
 | 
				
			||||||
 | 
					msgstr "Compte club"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					#, python-format
 | 
				
			||||||
 | 
					msgid "%(club_account)s on %(bank_account)s"
 | 
				
			||||||
 | 
					msgstr "%(club_account)s sur %(bank_account)s"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py club/models.py counter/models.py election/models.py
 | 
				
			||||||
 | 
					#: launderette/models.py
 | 
				
			||||||
 | 
					msgid "start date"
 | 
				
			||||||
 | 
					msgstr "date de début"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py club/models.py counter/models.py election/models.py
 | 
				
			||||||
 | 
					msgid "end date"
 | 
				
			||||||
 | 
					msgstr "date de fin"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "is closed"
 | 
				
			||||||
 | 
					msgstr "est fermé"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "club account"
 | 
				
			||||||
 | 
					msgstr "compte club"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py counter/models.py
 | 
				
			||||||
 | 
					msgid "amount"
 | 
				
			||||||
 | 
					msgstr "montant"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "effective_amount"
 | 
				
			||||||
 | 
					msgstr "montant effectif"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "General journal"
 | 
				
			||||||
 | 
					msgstr "Classeur"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "number"
 | 
				
			||||||
 | 
					msgstr "numéro"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "journal"
 | 
				
			||||||
 | 
					msgstr "classeur"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py core/models.py counter/models.py eboutic/models.py
 | 
				
			||||||
 | 
					#: forum/models.py
 | 
				
			||||||
 | 
					msgid "date"
 | 
				
			||||||
 | 
					msgstr "date"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py counter/models.py pedagogy/models.py
 | 
				
			||||||
 | 
					msgid "comment"
 | 
				
			||||||
 | 
					msgstr "commentaire"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py counter/models.py subscription/models.py
 | 
				
			||||||
 | 
					msgid "payment method"
 | 
				
			||||||
 | 
					msgstr "méthode de paiement"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "cheque number"
 | 
				
			||||||
 | 
					msgstr "numéro de chèque"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py eboutic/models.py
 | 
				
			||||||
 | 
					msgid "invoice"
 | 
				
			||||||
 | 
					msgstr "facture"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "is done"
 | 
				
			||||||
 | 
					msgstr "est fait"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "simple type"
 | 
				
			||||||
 | 
					msgstr "type simplifié"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "accounting type"
 | 
				
			||||||
 | 
					msgstr "type comptable"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py core/models.py counter/models.py
 | 
				
			||||||
 | 
					msgid "label"
 | 
				
			||||||
 | 
					msgstr "étiquette"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "target type"
 | 
				
			||||||
 | 
					msgstr "type de cible"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py club/models.py club/templates/club/club_members.jinja
 | 
				
			||||||
 | 
					#: club/templates/club/club_old_members.jinja club/templates/club/mailing.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/cash_summary_list.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/stats.jinja
 | 
				
			||||||
 | 
					#: launderette/templates/launderette/launderette_admin.jinja
 | 
				
			||||||
 | 
					msgid "User"
 | 
				
			||||||
 | 
					msgstr "Utilisateur"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py club/models.py club/templates/club/club_detail.jinja
 | 
				
			||||||
 | 
					#: com/templates/com/mailing_admin.jinja
 | 
				
			||||||
 | 
					#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_clubs.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/invoices_call.jinja
 | 
				
			||||||
 | 
					#: trombi/templates/trombi/edit_profile.jinja
 | 
				
			||||||
 | 
					#: trombi/templates/trombi/export.jinja
 | 
				
			||||||
 | 
					#: trombi/templates/trombi/user_profile.jinja
 | 
				
			||||||
 | 
					msgid "Club"
 | 
				
			||||||
 | 
					msgstr "Club"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py core/views/user.py
 | 
				
			||||||
 | 
					msgid "Account"
 | 
				
			||||||
 | 
					msgstr "Compte"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "Company"
 | 
				
			||||||
 | 
					msgstr "Entreprise"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py core/models.py sith/settings.py
 | 
				
			||||||
 | 
					msgid "Other"
 | 
				
			||||||
 | 
					msgstr "Autre"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "target id"
 | 
				
			||||||
 | 
					msgstr "id de la cible"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "target label"
 | 
				
			||||||
 | 
					msgstr "nom de la cible"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "linked operation"
 | 
				
			||||||
 | 
					msgstr "opération liée"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "The date must be set."
 | 
				
			||||||
 | 
					msgstr "La date doit être indiquée."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					#, python-format
 | 
				
			||||||
 | 
					msgid ""
 | 
				
			||||||
 | 
					"The date can not be before the start date of the journal, which is\n"
 | 
				
			||||||
 | 
					"%(start_date)s."
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					"La date ne peut pas être avant la date de début du journal, qui est\n"
 | 
				
			||||||
 | 
					"%(start_date)s."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "Target does not exists"
 | 
				
			||||||
 | 
					msgstr "La cible n'existe pas."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "Please add a target label if you set no existing target"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					"Merci d'ajouter un nom de cible si vous ne spécifiez pas de cible existante"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid ""
 | 
				
			||||||
 | 
					"You need to provide ether a simplified accounting type or a standard "
 | 
				
			||||||
 | 
					"accounting type"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					"Vous devez fournir soit un type comptable simplifié ou un type comptable "
 | 
				
			||||||
 | 
					"standard"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py counter/models.py pedagogy/models.py
 | 
				
			||||||
 | 
					msgid "code"
 | 
				
			||||||
 | 
					msgstr "code"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "An accounting type code contains only numbers"
 | 
				
			||||||
 | 
					msgstr "Un code comptable ne contient que des numéros"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "movement type"
 | 
				
			||||||
 | 
					msgstr "type de mouvement"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_nature.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_person.jinja
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Credit"
 | 
				
			||||||
 | 
					msgstr "Crédit"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_nature.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_person.jinja
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Debit"
 | 
				
			||||||
 | 
					msgstr "Débit"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "Neutral"
 | 
				
			||||||
 | 
					msgstr "Neutre"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "simplified accounting types"
 | 
				
			||||||
 | 
					msgstr "type simplifié"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/models.py
 | 
				
			||||||
 | 
					msgid "simplified type"
 | 
				
			||||||
 | 
					msgstr "type simplifié"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/accountingtype_list.jinja
 | 
				
			||||||
 | 
					msgid "Accounting type list"
 | 
				
			||||||
 | 
					msgstr "Liste des types comptable"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/accountingtype_list.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_list.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/label_list.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/operation_edit.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_tools.jinja
 | 
				
			||||||
 | 
					msgid "Accounting"
 | 
				
			||||||
 | 
					msgstr "Comptabilité"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/accountingtype_list.jinja
 | 
				
			||||||
 | 
					msgid "Accounting types"
 | 
				
			||||||
 | 
					msgstr "Type comptable"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/accountingtype_list.jinja
 | 
				
			||||||
 | 
					msgid "New accounting type"
 | 
				
			||||||
 | 
					msgstr "Nouveau type comptable"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/accountingtype_list.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
 | 
				
			||||||
 | 
					msgid "There is no types in this website."
 | 
				
			||||||
 | 
					msgstr "Il n'y a pas de types comptable dans ce site web."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_details.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_tools.jinja
 | 
				
			||||||
 | 
					msgid "Bank account: "
 | 
				
			||||||
 | 
					msgstr "Compte en banque : "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/label_list.jinja
 | 
				
			||||||
 | 
					#: club/templates/club/club_sellings.jinja club/templates/club/mailing.jinja
 | 
				
			||||||
 | 
					#: com/templates/com/macros.jinja com/templates/com/mailing_admin.jinja
 | 
				
			||||||
 | 
					#: com/templates/com/news_admin_list.jinja com/templates/com/poster_edit.jinja
 | 
				
			||||||
 | 
					#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/file_detail.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/file_moderation.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/group_detail.jinja core/templates/core/group_list.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/macros.jinja core/templates/core/page_prop.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_account_detail.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_clubs.jinja core/templates/core/user_edit.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/fragments/create_student_card.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/last_ops.jinja
 | 
				
			||||||
 | 
					#: election/templates/election/election_detail.jinja
 | 
				
			||||||
 | 
					#: forum/templates/forum/macros.jinja
 | 
				
			||||||
 | 
					#: launderette/templates/launderette/launderette_admin.jinja
 | 
				
			||||||
 | 
					#: launderette/views.py pedagogy/templates/pedagogy/guide.jinja
 | 
				
			||||||
 | 
					#: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja
 | 
				
			||||||
 | 
					#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
 | 
				
			||||||
 | 
					#: trombi/templates/trombi/detail.jinja
 | 
				
			||||||
 | 
					#: trombi/templates/trombi/edit_profile.jinja
 | 
				
			||||||
 | 
					msgid "Delete"
 | 
				
			||||||
 | 
					msgstr "Supprimer"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_details.jinja club/views.py
 | 
				
			||||||
 | 
					#: core/views/user.py sas/templates/sas/picture.jinja
 | 
				
			||||||
 | 
					msgid "Infos"
 | 
				
			||||||
 | 
					msgstr "Infos"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_details.jinja
 | 
				
			||||||
 | 
					msgid "IBAN: "
 | 
				
			||||||
 | 
					msgstr "IBAN : "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_details.jinja
 | 
				
			||||||
 | 
					msgid "Number: "
 | 
				
			||||||
 | 
					msgstr "Numéro : "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_details.jinja
 | 
				
			||||||
 | 
					msgid "New club account"
 | 
				
			||||||
 | 
					msgstr "Nouveau compte club"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_list.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja club/views.py
 | 
				
			||||||
 | 
					#: com/templates/com/news_admin_list.jinja com/templates/com/poster_list.jinja
 | 
				
			||||||
 | 
					#: com/templates/com/screen_list.jinja com/templates/com/weekmail.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/file.jinja core/templates/core/group_list.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/page.jinja core/templates/core/user_tools.jinja
 | 
				
			||||||
 | 
					#: core/views/user.py counter/templates/counter/cash_summary_list.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/counter_list.jinja
 | 
				
			||||||
 | 
					#: election/templates/election/election_detail.jinja
 | 
				
			||||||
 | 
					#: forum/templates/forum/macros.jinja
 | 
				
			||||||
 | 
					#: launderette/templates/launderette/launderette_list.jinja
 | 
				
			||||||
 | 
					#: pedagogy/templates/pedagogy/guide.jinja
 | 
				
			||||||
 | 
					#: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja
 | 
				
			||||||
 | 
					#: trombi/templates/trombi/detail.jinja
 | 
				
			||||||
 | 
					#: trombi/templates/trombi/edit_profile.jinja
 | 
				
			||||||
 | 
					msgid "Edit"
 | 
				
			||||||
 | 
					msgstr "Éditer"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_list.jinja
 | 
				
			||||||
 | 
					msgid "Bank account list"
 | 
				
			||||||
 | 
					msgstr "Liste des comptes en banque"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_list.jinja
 | 
				
			||||||
 | 
					msgid "Manage simplified types"
 | 
				
			||||||
 | 
					msgstr "Gérer les types simplifiés"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_list.jinja
 | 
				
			||||||
 | 
					msgid "Manage accounting types"
 | 
				
			||||||
 | 
					msgstr "Gérer les types comptable"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_list.jinja
 | 
				
			||||||
 | 
					msgid "New bank account"
 | 
				
			||||||
 | 
					msgstr "Nouveau compte en banque"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/bank_account_list.jinja
 | 
				
			||||||
 | 
					msgid "There is no accounts in this website."
 | 
				
			||||||
 | 
					msgstr "Il n'y a pas de comptes dans ce site web."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					msgid "Club account:"
 | 
				
			||||||
 | 
					msgstr "Compte club : "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/label_list.jinja
 | 
				
			||||||
 | 
					msgid "New label"
 | 
				
			||||||
 | 
					msgstr "Nouvelle étiquette"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/label_list.jinja
 | 
				
			||||||
 | 
					msgid "Label list"
 | 
				
			||||||
 | 
					msgstr "Liste des étiquettes"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					msgid "New journal"
 | 
				
			||||||
 | 
					msgstr "Nouveau classeur"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					msgid "You can not create new journal while you still have one opened"
 | 
				
			||||||
 | 
					msgstr "Vous ne pouvez pas créer de journal tant qu'il y en a un d'ouvert"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					#: launderette/templates/launderette/launderette_admin.jinja
 | 
				
			||||||
 | 
					msgid "Name"
 | 
				
			||||||
 | 
					msgstr "Nom"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					#: com/templates/com/news_admin_list.jinja
 | 
				
			||||||
 | 
					msgid "Start"
 | 
				
			||||||
 | 
					msgstr "Début"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					#: com/templates/com/news_admin_list.jinja
 | 
				
			||||||
 | 
					msgid "End"
 | 
				
			||||||
 | 
					msgstr "Fin"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_account_detail.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/last_ops.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/refilling_list.jinja
 | 
				
			||||||
 | 
					msgid "Amount"
 | 
				
			||||||
 | 
					msgstr "Montant"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					msgid "Effective amount"
 | 
				
			||||||
 | 
					msgstr "Montant effectif"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja sith/settings.py
 | 
				
			||||||
 | 
					msgid "Closed"
 | 
				
			||||||
 | 
					msgstr "Fermé"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					#: com/templates/com/mailing_admin.jinja
 | 
				
			||||||
 | 
					#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/refilling_list.jinja
 | 
				
			||||||
 | 
					msgid "Actions"
 | 
				
			||||||
 | 
					msgstr "Actions"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid "Yes"
 | 
				
			||||||
 | 
					msgstr "Oui"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid "No"
 | 
				
			||||||
 | 
					msgstr "Non"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/club_account_details.jinja
 | 
				
			||||||
 | 
					#: com/templates/com/news_admin_list.jinja core/templates/core/file.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/page.jinja
 | 
				
			||||||
 | 
					msgid "View"
 | 
				
			||||||
 | 
					msgstr "Voir"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/co_list.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_tools.jinja
 | 
				
			||||||
 | 
					msgid "Company list"
 | 
				
			||||||
 | 
					msgstr "Liste des entreprises"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/co_list.jinja
 | 
				
			||||||
 | 
					msgid "Create new company"
 | 
				
			||||||
 | 
					msgstr "Nouvelle entreprise"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/co_list.jinja
 | 
				
			||||||
 | 
					msgid "Companies"
 | 
				
			||||||
 | 
					msgstr "Entreprises"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_accounting.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_nature.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_person.jinja
 | 
				
			||||||
 | 
					msgid "General journal:"
 | 
				
			||||||
 | 
					msgstr "Classeur : "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_accounting.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_account.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_account_detail.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/counter_click.jinja
 | 
				
			||||||
 | 
					msgid "Amount: "
 | 
				
			||||||
 | 
					msgstr "Montant : "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_accounting.jinja
 | 
				
			||||||
 | 
					msgid "Effective amount: "
 | 
				
			||||||
 | 
					msgstr "Montant effectif: "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid "Journal is closed, you can not create operation"
 | 
				
			||||||
 | 
					msgstr "Le classeur est fermé, vous ne pouvez pas créer d'opération"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid "New operation"
 | 
				
			||||||
 | 
					msgstr "Nouvelle opération"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid "Nb"
 | 
				
			||||||
 | 
					msgstr "No"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					#: club/templates/club/club_sellings.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_account_detail.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/cash_summary_list.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/last_ops.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/refilling_list.jinja
 | 
				
			||||||
 | 
					#: rootplace/templates/rootplace/logs.jinja sas/forms.py
 | 
				
			||||||
 | 
					#: trombi/templates/trombi/user_profile.jinja
 | 
				
			||||||
 | 
					msgid "Date"
 | 
				
			||||||
 | 
					msgstr "Date"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					#: club/templates/club/club_sellings.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_account_detail.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/last_ops.jinja
 | 
				
			||||||
 | 
					#: rootplace/templates/rootplace/logs.jinja
 | 
				
			||||||
 | 
					msgid "Label"
 | 
				
			||||||
 | 
					msgstr "Étiquette"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid "Payment mode"
 | 
				
			||||||
 | 
					msgstr "Méthode de paiement"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid "Target"
 | 
				
			||||||
 | 
					msgstr "Cible"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid "Code"
 | 
				
			||||||
 | 
					msgstr "Code"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid "Nature"
 | 
				
			||||||
 | 
					msgstr "Nature"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid "Done"
 | 
				
			||||||
 | 
					msgstr "Effectuées"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py
 | 
				
			||||||
 | 
					#: pedagogy/templates/pedagogy/moderation.jinja
 | 
				
			||||||
 | 
					#: pedagogy/templates/pedagogy/uv_detail.jinja
 | 
				
			||||||
 | 
					#: trombi/templates/trombi/comment.jinja
 | 
				
			||||||
 | 
					#: trombi/templates/trombi/user_tools.jinja
 | 
				
			||||||
 | 
					msgid "Comment"
 | 
				
			||||||
 | 
					msgstr "Commentaire"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid "File"
 | 
				
			||||||
 | 
					msgstr "Fichier"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid "PDF"
 | 
				
			||||||
 | 
					msgstr "PDF"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid ""
 | 
				
			||||||
 | 
					"Warning: this operation has no linked operation because the targeted club "
 | 
				
			||||||
 | 
					"account has no opened journal."
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					"Attention: cette opération n'a pas d'opération liée parce qu'il n'y a pas de "
 | 
				
			||||||
 | 
					"classeur ouvert dans le compte club cible"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					#, python-format
 | 
				
			||||||
 | 
					msgid ""
 | 
				
			||||||
 | 
					"Open a journal in <a href=\"%(url)s\">this club account</a>, then save this "
 | 
				
			||||||
 | 
					"operation again to make the linked operation."
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					"Ouvrez un classeur dans <a href=\"%(url)s\">ce compte club</a>, puis sauver "
 | 
				
			||||||
 | 
					"cette opération à nouveau pour créer l'opération liée."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_details.jinja
 | 
				
			||||||
 | 
					msgid "Generate"
 | 
				
			||||||
 | 
					msgstr "Générer"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_accounting.jinja
 | 
				
			||||||
 | 
					msgid "Accounting statement: "
 | 
				
			||||||
 | 
					msgstr "Bilan comptable : "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_accounting.jinja
 | 
				
			||||||
 | 
					#: rootplace/templates/rootplace/logs.jinja
 | 
				
			||||||
 | 
					msgid "Operation type"
 | 
				
			||||||
 | 
					msgstr "Type d'opération"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_accounting.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_nature.jinja
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_person.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/invoices_call.jinja
 | 
				
			||||||
 | 
					msgid "Sum"
 | 
				
			||||||
 | 
					msgstr "Somme"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_nature.jinja
 | 
				
			||||||
 | 
					msgid "Nature of operation"
 | 
				
			||||||
 | 
					msgstr "Nature de l'opération"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_nature.jinja
 | 
				
			||||||
 | 
					#: club/templates/club/club_sellings.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/counter_main.jinja
 | 
				
			||||||
 | 
					msgid "Total: "
 | 
				
			||||||
 | 
					msgstr "Total : "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_nature.jinja
 | 
				
			||||||
 | 
					msgid "Statement by nature: "
 | 
				
			||||||
 | 
					msgstr "Bilan par nature : "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_person.jinja
 | 
				
			||||||
 | 
					msgid "Statement by person: "
 | 
				
			||||||
 | 
					msgstr "Bilan par personne : "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/journal_statement_person.jinja
 | 
				
			||||||
 | 
					msgid "Target of the operation"
 | 
				
			||||||
 | 
					msgstr "Cible de l'opération"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/label_list.jinja
 | 
				
			||||||
 | 
					msgid "Back to club account"
 | 
				
			||||||
 | 
					msgstr "Retour au compte club"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/label_list.jinja
 | 
				
			||||||
 | 
					msgid "There is no label in this club account."
 | 
				
			||||||
 | 
					msgstr "Il n'y a pas d'étiquette dans ce compte club."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/operation_edit.jinja
 | 
				
			||||||
 | 
					msgid "Edit operation"
 | 
				
			||||||
 | 
					msgstr "Éditer l'opération"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/operation_edit.jinja
 | 
				
			||||||
 | 
					msgid ""
 | 
				
			||||||
 | 
					"Warning: if you select <em>Account</em>, the opposite operation will be "
 | 
				
			||||||
 | 
					"created in the target account. If you don't want that, select <em>Club</em> "
 | 
				
			||||||
 | 
					"instead of <em>Account</em>."
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					"Attention : si vous sélectionnez <em>Compte</em>, l'opération inverse sera "
 | 
				
			||||||
 | 
					"créée dans le compte cible. Si vous ne le voulez pas, sélectionnez <em>Club</"
 | 
				
			||||||
 | 
					"em> à la place de <em>Compte</em>."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/operation_edit.jinja
 | 
				
			||||||
 | 
					msgid "Linked operation:"
 | 
				
			||||||
 | 
					msgstr "Opération liée : "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/operation_edit.jinja
 | 
				
			||||||
 | 
					#: com/templates/com/news_edit.jinja com/templates/com/poster_edit.jinja
 | 
				
			||||||
 | 
					#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/create.jinja core/templates/core/edit.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/file_edit.jinja core/templates/core/macros_pages.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/page_prop.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_godfathers.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_godfathers_tree.jinja
 | 
				
			||||||
 | 
					#: core/templates/core/user_preferences.jinja
 | 
				
			||||||
 | 
					#: counter/templates/counter/cash_register_summary.jinja
 | 
				
			||||||
 | 
					#: forum/templates/forum/reply.jinja
 | 
				
			||||||
 | 
					#: subscription/templates/subscription/fragments/creation_form.jinja
 | 
				
			||||||
 | 
					#: trombi/templates/trombi/comment.jinja
 | 
				
			||||||
 | 
					#: trombi/templates/trombi/edit_profile.jinja
 | 
				
			||||||
 | 
					#: trombi/templates/trombi/user_tools.jinja
 | 
				
			||||||
 | 
					msgid "Save"
 | 
				
			||||||
 | 
					msgstr "Sauver"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/refound_account.jinja accounting/views.py
 | 
				
			||||||
 | 
					msgid "Refound account"
 | 
				
			||||||
 | 
					msgstr "Remboursement de compte"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/refound_account.jinja
 | 
				
			||||||
 | 
					msgid "Refound"
 | 
				
			||||||
 | 
					msgstr "Rembourser"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
 | 
				
			||||||
 | 
					msgid "Simplified type list"
 | 
				
			||||||
 | 
					msgstr "Liste des types simplifiés"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
 | 
				
			||||||
 | 
					msgid "Simplified types"
 | 
				
			||||||
 | 
					msgstr "Types simplifiés"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
 | 
				
			||||||
 | 
					msgid "New simplified type"
 | 
				
			||||||
 | 
					msgstr "Nouveau type simplifié"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Journal"
 | 
				
			||||||
 | 
					msgstr "Classeur"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Statement by nature"
 | 
				
			||||||
 | 
					msgstr "Bilan par nature"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Statement by person"
 | 
				
			||||||
 | 
					msgstr "Bilan par personne"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Accounting statement"
 | 
				
			||||||
 | 
					msgstr "Bilan comptable"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Link this operation to the target account"
 | 
				
			||||||
 | 
					msgstr "Lier cette opération au compte cible"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "The target must be set."
 | 
				
			||||||
 | 
					msgstr "La cible doit être indiquée."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "The amount must be set."
 | 
				
			||||||
 | 
					msgstr "Le montant doit être indiqué."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Operation"
 | 
				
			||||||
 | 
					msgstr "Opération"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Financial proof: "
 | 
				
			||||||
 | 
					msgstr "Justificatif de libellé : "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					#, python-format
 | 
				
			||||||
 | 
					msgid "Club: %(club_name)s"
 | 
				
			||||||
 | 
					msgstr "Club : %(club_name)s"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					#, python-format
 | 
				
			||||||
 | 
					msgid "Label: %(op_label)s"
 | 
				
			||||||
 | 
					msgstr "Libellé : %(op_label)s"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					#, python-format
 | 
				
			||||||
 | 
					msgid "Date: %(date)s"
 | 
				
			||||||
 | 
					msgstr "Date : %(date)s"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					#, python-format
 | 
				
			||||||
 | 
					msgid "Amount: %(amount).2f €"
 | 
				
			||||||
 | 
					msgstr "Montant : %(amount).2f €"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Debtor"
 | 
				
			||||||
 | 
					msgstr "Débiteur"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Creditor"
 | 
				
			||||||
 | 
					msgstr "Créditeur"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Comment:"
 | 
				
			||||||
 | 
					msgstr "Commentaire :"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Signature:"
 | 
				
			||||||
 | 
					msgstr "Signature :"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "General statement"
 | 
				
			||||||
 | 
					msgstr "Bilan général"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "No label operations"
 | 
				
			||||||
 | 
					msgstr "Opérations sans étiquette"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: accounting/views.py
 | 
				
			||||||
 | 
					msgid "Refound this account"
 | 
				
			||||||
 | 
					msgstr "Rembourser ce compte"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: antispam/forms.py
 | 
					#: antispam/forms.py
 | 
				
			||||||
msgid "Email domain is not allowed."
 | 
					msgid "Email domain is not allowed."
 | 
				
			||||||
msgstr "Le domaine de l'addresse e-mail n'est pas autorisé."
 | 
					msgstr "Le domaine de l'addresse e-mail n'est pas autorisé."
 | 
				
			||||||
@@ -267,7 +1028,7 @@ msgid "Enter a valid address. Only the root of the address is needed."
 | 
				
			|||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
"Entrez une adresse valide. Seule la racine de l'adresse est nécessaire."
 | 
					"Entrez une adresse valide. Seule la racine de l'adresse est nécessaire."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: club/models.py com/models.py core/models.py
 | 
					#: club/models.py com/models.py core/models.py sas/models.py
 | 
				
			||||||
msgid "is moderated"
 | 
					msgid "is moderated"
 | 
				
			||||||
msgstr "est modéré"
 | 
					msgstr "est modéré"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1534,11 +2295,11 @@ msgstr "avoir une notification pour chaque click"
 | 
				
			|||||||
msgid "get a notification for every refilling"
 | 
					msgid "get a notification for every refilling"
 | 
				
			||||||
msgstr "avoir une notification pour chaque rechargement"
 | 
					msgstr "avoir une notification pour chaque rechargement"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: core/models.py sas/forms.py
 | 
					#: core/models.py sas/models.py
 | 
				
			||||||
msgid "file name"
 | 
					msgid "file name"
 | 
				
			||||||
msgstr "nom du fichier"
 | 
					msgstr "nom du fichier"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: core/models.py
 | 
					#: core/models.py sas/models.py
 | 
				
			||||||
msgid "parent"
 | 
					msgid "parent"
 | 
				
			||||||
msgstr "parent"
 | 
					msgstr "parent"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1546,10 +2307,14 @@ msgstr "parent"
 | 
				
			|||||||
msgid "compressed file"
 | 
					msgid "compressed file"
 | 
				
			||||||
msgstr "version allégée"
 | 
					msgstr "version allégée"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: core/models.py
 | 
					#: core/models.py sas/models.py
 | 
				
			||||||
msgid "thumbnail"
 | 
					msgid "thumbnail"
 | 
				
			||||||
msgstr "miniature"
 | 
					msgstr "miniature"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: core/models.py sas/models.py
 | 
				
			||||||
 | 
					msgid "owner"
 | 
				
			||||||
 | 
					msgstr "propriétaire"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: core/models.py
 | 
					#: core/models.py
 | 
				
			||||||
msgid "edit group"
 | 
					msgid "edit group"
 | 
				
			||||||
msgstr "groupe d'édition"
 | 
					msgstr "groupe d'édition"
 | 
				
			||||||
@@ -1578,10 +2343,6 @@ msgstr "date"
 | 
				
			|||||||
msgid "asked for removal"
 | 
					msgid "asked for removal"
 | 
				
			||||||
msgstr "retrait demandé"
 | 
					msgstr "retrait demandé"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: core/models.py
 | 
					 | 
				
			||||||
msgid "is in the SAS"
 | 
					 | 
				
			||||||
msgstr "est dans le SAS"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: core/models.py
 | 
					#: core/models.py
 | 
				
			||||||
msgid "Character '/' not authorized in name"
 | 
					msgid "Character '/' not authorized in name"
 | 
				
			||||||
msgstr "Le caractère '/' n'est pas autorisé dans les noms de fichier"
 | 
					msgstr "Le caractère '/' n'est pas autorisé dans les noms de fichier"
 | 
				
			||||||
@@ -3169,7 +3930,7 @@ msgstr "élément de relevé de caisse"
 | 
				
			|||||||
msgid "banner"
 | 
					msgid "banner"
 | 
				
			||||||
msgstr "bannière"
 | 
					msgstr "bannière"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: counter/models.py
 | 
					#: counter/models.py sas/models.py
 | 
				
			||||||
msgid "event date"
 | 
					msgid "event date"
 | 
				
			||||||
msgstr "date de l'événement"
 | 
					msgstr "date de l'événement"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -3893,11 +4654,11 @@ msgstr "début des candidatures"
 | 
				
			|||||||
msgid "end candidature"
 | 
					msgid "end candidature"
 | 
				
			||||||
msgstr "fin des candidatures"
 | 
					msgstr "fin des candidatures"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: election/models.py
 | 
					#: election/models.py sas/models.py
 | 
				
			||||||
msgid "edit groups"
 | 
					msgid "edit groups"
 | 
				
			||||||
msgstr "groupe d'édition"
 | 
					msgstr "groupe d'édition"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: election/models.py
 | 
					#: election/models.py sas/models.py
 | 
				
			||||||
msgid "view groups"
 | 
					msgid "view groups"
 | 
				
			||||||
msgstr "groupe de vue"
 | 
					msgstr "groupe de vue"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -4612,6 +5373,22 @@ msgstr "Envoyer les images"
 | 
				
			|||||||
msgid "You already requested moderation for this picture."
 | 
					msgid "You already requested moderation for this picture."
 | 
				
			||||||
msgstr "Vous avez déjà déposé une demande de retrait pour cette photo."
 | 
					msgstr "Vous avez déjà déposé une demande de retrait pour cette photo."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: sas/models.py
 | 
				
			||||||
 | 
					msgid "The date on which the photos in this album were taken"
 | 
				
			||||||
 | 
					msgstr "La date à laquelle les photos de cet album ont été prises"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: sas/models.py
 | 
				
			||||||
 | 
					msgid "album"
 | 
				
			||||||
 | 
					msgstr "album"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: sas/models.py
 | 
				
			||||||
 | 
					msgid "original image"
 | 
				
			||||||
 | 
					msgstr "image originale"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: sas/models.py
 | 
				
			||||||
 | 
					msgid "compressed image"
 | 
				
			||||||
 | 
					msgstr "version compressée"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: sas/models.py
 | 
					#: sas/models.py
 | 
				
			||||||
msgid "picture"
 | 
					msgid "picture"
 | 
				
			||||||
msgstr "photo"
 | 
					msgstr "photo"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,9 +20,9 @@ from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationR
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
@admin.register(Picture)
 | 
					@admin.register(Picture)
 | 
				
			||||||
class PictureAdmin(admin.ModelAdmin):
 | 
					class PictureAdmin(admin.ModelAdmin):
 | 
				
			||||||
    list_display = ("name", "parent", "date", "size", "is_moderated")
 | 
					    list_display = ("name", "parent", "is_moderated")
 | 
				
			||||||
    search_fields = ("name",)
 | 
					    search_fields = ("name",)
 | 
				
			||||||
    autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups", "moderator")
 | 
					    autocomplete_fields = ("owner", "parent", "moderator")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@admin.register(PeoplePictureRelation)
 | 
					@admin.register(PeoplePictureRelation)
 | 
				
			||||||
@@ -33,9 +33,9 @@ class PeoplePictureRelationAdmin(admin.ModelAdmin):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
@admin.register(Album)
 | 
					@admin.register(Album)
 | 
				
			||||||
class AlbumAdmin(admin.ModelAdmin):
 | 
					class AlbumAdmin(admin.ModelAdmin):
 | 
				
			||||||
    list_display = ("name", "parent", "date", "owner", "is_moderated")
 | 
					    list_display = ("name", "parent")
 | 
				
			||||||
    search_fields = ("name",)
 | 
					    search_fields = ("name",)
 | 
				
			||||||
    autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups")
 | 
					    autocomplete_fields = ("parent", "edit_groups", "view_groups")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@admin.register(PictureModerationRequest)
 | 
					@admin.register(PictureModerationRequest)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										64
									
								
								sas/api.py
									
									
									
									
									
								
							
							
						
						
									
										64
									
								
								sas/api.py
									
									
									
									
									
								
							@@ -2,8 +2,10 @@ from typing import Any, Literal
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core.exceptions import ValidationError
 | 
					from django.core.exceptions import ValidationError
 | 
				
			||||||
 | 
					from django.shortcuts import get_list_or_404
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from ninja import Body, File, Query
 | 
					from ninja import Body, Query, UploadedFile
 | 
				
			||||||
 | 
					from ninja.errors import HttpError
 | 
				
			||||||
from ninja.security import SessionAuth
 | 
					from ninja.security import SessionAuth
 | 
				
			||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
 | 
					from ninja_extra import ControllerBase, api_controller, paginate, route
 | 
				
			||||||
from ninja_extra.exceptions import NotFound, PermissionDenied
 | 
					from ninja_extra.exceptions import NotFound, PermissionDenied
 | 
				
			||||||
@@ -17,11 +19,12 @@ from api.permissions import (
 | 
				
			|||||||
    CanAccessLookup,
 | 
					    CanAccessLookup,
 | 
				
			||||||
    CanEdit,
 | 
					    CanEdit,
 | 
				
			||||||
    CanView,
 | 
					    CanView,
 | 
				
			||||||
 | 
					    HasPerm,
 | 
				
			||||||
    IsInGroup,
 | 
					    IsInGroup,
 | 
				
			||||||
    IsRoot,
 | 
					    IsRoot,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from core.models import Notification, User
 | 
					from core.models import Notification, User
 | 
				
			||||||
from core.schemas import UploadedImage
 | 
					from core.utils import get_list_exact_or_404
 | 
				
			||||||
from sas.models import Album, PeoplePictureRelation, Picture
 | 
					from sas.models import Album, PeoplePictureRelation, Picture
 | 
				
			||||||
from sas.schemas import (
 | 
					from sas.schemas import (
 | 
				
			||||||
    AlbumAutocompleteSchema,
 | 
					    AlbumAutocompleteSchema,
 | 
				
			||||||
@@ -29,6 +32,7 @@ from sas.schemas import (
 | 
				
			|||||||
    AlbumSchema,
 | 
					    AlbumSchema,
 | 
				
			||||||
    IdentifiedUserSchema,
 | 
					    IdentifiedUserSchema,
 | 
				
			||||||
    ModerationRequestSchema,
 | 
					    ModerationRequestSchema,
 | 
				
			||||||
 | 
					    MoveAlbumSchema,
 | 
				
			||||||
    PictureFilterSchema,
 | 
					    PictureFilterSchema,
 | 
				
			||||||
    PictureSchema,
 | 
					    PictureSchema,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -71,6 +75,48 @@ class AlbumController(ControllerBase):
 | 
				
			|||||||
            Album.objects.viewable_by(self.context.request.user).order_by("-date")
 | 
					            Album.objects.viewable_by(self.context.request.user).order_by("-date")
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @route.patch("/parent", permissions=[IsAuthenticated])
 | 
				
			||||||
 | 
					    def change_album_parent(self, payload: list[MoveAlbumSchema]):
 | 
				
			||||||
 | 
					        """Change parents of albums
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Note:
 | 
				
			||||||
 | 
					            For this operation to work, the user must be authorized
 | 
				
			||||||
 | 
					            to edit both the moved albums and their new parent.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        user: User = self.context.request.user
 | 
				
			||||||
 | 
					        albums: list[Album] = get_list_exact_or_404(
 | 
				
			||||||
 | 
					            Album, pk__in={a.id for a in payload}
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if not user.has_perm("sas.change_album"):
 | 
				
			||||||
 | 
					            unauthorized = [a.id for a in albums if not user.can_edit(a)]
 | 
				
			||||||
 | 
					            raise PermissionDenied(
 | 
				
			||||||
 | 
					                f"You can't move the following albums : {unauthorized}"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        parents: list[Album] = get_list_exact_or_404(
 | 
				
			||||||
 | 
					            Album, pk__in={a.new_parent_id for a in payload}
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if not user.has_perm("sas.change_album"):
 | 
				
			||||||
 | 
					            unauthorized = [a.id for a in parents if not user.can_edit(a)]
 | 
				
			||||||
 | 
					            raise PermissionDenied(
 | 
				
			||||||
 | 
					                f"You can't move to the following albums : {unauthorized}"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        id_to_new_parent = {i.id: i.new_parent_id for i in payload}
 | 
				
			||||||
 | 
					        for album in albums:
 | 
				
			||||||
 | 
					            album.parent_id = id_to_new_parent[album.id]
 | 
				
			||||||
 | 
					        # known caveat : moving an album won't move it's thumbnail.
 | 
				
			||||||
 | 
					        # E.g. if the album foo/bar is moved to foo/baz,
 | 
				
			||||||
 | 
					        # the thumbnail will still be foo/bar/thumb.webp
 | 
				
			||||||
 | 
					        # This has no impact for the end user
 | 
				
			||||||
 | 
					        # and doing otherwise would be hard for us to implement,
 | 
				
			||||||
 | 
					        # because we would then have to manage rollbacks on fail.
 | 
				
			||||||
 | 
					        Album.objects.bulk_update(albums, fields=["parent_id"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @route.delete("", permissions=[HasPerm("sas.delete_album")])
 | 
				
			||||||
 | 
					    def delete_album(self, album_ids: list[int]):
 | 
				
			||||||
 | 
					        # known caveat : deleting an album doesn't delete the pictures on the disk.
 | 
				
			||||||
 | 
					        # It's a db only operation.
 | 
				
			||||||
 | 
					        albums: list[Album] = get_list_or_404(Album, pk__in=album_ids)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@api_controller("/sas/picture")
 | 
					@api_controller("/sas/picture")
 | 
				
			||||||
class PicturesController(ControllerBase):
 | 
					class PicturesController(ControllerBase):
 | 
				
			||||||
@@ -103,7 +149,7 @@ class PicturesController(ControllerBase):
 | 
				
			|||||||
        return (
 | 
					        return (
 | 
				
			||||||
            filters.filter(Picture.objects.viewable_by(user))
 | 
					            filters.filter(Picture.objects.viewable_by(user))
 | 
				
			||||||
            .distinct()
 | 
					            .distinct()
 | 
				
			||||||
            .order_by("-parent__date", "date")
 | 
					            .order_by("-parent__event_date", "created_at")
 | 
				
			||||||
            .select_related("owner", "parent")
 | 
					            .select_related("owner", "parent")
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -117,27 +163,25 @@ class PicturesController(ControllerBase):
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
        url_name="upload_picture",
 | 
					        url_name="upload_picture",
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]):
 | 
					    def upload_picture(self, album_id: Body[int], picture: UploadedFile):
 | 
				
			||||||
        album = self.get_object_or_exception(Album, pk=album_id)
 | 
					        album = self.get_object_or_exception(Album, pk=album_id)
 | 
				
			||||||
        user = self.context.request.user
 | 
					        user = self.context.request.user
 | 
				
			||||||
        self_moderate = user.has_perm("sas.moderate_sasfile")
 | 
					        self_moderate = user.has_perm("sas.moderate_sasfile")
 | 
				
			||||||
        new = Picture(
 | 
					        new = Picture(
 | 
				
			||||||
            parent=album,
 | 
					            parent=album,
 | 
				
			||||||
            name=picture.name,
 | 
					            name=picture.name,
 | 
				
			||||||
            file=picture,
 | 
					            original=picture,
 | 
				
			||||||
            owner=user,
 | 
					            owner=user,
 | 
				
			||||||
            is_moderated=self_moderate,
 | 
					            is_moderated=self_moderate,
 | 
				
			||||||
            is_folder=False,
 | 
					 | 
				
			||||||
            mime_type=picture.content_type,
 | 
					 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        if self_moderate:
 | 
					        if self_moderate:
 | 
				
			||||||
            new.moderator = user
 | 
					            new.moderator = user
 | 
				
			||||||
 | 
					        new.generate_thumbnails()
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            new.generate_thumbnails()
 | 
					 | 
				
			||||||
            new.full_clean()
 | 
					            new.full_clean()
 | 
				
			||||||
            new.save()
 | 
					 | 
				
			||||||
        except ValidationError as e:
 | 
					        except ValidationError as e:
 | 
				
			||||||
            return self.create_response({"detail": dict(e)}, status_code=409)
 | 
					            raise HttpError(status_code=409, message=str(e)) from e
 | 
				
			||||||
 | 
					        new.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @route.get(
 | 
					    @route.get(
 | 
				
			||||||
        "/{picture_id}/identified",
 | 
					        "/{picture_id}/identified",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,18 +1,35 @@
 | 
				
			|||||||
 | 
					from django.core.files.uploadedfile import SimpleUploadedFile
 | 
				
			||||||
from model_bakery import seq
 | 
					from model_bakery import seq
 | 
				
			||||||
from model_bakery.recipe import Recipe
 | 
					from model_bakery.recipe import Recipe
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from sas.models import Picture
 | 
					from core.utils import RED_PIXEL_PNG
 | 
				
			||||||
 | 
					from sas.models import Album, Picture
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					album_recipe = Recipe(
 | 
				
			||||||
 | 
					    Album,
 | 
				
			||||||
 | 
					    name=seq("Album "),
 | 
				
			||||||
 | 
					    thumbnail=SimpleUploadedFile(
 | 
				
			||||||
 | 
					        name="thumb.webp", content=b"", content_type="image/webp"
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
picture_recipe = Recipe(
 | 
					picture_recipe = Recipe(
 | 
				
			||||||
    Picture,
 | 
					    Picture,
 | 
				
			||||||
    is_in_sas=True,
 | 
					 | 
				
			||||||
    is_folder=False,
 | 
					 | 
				
			||||||
    is_moderated=True,
 | 
					    is_moderated=True,
 | 
				
			||||||
    name=seq("Picture "),
 | 
					    name=seq("Picture "),
 | 
				
			||||||
 | 
					    original=SimpleUploadedFile(
 | 
				
			||||||
 | 
					        # compressed and thumbnail are generated on save (except if bulk creating).
 | 
				
			||||||
 | 
					        # For this step no to fail, original must be a valid image.
 | 
				
			||||||
 | 
					        name="img.png",
 | 
				
			||||||
 | 
					        content=RED_PIXEL_PNG,
 | 
				
			||||||
 | 
					        content_type="image/png",
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    compressed=SimpleUploadedFile(
 | 
				
			||||||
 | 
					        name="img.webp", content=b"", content_type="image/webp"
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    thumbnail=SimpleUploadedFile(
 | 
				
			||||||
 | 
					        name="img.webp", content=b"", content_type="image/webp"
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
"""A SAS Picture fixture.
 | 
					"""A SAS Picture fixture."""
 | 
				
			||||||
 | 
					 | 
				
			||||||
Warnings:
 | 
					 | 
				
			||||||
    If you don't `bulk_create` this, you need
 | 
					 | 
				
			||||||
    to explicitly set the parent album, or it won't work
 | 
					 | 
				
			||||||
"""
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -48,13 +48,12 @@ class PictureEditForm(forms.ModelForm):
 | 
				
			|||||||
class AlbumEditForm(forms.ModelForm):
 | 
					class AlbumEditForm(forms.ModelForm):
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = Album
 | 
					        model = Album
 | 
				
			||||||
        fields = ["name", "date", "file", "parent", "edit_groups"]
 | 
					        fields = ["name", "date", "thumbnail", "parent", "edit_groups"]
 | 
				
			||||||
        widgets = {
 | 
					        widgets = {
 | 
				
			||||||
            "parent": AutoCompleteSelectAlbum,
 | 
					            "parent": AutoCompleteSelectAlbum,
 | 
				
			||||||
            "edit_groups": AutoCompleteSelectMultipleGroup,
 | 
					            "edit_groups": AutoCompleteSelectMultipleGroup,
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
 | 
					 | 
				
			||||||
    date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
 | 
					    date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
 | 
				
			||||||
    recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
 | 
					    recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										357
									
								
								sas/migrations/0006_move_the_whole_sas.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										357
									
								
								sas/migrations/0006_move_the_whole_sas.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,357 @@
 | 
				
			|||||||
 | 
					# Generated by Django 4.2.17 on 2025-01-22 21:53
 | 
				
			||||||
 | 
					import collections
 | 
				
			||||||
 | 
					import itertools
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					from typing import TYPE_CHECKING
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					from django.db.migrations.state import StateApps
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sas.models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if TYPE_CHECKING:
 | 
				
			||||||
 | 
					    import core.models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# NB : tous les commentaires sont écrits en français,
 | 
				
			||||||
 | 
					#      parce qu'on est sur des opérations qui sont complexes,
 | 
				
			||||||
 | 
					#      et qui sont surtout DANGEREUSES.
 | 
				
			||||||
 | 
					#      Ici, la clarté des explications prime sur toute autre considération.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def copy_albums_and_pictures(apps: StateApps, schema_editor):
 | 
				
			||||||
 | 
					    SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
 | 
				
			||||||
 | 
					    Album: type[sas.models.Album] = apps.get_model("sas", "Album")
 | 
				
			||||||
 | 
					    Picture: type[sas.models.Picture] = apps.get_model("sas", "Picture")
 | 
				
			||||||
 | 
					    logger = logging.getLogger("django")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Il y a environ 1800 albums, 257k photos et 488k identifications
 | 
				
			||||||
 | 
					    # d'utilisateurs dans la db de prod.
 | 
				
			||||||
 | 
					    # En supposant qu'une insertion prenne 10ms (ce qui est très optimiste),
 | 
				
			||||||
 | 
					    # migrer tous les enregistrements de la db prendrait plus de 2h.
 | 
				
			||||||
 | 
					    # C'est trop long.
 | 
				
			||||||
 | 
					    # Mais d'un autre côté, j'ai pas assez confiance dans les capacités de nos
 | 
				
			||||||
 | 
					    # machines pour charger presque un million d'objets en mémoire.
 | 
				
			||||||
 | 
					    # Pour faire un compromis, les albums sont migrés individuellement un à un,
 | 
				
			||||||
 | 
					    # mais tous les objets liés à ces albums
 | 
				
			||||||
 | 
					    # (photos, groupes de vue, groupe d'édition, identification d'utilisateurs)
 | 
				
			||||||
 | 
					    # sont migrés en tas.
 | 
				
			||||||
 | 
					    #
 | 
				
			||||||
 | 
					    # Ordre des opérations :
 | 
				
			||||||
 | 
					    # 1. On migre les albums 1 à 1 (il y en a 1800, donc c'est relativement court)
 | 
				
			||||||
 | 
					    # 2. On migre les photos par paquet de 2500 (soit ~une centaine d'opérations)
 | 
				
			||||||
 | 
					    # 3. On migre tous les groupes de vue et tous les groupes d'édition des albums
 | 
				
			||||||
 | 
					    #
 | 
				
			||||||
 | 
					    # Au total, la migration devrait demander aux alentours de 2000 insertions,
 | 
				
			||||||
 | 
					    # ce qui est un compromis acceptable entre une migration
 | 
				
			||||||
 | 
					    # pas trop longue et une RAM pas trop surchargée.
 | 
				
			||||||
 | 
					    #
 | 
				
			||||||
 | 
					    # Pour ce qui est de la répartition des tables, quatre nouvelles tables
 | 
				
			||||||
 | 
					    # sont créées : sas_album, sas_picture,
 | 
				
			||||||
 | 
					    # sas_pictureviewgroups et sas_picture_editgroups.
 | 
				
			||||||
 | 
					    # Tous les albums et toutes les photos qui sont dans core_sithfile
 | 
				
			||||||
 | 
					    # vont être copiés dans ces tables.
 | 
				
			||||||
 | 
					    # Comme les albums sont migrés un à un, ils recevront une nouvelle
 | 
				
			||||||
 | 
					    # clef primaire.
 | 
				
			||||||
 | 
					    # Pour les photos, en revanche, c'est beaucoup plus sûr de leur donner
 | 
				
			||||||
 | 
					    # le même id que celui qu'il y avait dans core_sithfile.
 | 
				
			||||||
 | 
					    #
 | 
				
			||||||
 | 
					    # Les identifications des photos ne sont pas migrées pour l'instant.
 | 
				
			||||||
 | 
					    # Ce qu'on va faire, c'est qu'on va changer la contrainte de clef étrangère
 | 
				
			||||||
 | 
					    # sur la colonne des photos pour pointer vers sas_picture
 | 
				
			||||||
 | 
					    # au lieu de core_sithfile.
 | 
				
			||||||
 | 
					    # Cependant, pour que ça marche,
 | 
				
			||||||
 | 
					    # il faut qu'au moment où ce changement est effectué,
 | 
				
			||||||
 | 
					    # toutes les clefs primaires référencées existent à la fois dans
 | 
				
			||||||
 | 
					    # les deux tables, sinon les contraintes d'intégrité ne sont pas respectées.
 | 
				
			||||||
 | 
					    # La migration de ce fichier va donc s'occuper de créer les nouvelles tables
 | 
				
			||||||
 | 
					    # et d'y copier les données nécessaires.
 | 
				
			||||||
 | 
					    # Puis une deuxième migration s'occupera de changer les contraintes.
 | 
				
			||||||
 | 
					    # Et enfin une troisième migration supprimera les anciennes données.
 | 
				
			||||||
 | 
					    #
 | 
				
			||||||
 | 
					    # Pavé César
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).prefetch_related(
 | 
				
			||||||
 | 
					        "view_groups", "edit_groups"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    old_albums = collections.deque(
 | 
				
			||||||
 | 
					        albums.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID)
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Changement de représentation en DB.
 | 
				
			||||||
 | 
					    # Dans l'ancien système, un fichier était dans le SAS si
 | 
				
			||||||
 | 
					    # un fichier spécial (le SAS_ROOT) était parmi ses ancêtres.
 | 
				
			||||||
 | 
					    # Comme maintenant les fichiers du SAS sont dans des tables à part,
 | 
				
			||||||
 | 
					    # il ne peut plus y avoir de confusion.
 | 
				
			||||||
 | 
					    # Les photos ont donc obligatoirement un parent (qui est un album)
 | 
				
			||||||
 | 
					    # et les albums peuvent avoir un parent null.
 | 
				
			||||||
 | 
					    # Un album sans parent est considéré comme se trouvant à la racine
 | 
				
			||||||
 | 
					    # de l'arborescence.
 | 
				
			||||||
 | 
					    # En quelque sorte, None est le nouveau SITH_SAS_ROOT_DIR_ID
 | 
				
			||||||
 | 
					    album_id_old_to_new = {settings.SITH_SAS_ROOT_DIR_ID: None}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    logger.info(f"migrating {albums.count()} albums")
 | 
				
			||||||
 | 
					    while len(old_albums) > 0:
 | 
				
			||||||
 | 
					        # Comme les albums référencent leur parent, les albums doivent être migrés
 | 
				
			||||||
 | 
					        # par ordre croissant de profondeur dans l'arborescence.
 | 
				
			||||||
 | 
					        # Chaque album est donc pris par la gauche de la file
 | 
				
			||||||
 | 
					        # et ses enfants ajoutés sur la droite.
 | 
				
			||||||
 | 
					        old_album = old_albums.popleft()
 | 
				
			||||||
 | 
					        old_albums.extend(list(albums.filter(parent=old_album)))
 | 
				
			||||||
 | 
					        new_album = Album.objects.create(
 | 
				
			||||||
 | 
					            parent_id=album_id_old_to_new[old_album.parent_id],
 | 
				
			||||||
 | 
					            event_date=old_album.date.date(),
 | 
				
			||||||
 | 
					            name=old_album.name,
 | 
				
			||||||
 | 
					            thumbnail=(old_album.file or None),
 | 
				
			||||||
 | 
					            is_moderated=old_album.is_moderated,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # on garde un dictionnaire qui associe les id des albums dans l'ancienne table
 | 
				
			||||||
 | 
					        # à leur id dans la nouvelle table, pour pouvoir recréer
 | 
				
			||||||
 | 
					        # les liens de parenté entre albums
 | 
				
			||||||
 | 
					        album_id_old_to_new[old_album.id] = new_album.id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    pictures = SithFile.objects.filter(is_in_sas=True, is_folder=False)
 | 
				
			||||||
 | 
					    nb_pictures = pictures.count()
 | 
				
			||||||
 | 
					    logger.info(f"migrating {nb_pictures} pictures")
 | 
				
			||||||
 | 
					    for i, pictures_batch in enumerate(itertools.batched(pictures, 2500), start=1):
 | 
				
			||||||
 | 
					        Picture.objects.bulk_create(
 | 
				
			||||||
 | 
					            [
 | 
				
			||||||
 | 
					                Picture(
 | 
				
			||||||
 | 
					                    id=p.id,
 | 
				
			||||||
 | 
					                    name=p.name,
 | 
				
			||||||
 | 
					                    parent_id=album_id_old_to_new[p.parent_id],
 | 
				
			||||||
 | 
					                    thumbnail=p.thumbnail,
 | 
				
			||||||
 | 
					                    compressed=p.compressed,
 | 
				
			||||||
 | 
					                    original=p.file,
 | 
				
			||||||
 | 
					                    owner_id=p.owner_id,
 | 
				
			||||||
 | 
					                    created_at=p.date,
 | 
				
			||||||
 | 
					                    is_moderated=p.is_moderated,
 | 
				
			||||||
 | 
					                    asked_for_removal=p.asked_for_removal,
 | 
				
			||||||
 | 
					                    moderator_id=p.moderator_id,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                for p in pictures_batch
 | 
				
			||||||
 | 
					            ]
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        logger.info(f"Migrated {min(i * 2500, nb_pictures)} / {nb_pictures} pictures")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    logger.info("Migrating album groups")
 | 
				
			||||||
 | 
					    albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).exclude(
 | 
				
			||||||
 | 
					        id=settings.SITH_SAS_ROOT_DIR_ID
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    Album.edit_groups.through.objects.bulk_create(
 | 
				
			||||||
 | 
					        [
 | 
				
			||||||
 | 
					            Album.view_groups.through(
 | 
				
			||||||
 | 
					                album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums)
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    Album.edit_groups.through.objects.bulk_create(
 | 
				
			||||||
 | 
					        [
 | 
				
			||||||
 | 
					            Album.view_groups.through(
 | 
				
			||||||
 | 
					                album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums)
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
				
			||||||
 | 
					        ("core", "0044_alter_userban_options"),
 | 
				
			||||||
 | 
					        ("sas", "0005_alter_sasfile_options"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        # les relations et les demandes de modération étaient liées à SithFile,
 | 
				
			||||||
 | 
					        # via le model proxy Picture.
 | 
				
			||||||
 | 
					        # Pour que la migration marche malgré la disparition du modèle Proxy,
 | 
				
			||||||
 | 
					        # on change la relation pour qu'elle pointe directement vers SithFile
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="peoplepicturerelation",
 | 
				
			||||||
 | 
					            name="picture",
 | 
				
			||||||
 | 
					            field=models.ForeignKey(
 | 
				
			||||||
 | 
					                on_delete=django.db.models.deletion.CASCADE,
 | 
				
			||||||
 | 
					                related_name="people",
 | 
				
			||||||
 | 
					                to="core.sithfile",
 | 
				
			||||||
 | 
					                verbose_name="picture",
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="picturemoderationrequest",
 | 
				
			||||||
 | 
					            name="picture",
 | 
				
			||||||
 | 
					            field=models.ForeignKey(
 | 
				
			||||||
 | 
					                on_delete=django.db.models.deletion.CASCADE,
 | 
				
			||||||
 | 
					                related_name="moderation_requests",
 | 
				
			||||||
 | 
					                to="core.sithfile",
 | 
				
			||||||
 | 
					                verbose_name="Picture",
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.DeleteModel(name="Album"),
 | 
				
			||||||
 | 
					        migrations.DeleteModel(name="Picture"),
 | 
				
			||||||
 | 
					        migrations.DeleteModel(name="SasFile"),
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name="Album",
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "id",
 | 
				
			||||||
 | 
					                    models.AutoField(
 | 
				
			||||||
 | 
					                        auto_created=True,
 | 
				
			||||||
 | 
					                        primary_key=True,
 | 
				
			||||||
 | 
					                        serialize=False,
 | 
				
			||||||
 | 
					                        verbose_name="ID",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "thumbnail",
 | 
				
			||||||
 | 
					                    models.FileField(
 | 
				
			||||||
 | 
					                        max_length=256,
 | 
				
			||||||
 | 
					                        upload_to=sas.models.get_thumbnail_directory,
 | 
				
			||||||
 | 
					                        verbose_name="thumbnail",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ("name", models.CharField(max_length=100, verbose_name="name")),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "event_date",
 | 
				
			||||||
 | 
					                    models.DateField(
 | 
				
			||||||
 | 
					                        default=django.utils.timezone.localdate,
 | 
				
			||||||
 | 
					                        help_text="The date on which the photos in this album were taken",
 | 
				
			||||||
 | 
					                        verbose_name="event date",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "is_moderated",
 | 
				
			||||||
 | 
					                    models.BooleanField(default=False, verbose_name="is moderated"),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "edit_groups",
 | 
				
			||||||
 | 
					                    models.ManyToManyField(
 | 
				
			||||||
 | 
					                        related_name="editable_albums",
 | 
				
			||||||
 | 
					                        to="core.group",
 | 
				
			||||||
 | 
					                        verbose_name="edit groups",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "parent",
 | 
				
			||||||
 | 
					                    models.ForeignKey(
 | 
				
			||||||
 | 
					                        blank=True,
 | 
				
			||||||
 | 
					                        null=True,
 | 
				
			||||||
 | 
					                        on_delete=django.db.models.deletion.CASCADE,
 | 
				
			||||||
 | 
					                        related_name="children",
 | 
				
			||||||
 | 
					                        to="sas.album",
 | 
				
			||||||
 | 
					                        verbose_name="parent",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "view_groups",
 | 
				
			||||||
 | 
					                    models.ManyToManyField(
 | 
				
			||||||
 | 
					                        related_name="viewable_albums",
 | 
				
			||||||
 | 
					                        to="core.group",
 | 
				
			||||||
 | 
					                        verbose_name="view groups",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={"verbose_name": "album"},
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name="Picture",
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "id",
 | 
				
			||||||
 | 
					                    models.AutoField(
 | 
				
			||||||
 | 
					                        auto_created=True,
 | 
				
			||||||
 | 
					                        primary_key=True,
 | 
				
			||||||
 | 
					                        serialize=False,
 | 
				
			||||||
 | 
					                        verbose_name="ID",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "thumbnail",
 | 
				
			||||||
 | 
					                    models.FileField(
 | 
				
			||||||
 | 
					                        unique=True,
 | 
				
			||||||
 | 
					                        upload_to=sas.models.get_thumbnail_directory,
 | 
				
			||||||
 | 
					                        verbose_name="thumbnail",
 | 
				
			||||||
 | 
					                        max_length=256,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ("name", models.CharField(max_length=256, verbose_name="file name")),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "original",
 | 
				
			||||||
 | 
					                    models.FileField(
 | 
				
			||||||
 | 
					                        unique=True,
 | 
				
			||||||
 | 
					                        upload_to=sas.models.get_directory,
 | 
				
			||||||
 | 
					                        verbose_name="original image",
 | 
				
			||||||
 | 
					                        max_length=256,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "compressed",
 | 
				
			||||||
 | 
					                    models.FileField(
 | 
				
			||||||
 | 
					                        unique=True,
 | 
				
			||||||
 | 
					                        upload_to=sas.models.get_compressed_directory,
 | 
				
			||||||
 | 
					                        verbose_name="compressed image",
 | 
				
			||||||
 | 
					                        max_length=256,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ("created_at", models.DateTimeField(default=django.utils.timezone.now)),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "is_moderated",
 | 
				
			||||||
 | 
					                    models.BooleanField(default=False, verbose_name="is moderated"),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "asked_for_removal",
 | 
				
			||||||
 | 
					                    models.BooleanField(
 | 
				
			||||||
 | 
					                        default=False, verbose_name="asked for removal"
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "moderator",
 | 
				
			||||||
 | 
					                    models.ForeignKey(
 | 
				
			||||||
 | 
					                        blank=True,
 | 
				
			||||||
 | 
					                        null=True,
 | 
				
			||||||
 | 
					                        on_delete=django.db.models.deletion.SET_NULL,
 | 
				
			||||||
 | 
					                        related_name="moderated_pictures",
 | 
				
			||||||
 | 
					                        to=settings.AUTH_USER_MODEL,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "owner",
 | 
				
			||||||
 | 
					                    models.ForeignKey(
 | 
				
			||||||
 | 
					                        on_delete=django.db.models.deletion.PROTECT,
 | 
				
			||||||
 | 
					                        related_name="owned_pictures",
 | 
				
			||||||
 | 
					                        to=settings.AUTH_USER_MODEL,
 | 
				
			||||||
 | 
					                        verbose_name="owner",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "parent",
 | 
				
			||||||
 | 
					                    models.ForeignKey(
 | 
				
			||||||
 | 
					                        on_delete=django.db.models.deletion.CASCADE,
 | 
				
			||||||
 | 
					                        related_name="pictures",
 | 
				
			||||||
 | 
					                        to="sas.album",
 | 
				
			||||||
 | 
					                        verbose_name="album",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={"abstract": False, "verbose_name": "picture"},
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddConstraint(
 | 
				
			||||||
 | 
					            model_name="picture",
 | 
				
			||||||
 | 
					            constraint=models.UniqueConstraint(
 | 
				
			||||||
 | 
					                fields=("name", "parent"), name="sas_picture_unique_per_album"
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddConstraint(
 | 
				
			||||||
 | 
					            model_name="album",
 | 
				
			||||||
 | 
					            constraint=models.UniqueConstraint(
 | 
				
			||||||
 | 
					                fields=("name", "parent"), name="unique_album_name_if_same_parent"
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.RunPython(
 | 
				
			||||||
 | 
					            copy_albums_and_pictures,
 | 
				
			||||||
 | 
					            reverse_code=migrations.RunPython.noop,
 | 
				
			||||||
 | 
					            elidable=True,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@@ -0,0 +1,31 @@
 | 
				
			|||||||
 | 
					# Generated by Django 4.2.17 on 2025-01-25 23:50
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					    dependencies = [("sas", "0006_move_the_whole_sas")]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="peoplepicturerelation",
 | 
				
			||||||
 | 
					            name="picture",
 | 
				
			||||||
 | 
					            field=models.ForeignKey(
 | 
				
			||||||
 | 
					                on_delete=django.db.models.deletion.CASCADE,
 | 
				
			||||||
 | 
					                related_name="people",
 | 
				
			||||||
 | 
					                to="sas.picture",
 | 
				
			||||||
 | 
					                verbose_name="picture",
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="picturemoderationrequest",
 | 
				
			||||||
 | 
					            name="picture",
 | 
				
			||||||
 | 
					            field=models.ForeignKey(
 | 
				
			||||||
 | 
					                on_delete=django.db.models.deletion.CASCADE,
 | 
				
			||||||
 | 
					                related_name="moderation_requests",
 | 
				
			||||||
 | 
					                to="sas.picture",
 | 
				
			||||||
 | 
					                verbose_name="Picture",
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										401
									
								
								sas/models.py
									
									
									
									
									
								
							
							
						
						
									
										401
									
								
								sas/models.py
									
									
									
									
									
								
							@@ -18,29 +18,52 @@ from __future__ import annotations
 | 
				
			|||||||
import contextlib
 | 
					import contextlib
 | 
				
			||||||
from io import BytesIO
 | 
					from io import BytesIO
 | 
				
			||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
from typing import ClassVar, Self
 | 
					from typing import TYPE_CHECKING, ClassVar, Self
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
 | 
					from django.core.exceptions import ValidationError
 | 
				
			||||||
 | 
					from django.core.files.base import ContentFile
 | 
				
			||||||
from django.db import models
 | 
					from django.db import models
 | 
				
			||||||
from django.db.models import Exists, OuterRef, Q
 | 
					from django.db.models import Exists, OuterRef, Q
 | 
				
			||||||
 | 
					from django.db.models.deletion import Collector
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from django.utils import timezone
 | 
				
			||||||
 | 
					from django.utils.functional import cached_property
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from PIL import Image
 | 
					from PIL import Image
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.models import Notification, SithFile, User
 | 
					from core.models import Group, Notification, User
 | 
				
			||||||
from core.utils import exif_auto_rotate, resize_image
 | 
					from core.utils import exif_auto_rotate, resize_image
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if TYPE_CHECKING:
 | 
				
			||||||
 | 
					    from django.db.models.fields.files import FieldFile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SasFile(SithFile):
 | 
					
 | 
				
			||||||
    """Proxy model for any file in the SAS.
 | 
					def get_directory(instance: SasFile, filename: str):
 | 
				
			||||||
 | 
					    return f"./{instance.parent_path}/{filename}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_compressed_directory(instance: SasFile, filename: str):
 | 
				
			||||||
 | 
					    return f"./.compressed/{instance.parent_path}/{filename}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_thumbnail_directory(instance: SasFile, filename: str):
 | 
				
			||||||
 | 
					    if isinstance(instance, Album):
 | 
				
			||||||
 | 
					        _, extension = filename.rsplit(".", 1)
 | 
				
			||||||
 | 
					        filename = f"{instance.name}/thumb.{extension}"
 | 
				
			||||||
 | 
					    return f"./.thumbnails/{instance.parent_path}/{filename}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SasFile(models.Model):
 | 
				
			||||||
 | 
					    """Abstract model for SAS files
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    May be used to have logic that should be shared by both
 | 
					    May be used to have logic that should be shared by both
 | 
				
			||||||
    [Picture][sas.models.Picture] and [Album][sas.models.Album].
 | 
					    [Picture][sas.models.Picture] and [Album][sas.models.Album].
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        proxy = True
 | 
					        abstract = True
 | 
				
			||||||
        permissions = [
 | 
					        permissions = [
 | 
				
			||||||
            ("moderate_sasfile", "Can moderate SAS files"),
 | 
					            ("moderate_sasfile", "Can moderate SAS files"),
 | 
				
			||||||
            ("view_unmoderated_sasfile", "Can view not moderated SAS files"),
 | 
					            ("view_unmoderated_sasfile", "Can view not moderated SAS files"),
 | 
				
			||||||
@@ -65,6 +88,169 @@ class SasFile(SithFile):
 | 
				
			|||||||
    def can_be_edited_by(self, user):
 | 
					    def can_be_edited_by(self, user):
 | 
				
			||||||
        return user.has_perm("sas.change_sasfile")
 | 
					        return user.has_perm("sas.change_sasfile")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @cached_property
 | 
				
			||||||
 | 
					    def parent_path(self) -> str:
 | 
				
			||||||
 | 
					        """The parent location in the SAS album tree (e.g. `SAS/foo/bar`)."""
 | 
				
			||||||
 | 
					        return "/".join(["SAS", *[p.name for p in self.parent_list]])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @cached_property
 | 
				
			||||||
 | 
					    def parent_list(self) -> list[Album]:
 | 
				
			||||||
 | 
					        """The ancestors of this SAS object.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        The result is ordered from the direct parent to the farthest one.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        parents = []
 | 
				
			||||||
 | 
					        current = self.parent
 | 
				
			||||||
 | 
					        while current is not None:
 | 
				
			||||||
 | 
					            parents.append(current)
 | 
				
			||||||
 | 
					            current = current.parent
 | 
				
			||||||
 | 
					        return parents
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AlbumQuerySet(models.QuerySet):
 | 
				
			||||||
 | 
					    def viewable_by(self, user: User) -> Self:
 | 
				
			||||||
 | 
					        """Filter the albums that this user can view.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Warning:
 | 
				
			||||||
 | 
					            Calling this queryset method may add several additional requests.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
 | 
				
			||||||
 | 
					            return self.all()
 | 
				
			||||||
 | 
					        if user.was_subscribed:
 | 
				
			||||||
 | 
					            return self.filter(is_moderated=True)
 | 
				
			||||||
 | 
					        # known bug : if all children of an album are also albums
 | 
				
			||||||
 | 
					        # then this album is excluded, even if one of the sub-albums should be visible.
 | 
				
			||||||
 | 
					        # The fs-like navigation is likely to be half-broken for non-subscribers,
 | 
				
			||||||
 | 
					        # but that's ok, since non-subscribers are expected to see only the albums
 | 
				
			||||||
 | 
					        # containing pictures on which they have been identified (hence, very few).
 | 
				
			||||||
 | 
					        # Most, if not all, of their albums will be displayed on the
 | 
				
			||||||
 | 
					        # `latest albums` section of the SAS.
 | 
				
			||||||
 | 
					        # Moreover, they will still see all of their picture in their profile.
 | 
				
			||||||
 | 
					        return self.filter(
 | 
				
			||||||
 | 
					            Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Album(SasFile):
 | 
				
			||||||
 | 
					    NAME_MAX_LENGTH: ClassVar[int] = 50
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    name = models.CharField(_("name"), max_length=100)
 | 
				
			||||||
 | 
					    parent = models.ForeignKey(
 | 
				
			||||||
 | 
					        "self",
 | 
				
			||||||
 | 
					        related_name="children",
 | 
				
			||||||
 | 
					        verbose_name=_("parent"),
 | 
				
			||||||
 | 
					        null=True,
 | 
				
			||||||
 | 
					        blank=True,
 | 
				
			||||||
 | 
					        on_delete=models.CASCADE,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    thumbnail = models.FileField(
 | 
				
			||||||
 | 
					        upload_to=get_thumbnail_directory,
 | 
				
			||||||
 | 
					        verbose_name=_("thumbnail"),
 | 
				
			||||||
 | 
					        max_length=256,
 | 
				
			||||||
 | 
					        blank=True,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    view_groups = models.ManyToManyField(
 | 
				
			||||||
 | 
					        Group, related_name="viewable_albums", verbose_name=_("view groups"), blank=True
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    edit_groups = models.ManyToManyField(
 | 
				
			||||||
 | 
					        Group, related_name="editable_albums", verbose_name=_("edit groups"), blank=True
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    event_date = models.DateField(
 | 
				
			||||||
 | 
					        _("event date"),
 | 
				
			||||||
 | 
					        help_text=_("The date on which the photos in this album were taken"),
 | 
				
			||||||
 | 
					        default=timezone.localdate,
 | 
				
			||||||
 | 
					        blank=True,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    is_moderated = models.BooleanField(_("is moderated"), default=False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    objects = AlbumQuerySet.as_manager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        verbose_name = _("album")
 | 
				
			||||||
 | 
					        constraints = [
 | 
				
			||||||
 | 
					            models.UniqueConstraint(
 | 
				
			||||||
 | 
					                fields=["name", "parent"],
 | 
				
			||||||
 | 
					                name="unique_album_name_if_same_parent",
 | 
				
			||||||
 | 
					                # TODO : add `nulls_distinct=True` after upgrading to django>=5.0
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self):
 | 
				
			||||||
 | 
					        return f"Album {self.name}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        super().save(*args, **kwargs)
 | 
				
			||||||
 | 
					        for user in User.objects.filter(
 | 
				
			||||||
 | 
					            groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					            Notification(
 | 
				
			||||||
 | 
					                user=user,
 | 
				
			||||||
 | 
					                url=reverse("sas:moderation"),
 | 
				
			||||||
 | 
					                type="SAS_MODERATION",
 | 
				
			||||||
 | 
					                param="1",
 | 
				
			||||||
 | 
					            ).save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_absolute_url(self):
 | 
				
			||||||
 | 
					        return reverse("sas:album", kwargs={"album_id": self.id})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def clean(self):
 | 
				
			||||||
 | 
					        super().clean()
 | 
				
			||||||
 | 
					        if "/" in self.name:
 | 
				
			||||||
 | 
					            raise ValidationError(_("Character '/' not authorized in name"))
 | 
				
			||||||
 | 
					        if self.parent_id is not None and (
 | 
				
			||||||
 | 
					            self.id == self.parent_id or self in self.parent_list
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					            raise ValidationError(_("Loop in album tree"), code="loop")
 | 
				
			||||||
 | 
					        if self.thumbnail:
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                Image.open(BytesIO(self.thumbnail.read()))
 | 
				
			||||||
 | 
					            except Image.UnidentifiedImageError as e:
 | 
				
			||||||
 | 
					                raise ValidationError(_("This is not a valid album thumbnail")) from e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def delete(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        """Delete the album, all of its children and all linked disk files"""
 | 
				
			||||||
 | 
					        collector = Collector(using="default")
 | 
				
			||||||
 | 
					        collector.collect([self])
 | 
				
			||||||
 | 
					        albums: set[Album] = collector.data[Album]
 | 
				
			||||||
 | 
					        pictures: set[Picture] = collector.data[Picture]
 | 
				
			||||||
 | 
					        files: list[FieldFile] = [
 | 
				
			||||||
 | 
					            *[a.thumbnail for a in albums],
 | 
				
			||||||
 | 
					            *[p.thumbnail for p in pictures],
 | 
				
			||||||
 | 
					            *[p.compressed for p in pictures],
 | 
				
			||||||
 | 
					            *[p.original for p in pictures],
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        # `bool(f)` checks that the file actually exists on the disk
 | 
				
			||||||
 | 
					        files = [f for f in files if bool(f)]
 | 
				
			||||||
 | 
					        folders = {Path(f.path).parent for f in files}
 | 
				
			||||||
 | 
					        res = super().delete(*args, **kwargs)
 | 
				
			||||||
 | 
					        # once the model instances have been deleted,
 | 
				
			||||||
 | 
					        # delete the actual files.
 | 
				
			||||||
 | 
					        for file in files:
 | 
				
			||||||
 | 
					            # save=False ensures that django doesn't recreate the db record,
 | 
				
			||||||
 | 
					            # which would make the whole deletion pointless
 | 
				
			||||||
 | 
					            # cf. https://docs.djangoproject.com/en/stable/ref/models/fields/#django.db.models.fields.files.FieldFile.delete
 | 
				
			||||||
 | 
					            file.delete(save=False)
 | 
				
			||||||
 | 
					        for folder in folders:
 | 
				
			||||||
 | 
					            # now that the files are deleted, remove the empty folders
 | 
				
			||||||
 | 
					            if folder.is_dir() and next(folder.iterdir(), None) is None:
 | 
				
			||||||
 | 
					                folder.rmdir()
 | 
				
			||||||
 | 
					        return res
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_download_url(self):
 | 
				
			||||||
 | 
					        return reverse("sas:album_preview", kwargs={"album_id": self.id})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def generate_thumbnail(self):
 | 
				
			||||||
 | 
					        p = (
 | 
				
			||||||
 | 
					            self.pictures.exclude(thumbnail="").order_by("?").first()
 | 
				
			||||||
 | 
					            or self.children.exclude(thumbnail="").order_by("?").first()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if p:
 | 
				
			||||||
 | 
					            # The file is loaded into memory to duplicate it.
 | 
				
			||||||
 | 
					            # It may not be the most efficient way, but thumbnails are
 | 
				
			||||||
 | 
					            # usually quite small, so it's still ok
 | 
				
			||||||
 | 
					            self.thumbnail = ContentFile(p.thumbnail.read(), name="thumb.webp")
 | 
				
			||||||
 | 
					            self.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PictureQuerySet(models.QuerySet):
 | 
					class PictureQuerySet(models.QuerySet):
 | 
				
			||||||
    def viewable_by(self, user: User) -> Self:
 | 
					    def viewable_by(self, user: User) -> Self:
 | 
				
			||||||
@@ -80,23 +266,65 @@ class PictureQuerySet(models.QuerySet):
 | 
				
			|||||||
        return self.filter(people__user_id=user.id, is_moderated=True)
 | 
					        return self.filter(people__user_id=user.id, is_moderated=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SASPictureManager(models.Manager):
 | 
					 | 
				
			||||||
    def get_queryset(self):
 | 
					 | 
				
			||||||
        return super().get_queryset().filter(is_in_sas=True, is_folder=False)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Picture(SasFile):
 | 
					class Picture(SasFile):
 | 
				
			||||||
 | 
					    name = models.CharField(_("file name"), max_length=256)
 | 
				
			||||||
 | 
					    parent = models.ForeignKey(
 | 
				
			||||||
 | 
					        Album,
 | 
				
			||||||
 | 
					        related_name="pictures",
 | 
				
			||||||
 | 
					        verbose_name=_("album"),
 | 
				
			||||||
 | 
					        on_delete=models.CASCADE,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    thumbnail = models.FileField(
 | 
				
			||||||
 | 
					        upload_to=get_thumbnail_directory,
 | 
				
			||||||
 | 
					        verbose_name=_("thumbnail"),
 | 
				
			||||||
 | 
					        max_length=256,
 | 
				
			||||||
 | 
					        unique=True,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    original = models.FileField(
 | 
				
			||||||
 | 
					        upload_to=get_directory,
 | 
				
			||||||
 | 
					        verbose_name=_("original image"),
 | 
				
			||||||
 | 
					        max_length=256,
 | 
				
			||||||
 | 
					        unique=True,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    compressed = models.FileField(
 | 
				
			||||||
 | 
					        upload_to=get_compressed_directory,
 | 
				
			||||||
 | 
					        verbose_name=_("compressed image"),
 | 
				
			||||||
 | 
					        max_length=256,
 | 
				
			||||||
 | 
					        unique=True,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    created_at = models.DateTimeField(default=timezone.now)
 | 
				
			||||||
 | 
					    owner = models.ForeignKey(
 | 
				
			||||||
 | 
					        User,
 | 
				
			||||||
 | 
					        related_name="owned_pictures",
 | 
				
			||||||
 | 
					        verbose_name=_("owner"),
 | 
				
			||||||
 | 
					        on_delete=models.PROTECT,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    is_moderated = models.BooleanField(_("is moderated"), default=False)
 | 
				
			||||||
 | 
					    asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
 | 
				
			||||||
 | 
					    moderator = models.ForeignKey(
 | 
				
			||||||
 | 
					        User,
 | 
				
			||||||
 | 
					        related_name="moderated_pictures",
 | 
				
			||||||
 | 
					        null=True,
 | 
				
			||||||
 | 
					        blank=True,
 | 
				
			||||||
 | 
					        on_delete=models.SET_NULL,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    objects = PictureQuerySet.as_manager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        proxy = True
 | 
					        verbose_name = _("picture")
 | 
				
			||||||
 | 
					        constraints = [
 | 
				
			||||||
 | 
					            models.UniqueConstraint(
 | 
				
			||||||
 | 
					                fields=["name", "parent"], name="sas_picture_unique_per_album"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    objects = SASPictureManager.from_queryset(PictureQuerySet)()
 | 
					    def __str__(self):
 | 
				
			||||||
 | 
					        return self.name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    def get_absolute_url(self):
 | 
				
			||||||
    def is_vertical(self):
 | 
					        return reverse("sas:picture", kwargs={"picture_id": self.id})
 | 
				
			||||||
        with open(settings.MEDIA_ROOT / self.file.name, "rb") as f:
 | 
					 | 
				
			||||||
            im = Image.open(BytesIO(f.read()))
 | 
					 | 
				
			||||||
            (w, h) = im.size
 | 
					 | 
				
			||||||
            return (w / h) < 1
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_download_url(self):
 | 
					    def get_download_url(self):
 | 
				
			||||||
        return reverse("sas:download", kwargs={"picture_id": self.id})
 | 
					        return reverse("sas:download", kwargs={"picture_id": self.id})
 | 
				
			||||||
@@ -107,41 +335,34 @@ class Picture(SasFile):
 | 
				
			|||||||
    def get_download_thumb_url(self):
 | 
					    def get_download_thumb_url(self):
 | 
				
			||||||
        return reverse("sas:download_thumb", kwargs={"picture_id": self.id})
 | 
					        return reverse("sas:download_thumb", kwargs={"picture_id": self.id})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_absolute_url(self):
 | 
					    @property
 | 
				
			||||||
        return reverse("sas:picture", kwargs={"picture_id": self.id})
 | 
					    def is_vertical(self):
 | 
				
			||||||
 | 
					        # original, compressed and thumbnail image have all three the same ratio,
 | 
				
			||||||
 | 
					        # so the smallest one is used to tell if the image is vertical
 | 
				
			||||||
 | 
					        im = Image.open(BytesIO(self.thumbnail.read()))
 | 
				
			||||||
 | 
					        (w, h) = im.size
 | 
				
			||||||
 | 
					        return w < h
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def generate_thumbnails(self, *, overwrite=False):
 | 
					    def generate_thumbnails(self):
 | 
				
			||||||
        im = Image.open(BytesIO(self.file.read()))
 | 
					        im = Image.open(self.original)
 | 
				
			||||||
        with contextlib.suppress(Exception):
 | 
					        with contextlib.suppress(Exception):
 | 
				
			||||||
            im = exif_auto_rotate(im)
 | 
					            im = exif_auto_rotate(im)
 | 
				
			||||||
        # convert the compressed image and the thumbnail into webp
 | 
					        # convert the compressed image and the thumbnail into webp
 | 
				
			||||||
        # The original image keeps its original type, because it's not
 | 
					 | 
				
			||||||
        # meant to be shown on the website, but rather to keep the real image
 | 
					 | 
				
			||||||
        # for less frequent cases (like downloading the pictures of an user)
 | 
					 | 
				
			||||||
        extension = self.mime_type.split("/")[-1]
 | 
					 | 
				
			||||||
        # the HD version of the image doesn't need to be optimized, because :
 | 
					        # the HD version of the image doesn't need to be optimized, because :
 | 
				
			||||||
        # - it isn't frequently queried
 | 
					        # - it isn't frequently queried
 | 
				
			||||||
        # - optimizing large images takes a lot time, which greatly hinders the UX
 | 
					        # - optimizing large images takes a lot of time, which greatly hinders the UX
 | 
				
			||||||
        # - photographers usually already optimize their images
 | 
					        # - photographers usually already optimize their images
 | 
				
			||||||
        file = resize_image(im, max(im.size), extension, optimize=False)
 | 
					 | 
				
			||||||
        thumb = resize_image(im, 200, "webp")
 | 
					        thumb = resize_image(im, 200, "webp")
 | 
				
			||||||
        compressed = resize_image(im, 1200, "webp")
 | 
					        compressed = resize_image(im, 1200, "webp")
 | 
				
			||||||
        if overwrite:
 | 
					        new_extension_name = str(Path(self.original.name).with_suffix(".webp"))
 | 
				
			||||||
            self.file.delete()
 | 
					 | 
				
			||||||
            self.thumbnail.delete()
 | 
					 | 
				
			||||||
            self.compressed.delete()
 | 
					 | 
				
			||||||
        new_extension_name = str(Path(self.name).with_suffix(".webp"))
 | 
					 | 
				
			||||||
        self.file = file
 | 
					 | 
				
			||||||
        self.file.name = self.name
 | 
					 | 
				
			||||||
        self.thumbnail = thumb
 | 
					        self.thumbnail = thumb
 | 
				
			||||||
        self.thumbnail.name = new_extension_name
 | 
					        self.thumbnail.name = new_extension_name
 | 
				
			||||||
        self.compressed = compressed
 | 
					        self.compressed = compressed
 | 
				
			||||||
        self.compressed.name = new_extension_name
 | 
					        self.compressed.name = new_extension_name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def rotate(self, degree):
 | 
					    def rotate(self, degree):
 | 
				
			||||||
        for attr in ["file", "compressed", "thumbnail"]:
 | 
					        for field in self.original, self.compressed, self.thumbnail:
 | 
				
			||||||
            name = self.__getattribute__(attr).name
 | 
					            with open(field.file, "r+b") as file:
 | 
				
			||||||
            with open(settings.MEDIA_ROOT / name, "r+b") as file:
 | 
					 | 
				
			||||||
                if file:
 | 
					                if file:
 | 
				
			||||||
                    im = Image.open(BytesIO(file.read()))
 | 
					                    im = Image.open(BytesIO(file.read()))
 | 
				
			||||||
                    file.seek(0)
 | 
					                    file.seek(0)
 | 
				
			||||||
@@ -154,110 +375,6 @@ class Picture(SasFile):
 | 
				
			|||||||
                        progressive=True,
 | 
					                        progressive=True,
 | 
				
			||||||
                    )
 | 
					                    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_next(self):
 | 
					 | 
				
			||||||
        if self.is_moderated:
 | 
					 | 
				
			||||||
            pictures_qs = self.parent.children.filter(
 | 
					 | 
				
			||||||
                is_moderated=True,
 | 
					 | 
				
			||||||
                asked_for_removal=False,
 | 
					 | 
				
			||||||
                is_folder=False,
 | 
					 | 
				
			||||||
                id__gt=self.id,
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False)
 | 
					 | 
				
			||||||
        return pictures_qs.order_by("id").first()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_previous(self):
 | 
					 | 
				
			||||||
        if self.is_moderated:
 | 
					 | 
				
			||||||
            pictures_qs = self.parent.children.filter(
 | 
					 | 
				
			||||||
                is_moderated=True,
 | 
					 | 
				
			||||||
                asked_for_removal=False,
 | 
					 | 
				
			||||||
                is_folder=False,
 | 
					 | 
				
			||||||
                id__lt=self.id,
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False)
 | 
					 | 
				
			||||||
        return pictures_qs.order_by("-id").first()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class AlbumQuerySet(models.QuerySet):
 | 
					 | 
				
			||||||
    def viewable_by(self, user: User) -> Self:
 | 
					 | 
				
			||||||
        """Filter the albums that this user can view.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        Warning:
 | 
					 | 
				
			||||||
            Calling this queryset method may add several additional requests.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        if user.has_perm("sas.moderate_sasfile"):
 | 
					 | 
				
			||||||
            return self.all()
 | 
					 | 
				
			||||||
        if user.was_subscribed:
 | 
					 | 
				
			||||||
            return self.filter(Q(is_moderated=True) | Q(owner=user))
 | 
					 | 
				
			||||||
        # known bug : if all children of an album are also albums
 | 
					 | 
				
			||||||
        # then this album is excluded, even if one of the sub-albums should be visible.
 | 
					 | 
				
			||||||
        # The fs-like navigation is likely to be half-broken for non-subscribers,
 | 
					 | 
				
			||||||
        # but that's ok, since non-subscribers are expected to see only the albums
 | 
					 | 
				
			||||||
        # containing pictures on which they have been identified (hence, very few).
 | 
					 | 
				
			||||||
        # Most, if not all, of their albums will be displayed on the
 | 
					 | 
				
			||||||
        # `latest albums` section of the SAS.
 | 
					 | 
				
			||||||
        # Moreover, they will still see all of their picture in their profile.
 | 
					 | 
				
			||||||
        return self.filter(
 | 
					 | 
				
			||||||
            Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class SASAlbumManager(models.Manager):
 | 
					 | 
				
			||||||
    def get_queryset(self):
 | 
					 | 
				
			||||||
        return super().get_queryset().filter(is_in_sas=True, is_folder=True)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Album(SasFile):
 | 
					 | 
				
			||||||
    NAME_MAX_LENGTH: ClassVar[int] = 50
 | 
					 | 
				
			||||||
    """Maximum length of an album's name.
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    [SithFile][core.models.SithFile] have a maximum length
 | 
					 | 
				
			||||||
    of 256 characters.
 | 
					 | 
				
			||||||
    However, this limit is too high for albums.
 | 
					 | 
				
			||||||
    Names longer than 50 characters are harder to read
 | 
					 | 
				
			||||||
    and harder to display on the SAS page.
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    It is to be noted, though, that this does not
 | 
					 | 
				
			||||||
    add or modify any db behaviour.
 | 
					 | 
				
			||||||
    It's just a constant to be used in views and forms.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					 | 
				
			||||||
        proxy = True
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    objects = SASAlbumManager.from_queryset(AlbumQuerySet)()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def children_pictures(self):
 | 
					 | 
				
			||||||
        return Picture.objects.filter(parent=self)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def children_albums(self):
 | 
					 | 
				
			||||||
        return Album.objects.filter(parent=self)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_absolute_url(self):
 | 
					 | 
				
			||||||
        if self.id == settings.SITH_SAS_ROOT_DIR_ID:
 | 
					 | 
				
			||||||
            return reverse("sas:main")
 | 
					 | 
				
			||||||
        return reverse("sas:album", kwargs={"album_id": self.id})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_download_url(self):
 | 
					 | 
				
			||||||
        return reverse("sas:album_preview", kwargs={"album_id": self.id})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def generate_thumbnail(self):
 | 
					 | 
				
			||||||
        p = (
 | 
					 | 
				
			||||||
            self.children_pictures.order_by("?").first()
 | 
					 | 
				
			||||||
            or self.children_albums.exclude(file=None)
 | 
					 | 
				
			||||||
            .exclude(file="")
 | 
					 | 
				
			||||||
            .order_by("?")
 | 
					 | 
				
			||||||
            .first()
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        if p and p.file:
 | 
					 | 
				
			||||||
            image = resize_image(Image.open(BytesIO(p.file.read())), 200, "webp")
 | 
					 | 
				
			||||||
            self.file = image
 | 
					 | 
				
			||||||
            self.file.name = f"{self.name}/thumb.webp"
 | 
					 | 
				
			||||||
            self.save()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
def sas_notification_callback(notif: Notification):
 | 
					def sas_notification_callback(notif: Notification):
 | 
				
			||||||
    count = Picture.objects.filter(is_moderated=False).count()
 | 
					    count = Picture.objects.filter(is_moderated=False).count()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -56,7 +56,12 @@ class AlbumAutocompleteSchema(ModelSchema):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    def resolve_path(obj: Album) -> str:
 | 
					    def resolve_path(obj: Album) -> str:
 | 
				
			||||||
        return str(Path(obj.get_parent_path()) / obj.name)
 | 
					        return str(Path(obj.parent_path) / obj.name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class MoveAlbumSchema(Schema):
 | 
				
			||||||
 | 
					    id: int
 | 
				
			||||||
 | 
					    new_parent_id: int
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PictureFilterSchema(FilterSchema):
 | 
					class PictureFilterSchema(FilterSchema):
 | 
				
			||||||
@@ -69,7 +74,7 @@ class PictureFilterSchema(FilterSchema):
 | 
				
			|||||||
class PictureSchema(ModelSchema):
 | 
					class PictureSchema(ModelSchema):
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = Picture
 | 
					        model = Picture
 | 
				
			||||||
        fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"]
 | 
					        fields = ["id", "name", "created_at", "is_moderated", "asked_for_removal"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    owner: UserProfileSchema
 | 
					    owner: UserProfileSchema
 | 
				
			||||||
    sas_url: str
 | 
					    sas_url: str
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -125,3 +125,108 @@ document.addEventListener("alpine:init", () => {
 | 
				
			|||||||
    },
 | 
					    },
 | 
				
			||||||
  }));
 | 
					  }));
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Todo: migrate to alpine.js if we have some time
 | 
				
			||||||
 | 
					// $("form#upload_form").submit(function (event) {
 | 
				
			||||||
 | 
					//   const formData = new FormData($(this)[0]);
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   if (!formData.get("album_name") && !formData.get("images").name) return false;
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   if (!formData.get("images").name) {
 | 
				
			||||||
 | 
					//     return true;
 | 
				
			||||||
 | 
					//   }
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   event.preventDefault();
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   let errorList = this.querySelector("#upload_form ul.errorlist.nonfield");
 | 
				
			||||||
 | 
					//   if (errorList === null) {
 | 
				
			||||||
 | 
					//     errorList = document.createElement("ul");
 | 
				
			||||||
 | 
					//     errorList.classList.add("errorlist", "nonfield");
 | 
				
			||||||
 | 
					//     this.insertBefore(errorList, this.firstElementChild);
 | 
				
			||||||
 | 
					//   }
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   while (errorList.childElementCount > 0)
 | 
				
			||||||
 | 
					//     errorList.removeChild(errorList.firstElementChild);
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   let progress = this.querySelector("progress");
 | 
				
			||||||
 | 
					//   if (progress === null) {
 | 
				
			||||||
 | 
					//     progress = document.createElement("progress");
 | 
				
			||||||
 | 
					//     progress.value = 0;
 | 
				
			||||||
 | 
					//     const p = document.createElement("p");
 | 
				
			||||||
 | 
					//     p.appendChild(progress);
 | 
				
			||||||
 | 
					//     this.insertBefore(p, this.lastElementChild);
 | 
				
			||||||
 | 
					//   }
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   let dataHolder;
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   if (formData.get("album_name")) {
 | 
				
			||||||
 | 
					//     dataHolder = new FormData();
 | 
				
			||||||
 | 
					//     dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}");
 | 
				
			||||||
 | 
					//     dataHolder.set("album_name", formData.get("album_name"));
 | 
				
			||||||
 | 
					//     $.ajax({
 | 
				
			||||||
 | 
					//       method: "POST",
 | 
				
			||||||
 | 
					//       url: "{{ url('sas:album_upload', album_id=object.id) }}",
 | 
				
			||||||
 | 
					//       data: dataHolder,
 | 
				
			||||||
 | 
					//       processData: false,
 | 
				
			||||||
 | 
					//       contentType: false,
 | 
				
			||||||
 | 
					//       success: onSuccess,
 | 
				
			||||||
 | 
					//     });
 | 
				
			||||||
 | 
					//   }
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   const images = formData.getAll("images");
 | 
				
			||||||
 | 
					//   const imagesCount = images.length;
 | 
				
			||||||
 | 
					//   let completeCount = 0;
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   const poolSize = 1;
 | 
				
			||||||
 | 
					//   const imagePool = [];
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   while (images.length > 0 && imagePool.length < poolSize) {
 | 
				
			||||||
 | 
					//     const image = images.shift();
 | 
				
			||||||
 | 
					//     imagePool.push(image);
 | 
				
			||||||
 | 
					//     sendImage(image);
 | 
				
			||||||
 | 
					//   }
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   function sendImage(image) {
 | 
				
			||||||
 | 
					//     dataHolder = new FormData();
 | 
				
			||||||
 | 
					//     dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}");
 | 
				
			||||||
 | 
					//     dataHolder.set("images", image);
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//     $.ajax({
 | 
				
			||||||
 | 
					//       method: "POST",
 | 
				
			||||||
 | 
					//       url: "{{ url('sas:album_upload', album_id=object.id) }}",
 | 
				
			||||||
 | 
					//       data: dataHolder,
 | 
				
			||||||
 | 
					//       processData: false,
 | 
				
			||||||
 | 
					//       contentType: false,
 | 
				
			||||||
 | 
					//     })
 | 
				
			||||||
 | 
					//       .fail(onSuccess.bind(undefined, image))
 | 
				
			||||||
 | 
					//       .done(onSuccess.bind(undefined, image))
 | 
				
			||||||
 | 
					//       .always(next.bind(undefined, image));
 | 
				
			||||||
 | 
					//   }
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   function next(image, _, __) {
 | 
				
			||||||
 | 
					//     const index = imagePool.indexOf(image);
 | 
				
			||||||
 | 
					//     const nextImage = images.shift();
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//     if (index !== -1) {
 | 
				
			||||||
 | 
					//       imagePool.splice(index, 1);
 | 
				
			||||||
 | 
					//     }
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//     if (nextImage) {
 | 
				
			||||||
 | 
					//       imagePool.push(nextImage);
 | 
				
			||||||
 | 
					//       sendImage(nextImage);
 | 
				
			||||||
 | 
					//     }
 | 
				
			||||||
 | 
					//   }
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//   function onSuccess(image, data, _, __) {
 | 
				
			||||||
 | 
					//     let errors = [];
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//     if ($(data.responseText).find(".errorlist.nonfield")[0])
 | 
				
			||||||
 | 
					//       errors = Array.from($(data.responseText).find(".errorlist.nonfield")[0].children);
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//     while (errors.length > 0) errorList.appendChild(errors.shift());
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//     progress.value = ++completeCount / imagesCount;
 | 
				
			||||||
 | 
					//     if (progress.value === 1 && errorList.children.length === 0)
 | 
				
			||||||
 | 
					//       document.location.reload();
 | 
				
			||||||
 | 
					//   }
 | 
				
			||||||
 | 
					// });
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -30,10 +30,10 @@ document.addEventListener("alpine:init", () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      await Promise.all(
 | 
					      await Promise.all(
 | 
				
			||||||
        this.pictures.map((p: PictureSchema) => {
 | 
					        this.pictures.map((p: PictureSchema) => {
 | 
				
			||||||
          const imgName = `${p.album}/IMG_${p.date.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
 | 
					          const imgName = `${p.album}/IMG_${p.created_at.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
 | 
				
			||||||
          return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
 | 
					          return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
 | 
				
			||||||
            level: 9,
 | 
					            level: 9,
 | 
				
			||||||
            lastModDate: new Date(p.date),
 | 
					            lastModDate: new Date(p.created_at),
 | 
				
			||||||
            onstart: incrementProgressBar,
 | 
					            onstart: incrementProgressBar,
 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -142,7 +142,8 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
 | 
				
			|||||||
        // biome-ignore lint/style/useNamingConvention: api is in snake_case
 | 
					        // biome-ignore lint/style/useNamingConvention: api is in snake_case
 | 
				
			||||||
        full_size_url: "",
 | 
					        full_size_url: "",
 | 
				
			||||||
        owner: "",
 | 
					        owner: "",
 | 
				
			||||||
        date: new Date(),
 | 
					        // biome-ignore lint/style/useNamingConvention: api is in snake_case
 | 
				
			||||||
 | 
					        created_at: new Date(),
 | 
				
			||||||
        identifications: [] as IdentifiedUserSchema[],
 | 
					        identifications: [] as IdentifiedUserSchema[],
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      /**
 | 
					      /**
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,7 +20,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
  <code>
 | 
					  <code>
 | 
				
			||||||
    <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.get_display_name() }}
 | 
					    <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.name }}
 | 
				
			||||||
  </code>
 | 
					  </code>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  {% set is_sas_admin = user.can_edit(album) %}
 | 
					  {% set is_sas_admin = user.can_edit(album) %}
 | 
				
			||||||
@@ -30,7 +30,7 @@
 | 
				
			|||||||
    <form action="" method="post" enctype="multipart/form-data">
 | 
					    <form action="" method="post" enctype="multipart/form-data">
 | 
				
			||||||
      {% csrf_token %}
 | 
					      {% csrf_token %}
 | 
				
			||||||
      <div class="album-navbar">
 | 
					      <div class="album-navbar">
 | 
				
			||||||
        <h3>{{ album.get_display_name() }}</h3>
 | 
					        <h3>{{ album.name }}</h3>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div class="toolbar">
 | 
					        <div class="toolbar">
 | 
				
			||||||
          <a href="{{ url('sas:album_edit', album_id=album.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
					          <a href="{{ url('sas:album_edit', album_id=album.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
				
			||||||
@@ -40,17 +40,17 @@
 | 
				
			|||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {% if clipboard %}
 | 
					{#      {% if clipboard %}#}
 | 
				
			||||||
        <div class="clipboard">
 | 
					{#        <div class="clipboard">#}
 | 
				
			||||||
          {% trans %}Clipboard: {% endtrans %}
 | 
					{#          {% trans %}Clipboard: {% endtrans %}#}
 | 
				
			||||||
          <ul>
 | 
					{#          <ul>#}
 | 
				
			||||||
            {% for f in clipboard %}
 | 
					{#            {% for f in clipboard["albums"] %}#}
 | 
				
			||||||
              <li>{{ f.get_full_path() }}</li>
 | 
					{#              <li>{{ f.get_full_path() }}</li>#}
 | 
				
			||||||
            {% endfor %}
 | 
					{#            {% endfor %}#}
 | 
				
			||||||
          </ul>
 | 
					{#          </ul>#}
 | 
				
			||||||
          <input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}">
 | 
					{#          <input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}">#}
 | 
				
			||||||
        </div>
 | 
					{#        </div>#}
 | 
				
			||||||
      {% endif %}
 | 
					{#      {% endif %}#}
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  {% if show_albums %}
 | 
					  {% if show_albums %}
 | 
				
			||||||
@@ -73,8 +73,8 @@
 | 
				
			|||||||
                <div class="text">{% trans %}To be moderated{% endtrans %}</div>
 | 
					                <div class="text">{% trans %}To be moderated{% endtrans %}</div>
 | 
				
			||||||
              </template>
 | 
					              </template>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
            {% if is_sas_admin %}
 | 
					            {% if edit_mode %}
 | 
				
			||||||
              <input type="checkbox" name="file_list" :value="album.id">
 | 
					              <input type="checkbox" name="album_list" :value="album.id">
 | 
				
			||||||
            {% endif %}
 | 
					            {% endif %}
 | 
				
			||||||
          </a>
 | 
					          </a>
 | 
				
			||||||
        </template>
 | 
					        </template>
 | 
				
			||||||
@@ -100,7 +100,7 @@
 | 
				
			|||||||
            </template>
 | 
					            </template>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          {% if is_sas_admin %}
 | 
					          {% if is_sas_admin %}
 | 
				
			||||||
            <input type="checkbox" name="file_list" :value="picture.id">
 | 
					            <input type="checkbox" name="picture_list" :value="picture.id">
 | 
				
			||||||
          {% endif %}
 | 
					          {% endif %}
 | 
				
			||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
      </template>
 | 
					      </template>
 | 
				
			||||||
@@ -120,9 +120,9 @@
 | 
				
			|||||||
      {% csrf_token %}
 | 
					      {% csrf_token %}
 | 
				
			||||||
      <div class="inputs">
 | 
					      <div class="inputs">
 | 
				
			||||||
        <p>
 | 
					        <p>
 | 
				
			||||||
          <label for="{{ upload_form.images.id_for_label }}">{{ upload_form.images.label }} :</label>
 | 
					          <label for="{{ form.images.id_for_label }}">{{ form.images.label }} :</label>
 | 
				
			||||||
          {{ upload_form.images|add_attr("x-ref=pictures") }}
 | 
					          {{ form.images|add_attr("x-ref=pictures") }}
 | 
				
			||||||
          <span class="helptext">{{ upload_form.images.help_text }}</span>
 | 
					          <span class="helptext">{{ form.images.help_text }}</span>
 | 
				
			||||||
        </p>
 | 
					        </p>
 | 
				
			||||||
        <input type="submit" value="{% trans %}Upload{% endtrans %}" />
 | 
					        <input type="submit" value="{% trans %}Upload{% endtrans %}" />
 | 
				
			||||||
        <progress x-ref="progress" x-show="sending"></progress>
 | 
					        <progress x-ref="progress" x-show="sending"></progress>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,19 +1,13 @@
 | 
				
			|||||||
{% macro display_album(a, edit_mode) %}
 | 
					{% macro display_album(a, edit_mode) %}
 | 
				
			||||||
  <a href="{{ url('sas:album', album_id=a.id) }}">
 | 
					  <a href="{{ url('sas:album', album_id=a.id) }}">
 | 
				
			||||||
    {% if a.file %}
 | 
					    {% if a.thumbnail %}
 | 
				
			||||||
      {% set img = a.get_download_url() %}
 | 
					      {% set img = a.get_download_url() %}
 | 
				
			||||||
      {% set src = a.name %}
 | 
					      {% set src = a.name %}
 | 
				
			||||||
    {% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
 | 
					 | 
				
			||||||
      {% set picture = a.children.filter(is_folder=False).first().as_picture %}
 | 
					 | 
				
			||||||
      {% set img = picture.get_download_thumb_url()  %}
 | 
					 | 
				
			||||||
      {% set src = picture.name %}
 | 
					 | 
				
			||||||
    {% else %}
 | 
					    {% else %}
 | 
				
			||||||
      {% set img = static('core/img/sas.jpg') %}
 | 
					      {% set img = static('core/img/sas.jpg') %}
 | 
				
			||||||
      {% set src = "sas.jpg" %}
 | 
					      {% set src = "sas.jpg" %}
 | 
				
			||||||
    {% endif %}
 | 
					    {% endif %}
 | 
				
			||||||
    <div
 | 
					    <div class="album{% if not a.is_moderated %} not_moderated{% endif %}">
 | 
				
			||||||
      class="album{% if not a.is_moderated %} not_moderated{% endif %}"
 | 
					 | 
				
			||||||
    >
 | 
					 | 
				
			||||||
      <img src="{{ img }}" alt="{{ src }}" loading="lazy" />
 | 
					      <img src="{{ img }}" alt="{{ src }}" loading="lazy" />
 | 
				
			||||||
      {% if not a.is_moderated %}
 | 
					      {% if not a.is_moderated %}
 | 
				
			||||||
        <div class="overlay"> </div>
 | 
					        <div class="overlay"> </div>
 | 
				
			||||||
@@ -31,7 +25,7 @@
 | 
				
			|||||||
{% macro print_path(file) %}
 | 
					{% macro print_path(file) %}
 | 
				
			||||||
  {% if file and file.parent %}
 | 
					  {% if file and file.parent %}
 | 
				
			||||||
    {{ print_path(file.parent) }}
 | 
					    {{ print_path(file.parent) }}
 | 
				
			||||||
    <a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
 | 
					    <a href="{{ url("sas:album", album_id=file.id) }}">{{ file.name }}</a> /
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
{% endmacro %}
 | 
					{% endmacro %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,9 +1,9 @@
 | 
				
			|||||||
{% extends "core/base.jinja" %}
 | 
					{% extends "core/base.jinja" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{%- block additional_css -%}
 | 
					{%- block additional_css -%}
 | 
				
			||||||
  <link defer rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
 | 
					  <link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
 | 
				
			||||||
  <link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
 | 
					  <link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
 | 
				
			||||||
  <link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
 | 
					  <link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
 | 
				
			||||||
{%- endblock -%}
 | 
					{%- endblock -%}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{%- block additional_js -%}
 | 
					{%- block additional_js -%}
 | 
				
			||||||
@@ -104,7 +104,7 @@
 | 
				
			|||||||
                <span
 | 
					                <span
 | 
				
			||||||
                  x-text="Intl.DateTimeFormat(
 | 
					                  x-text="Intl.DateTimeFormat(
 | 
				
			||||||
                          '{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
 | 
					                          '{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
 | 
				
			||||||
                          ).format(new Date(currentPicture.date))"
 | 
					                          ).format(new Date(currentPicture.created_at))"
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
                </span>
 | 
					                </span>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -27,8 +27,8 @@ class TestSas(TestCase):
 | 
				
			|||||||
        cls.user_b, cls.user_c = subscriber_user.make(_quantity=2)
 | 
					        cls.user_b, cls.user_c = subscriber_user.make(_quantity=2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        picture = picture_recipe.extend(owner=owner)
 | 
					        picture = picture_recipe.extend(owner=owner)
 | 
				
			||||||
        cls.album_a = baker.make(Album, is_in_sas=True, parent=sas)
 | 
					        cls.album_a = baker.make(Album)
 | 
				
			||||||
        cls.album_b = baker.make(Album, is_in_sas=True, parent=sas)
 | 
					        cls.album_b = baker.make(Album)
 | 
				
			||||||
        relation_recipe = Recipe(PeoplePictureRelation)
 | 
					        relation_recipe = Recipe(PeoplePictureRelation)
 | 
				
			||||||
        relations = []
 | 
					        relations = []
 | 
				
			||||||
        for album in cls.album_a, cls.album_b:
 | 
					        for album in cls.album_a, cls.album_b:
 | 
				
			||||||
@@ -61,7 +61,7 @@ class TestPictureSearch(TestSas):
 | 
				
			|||||||
        self.client.force_login(self.user_b)
 | 
					        self.client.force_login(self.user_b)
 | 
				
			||||||
        res = self.client.get(self.url + f"?album_id={self.album_a.id}")
 | 
					        res = self.client.get(self.url + f"?album_id={self.album_a.id}")
 | 
				
			||||||
        assert res.status_code == 200
 | 
					        assert res.status_code == 200
 | 
				
			||||||
        expected = list(self.album_a.children_pictures.values_list("id", flat=True))
 | 
					        expected = list(self.album_a.pictures.values_list("id", flat=True))
 | 
				
			||||||
        assert [i["id"] for i in res.json()["results"]] == expected
 | 
					        assert [i["id"] for i in res.json()["results"]] == expected
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_filter_by_user(self):
 | 
					    def test_filter_by_user(self):
 | 
				
			||||||
@@ -70,7 +70,7 @@ class TestPictureSearch(TestSas):
 | 
				
			|||||||
        assert res.status_code == 200
 | 
					        assert res.status_code == 200
 | 
				
			||||||
        expected = list(
 | 
					        expected = list(
 | 
				
			||||||
            self.user_a.pictures.order_by(
 | 
					            self.user_a.pictures.order_by(
 | 
				
			||||||
                "-picture__parent__date", "picture__date"
 | 
					                "-picture__parent__event_date", "picture__created_at"
 | 
				
			||||||
            ).values_list("picture_id", flat=True)
 | 
					            ).values_list("picture_id", flat=True)
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        assert [i["id"] for i in res.json()["results"]] == expected
 | 
					        assert [i["id"] for i in res.json()["results"]] == expected
 | 
				
			||||||
@@ -84,7 +84,7 @@ class TestPictureSearch(TestSas):
 | 
				
			|||||||
        assert res.status_code == 200
 | 
					        assert res.status_code == 200
 | 
				
			||||||
        expected = list(
 | 
					        expected = list(
 | 
				
			||||||
            self.user_a.pictures.union(self.user_b.pictures.all())
 | 
					            self.user_a.pictures.union(self.user_b.pictures.all())
 | 
				
			||||||
            .order_by("-picture__parent__date", "picture__date")
 | 
					            .order_by("-picture__parent__event_date", "picture__created_at")
 | 
				
			||||||
            .values_list("picture_id", flat=True)
 | 
					            .values_list("picture_id", flat=True)
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        assert [i["id"] for i in res.json()["results"]] == expected
 | 
					        assert [i["id"] for i in res.json()["results"]] == expected
 | 
				
			||||||
@@ -97,7 +97,7 @@ class TestPictureSearch(TestSas):
 | 
				
			|||||||
        assert res.status_code == 200
 | 
					        assert res.status_code == 200
 | 
				
			||||||
        expected = list(
 | 
					        expected = list(
 | 
				
			||||||
            self.user_a.pictures.order_by(
 | 
					            self.user_a.pictures.order_by(
 | 
				
			||||||
                "-picture__parent__date", "picture__date"
 | 
					                "-picture__parent__event_date", "picture__created_at"
 | 
				
			||||||
            ).values_list("picture_id", flat=True)
 | 
					            ).values_list("picture_id", flat=True)
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        assert [i["id"] for i in res.json()["results"]] == expected
 | 
					        assert [i["id"] for i in res.json()["results"]] == expected
 | 
				
			||||||
@@ -123,7 +123,7 @@ class TestPictureSearch(TestSas):
 | 
				
			|||||||
        assert res.status_code == 200
 | 
					        assert res.status_code == 200
 | 
				
			||||||
        expected = list(
 | 
					        expected = list(
 | 
				
			||||||
            self.user_b.pictures.intersection(self.user_a.pictures.all())
 | 
					            self.user_b.pictures.intersection(self.user_a.pictures.all())
 | 
				
			||||||
            .order_by("-picture__parent__date", "picture__date")
 | 
					            .order_by("-picture__parent__event_date", "picture__created_at")
 | 
				
			||||||
            .values_list("picture_id", flat=True)
 | 
					            .values_list("picture_id", flat=True)
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        assert [i["id"] for i in res.json()["results"]] == expected
 | 
					        assert [i["id"] for i in res.json()["results"]] == expected
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,8 +3,8 @@ from model_bakery import baker
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
 | 
					from core.baker_recipes import old_subscriber_user, subscriber_user
 | 
				
			||||||
from core.models import User
 | 
					from core.models import User
 | 
				
			||||||
from sas.baker_recipes import picture_recipe
 | 
					from sas.baker_recipes import album_recipe, picture_recipe
 | 
				
			||||||
from sas.models import Picture
 | 
					from sas.models import Album, Picture
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestPictureQuerySet(TestCase):
 | 
					class TestPictureQuerySet(TestCase):
 | 
				
			||||||
@@ -44,3 +44,22 @@ class TestPictureQuerySet(TestCase):
 | 
				
			|||||||
        user.pictures.create(picture=self.pictures[1])  # moderated
 | 
					        user.pictures.create(picture=self.pictures[1])  # moderated
 | 
				
			||||||
        pictures = list(Picture.objects.viewable_by(user))
 | 
					        pictures = list(Picture.objects.viewable_by(user))
 | 
				
			||||||
        assert pictures == [self.pictures[1]]
 | 
					        assert pictures == [self.pictures[1]]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestDeleteAlbum(TestCase):
 | 
				
			||||||
 | 
					    def setUp(cls):
 | 
				
			||||||
 | 
					        cls.album: Album = album_recipe.make()
 | 
				
			||||||
 | 
					        cls.album_pictures = picture_recipe.make(parent=cls.album, _quantity=5)
 | 
				
			||||||
 | 
					        cls.sub_album = album_recipe.make(parent=cls.album)
 | 
				
			||||||
 | 
					        cls.sub_album_pictures = picture_recipe.make(parent=cls.sub_album, _quantity=5)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_delete(self):
 | 
				
			||||||
 | 
					        album_ids = [self.album.id, self.sub_album.id]
 | 
				
			||||||
 | 
					        picture_ids = [
 | 
				
			||||||
 | 
					            *[p.id for p in self.album_pictures],
 | 
				
			||||||
 | 
					            *[p.id for p in self.sub_album_pictures],
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        self.album.delete()
 | 
				
			||||||
 | 
					        # assert not p.exists()
 | 
				
			||||||
 | 
					        assert not Album.objects.filter(id__in=album_ids).exists()
 | 
				
			||||||
 | 
					        assert not Picture.objects.filter(id__in=picture_ids).exists()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -136,9 +136,7 @@ class TestAlbumUpload:
 | 
				
			|||||||
class TestSasModeration(TestCase):
 | 
					class TestSasModeration(TestCase):
 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def setUpTestData(cls):
 | 
					    def setUpTestData(cls):
 | 
				
			||||||
        album = baker.make(
 | 
					        album = baker.make(Album)
 | 
				
			||||||
            Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        cls.pictures = picture_recipe.make(
 | 
					        cls.pictures = picture_recipe.make(
 | 
				
			||||||
            parent=album, _quantity=10, _bulk_create=True
 | 
					            parent=album, _quantity=10, _bulk_create=True
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										87
									
								
								sas/views.py
									
									
									
									
									
								
							
							
						
						
									
										87
									
								
								sas/views.py
									
									
									
									
									
								
							@@ -12,6 +12,7 @@
 | 
				
			|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
					# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
from typing import Any
 | 
					from typing import Any
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
@@ -21,12 +22,12 @@ from django.shortcuts import get_object_or_404
 | 
				
			|||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from django.utils.safestring import SafeString
 | 
					from django.utils.safestring import SafeString
 | 
				
			||||||
from django.views.generic import CreateView, DetailView, TemplateView
 | 
					from django.views.generic import CreateView, DetailView, TemplateView
 | 
				
			||||||
from django.views.generic.edit import FormView, UpdateView
 | 
					from django.views.generic.edit import FormMixin, FormView, UpdateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.auth.mixins import CanEditMixin, CanViewMixin
 | 
					from core.auth.mixins import CanEditMixin, CanViewMixin
 | 
				
			||||||
from core.models import SithFile, User
 | 
					from core.models import SithFile, User
 | 
				
			||||||
from core.views import UseFragmentsMixin
 | 
					from core.views import FileView, UseFragmentsMixin
 | 
				
			||||||
from core.views.files import FileView, send_file
 | 
					from core.views.files import send_raw_file
 | 
				
			||||||
from core.views.mixins import FragmentMixin, FragmentRenderer
 | 
					from core.views.mixins import FragmentMixin, FragmentRenderer
 | 
				
			||||||
from core.views.user import UserTabsMixin
 | 
					from core.views.user import UserTabsMixin
 | 
				
			||||||
from sas.forms import (
 | 
					from sas.forms import (
 | 
				
			||||||
@@ -62,6 +63,7 @@ class AlbumCreateFragment(FragmentMixin, CreateView):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SASMainView(UseFragmentsMixin, TemplateView):
 | 
					class SASMainView(UseFragmentsMixin, TemplateView):
 | 
				
			||||||
 | 
					    form_class = AlbumCreateForm
 | 
				
			||||||
    template_name = "sas/main.jinja"
 | 
					    template_name = "sas/main.jinja"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_fragments(self) -> dict[str, FragmentRenderer]:
 | 
					    def get_fragments(self) -> dict[str, FragmentRenderer]:
 | 
				
			||||||
@@ -78,12 +80,26 @@ class SASMainView(UseFragmentsMixin, TemplateView):
 | 
				
			|||||||
        root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
 | 
					        root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
 | 
				
			||||||
        return {"album_create_fragment": {"owner": root_user}}
 | 
					        return {"album_create_fragment": {"owner": root_user}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def dispatch(self, request, *args, **kwargs):
 | 
				
			||||||
 | 
					        if request.method == "POST" and not self.request.user.has_perm("sas.add_album"):
 | 
				
			||||||
 | 
					            raise PermissionDenied
 | 
				
			||||||
 | 
					        return super().dispatch(request, *args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_form(self, form_class=None):
 | 
				
			||||||
 | 
					        if not self.request.user.has_perm("sas.add_album"):
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					        return super().get_form(form_class)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_form_kwargs(self):
 | 
				
			||||||
 | 
					        return super().get_form_kwargs() | {
 | 
				
			||||||
 | 
					            "owner": User.objects.get(pk=settings.SITH_ROOT_USER_ID),
 | 
				
			||||||
 | 
					            "parent": None,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_context_data(self, **kwargs):
 | 
					    def get_context_data(self, **kwargs):
 | 
				
			||||||
        kwargs = super().get_context_data(**kwargs)
 | 
					        kwargs = super().get_context_data(**kwargs)
 | 
				
			||||||
        albums_qs = Album.objects.viewable_by(self.request.user)
 | 
					        albums_qs = Album.objects.viewable_by(self.request.user)
 | 
				
			||||||
        kwargs["categories"] = list(
 | 
					        kwargs["categories"] = list(albums_qs.filter(parent=None).order_by("id"))
 | 
				
			||||||
            albums_qs.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID).order_by("id")
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        kwargs["latest"] = list(albums_qs.order_by("-id")[:5])
 | 
					        kwargs["latest"] = list(albums_qs.order_by("-id")[:5])
 | 
				
			||||||
        return kwargs
 | 
					        return kwargs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -93,6 +109,9 @@ class PictureView(CanViewMixin, DetailView):
 | 
				
			|||||||
    pk_url_kwarg = "picture_id"
 | 
					    pk_url_kwarg = "picture_id"
 | 
				
			||||||
    template_name = "sas/picture.jinja"
 | 
					    template_name = "sas/picture.jinja"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_queryset(self):
 | 
				
			||||||
 | 
					        return super().get_queryset().select_related("parent")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get(self, request, *args, **kwargs):
 | 
					    def get(self, request, *args, **kwargs):
 | 
				
			||||||
        self.object = self.get_object()
 | 
					        self.object = self.get_object()
 | 
				
			||||||
        if "rotate_right" in request.GET:
 | 
					        if "rotate_right" in request.GET:
 | 
				
			||||||
@@ -102,31 +121,42 @@ class PictureView(CanViewMixin, DetailView):
 | 
				
			|||||||
        return super().get(request, *args, **kwargs)
 | 
					        return super().get(request, *args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_context_data(self, **kwargs):
 | 
					    def get_context_data(self, **kwargs):
 | 
				
			||||||
        return super().get_context_data(**kwargs) | {
 | 
					        return super().get_context_data(**kwargs) | {"album": self.object.parent}
 | 
				
			||||||
            "album": Album.objects.get(children=self.object)
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def send_album(request, album_id):
 | 
					def send_album(request, album_id):
 | 
				
			||||||
    return send_file(request, album_id, Album)
 | 
					    album = get_object_or_404(Album, id=album_id)
 | 
				
			||||||
 | 
					    if not album.can_be_viewed_by(request.user):
 | 
				
			||||||
 | 
					        raise PermissionDenied
 | 
				
			||||||
 | 
					    return send_raw_file(Path(album.thumbnail.path))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def send_pict(request, picture_id):
 | 
					def send_pict(request, picture_id):
 | 
				
			||||||
    return send_file(request, picture_id, Picture)
 | 
					    picture = get_object_or_404(Picture, id=picture_id)
 | 
				
			||||||
 | 
					    if not picture.can_be_viewed_by(request.user):
 | 
				
			||||||
 | 
					        raise PermissionDenied
 | 
				
			||||||
 | 
					    return send_raw_file(Path(picture.original.path))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def send_compressed(request, picture_id):
 | 
					def send_compressed(request, picture_id):
 | 
				
			||||||
    return send_file(request, picture_id, Picture, "compressed")
 | 
					    picture = get_object_or_404(Picture, id=picture_id)
 | 
				
			||||||
 | 
					    if not picture.can_be_viewed_by(request.user):
 | 
				
			||||||
 | 
					        raise PermissionDenied
 | 
				
			||||||
 | 
					    return send_raw_file(Path(picture.compressed.path))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def send_thumb(request, picture_id):
 | 
					def send_thumb(request, picture_id):
 | 
				
			||||||
    return send_file(request, picture_id, Picture, "thumbnail")
 | 
					    picture = get_object_or_404(Picture, id=picture_id)
 | 
				
			||||||
 | 
					    if not picture.can_be_viewed_by(request.user):
 | 
				
			||||||
 | 
					        raise PermissionDenied
 | 
				
			||||||
 | 
					    return send_raw_file(Path(picture.thumbnail.path))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
 | 
					class AlbumView(CanViewMixin, UseFragmentsMixin, FormMixin, DetailView):
 | 
				
			||||||
    model = Album
 | 
					    model = Album
 | 
				
			||||||
    pk_url_kwarg = "album_id"
 | 
					    pk_url_kwarg = "album_id"
 | 
				
			||||||
    template_name = "sas/album.jinja"
 | 
					    template_name = "sas/album.jinja"
 | 
				
			||||||
 | 
					    form_class = PictureUploadForm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_fragments(self) -> dict[str, FragmentRenderer]:
 | 
					    def get_fragments(self) -> dict[str, FragmentRenderer]:
 | 
				
			||||||
        return {
 | 
					        return {
 | 
				
			||||||
@@ -141,27 +171,32 @@ class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
 | 
				
			|||||||
        except ValueError as e:
 | 
					        except ValueError as e:
 | 
				
			||||||
            raise Http404 from e
 | 
					            raise Http404 from e
 | 
				
			||||||
        if "clipboard" not in request.session:
 | 
					        if "clipboard" not in request.session:
 | 
				
			||||||
            request.session["clipboard"] = []
 | 
					            request.session["clipboard"] = {"albums": [], "pictures": []}
 | 
				
			||||||
        return super().dispatch(request, *args, **kwargs)
 | 
					        return super().dispatch(request, *args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_form(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        if not self.request.user.can_edit(self.object):
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					        return super().get_form(*args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def post(self, request, *args, **kwargs):
 | 
					    def post(self, request, *args, **kwargs):
 | 
				
			||||||
        self.object = self.get_object()
 | 
					        self.object = self.get_object()
 | 
				
			||||||
        if not self.object.file:
 | 
					        form = self.get_form()
 | 
				
			||||||
            self.object.generate_thumbnail()
 | 
					        if not form:
 | 
				
			||||||
        if request.user.can_edit(self.object):  # Handle the copy-paste functions
 | 
					            # the form is reserved for users that can edit this album.
 | 
				
			||||||
            FileView.handle_clipboard(request, self.object)
 | 
					            # If there is no form, it means the user has no right to do a POST
 | 
				
			||||||
        return HttpResponseRedirect(self.request.path)
 | 
					            raise PermissionDenied
 | 
				
			||||||
 | 
					        FileView.handle_clipboard(self.request, self.object)
 | 
				
			||||||
 | 
					        if not form.is_valid():
 | 
				
			||||||
 | 
					            return self.form_invalid(form)
 | 
				
			||||||
 | 
					        return self.form_valid(form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_fragment_data(self) -> dict[str, dict[str, Any]]:
 | 
					    def get_fragment_data(self) -> dict[str, dict[str, Any]]:
 | 
				
			||||||
        return {"album_create_fragment": {"owner": self.request.user}}
 | 
					        return {"album_create_fragment": {"owner": self.request.user}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_context_data(self, **kwargs):
 | 
					    def get_context_data(self, **kwargs):
 | 
				
			||||||
        kwargs = super().get_context_data(**kwargs)
 | 
					        kwargs = super().get_context_data(**kwargs)
 | 
				
			||||||
        if ids := self.request.session.get("clipboard", None):
 | 
					        kwargs["clipboard"] = {}
 | 
				
			||||||
            kwargs["clipboard"] = SithFile.objects.filter(id__in=ids)
 | 
					 | 
				
			||||||
        kwargs["upload_form"] = PictureUploadForm()
 | 
					 | 
				
			||||||
        # if True, the albums will be fetched with a request to the API
 | 
					 | 
				
			||||||
        # if False, the section won't be displayed at all
 | 
					 | 
				
			||||||
        kwargs["show_albums"] = (
 | 
					        kwargs["show_albums"] = (
 | 
				
			||||||
            Album.objects.viewable_by(self.request.user)
 | 
					            Album.objects.viewable_by(self.request.user)
 | 
				
			||||||
            .filter(parent_id=self.object.id)
 | 
					            .filter(parent_id=self.object.id)
 | 
				
			||||||
@@ -207,7 +242,7 @@ class ModerationView(TemplateView):
 | 
				
			|||||||
    def get_context_data(self, **kwargs):
 | 
					    def get_context_data(self, **kwargs):
 | 
				
			||||||
        kwargs = super().get_context_data(**kwargs)
 | 
					        kwargs = super().get_context_data(**kwargs)
 | 
				
			||||||
        kwargs["albums_to_moderate"] = Album.objects.filter(
 | 
					        kwargs["albums_to_moderate"] = Album.objects.filter(
 | 
				
			||||||
            is_moderated=False, is_in_sas=True, is_folder=True
 | 
					            is_moderated=False
 | 
				
			||||||
        ).order_by("id")
 | 
					        ).order_by("id")
 | 
				
			||||||
        pictures = Picture.objects.filter(is_moderated=False).select_related("parent")
 | 
					        pictures = Picture.objects.filter(is_moderated=False).select_related("parent")
 | 
				
			||||||
        kwargs["pictures"] = pictures
 | 
					        kwargs["pictures"] = pictures
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -182,13 +182,12 @@ class OpenApi:
 | 
				
			|||||||
                path[action]["operationId"] = "_".join(
 | 
					                path[action]["operationId"] = "_".join(
 | 
				
			||||||
                    desc["operationId"].split("_")[:-1]
 | 
					                    desc["operationId"].split("_")[:-1]
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        schema = str(schema)
 | 
					        schema = str(schema)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if old_hash == sha1(schema.encode("utf-8")).hexdigest():
 | 
					        if old_hash == sha1(schema.encode("utf-8")).hexdigest():
 | 
				
			||||||
            logging.getLogger("django").info("✨ Api did not change, nothing to do ✨")
 | 
					            logging.getLogger("django").info("✨ Api did not change, nothing to do ✨")
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        with open(out, "w") as f:
 | 
					        out.write_text(schema)
 | 
				
			||||||
            _ = f.write(schema)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return subprocess.Popen(["npm", "run", "openapi"])
 | 
					        return subprocess.Popen(["npm", "run", "openapi"])
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user