-
Notifications
You must be signed in to change notification settings - Fork 3
Feat/117 quiz library endpoint #144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| # Generated by Django 6.0.1 on 2026-02-07 23:26 | ||
|
|
||
| import django.db.models.deletion | ||
| import uuid | ||
| from django.conf import settings | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ('quizzes', '0016_quizsession_updated_at'), | ||
| ('users', '0005_add_user_ban_fields'), | ||
| migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.CreateModel( | ||
| name='SharedFolder', | ||
| fields=[ | ||
| ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), | ||
| ('allow_edit', models.BooleanField(default=False)), | ||
| ('folder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='quizzes.folder')), | ||
| ('study_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shared_folders', to='users.studygroup')), | ||
| ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shared_folders', to=settings.AUTH_USER_MODEL)), | ||
| ], | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -537,3 +537,28 @@ def validate_folder_id(self, value): | |
| raise serializers.ValidationError("The folder does not exist or you do not have access to it.") | ||
|
|
||
| return value | ||
|
|
||
|
|
||
| class LibraryItemSerializer(serializers.Serializer): | ||
| def to_representation(self, instance): | ||
| user = self.context["request"].user | ||
|
|
||
| if isinstance(instance, Folder): | ||
| return { | ||
| "id": instance.id, | ||
| "name": instance.name, | ||
| "type": "folder", | ||
| "is_shared": (instance.owner_id != user.id), | ||
| "created_at": instance.created_at, | ||
| } | ||
|
|
||
| if isinstance(instance, Quiz): | ||
| return { | ||
| "id": instance.id, | ||
| "title": instance.title, | ||
| "type": "quiz", | ||
| "is_shared": (instance.maintainer_id != user.id), | ||
| "created_at": instance.created_at, | ||
| } | ||
|
|
||
| return super().to_representation(instance) | ||
|
Comment on lines
+542
to
+564
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| from django.contrib.auth import get_user_model | ||
| from django.urls import reverse | ||
| from rest_framework import status | ||
| from rest_framework.test import APITestCase | ||
|
|
||
| from quizzes.models import Folder, Quiz, SharedFolder | ||
| from users.models import StudyGroup | ||
|
|
||
| User = get_user_model() | ||
|
|
||
|
|
||
| class LibraryTests(APITestCase): | ||
| def setUp(self): | ||
| self.user_a = User.objects.create_user( | ||
| email="user_a@example.com", password="password123", first_name="Jan", last_name="Kowalski" | ||
| ) | ||
| self.user_b = User.objects.create_user( | ||
| email="user_b@example.com", password="password123", first_name="Anna", last_name="Nowak" | ||
WiktorGruszczynski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ) | ||
|
|
||
| self.group = StudyGroup.objects.create(id="test-group", name="Solvro Group") | ||
| self.group.members.add(self.user_b) | ||
|
|
||
| self.folder_main = Folder.objects.create(name="Main", owner=self.user_a) | ||
| self.folder_sub = Folder.objects.create(name="Sub", owner=self.user_a, parent=self.folder_main) | ||
|
|
||
| self.quiz_hidden = Quiz.objects.create(title="Hidden Quiz", maintainer=self.user_a, folder=self.folder_sub) | ||
| self.quiz_root = Quiz.objects.create(title="Root Quiz", maintainer=self.user_a, folder=None) | ||
|
|
||
| def test_root_shows_only_own_toplevel_items(self): | ||
| """Sprawdza dokładną zawartość root (IDs i brak elementow ukrytych).""" | ||
| self.client.force_authenticate(user=self.user_a) | ||
| response = self.client.get(reverse("library-root")) | ||
|
|
||
| self.assertEqual(response.status_code, status.HTTP_200_OK) | ||
|
|
||
| # Wyciągamy ID i nazwy wszystkich zwróconych elementów | ||
| returned_ids = [str(item["id"]) for item in response.data] | ||
| returned_names = [item.get("name") or item.get("title") for item in response.data] | ||
|
|
||
| expected_ids = {str(self.folder_main.id), str(self.quiz_root.id)} | ||
| self.assertEqual(set(returned_ids), expected_ids) | ||
|
|
||
| self.assertNotIn(str(self.folder_sub.id), returned_ids) | ||
| self.assertNotIn("Hidden Quiz", returned_names) | ||
|
|
||
| def test_shared_subfolder_floats_to_root(self): | ||
| """Sprawdza floating logic w sposób odporny na kolejność.""" | ||
| SharedFolder.objects.create(folder=self.folder_sub, user=self.user_b) | ||
|
|
||
| self.client.force_authenticate(user=self.user_b) | ||
| response = self.client.get(reverse("library-root")) | ||
|
|
||
| returned_names = {item.get("name") for item in response.data} | ||
| self.assertEqual(returned_names, {"Sub"}) | ||
|
|
||
| def test_study_group_sharing(self): | ||
| """Sprawdza udostępnianie grupowe w sposób odporny na kolejność.""" | ||
| SharedFolder.objects.create(folder=self.folder_main, study_group=self.group) | ||
|
|
||
| self.client.force_authenticate(user=self.user_b) | ||
| response = self.client.get(reverse("library-root")) | ||
|
|
||
| returned_names = {item.get("name") for item in response.data} | ||
| self.assertEqual(returned_names, {"Main"}) | ||
|
|
||
| def test_authorized_folder_access(self): | ||
| """Sprawdza czy wejście do folderu zwraca właściwe metadane i zawartość.""" | ||
| SharedFolder.objects.create(folder=self.folder_main, user=self.user_b) | ||
| SharedFolder.objects.create(folder=self.folder_sub, user=self.user_b) | ||
|
|
||
| self.client.force_authenticate(user=self.user_b) | ||
| url = reverse("library-folder", kwargs={"folder_id": self.folder_main.id}) | ||
| response = self.client.get(url) | ||
|
|
||
| self.assertEqual(response.status_code, status.HTTP_200_OK) | ||
|
|
||
| # Sprawdzamy czy w środku jest dokładnie Subfolder | ||
| returned_ids = {str(item["id"]) for item in response.data} | ||
| self.assertIn(str(self.folder_sub.id), returned_ids) | ||
|
|
||
| self.assertNotIn(str(self.quiz_root.id), returned_ids) | ||
|
|
||
| def test_unauthorized_folder_access(self): | ||
| """Bez zmian, status code 403 jest tu wystarczający.""" | ||
| self.client.force_authenticate(user=self.user_b) | ||
| url = reverse("library-folder", kwargs={"folder_id": self.folder_main.id}) | ||
| response = self.client.get(url) | ||
| self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -45,6 +45,7 @@ | |||||
| AnswerRecordSerializer, | ||||||
| AnswerSerializer, | ||||||
| FolderSerializer, | ||||||
| LibraryItemSerializer, | ||||||
| MoveFolderSerializer, | ||||||
| MoveQuizSerializer, | ||||||
| QuestionSerializer, | ||||||
|
|
@@ -698,3 +699,95 @@ def move(self, request, pk=None): | |||||
| return Response({"status": "Folder moved successfully"}) | ||||||
|
|
||||||
| return Response(serializer.errors) | ||||||
|
|
||||||
|
|
||||||
| class BaseLibraryView(APIView): | ||||||
| permission_classes = [IsAuthenticated] | ||||||
|
|
||||||
| # cache | ||||||
| available_folder_ids = None | ||||||
|
|
||||||
| def get_available_folder_ids(self, user): | ||||||
| if self.available_folder_ids is None: | ||||||
| self.available_folder_ids = list( | ||||||
| Folder.objects.filter( | ||||||
| Q(owner=user) | Q(shares__user=user) | Q(shares__study_group__in=user.study_groups.all()) | ||||||
| ) | ||||||
| .distinct() | ||||||
| .values_list("id", flat=True) | ||||||
| ) | ||||||
|
|
||||||
| return self.available_folder_ids | ||||||
|
|
||||||
| def is_folder_content_available(self, user, folder_id): | ||||||
| return folder_id in self.get_available_folder_ids(user) | ||||||
|
|
||||||
|
|
||||||
| class LibraryRootView(BaseLibraryView): | ||||||
| def get_toplevel_folders(self, user): | ||||||
| # user owns root folder | ||||||
| is_user_root_folder = Q(owner=user, parent=None) | ||||||
|
|
||||||
| # user has access to shared folder | ||||||
| is_shared_with_user = Q(shares__user=user) | Q(shares__study_group__in=user.study_groups.all()) | ||||||
|
|
||||||
| # user owns folder | ||||||
| is_user_owner = Q(owner=user) | ||||||
|
|
||||||
| # folder doesn't have a parent OR its parent is not in the list of available folders | ||||||
| appears_as_root = Q(parent=None) | ~Q(parent_id__in=self.get_available_folder_ids(user)) | ||||||
|
|
||||||
| return ( | ||||||
| Folder.objects.filter(is_user_root_folder | (is_shared_with_user & ~is_user_owner & appears_as_root)) | ||||||
| .distinct() | ||||||
| .order_by("-created_at") | ||||||
| ) | ||||||
|
|
||||||
| def get_toplevel_quizzes(self, user): | ||||||
| is_user_owner = Q(maintainer=user) | ||||||
|
|
||||||
| is_shared_with_user = Q(sharedquiz__user=user) | Q(sharedquiz__study_group__in=user.study_groups.all()) | ||||||
|
|
||||||
| # quiz is not contained in any folder OR the folder it is contained is, is not accessible by user | ||||||
| appears_as_root = Q(folder=None) | ~Q(folder_id__in=self.get_available_folder_ids(user)) | ||||||
|
|
||||||
| return ( | ||||||
| Quiz.objects.filter((is_user_owner | is_shared_with_user) & appears_as_root) | ||||||
| .distinct() | ||||||
| .order_by("-created_at") | ||||||
| ) | ||||||
|
|
||||||
| def get(self, request): | ||||||
| user = request.user | ||||||
| items = list(self.get_toplevel_folders(user)) + list(self.get_toplevel_quizzes(user)) | ||||||
|
|
||||||
|
||||||
| items.sort(key=lambda item: item.created_at, reverse=True) |
github-code-quality[bot] marked this conversation as resolved.
Fixed
Show fixed
Hide fixed
Copilot
AI
Feb 8, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as in the root view: subfolders and quizzes are each ordered by created_at, but concatenating the two lists breaks a global created_at sort. Consider sorting the combined items list by created_at before serializing so the folder view matches the advertised ordering.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SharedFolder allows rows where both user and study_group are NULL (or both set), which makes the share ambiguous/ineffective and can lead to confusing permission behavior. Add a DB-level CheckConstraint to enforce exactly one of (user, study_group) is set, and consider UniqueConstraint(s) to prevent duplicate shares per (folder,user) / (folder,study_group).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@WiktorGruszczynski wdg mnie słuszna uwaga