mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-10-31 09:03:06 +00:00 
			
		
		
		
	| @@ -982,7 +982,7 @@ class SithFile(models.Model): | |||||||
|             return True |             return True | ||||||
|         if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID): |         if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID): | ||||||
|             return True |             return True | ||||||
|         return user.id == self.owner.id |         return user.id == self.owner_id | ||||||
|  |  | ||||||
|     def can_be_viewed_by(self, user): |     def can_be_viewed_by(self, user): | ||||||
|         if hasattr(self, "profile_of"): |         if hasattr(self, "profile_of"): | ||||||
|   | |||||||
| @@ -65,3 +65,21 @@ function display_notif() { | |||||||
| function getCSRFToken() { | function getCSRFToken() { | ||||||
|     return $("[name=csrfmiddlewaretoken]").val(); |     return $("[name=csrfmiddlewaretoken]").val(); | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | const initialUrlParams = new URLSearchParams(window.location.search); | ||||||
|  |  | ||||||
|  | function update_query_string(key, value) { | ||||||
|  |     const url = new URL(window.location.href); | ||||||
|  |     if (!value) { | ||||||
|  |         // If the value is null, undefined or empty => delete it | ||||||
|  |         url.searchParams.delete(key) | ||||||
|  |     } else if (Array.isArray(value)) { | ||||||
|  |  | ||||||
|  |         url.searchParams.delete(key) | ||||||
|  |         value.forEach((v) => url.searchParams.append(key, v)) | ||||||
|  |     } else { | ||||||
|  |         url.searchParams.set(key, value); | ||||||
|  |     } | ||||||
|  |     history.pushState(null, document.title, url.toString()); | ||||||
|  | } | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ | |||||||
| .pagination { | .pagination { | ||||||
|   text-align: center; |   text-align: center; | ||||||
|   gap: 10px; |   gap: 10px; | ||||||
|  |   margin: 30px; | ||||||
|  |  | ||||||
|   button { |   button { | ||||||
|     background-color: $secondary-neutral-light-color; |     background-color: $secondary-neutral-light-color; | ||||||
|   | |||||||
| @@ -93,6 +93,32 @@ a:not(.button) { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | [aria-busy] { | ||||||
|  |   --loading-size: 50px; | ||||||
|  |   --loading-stroke: 5px; | ||||||
|  |   --loading-duration: 1s; | ||||||
|  |   position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | [aria-busy]:after { | ||||||
|  |   content: ''; | ||||||
|  |   position: absolute; | ||||||
|  |   top: 50%; | ||||||
|  |   left: 50%; | ||||||
|  |   width: var(--loading-size); | ||||||
|  |   height: var(--loading-size); | ||||||
|  |   margin-top: calc(var(--loading-size) / 2 * -1); | ||||||
|  |   margin-left: calc(var(--loading-size) / 2 * -1); | ||||||
|  |   border: var(--loading-stroke) solid rgba(0, 0, 0, .15); | ||||||
|  |   border-radius: 50%; | ||||||
|  |   border-top-color: rgba(0, 0, 0, 0.5); | ||||||
|  |   animation: rotate calc(var(--loading-duration)) linear infinite; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes rotate { | ||||||
|  |   100% { transform: rotate(360deg); } | ||||||
|  | } | ||||||
|  |  | ||||||
| .ib { | .ib { | ||||||
|   display: inline-block; |   display: inline-block; | ||||||
|   padding: 1px; |   padding: 1px; | ||||||
|   | |||||||
| @@ -102,20 +102,10 @@ main { | |||||||
|   border-radius: 10px; |   border-radius: 10px; | ||||||
| } | } | ||||||
|  |  | ||||||
| .paginator { |  | ||||||
|   display: flex; |  | ||||||
|   justify-content: center; |  | ||||||
|   gap: 10px; |  | ||||||
|   width: -moz-fit-content; |  | ||||||
|   width: fit-content; |  | ||||||
|   background-color: rgba(0,0,0,.1); |  | ||||||
|   border-radius: 10px; |  | ||||||
|   padding: 10px; |  | ||||||
|   margin: 10px 0 10px auto; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .photos, | .photos, | ||||||
| .albums { | .albums { | ||||||
|  |   margin: 20px; | ||||||
|  |   min-height: 50px;  // To contain the aria-busy loading wheel, even if empty | ||||||
|   box-sizing: border-box; |   box-sizing: border-box; | ||||||
|   display: flex; |   display: flex; | ||||||
|   flex-direction: row; |   flex-direction: row; | ||||||
| @@ -161,17 +151,13 @@ main { | |||||||
|     > .album { |     > .album { | ||||||
|       box-sizing: border-box; |       box-sizing: border-box; | ||||||
|       background-color: #333333; |       background-color: #333333; | ||||||
|       background-size: cover; |       background-size: contain; | ||||||
|       background-repeat: no-repeat; |       background-repeat: no-repeat; | ||||||
|       background-position: center center; |       background-position: center center; | ||||||
|  |  | ||||||
|       width: calc(16 / 9 * 128px); |       width: calc(16 / 9 * 128px); | ||||||
|       height: 128px; |       height: 128px; | ||||||
|  |  | ||||||
|       &.vertical { |  | ||||||
|         background-size: contain; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       margin: 0; |       margin: 0; | ||||||
|       padding: 0; |       padding: 0; | ||||||
|       box-shadow: none; |       box-shadow: none; | ||||||
|   | |||||||
| @@ -23,7 +23,7 @@ | |||||||
|         <button |         <button | ||||||
|           :disabled="in_progress" |           :disabled="in_progress" | ||||||
|           class="btn btn-blue" |           class="btn btn-blue" | ||||||
|           @click="download('{{ url("api:pictures") }}?users_identified={{ object.id }}')" |           @click="download_zip()" | ||||||
|         > |         > | ||||||
|           <i class="fa fa-download"></i> |           <i class="fa fa-download"></i> | ||||||
|           {% trans %}Download all my pictures{% endtrans %} |           {% trans %}Download all my pictures{% endtrans %} | ||||||
| @@ -86,13 +86,34 @@ | |||||||
|         Alpine.data("picture_download", () => ({ |         Alpine.data("picture_download", () => ({ | ||||||
|           in_progress: false, |           in_progress: false, | ||||||
|  |  | ||||||
|           async download(url) { |           /** | ||||||
|  |            * @return {Promise<Picture[]>} | ||||||
|  |            */ | ||||||
|  |           async get_pictures() { | ||||||
|  |             {# The API forbids to get more than 199 items at once | ||||||
|  |              from paginated routes. | ||||||
|  |              In order to download all the user pictures, it may be needed | ||||||
|  |              to performs multiple requests #} | ||||||
|  |             const max_per_page = 1; | ||||||
|  |             const url = "{{ url("api:pictures") }}" | ||||||
|  |             + "?users_identified={{ object.id }}" | ||||||
|  |             + `&page_size=${max_per_page}`; | ||||||
|  |             let promises = []; | ||||||
|  |             const nb_pages = Math.ceil({{ nb_pictures }} / max_per_page); | ||||||
|  |             for (let i = 1; i <= nb_pages; i++) { | ||||||
|  |               promises.push( | ||||||
|  |                 fetch(url + `&page=${i}`).then(res => res.json().then(json => json.results)) | ||||||
|  |               ); | ||||||
|  |             } | ||||||
|  |             return (await Promise.all(promises)).flat() | ||||||
|  |           }, | ||||||
|  |  | ||||||
|  |  | ||||||
|  |           async download_zip(){ | ||||||
|             this.in_progress = true; |             this.in_progress = true; | ||||||
|             const bar = this.$refs.progress; |             const bar = this.$refs.progress; | ||||||
|             bar.value = 0; |             bar.value = 0; | ||||||
|  |             const pictures = await this.get_pictures(); | ||||||
|             /** @type Picture[] */ |  | ||||||
|             const pictures = await (await fetch(url)).json(); |  | ||||||
|             bar.max = pictures.length; |             bar.max = pictures.length; | ||||||
|  |  | ||||||
|             const fileHandle = await window.showSaveFilePicker({ |             const fileHandle = await window.showSaveFilePicker({ | ||||||
|   | |||||||
| @@ -96,10 +96,7 @@ def get_semester_code(d: Optional[date] = None) -> str: | |||||||
|  |  | ||||||
|  |  | ||||||
| def scale_dimension(width, height, long_edge): | def scale_dimension(width, height, long_edge): | ||||||
|     if width > height: |     ratio = long_edge / max(width, height) | ||||||
|         ratio = long_edge * 1.0 / width |  | ||||||
|     else: |  | ||||||
|         ratio = long_edge * 1.0 / height |  | ||||||
|     return int(width * ratio), int(height * ratio) |     return int(width * ratio), int(height * ratio) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -107,8 +104,8 @@ def resize_image(im, edge, img_format): | |||||||
|     (w, h) = im.size |     (w, h) = im.size | ||||||
|     (width, height) = scale_dimension(w, h, long_edge=edge) |     (width, height) = scale_dimension(w, h, long_edge=edge) | ||||||
|     content = BytesIO() |     content = BytesIO() | ||||||
|     # use the lanczos filter for antialiasing |     # use the lanczos filter for antialiasing and discard the alpha channel | ||||||
|     im = im.resize((width, height), Resampling.LANCZOS) |     im = im.resize((width, height), Resampling.LANCZOS).convert("RGB") | ||||||
|     try: |     try: | ||||||
|         im.save( |         im.save( | ||||||
|             fp=content, |             fp=content, | ||||||
|   | |||||||
| @@ -319,6 +319,7 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView): | |||||||
|             .order_by("-parent__date", "-date") |             .order_by("-parent__date", "-date") | ||||||
|             .annotate(album=F("parent__name")) |             .annotate(album=F("parent__name")) | ||||||
|         ) |         ) | ||||||
|  |         kwargs["nb_pictures"] = len(pictures) | ||||||
|         kwargs["albums"] = { |         kwargs["albums"] = { | ||||||
|             album: list(picts) |             album: list(picts) | ||||||
|             for album, picts in itertools.groupby(pictures, lambda i: i.album) |             for album, picts in itertools.groupby(pictures, lambda i: i.album) | ||||||
|   | |||||||
| @@ -96,7 +96,7 @@ | |||||||
|           {% endif %} |           {% endif %} | ||||||
|         </tr> |         </tr> | ||||||
|       </thead> |       </thead> | ||||||
|       <tbody id="dynamic_view_content"> |       <tbody id="dynamic_view_content" :aria-busy="loading"> | ||||||
|         <template x-for="uv in uvs.results" :key="uv.id"> |         <template x-for="uv in uvs.results" :key="uv.id"> | ||||||
|           <tr @click="window.location.href = `/pedagogy/uv/${uv.id}`" class="clickable"> |           <tr @click="window.location.href = `/pedagogy/uv/${uv.id}`" class="clickable"> | ||||||
|             <td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td> |             <td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td> | ||||||
| @@ -126,22 +126,6 @@ | |||||||
|     </nav> |     </nav> | ||||||
|   </div> |   </div> | ||||||
|   <script> |   <script> | ||||||
|     const initialUrlParams = new URLSearchParams(window.location.search); |  | ||||||
|  |  | ||||||
|     function update_query_string(key, value) { |  | ||||||
|       const url = new URL(window.location.href); |  | ||||||
|       if (!value) { |  | ||||||
|             {# If the value is null, undefined or empty => delete it #} |  | ||||||
|         url.searchParams.delete(key) |  | ||||||
|       } else if (Array.isArray(value)) { |  | ||||||
|         url.searchParams.delete(key) |  | ||||||
|         value.forEach((v) => url.searchParams.append(key, v)) |  | ||||||
|       } else { |  | ||||||
|         url.searchParams.set(key, value); |  | ||||||
|       } |  | ||||||
|       history.pushState(null, document.title, url.toString()); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     {# |     {# | ||||||
|     How does this work : |     How does this work : | ||||||
|  |  | ||||||
| @@ -156,6 +140,7 @@ | |||||||
|     document.addEventListener("alpine:init", () => { |     document.addEventListener("alpine:init", () => { | ||||||
|       Alpine.data("uv_search", () => ({ |       Alpine.data("uv_search", () => ({ | ||||||
|         uvs: [], |         uvs: [], | ||||||
|  |         loading: false, | ||||||
|         page: parseInt(initialUrlParams.get("page")) || page_default, |         page: parseInt(initialUrlParams.get("page")) || page_default, | ||||||
|         page_size: parseInt(initialUrlParams.get("page_size")) || page_size_default, |         page_size: parseInt(initialUrlParams.get("page_size")) || page_size_default, | ||||||
|         search: initialUrlParams.get("search") || "", |         search: initialUrlParams.get("search") || "", | ||||||
| @@ -187,8 +172,10 @@ | |||||||
|         }, |         }, | ||||||
|  |  | ||||||
|         async fetch_data() { |         async fetch_data() { | ||||||
|  |           this.loading = true; | ||||||
|           const url = "{{ url("api:fetch_uvs") }}" + window.location.search; |           const url = "{{ url("api:fetch_uvs") }}" + window.location.search; | ||||||
|           this.uvs = await (await fetch(url)).json(); |           this.uvs = await (await fetch(url)).json(); | ||||||
|  |           this.loading = false; | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|         max_page() { |         max_page() { | ||||||
|   | |||||||
							
								
								
									
										24
									
								
								sas/api.py
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								sas/api.py
									
									
									
									
									
								
							| @@ -1,9 +1,11 @@ | |||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.db.models import F | from django.db.models import F | ||||||
| from ninja import Query | from ninja import Query | ||||||
| from ninja_extra import ControllerBase, api_controller, route | from ninja_extra import ControllerBase, api_controller, paginate, route | ||||||
| from ninja_extra.exceptions import PermissionDenied | from ninja_extra.exceptions import PermissionDenied | ||||||
|  | from ninja_extra.pagination import PageNumberPaginationExtra | ||||||
| from ninja_extra.permissions import IsAuthenticated | from ninja_extra.permissions import IsAuthenticated | ||||||
|  | from ninja_extra.schemas import PaginatedResponseSchema | ||||||
| from pydantic import NonNegativeInt | from pydantic import NonNegativeInt | ||||||
|  |  | ||||||
| from core.models import User | from core.models import User | ||||||
| @@ -15,10 +17,11 @@ from sas.schemas import PictureFilterSchema, PictureSchema | |||||||
| class PicturesController(ControllerBase): | class PicturesController(ControllerBase): | ||||||
|     @route.get( |     @route.get( | ||||||
|         "", |         "", | ||||||
|         response=list[PictureSchema], |         response=PaginatedResponseSchema[PictureSchema], | ||||||
|         permissions=[IsAuthenticated], |         permissions=[IsAuthenticated], | ||||||
|         url_name="pictures", |         url_name="pictures", | ||||||
|     ) |     ) | ||||||
|  |     @paginate(PageNumberPaginationExtra, page_size=100) | ||||||
|     def fetch_pictures(self, filters: Query[PictureFilterSchema]): |     def fetch_pictures(self, filters: Query[PictureFilterSchema]): | ||||||
|         """Find pictures viewable by the user corresponding to the given filters. |         """Find pictures viewable by the user corresponding to the given filters. | ||||||
|  |  | ||||||
| @@ -38,23 +41,12 @@ class PicturesController(ControllerBase): | |||||||
|             cf. https://ae.utbm.fr/user/32663/pictures/) |             cf. https://ae.utbm.fr/user/32663/pictures/) | ||||||
|         """ |         """ | ||||||
|         user: User = self.context.request.user |         user: User = self.context.request.user | ||||||
|         if not user.is_subscribed and filters.users_identified != {user.id}: |         return ( | ||||||
|             # User can view any moderated picture if he/she is subscribed. |             filters.filter(Picture.objects.viewable_by(user)) | ||||||
|             # If not, he/she can view only the one he/she has been identified on |  | ||||||
|             raise PermissionDenied |  | ||||||
|         pictures = list( |  | ||||||
|             filters.filter( |  | ||||||
|                 Picture.objects.filter(is_moderated=True, asked_for_removal=False) |  | ||||||
|             ) |  | ||||||
|             .distinct() |             .distinct() | ||||||
|             .order_by("-date") |             .order_by("-parent__date", "date") | ||||||
|             .annotate(album=F("parent__name")) |             .annotate(album=F("parent__name")) | ||||||
|         ) |         ) | ||||||
|         for picture in pictures: |  | ||||||
|             picture.full_size_url = picture.get_download_url() |  | ||||||
|             picture.compressed_url = picture.get_download_compressed_url() |  | ||||||
|             picture.thumb_url = picture.get_download_thumb_url() |  | ||||||
|         return pictures |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @api_controller("/sas/relation", tags="User identification on SAS pictures") | @api_controller("/sas/relation", tags="User identification on SAS pictures") | ||||||
|   | |||||||
							
								
								
									
										135
									
								
								sas/models.py
									
									
									
									
									
								
							
							
						
						
									
										135
									
								
								sas/models.py
									
									
									
									
									
								
							| @@ -13,11 +13,14 @@ | |||||||
| # | # | ||||||
| # | # | ||||||
|  |  | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| from io import BytesIO | from io import BytesIO | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db import models | from django.db import models | ||||||
|  | from django.db.models import Exists, OuterRef | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| @@ -27,21 +30,60 @@ from core.models import SithFile, User | |||||||
| from core.utils import exif_auto_rotate, resize_image | from core.utils import exif_auto_rotate, resize_image | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SasFile(SithFile): | ||||||
|  |     """Proxy model for any file in the SAS. | ||||||
|  |  | ||||||
|  |     May be used to have logic that should be shared by both | ||||||
|  |     [Picture][sas.models.Picture] and [Album][sas.models.Album]. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         proxy = True | ||||||
|  |  | ||||||
|  |     def can_be_viewed_by(self, user): | ||||||
|  |         if user.is_anonymous: | ||||||
|  |             return False | ||||||
|  |         cache_key = ( | ||||||
|  |             f"sas:{self._meta.model_name}_viewable_by_{user.id}_in_{self.parent_id}" | ||||||
|  |         ) | ||||||
|  |         viewable: list[int] | None = cache.get(cache_key) | ||||||
|  |         if viewable is None: | ||||||
|  |             viewable = list( | ||||||
|  |                 self.__class__.objects.filter(parent_id=self.parent_id) | ||||||
|  |                 .viewable_by(user) | ||||||
|  |                 .values_list("pk", flat=True) | ||||||
|  |             ) | ||||||
|  |             cache.set(cache_key, viewable, timeout=10) | ||||||
|  |         return self.id in viewable | ||||||
|  |  | ||||||
|  |     def can_be_edited_by(self, user): | ||||||
|  |         return user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PictureQuerySet(models.QuerySet): | ||||||
|  |     def viewable_by(self, user: User) -> PictureQuerySet: | ||||||
|  |         """Filter the pictures that this user can view. | ||||||
|  |  | ||||||
|  |         Warnings: | ||||||
|  |             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) | ||||||
|  |         return self.filter(people__user_id=user.id, is_moderated=True) | ||||||
|  |  | ||||||
|  |  | ||||||
| class SASPictureManager(models.Manager): | class SASPictureManager(models.Manager): | ||||||
|     def get_queryset(self): |     def get_queryset(self): | ||||||
|         return super().get_queryset().filter(is_in_sas=True, is_folder=False) |         return super().get_queryset().filter(is_in_sas=True, is_folder=False) | ||||||
|  |  | ||||||
|  |  | ||||||
| class SASAlbumManager(models.Manager): | class Picture(SasFile): | ||||||
|     def get_queryset(self): |  | ||||||
|         return super().get_queryset().filter(is_in_sas=True, is_folder=True) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Picture(SithFile): |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         proxy = True |         proxy = True | ||||||
|  |  | ||||||
|     objects = SASPictureManager() |     objects = SASPictureManager.from_queryset(PictureQuerySet)() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def is_vertical(self): |     def is_vertical(self): | ||||||
| @@ -50,29 +92,6 @@ class Picture(SithFile): | |||||||
|             (w, h) = im.size |             (w, h) = im.size | ||||||
|             return (w / h) < 1 |             return (w / h) < 1 | ||||||
|  |  | ||||||
|     def can_be_edited_by(self, user): |  | ||||||
|         perm = cache.get("%d_can_edit_pictures" % (user.id), None) |  | ||||||
|         if perm is None: |  | ||||||
|             perm = user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) |  | ||||||
|  |  | ||||||
|         cache.set("%d_can_edit_pictures" % (user.id), perm, timeout=4) |  | ||||||
|         return perm |  | ||||||
|  |  | ||||||
|     def can_be_viewed_by(self, user): |  | ||||||
|         # SAS pictures are visible to old subscribers |  | ||||||
|         # Result is cached 4s for this user |  | ||||||
|         if user.is_anonymous: |  | ||||||
|             return False |  | ||||||
|  |  | ||||||
|         perm = cache.get("%d_can_view_pictures" % (user.id), False) |  | ||||||
|         if not perm: |  | ||||||
|             perm = user.was_subscribed |  | ||||||
|  |  | ||||||
|         cache.set("%d_can_view_pictures" % (user.id), perm, timeout=4) |  | ||||||
|         return (perm and self.is_moderated and self.is_in_sas) or self.can_be_edited_by( |  | ||||||
|             user |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     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}) | ||||||
|  |  | ||||||
| @@ -124,48 +143,53 @@ class Picture(SithFile): | |||||||
|  |  | ||||||
|     def get_next(self): |     def get_next(self): | ||||||
|         if self.is_moderated: |         if self.is_moderated: | ||||||
|             return ( |             pictures_qs = self.parent.children.filter( | ||||||
|                 self.parent.children.filter( |  | ||||||
|                 is_moderated=True, |                 is_moderated=True, | ||||||
|                 asked_for_removal=False, |                 asked_for_removal=False, | ||||||
|                 is_folder=False, |                 is_folder=False, | ||||||
|                 id__gt=self.id, |                 id__gt=self.id, | ||||||
|             ) |             ) | ||||||
|                 .order_by("id") |  | ||||||
|                 .first() |  | ||||||
|             ) |  | ||||||
|         else: |         else: | ||||||
|             return ( |             pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False) | ||||||
|                 Picture.objects.filter(id__gt=self.id, is_moderated=False) |         return pictures_qs.order_by("id").first() | ||||||
|                 .order_by("id") |  | ||||||
|                 .first() |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     def get_previous(self): |     def get_previous(self): | ||||||
|         if self.is_moderated: |         if self.is_moderated: | ||||||
|             return ( |             pictures_qs = self.parent.children.filter( | ||||||
|                 self.parent.children.filter( |  | ||||||
|                 is_moderated=True, |                 is_moderated=True, | ||||||
|                 asked_for_removal=False, |                 asked_for_removal=False, | ||||||
|                 is_folder=False, |                 is_folder=False, | ||||||
|                 id__lt=self.id, |                 id__lt=self.id, | ||||||
|             ) |             ) | ||||||
|                 .order_by("id") |  | ||||||
|                 .last() |  | ||||||
|             ) |  | ||||||
|         else: |         else: | ||||||
|             return ( |             pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False) | ||||||
|                 Picture.objects.filter(id__lt=self.id, is_moderated=False) |         return pictures_qs.order_by("-id").first() | ||||||
|                 .order_by("-id") |  | ||||||
|                 .first() |  | ||||||
|  | class AlbumQuerySet(models.QuerySet): | ||||||
|  |     def viewable_by(self, user: User) -> PictureQuerySet: | ||||||
|  |         """Filter the albums that this user can view. | ||||||
|  |  | ||||||
|  |         Warnings: | ||||||
|  |             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() | ||||||
|  |         return self.filter( | ||||||
|  |             Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user)) | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Album(SithFile): | class SASAlbumManager(models.Manager): | ||||||
|  |     def get_queryset(self): | ||||||
|  |         return super().get_queryset().filter(is_in_sas=True, is_folder=True) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Album(SasFile): | ||||||
|     class Meta: |     class Meta: | ||||||
|         proxy = True |         proxy = True | ||||||
|  |  | ||||||
|     objects = SASAlbumManager() |     objects = SASAlbumManager.from_queryset(AlbumQuerySet)() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def children_pictures(self): |     def children_pictures(self): | ||||||
| @@ -175,15 +199,6 @@ class Album(SithFile): | |||||||
|     def children_albums(self): |     def children_albums(self): | ||||||
|         return Album.objects.filter(parent=self) |         return Album.objects.filter(parent=self) | ||||||
|  |  | ||||||
|     def can_be_edited_by(self, user): |  | ||||||
|         return user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) |  | ||||||
|  |  | ||||||
|     def can_be_viewed_by(self, user): |  | ||||||
|         # file = SithFile.objects.filter(id=self.id).first() |  | ||||||
|         return self.can_be_edited_by(user) or ( |  | ||||||
|             self.is_in_sas and self.is_moderated and user.was_subscribed |  | ||||||
|         )  # or user.can_view(file) |  | ||||||
|  |  | ||||||
|     def get_absolute_url(self): |     def get_absolute_url(self): | ||||||
|         return reverse("sas:album", kwargs={"album_id": self.id}) |         return reverse("sas:album", kwargs={"album_id": self.id}) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,7 +3,6 @@ from datetime import datetime | |||||||
| from ninja import FilterSchema, ModelSchema, Schema | from ninja import FilterSchema, ModelSchema, Schema | ||||||
| from pydantic import Field, NonNegativeInt | from pydantic import Field, NonNegativeInt | ||||||
|  |  | ||||||
| from core.schemas import SimpleUserSchema |  | ||||||
| from sas.models import PeoplePictureRelation, Picture | from sas.models import PeoplePictureRelation, Picture | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -17,14 +16,25 @@ class PictureFilterSchema(FilterSchema): | |||||||
| class PictureSchema(ModelSchema): | class PictureSchema(ModelSchema): | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = Picture |         model = Picture | ||||||
|         fields = ["id", "name", "date", "size"] |         fields = ["id", "name", "date", "size", "is_moderated"] | ||||||
|  |  | ||||||
|     author: SimpleUserSchema = Field(validation_alias="owner") |  | ||||||
|     full_size_url: str |     full_size_url: str | ||||||
|     compressed_url: str |     compressed_url: str | ||||||
|     thumb_url: str |     thumb_url: str | ||||||
|     album: str |     album: str | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def resolve_full_size_url(obj: Picture) -> str: | ||||||
|  |         return obj.get_download_url() | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def resolve_compressed_url(obj: Picture) -> str: | ||||||
|  |         return obj.get_download_compressed_url() | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def resolve_thumb_url(obj: Picture) -> str: | ||||||
|  |         return obj.get_download_thumb_url() | ||||||
|  |  | ||||||
|  |  | ||||||
| class PictureCreateRelationSchema(Schema): | class PictureCreateRelationSchema(Schema): | ||||||
|     user_id: NonNegativeInt |     user_id: NonNegativeInt | ||||||
|   | |||||||
| @@ -1,20 +1,15 @@ | |||||||
| {% extends "core/base.jinja" %} | {% extends "core/base.jinja" %} | ||||||
| {% from "core/macros.jinja" import paginate %} |  | ||||||
|  |  | ||||||
| {%- block additional_css -%} | {%- block additional_css -%} | ||||||
|   <link rel="stylesheet" href="{{ scss('sas/album.scss') }}"> |   <link rel="stylesheet" href="{{ scss('sas/album.scss') }}"> | ||||||
|  |   <link rel="stylesheet" href="{{ scss('core/pagination.scss') }}"> | ||||||
| {%- endblock -%} | {%- endblock -%} | ||||||
|  |  | ||||||
| {% block title %} | {% block title %} | ||||||
|   {% trans %}SAS{% endtrans %} |   {% trans %}SAS{% endtrans %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% macro print_path(file) %} | {% from "sas/macros.jinja" import display_album, print_path %} | ||||||
|   {% if file and file.parent %} |  | ||||||
|     {{ print_path(file.parent) }} |  | ||||||
|     <a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> / |  | ||||||
|   {% endif %} |  | ||||||
| {% endmacro %} |  | ||||||
|  |  | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
| @@ -22,10 +17,10 @@ | |||||||
|     <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.get_display_name() }} | ||||||
|   </code> |   </code> | ||||||
|  |  | ||||||
|   {% set edit_mode = user.can_edit(album) %} |   {% set is_sas_admin = user.can_edit(album) %} | ||||||
|   {% set start = timezone.now() %} |   {% set start = timezone.now() %} | ||||||
|  |  | ||||||
|   {% if edit_mode %} |   {% if is_sas_admin %} | ||||||
|     <form action="" method="post" enctype="multipart/form-data"> |     <form action="" method="post" enctype="multipart/form-data"> | ||||||
|       {% csrf_token %} |       {% csrf_token %} | ||||||
|  |  | ||||||
| @@ -53,73 +48,63 @@ | |||||||
|       {% endif %} |       {% endif %} | ||||||
|   {% endif %} |   {% endif %} | ||||||
|  |  | ||||||
|   {% if album.children_albums.count() > 0 %} |   {% if children_albums|length > 0 %} | ||||||
|     <h4>{% trans %}Albums{% endtrans %}</h4> |     <h4>{% trans %}Albums{% endtrans %}</h4> | ||||||
|     <div class="albums"> |     <div class="albums"> | ||||||
|       {% for a in album.children_albums.order_by('-date') %} |       {% for a in children_albums %} | ||||||
|         {% if a.can_be_viewed_by(user) %} |         {{ display_album(a, is_sas_admin) }} | ||||||
|           <a href="{{ url('sas:album', album_id=a.id) }}"> |  | ||||||
|             <div |  | ||||||
|               class="album{% if not a.is_moderated %} not_moderated{% endif %}" |  | ||||||
|               style="background-image: url('{% if a.file %}{{ a.get_download_url() }}{% else %}{{ static('core/img/sas.jpg') }}{% endif %}');" |  | ||||||
|             > |  | ||||||
|               {% if not a.is_moderated %} |  | ||||||
|                 <div class="overlay"> </div> |  | ||||||
|                 <div class="text">{% trans %}To be moderated{% endtrans %}</div> |  | ||||||
|               {% else %} |  | ||||||
|                 <div class="text">{{ a.name }}</div> |  | ||||||
|               {% endif %} |  | ||||||
|             </div> |  | ||||||
|             {% if edit_mode %} |  | ||||||
|               <input type="checkbox" name="file_list" value="{{ a.id }}"> |  | ||||||
|             {% endif %} |  | ||||||
|           </a> |  | ||||||
|         {% endif %} |  | ||||||
|       {% endfor %} |       {% endfor %} | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <br> |     <br> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|  |  | ||||||
|  |   <div x-data="pictures"> | ||||||
|     <h4>{% trans %}Pictures{% endtrans %}</h4> |     <h4>{% trans %}Pictures{% endtrans %}</h4> | ||||||
|   {% if pictures | length != 0 %} |     <div class="photos" :aria-busy="loading"> | ||||||
|     <div class="photos"> |       <template x-for="picture in pictures.results"> | ||||||
|       {% for p in pictures %} |         <a :href="`/sas/picture/${picture.id}#pict`"> | ||||||
|         {% if p.can_be_viewed_by(user) %} |           <div class="photo" :style="`background-image: url(${picture.thumb_url})`"> | ||||||
|           <a href="{{ url('sas:picture', picture_id=p.id) }}#pict"> |             <template x-if="!picture.is_moderated"> | ||||||
|             <div |  | ||||||
|               class="photo {% if p.is_vertical %}vertical{% endif %}" |  | ||||||
|               style="background-image: url('{{ p.get_download_thumb_url() }}')" |  | ||||||
|             > |  | ||||||
|               {% if not p.is_moderated %} |  | ||||||
|               <div class="overlay"> </div> |               <div class="overlay"> </div> | ||||||
|               <div class="text">{% trans %}To be moderated{% endtrans %}</div> |               <div class="text">{% trans %}To be moderated{% endtrans %}</div> | ||||||
|               {% else %} |             </template> | ||||||
|  |             <template x-if="picture.is_moderated"> | ||||||
|               <div class="text"> </div> |               <div class="text"> </div> | ||||||
|               {% endif %} |             </template> | ||||||
|           </div> |           </div> | ||||||
|             {% if edit_mode %} |           {% if is_sas_admin %} | ||||||
|               <input type="checkbox" name="file_list" value="{{ p.id }}"> |             <input type="checkbox" name="file_list" :value="picture.id"> | ||||||
|           {% endif %} |           {% endif %} | ||||||
|         </a> |         </a> | ||||||
|         {% endif %} |       </template> | ||||||
|       {% endfor %} |  | ||||||
|     </div> |     </div> | ||||||
|   {% else %} |     <nav class="pagination" x-show="nb_pages() > 1"> | ||||||
|     {% trans %}This album does not contain any photos.{% endtrans %} |       {# Adding the prevent here is important, because otherwise, | ||||||
|   {% endif %} |       clicking on the pagination buttons could submit the picture management form | ||||||
|  |       and reload the page #} | ||||||
|   {% if pictures.has_previous() or pictures.has_next() %} |       <button | ||||||
|     <div class="paginator"> |         @click.prevent="page--" | ||||||
|       {{ paginate(pictures, paginator) }} |         :disabled="page <= 1" | ||||||
|  |         @keyup.right.window="page = Math.min(nb_pages(), page + 1)" | ||||||
|  |       > | ||||||
|  |         <i class="fa fa-caret-left"></i> | ||||||
|  |       </button> | ||||||
|  |       <template x-for="i in nb_pages()"> | ||||||
|  |         <button x-text="i" @click.prevent="page = i" :class="{active: page === i}"></button> | ||||||
|  |       </template> | ||||||
|  |       <button | ||||||
|  |         @click.prevent="page++" | ||||||
|  |         :disabled="page >= nb_pages()" | ||||||
|  |         @keyup.left.window="page = Math.max(1, page - 1)" | ||||||
|  |       > | ||||||
|  |         <i class="fa fa-caret-right"></i> | ||||||
|  |       </button> | ||||||
|  |     </nav> | ||||||
|   </div> |   </div> | ||||||
|   {% endif %} |  | ||||||
|  |  | ||||||
|   {% if edit_mode %} |   {% if is_sas_admin %} | ||||||
|     </form> |     </form> | ||||||
|   {% endif %} |  | ||||||
|  |  | ||||||
|   {% if user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %} |  | ||||||
|     <form class="add-files" id="upload_form" action="" method="post" enctype="multipart/form-data"> |     <form class="add-files" id="upload_form" action="" method="post" enctype="multipart/form-data"> | ||||||
|       {% csrf_token %} |       {% csrf_token %} | ||||||
|       <div class="inputs"> |       <div class="inputs"> | ||||||
| @@ -140,6 +125,36 @@ | |||||||
| {% block script %} | {% block script %} | ||||||
|   {{ super() }} |   {{ super() }} | ||||||
|   <script> |   <script> | ||||||
|  |     document.addEventListener("alpine:init", () => { | ||||||
|  |       Alpine.data("pictures", () => ({ | ||||||
|  |         pictures: {}, | ||||||
|  |         page: parseInt(initialUrlParams.get("page")) || 1, | ||||||
|  |         loading: false, | ||||||
|  |  | ||||||
|  |         async init() { | ||||||
|  |           await this.fetch_pictures(); | ||||||
|  |           this.$watch("page", () => { | ||||||
|  |             update_query_string("page", this.page === 1 ? null : this.page); | ||||||
|  |             this.fetch_pictures() | ||||||
|  |           }); | ||||||
|  |         }, | ||||||
|  |  | ||||||
|  |         async fetch_pictures() { | ||||||
|  |           this.loading=true; | ||||||
|  |           const url = "{{ url("api:pictures") }}" | ||||||
|  |           +"?album_id={{ album.id }}" | ||||||
|  |           +`&page=${this.page}` | ||||||
|  |           +"&page_size={{ settings.SITH_SAS_IMAGES_PER_PAGE }}"; | ||||||
|  |           this.pictures = await (await fetch(url)).json(); | ||||||
|  |           this.loading=false; | ||||||
|  |         }, | ||||||
|  |  | ||||||
|  |         nb_pages() { | ||||||
|  |           return Math.ceil(this.pictures.count / {{ settings.SITH_SAS_IMAGES_PER_PAGE }}); | ||||||
|  |         } | ||||||
|  |       })) | ||||||
|  |     }) | ||||||
|  |  | ||||||
|     $("form#upload_form").submit(function (event) { |     $("form#upload_form").submit(function (event) { | ||||||
|       let formData = new FormData($(this)[0]); |       let formData = new FormData($(this)[0]); | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										32
									
								
								sas/templates/sas/macros.jinja
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								sas/templates/sas/macros.jinja
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | |||||||
|  | {% macro display_album(a, edit_mode) %} | ||||||
|  |   <a href="{{ url('sas:album', album_id=a.id) }}"> | ||||||
|  |     {% if a.file %} | ||||||
|  |       {% set img = a.get_download_url() %} | ||||||
|  |     {% elif a.children.filter(is_folder=False, is_moderated=True).exists() %} | ||||||
|  |       {% set img = a.children.filter(is_folder=False).first().as_picture.get_download_thumb_url() %} | ||||||
|  |     {% else %} | ||||||
|  |       {% set img = static('core/img/sas.jpg') %} | ||||||
|  |     {% endif %} | ||||||
|  |     <div | ||||||
|  |       class="album{% if not a.is_moderated %} not_moderated{% endif %}" | ||||||
|  |       style="background-image: url('{{ img }}');" | ||||||
|  |     > | ||||||
|  |       {% if not a.is_moderated %} | ||||||
|  |         <div class="overlay"> </div> | ||||||
|  |         <div class="text">{% trans %}To be moderated{% endtrans %}</div> | ||||||
|  |       {% else %} | ||||||
|  |         <div class="text">{{ a.name }}</div> | ||||||
|  |       {% endif %} | ||||||
|  |     </div> | ||||||
|  |     {% if edit_mode %} | ||||||
|  |       <input type="checkbox" name="file_list" value="{{ a.id }}"> | ||||||
|  |     {% endif %} | ||||||
|  |   </a> | ||||||
|  | {% endmacro %} | ||||||
|  |  | ||||||
|  | {% macro print_path(file) %} | ||||||
|  |   {% if file and file.parent %} | ||||||
|  |     {{ print_path(file.parent) }} | ||||||
|  |     <a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> / | ||||||
|  |   {% endif %} | ||||||
|  | {% endmacro %} | ||||||
| @@ -8,31 +8,9 @@ | |||||||
|   {% trans %}SAS{% endtrans %} |   {% trans %}SAS{% endtrans %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% set edit_mode = user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %} | {% set is_sas_admin = user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %} | ||||||
|  |  | ||||||
| {% macro display_album(a, checkbox) %} | {% from "sas/macros.jinja" import display_album %} | ||||||
|   <a href="{{ url('sas:album', album_id=a.id) }}"> |  | ||||||
|     {% if a.file %} |  | ||||||
|       {% set img = a.get_download_url() %} |  | ||||||
|     {% elif a.children.filter(is_folder=False, is_moderated=True).exists() %} |  | ||||||
|       {% set img = a.children.filter(is_folder=False).first().as_picture.get_download_thumb_url() %} |  | ||||||
|     {% else %} |  | ||||||
|       {% set img = static('core/img/sas.jpg') %} |  | ||||||
|     {% endif %} |  | ||||||
|  |  | ||||||
|     <div |  | ||||||
|       class="album" |  | ||||||
|       style="background-image: url('{{ img }}');" |  | ||||||
|     > |  | ||||||
|       <div class="text"> |  | ||||||
|         {{ a.name }} |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|         {# {% if edit_mode and checkbox %} |  | ||||||
|             <input type="checkbox" name="file_list" value="{{ a.id }}"> |  | ||||||
|         {% endif %} #} |  | ||||||
|   </a> |  | ||||||
| {% endmacro %} |  | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|   <main> |   <main> | ||||||
| @@ -46,22 +24,18 @@ | |||||||
|  |  | ||||||
|       <div class="albums"> |       <div class="albums"> | ||||||
|         {% for a in latest %} |         {% for a in latest %} | ||||||
|           {{ display_album(a) }} |           {{ display_album(a, edit_mode=False) }} | ||||||
|         {% endfor %} |         {% endfor %} | ||||||
|       </div> |       </div> | ||||||
|  |  | ||||||
|       <br> |       <br> | ||||||
|  |  | ||||||
|       {% if edit_mode %} |       {% if is_sas_admin %} | ||||||
|         <form action="" method="post" enctype="multipart/form-data"> |         <form action="" method="post" enctype="multipart/form-data"> | ||||||
|           {% csrf_token %} |           {% csrf_token %} | ||||||
|  |  | ||||||
|           <div class="navbar"> |           <div class="navbar"> | ||||||
|             <h4>{% trans %}All categories{% endtrans %}</h4> |             <h4>{% trans %}All categories{% endtrans %}</h4> | ||||||
|  |  | ||||||
|                         {# <div class="toolbar"> |  | ||||||
|                             <input name="delete" type="submit" value="{% trans %}Delete{% endtrans %}"> |  | ||||||
|                         </div> #} |  | ||||||
|           </div> |           </div> | ||||||
|  |  | ||||||
|           {% if clipboard %} |           {% if clipboard %} | ||||||
| @@ -81,11 +55,11 @@ | |||||||
|  |  | ||||||
|       <div class="albums"> |       <div class="albums"> | ||||||
|         {% for a in categories %} |         {% for a in categories %} | ||||||
|           {{ display_album(a, true) }} |           {{ display_album(a, edit_mode=False) }} | ||||||
|         {% endfor %} |         {% endfor %} | ||||||
|       </div> |       </div> | ||||||
|  |  | ||||||
|       {% if edit_mode %} |       {% if is_sas_admin %} | ||||||
|         </form> |         </form> | ||||||
|  |  | ||||||
|         <br> |         <br> | ||||||
|   | |||||||
| @@ -4,32 +4,11 @@ | |||||||
|   <link rel="stylesheet" href="{{ scss('sas/picture.scss') }}"> |   <link rel="stylesheet" href="{{ scss('sas/picture.scss') }}"> | ||||||
| {%- endblock -%} | {%- endblock -%} | ||||||
|  |  | ||||||
| {% block head %} |  | ||||||
|   {{ super() }} |  | ||||||
|  |  | ||||||
|   {% if picture.get_previous() %} |  | ||||||
|     <link |  | ||||||
|       rel="preload" |  | ||||||
|       as="image" |  | ||||||
|       href="{{ url("sas:download_compressed", picture_id=picture.get_previous().id) }}" |  | ||||||
|     > |  | ||||||
|   {% endif %} |  | ||||||
|   {% if picture.get_next() %} |  | ||||||
|     <link rel="preload" as="image" href="{{ url("sas:download_compressed", picture_id=picture.get_next().id) }}"> |  | ||||||
|   {% endif %} |  | ||||||
|  |  | ||||||
| {% endblock %} |  | ||||||
|  |  | ||||||
| {% block title %} | {% block title %} | ||||||
|   {% trans %}SAS{% endtrans %} |   {% trans %}SAS{% endtrans %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% macro print_path(file) %} | {% from "sas/macros.jinja" import print_path %} | ||||||
|   {% if file and file.parent %} |  | ||||||
|     {{ print_path(file.parent) }} |  | ||||||
|     <a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> / |  | ||||||
|   {% endif %} |  | ||||||
| {% endmacro %} |  | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|   <code> |   <code> | ||||||
| @@ -124,16 +103,16 @@ | |||||||
|     <div class="subsection"> |     <div class="subsection"> | ||||||
|       <div class="navigation"> |       <div class="navigation"> | ||||||
|         <div id="prev"> |         <div id="prev"> | ||||||
|           {% if picture.get_previous() %} |           {% if previous_pict %} | ||||||
|             <a href="{{ url( 'sas:picture', picture_id=picture.get_previous().id) }}#pict"> |             <a href="{{ url( 'sas:picture', picture_id=previous_pict.id) }}#pict"> | ||||||
|               <div style="background-image: url('{{ picture.get_previous().as_picture.get_download_thumb_url() }}');"></div> |               <div style="background-image: url('{{ previous_pict.get_download_thumb_url() }}');"></div> | ||||||
|             </a> |             </a> | ||||||
|           {% endif %} |           {% endif %} | ||||||
|         </div> |         </div> | ||||||
|         <div id="next"> |         <div id="next"> | ||||||
|           {% if picture.get_next() %} |           {% if next_pict %} | ||||||
|             <a href="{{ url( 'sas:picture', picture_id=picture.get_next().id) }}#pict"> |             <a href="{{ url( 'sas:picture', picture_id=next_pict.id) }}#pict"> | ||||||
|               <div style="background-image: url('{{ picture.get_next().as_picture.get_download_thumb_url() }}');"></div> |               <div style="background-image: url('{{ next_pict.get_download_thumb_url() }}');"></div> | ||||||
|             </a> |             </a> | ||||||
|           {% endif %} |           {% endif %} | ||||||
|         </div> |         </div> | ||||||
| @@ -141,11 +120,13 @@ | |||||||
|  |  | ||||||
|       <div class="tags"> |       <div class="tags"> | ||||||
|         <h5>{% trans %}People{% endtrans %}</h5> |         <h5>{% trans %}People{% endtrans %}</h5> | ||||||
|  |         {% if user.was_subscribed %} | ||||||
|           <form action="" method="post" enctype="multipart/form-data"> |           <form action="" method="post" enctype="multipart/form-data"> | ||||||
|             {% csrf_token %} |             {% csrf_token %} | ||||||
|             {{ form.as_p() }} |             {{ form.as_p() }} | ||||||
|             <input type="submit" value="{% trans %}Go{% endtrans %}" /> |             <input type="submit" value="{% trans %}Go{% endtrans %}" /> | ||||||
|           </form> |           </form> | ||||||
|  |         {% endif %} | ||||||
|         <ul x-data="user_identification"> |         <ul x-data="user_identification"> | ||||||
|           <template x-for="item in items" :key="item.id"> |           <template x-for="item in items" :key="item.id"> | ||||||
|             <li> |             <li> | ||||||
|   | |||||||
| @@ -44,12 +44,8 @@ class TestPictureSearch(TestSas): | |||||||
|         self.client.force_login(self.user_b) |         self.client.force_login(self.user_b) | ||||||
|         res = self.client.get(reverse("api:pictures") + f"?album_id={self.album_a.id}") |         res = self.client.get(reverse("api:pictures") + f"?album_id={self.album_a.id}") | ||||||
|         assert res.status_code == 200 |         assert res.status_code == 200 | ||||||
|         expected = list( |         expected = list(self.album_a.children_pictures.values_list("id", flat=True)) | ||||||
|             self.album_a.children_pictures.order_by("-date").values_list( |         assert [i["id"] for i in res.json()["results"]] == expected | ||||||
|                 "id", flat=True |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         assert [i["id"] for i in res.json()] == expected |  | ||||||
|  |  | ||||||
|     def test_filter_by_user(self): |     def test_filter_by_user(self): | ||||||
|         self.client.force_login(self.user_b) |         self.client.force_login(self.user_b) | ||||||
| @@ -58,11 +54,11 @@ class TestPictureSearch(TestSas): | |||||||
|         ) |         ) | ||||||
|         assert res.status_code == 200 |         assert res.status_code == 200 | ||||||
|         expected = list( |         expected = list( | ||||||
|             self.user_a.pictures.order_by("-picture__date").values_list( |             self.user_a.pictures.order_by( | ||||||
|                 "picture_id", flat=True |                 "-picture__parent__date", "picture__date" | ||||||
|  |             ).values_list("picture_id", flat=True) | ||||||
|         ) |         ) | ||||||
|         ) |         assert [i["id"] for i in res.json()["results"]] == expected | ||||||
|         assert [i["id"] for i in res.json()] == expected |  | ||||||
|  |  | ||||||
|     def test_filter_by_multiple_user(self): |     def test_filter_by_multiple_user(self): | ||||||
|         self.client.force_login(self.user_b) |         self.client.force_login(self.user_b) | ||||||
| @@ -73,38 +69,53 @@ 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__date") |             .order_by("-picture__parent__date", "picture__date") | ||||||
|             .values_list("picture_id", flat=True) |             .values_list("picture_id", flat=True) | ||||||
|         ) |         ) | ||||||
|         assert [i["id"] for i in res.json()] == expected |         assert [i["id"] for i in res.json()["results"]] == expected | ||||||
|  |  | ||||||
|     def test_not_subscribed_user(self): |     def test_not_subscribed_user(self): | ||||||
|         """Test that a user that is not subscribed can only its own pictures.""" |         """Test that a user that never subscribed can only its own pictures.""" | ||||||
|  |         self.user_a.subscriptions.all().delete() | ||||||
|         self.client.force_login(self.user_a) |         self.client.force_login(self.user_a) | ||||||
|         res = self.client.get( |         res = self.client.get( | ||||||
|             reverse("api:pictures") + f"?users_identified={self.user_a.id}" |             reverse("api:pictures") + f"?users_identified={self.user_a.id}" | ||||||
|         ) |         ) | ||||||
|         assert res.status_code == 200 |         assert res.status_code == 200 | ||||||
|         expected = list( |         expected = list( | ||||||
|             self.user_a.pictures.order_by("-picture__date").values_list( |             self.user_a.pictures.order_by( | ||||||
|                 "picture_id", flat=True |                 "-picture__parent__date", "picture__date" | ||||||
|  |             ).values_list("picture_id", flat=True) | ||||||
|         ) |         ) | ||||||
|         ) |         assert [i["id"] for i in res.json()["results"]] == expected | ||||||
|         assert [i["id"] for i in res.json()] == expected |  | ||||||
|  |  | ||||||
|         # trying to access the pictures of someone else |         # trying to access the pictures of someone else mixed with owned pictures | ||||||
|         res = self.client.get( |         # should return only owned pictures | ||||||
|             reverse("api:pictures") + f"?users_identified={self.user_b.id}" |  | ||||||
|         ) |  | ||||||
|         assert res.status_code == 403 |  | ||||||
|  |  | ||||||
|         # trying to access the pictures of someone else shouldn't success, |  | ||||||
|         # even if mixed with owned pictures |  | ||||||
|         res = self.client.get( |         res = self.client.get( | ||||||
|             reverse("api:pictures") |             reverse("api:pictures") | ||||||
|             + f"?users_identified={self.user_a.id}&users_identified={self.user_b.id}" |             + f"?users_identified={self.user_a.id}&users_identified={self.user_b.id}" | ||||||
|         ) |         ) | ||||||
|         assert res.status_code == 403 |         assert res.status_code == 200 | ||||||
|  |         assert [i["id"] for i in res.json()["results"]] == expected | ||||||
|  |  | ||||||
|  |         # trying to fetch everything should be the same | ||||||
|  |         # as fetching its own pictures for a non-subscriber | ||||||
|  |         res = self.client.get(reverse("api:pictures")) | ||||||
|  |         assert res.status_code == 200 | ||||||
|  |         assert [i["id"] for i in res.json()["results"]] == expected | ||||||
|  |  | ||||||
|  |         # trying to access the pictures of someone else should return only | ||||||
|  |         # the ones where the non-subscribed user is identified too | ||||||
|  |         res = self.client.get( | ||||||
|  |             reverse("api:pictures") + f"?users_identified={self.user_b.id}" | ||||||
|  |         ) | ||||||
|  |         assert res.status_code == 200 | ||||||
|  |         expected = list( | ||||||
|  |             self.user_b.pictures.intersection(self.user_a.pictures.all()) | ||||||
|  |             .order_by("-picture__parent__date", "picture__date") | ||||||
|  |             .values_list("picture_id", flat=True) | ||||||
|  |         ) | ||||||
|  |         assert [i["id"] for i in res.json()["results"]] == expected | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestPictureRelation(TestSas): | class TestPictureRelation(TestSas): | ||||||
|   | |||||||
							
								
								
									
										48
									
								
								sas/tests/test_model.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								sas/tests/test_model.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | |||||||
|  | from django.test import TestCase | ||||||
|  | from model_bakery import baker, seq | ||||||
|  |  | ||||||
|  | from core.baker_recipes import old_subscriber_user, subscriber_user | ||||||
|  | from core.models import User | ||||||
|  | from sas.models import Picture | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestPictureQuerySet(TestCase): | ||||||
|  |     @classmethod | ||||||
|  |     def setUpTestData(cls): | ||||||
|  |         Picture.objects.all().delete() | ||||||
|  |         cls.pictures = baker.make( | ||||||
|  |             Picture, | ||||||
|  |             is_moderated=True, | ||||||
|  |             is_in_sas=True, | ||||||
|  |             is_folder=False, | ||||||
|  |             name=seq(""), | ||||||
|  |             _quantity=10, | ||||||
|  |             _bulk_create=True, | ||||||
|  |         ) | ||||||
|  |         Picture.objects.filter(pk=cls.pictures[0].id).update(is_moderated=False) | ||||||
|  |  | ||||||
|  |     def test_root(self): | ||||||
|  |         root = baker.make(User, is_superuser=True) | ||||||
|  |         pictures = list(Picture.objects.viewable_by(root)) | ||||||
|  |         self.assertCountEqual(pictures, self.pictures) | ||||||
|  |  | ||||||
|  |     def test_subscriber(self): | ||||||
|  |         subscriber = subscriber_user.make() | ||||||
|  |         old_subcriber = old_subscriber_user.make() | ||||||
|  |         for user in (subscriber, old_subcriber): | ||||||
|  |             pictures = list(Picture.objects.viewable_by(user)) | ||||||
|  |             self.assertCountEqual(pictures, self.pictures[1:]) | ||||||
|  |  | ||||||
|  |     def test_not_subscribed_identified(self): | ||||||
|  |         user = baker.make( | ||||||
|  |             # This is the guy who asked the feature of making pictures | ||||||
|  |             # available for tagged users, even if not subscribed | ||||||
|  |             User, | ||||||
|  |             first_name="Pierrick", | ||||||
|  |             last_name="Dheilly", | ||||||
|  |             nick_name="Sahmer", | ||||||
|  |         ) | ||||||
|  |         user.pictures.create(picture=self.pictures[0]) | ||||||
|  |         user.pictures.create(picture=self.pictures[1]) | ||||||
|  |         pictures = list(Picture.objects.viewable_by(user)) | ||||||
|  |         assert pictures == [self.pictures[1]] | ||||||
							
								
								
									
										39
									
								
								sas/views.py
									
									
									
									
									
								
							
							
						
						
									
										39
									
								
								sas/views.py
									
									
									
									
									
								
							| @@ -18,7 +18,6 @@ from ajax_select.fields import AutoCompleteSelectMultipleField | |||||||
| from django import forms | from django import forms | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.core.exceptions import PermissionDenied | from django.core.exceptions import PermissionDenied | ||||||
| from django.core.paginator import InvalidPage, Paginator |  | ||||||
| from django.http import Http404, HttpResponse | from django.http import Http404, HttpResponse | ||||||
| from django.shortcuts import get_object_or_404, redirect | from django.shortcuts import get_object_or_404, redirect | ||||||
| from django.urls import reverse, reverse_lazy | from django.urls import reverse, reverse_lazy | ||||||
| @@ -120,10 +119,11 @@ class SASMainView(FormView): | |||||||
|  |  | ||||||
|     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["categories"] = Album.objects.filter( |         albums_qs = Album.objects.viewable_by(self.request.user) | ||||||
|             parent__id=settings.SITH_SAS_ROOT_DIR_ID |         kwargs["categories"] = list( | ||||||
|         ).order_by("id") |             albums_qs.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID).order_by("id") | ||||||
|         kwargs["latest"] = Album.objects.filter(is_moderated=True).order_by("-id")[:5] |         ) | ||||||
|  |         kwargs["latest"] = list(albums_qs.order_by("-id")[:5]) | ||||||
|         return kwargs |         return kwargs | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -181,7 +181,14 @@ class PictureView(CanViewMixin, DetailView, FormMixin): | |||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs): |     def get_context_data(self, **kwargs): | ||||||
|         kwargs = super().get_context_data(**kwargs) |         kwargs = super().get_context_data(**kwargs) | ||||||
|  |         pictures_qs = Picture.objects.viewable_by(self.request.user) | ||||||
|         kwargs["form"] = self.form |         kwargs["form"] = self.form | ||||||
|  |         kwargs["next_pict"] = ( | ||||||
|  |             pictures_qs.filter(id__gt=self.object.id).order_by("id").first() | ||||||
|  |         ) | ||||||
|  |         kwargs["previous_pict"] = ( | ||||||
|  |             pictures_qs.filter(id__lt=self.object.id).order_by("-id").first() | ||||||
|  |         ) | ||||||
|         return kwargs |         return kwargs | ||||||
|  |  | ||||||
|     def get_success_url(self): |     def get_success_url(self): | ||||||
| @@ -222,8 +229,9 @@ class AlbumUploadView(CanViewMixin, DetailView, FormMixin): | |||||||
|                     parent=parent, |                     parent=parent, | ||||||
|                     owner=request.user, |                     owner=request.user, | ||||||
|                     files=files, |                     files=files, | ||||||
|                     automodere=request.user.is_in_group( |                     automodere=( | ||||||
|                         pk=settings.SITH_GROUP_SAS_ADMIN_ID |                         request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) | ||||||
|  |                         or request.user.is_root | ||||||
|                     ), |                     ), | ||||||
|                 ) |                 ) | ||||||
|                 if self.form.is_valid(): |                 if self.form.is_valid(): | ||||||
| @@ -236,7 +244,6 @@ class AlbumView(CanViewMixin, DetailView, FormMixin): | |||||||
|     form_class = SASForm |     form_class = SASForm | ||||||
|     pk_url_kwarg = "album_id" |     pk_url_kwarg = "album_id" | ||||||
|     template_name = "sas/album.jinja" |     template_name = "sas/album.jinja" | ||||||
|     paginate_by = settings.SITH_SAS_IMAGES_PER_PAGE |  | ||||||
|  |  | ||||||
|     def dispatch(self, request, *args, **kwargs): |     def dispatch(self, request, *args, **kwargs): | ||||||
|         try: |         try: | ||||||
| @@ -283,17 +290,15 @@ class AlbumView(CanViewMixin, DetailView, FormMixin): | |||||||
|  |  | ||||||
|     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["paginator"] = Paginator( |  | ||||||
|             self.object.children_pictures.order_by("id"), self.paginate_by |  | ||||||
|         ) |  | ||||||
|         try: |  | ||||||
|             kwargs["pictures"] = kwargs["paginator"].page(self.asked_page) |  | ||||||
|         except InvalidPage as e: |  | ||||||
|             raise Http404 from e |  | ||||||
|         kwargs["form"] = self.form |         kwargs["form"] = self.form | ||||||
|         kwargs["clipboard"] = SithFile.objects.filter( |         kwargs["clipboard"] = SithFile.objects.filter( | ||||||
|             id__in=self.request.session["clipboard"] |             id__in=self.request.session["clipboard"] | ||||||
|         ) |         ) | ||||||
|  |         kwargs["children_albums"] = list( | ||||||
|  |             Album.objects.viewable_by(self.request.user) | ||||||
|  |             .filter(parent_id=self.object.id) | ||||||
|  |             .order_by("-date") | ||||||
|  |         ) | ||||||
|         return kwargs |         return kwargs | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -326,9 +331,7 @@ class ModerationView(TemplateView): | |||||||
|         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, is_in_sas=True, is_folder=True | ||||||
|         ).order_by("id") |         ).order_by("id") | ||||||
|         kwargs["pictures"] = Picture.objects.filter( |         kwargs["pictures"] = Picture.objects.filter(is_moderated=False) | ||||||
|             is_moderated=False, is_in_sas=True, is_folder=False |  | ||||||
|         ) |  | ||||||
|         kwargs["albums"] = Album.objects.filter( |         kwargs["albums"] = Album.objects.filter( | ||||||
|             id__in=kwargs["pictures"].values("parent").distinct("parent") |             id__in=kwargs["pictures"].values("parent").distinct("parent") | ||||||
|         ) |         ) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user