Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions quizzes/migrations/0017_sharedfolder.py
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)),
],
),
]
13 changes: 13 additions & 0 deletions quizzes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ def __str__(self):
return f"{self.name} ({self.owner})"


class SharedFolder(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
folder = models.ForeignKey(Folder, on_delete=models.CASCADE, related_name="shares")
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, related_name="shared_folders")
study_group = models.ForeignKey(
StudyGroup, on_delete=models.CASCADE, null=True, blank=True, related_name="shared_folders"
)
allow_edit = models.BooleanField(default=False)
Comment on lines +38 to +45
Copy link

Copilot AI Feb 8, 2026

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).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

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


def __str__(self):
return f"Folder {self.folder.name} shared with {self.user or self.study_group}"


class Quiz(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
title = models.CharField(max_length=255)
Expand Down
25 changes: 25 additions & 0 deletions quizzes/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LibraryItemSerializer defines no explicit fields and only overrides to_representation(). This makes the response schema hard/impossible for drf-spectacular to infer (and removes any type/format guarantees for id/created_at), so /library endpoints will likely show up as an untyped/empty object in OpenAPI. Consider defining explicit serializer fields (even if some are optional) or annotating the views with @extend_schema(responses=...) to document the union shape.

Copilot uses AI. Check for mistakes.
89 changes: 89 additions & 0 deletions quizzes/tests/test_library_endpoint.py
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"
)

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)
4 changes: 4 additions & 0 deletions quizzes/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from quizzes.views import (
FolderViewSet,
LastUsedQuizzesView,
LibraryFolderView,
LibraryRootView,
QuizViewSet,
RandomQuestionView,
ReportQuestionIssueView,
Expand All @@ -20,6 +22,8 @@
path("", include(router.urls)),
path("random-question/", RandomQuestionView.as_view(), name="random-question"),
path("last-used-quizzes/", LastUsedQuizzesView.as_view(), name="last-used-quizzes"),
path("library/", LibraryRootView.as_view(), name="library-root"),
path("library/<uuid:folder_id>/", LibraryFolderView.as_view(), name="library-folder"),
path(
"report-question-issue/",
ReportQuestionIssueView.as_view(),
Expand Down
93 changes: 93 additions & 0 deletions quizzes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
AnswerRecordSerializer,
AnswerSerializer,
FolderSerializer,
LibraryItemSerializer,
MoveFolderSerializer,
MoveQuizSerializer,
QuestionSerializer,
Expand Down Expand Up @@ -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))

Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response ordering isn’t actually created_at-desc across all library items: folders are ordered internally, quizzes are ordered internally, then the two lists are concatenated, which breaks global ordering. If clients rely on a single timeline sort, sort the combined items list by created_at (or use a DB-level UNION/annotation) before serializing.

Suggested change
items.sort(key=lambda item: item.created_at, reverse=True)

Copilot uses AI. Check for mistakes.
return Response(LibraryItemSerializer(items, many=True, context={"request": request}).data)


class LibraryFolderView(BaseLibraryView):
def get_quizzes(self, user, folder_id):
has_access = (
Q(maintainer=user) | Q(sharedquiz__user=user) | Q(sharedquiz__study_group__in=user.study_groups.all())
)

return Quiz.objects.filter(has_access, folder_id=folder_id).distinct().order_by("-created_at")

def get_subfolders(self, user, folder_id):
available_folder_ids = self.get_available_folder_ids(user)
is_visible = Q(id__in=available_folder_ids)

return Folder.objects.filter(is_visible, parent_id=folder_id).distinct().order_by("-created_at")

def get(self, request, folder_id=None):
user = request.user

if folder_id is None:
return Response({"error": "folder_id is required"}, status=status.HTTP_400_BAD_REQUEST)

if not self.is_folder_content_available(user, folder_id):
return Response(
{"error": "You do not have permission to access this folder"}, status=status.HTTP_403_FORBIDDEN
)

items = list(self.get_subfolders(user, folder_id)) + list(self.get_quizzes(user, folder_id))
return Response(LibraryItemSerializer(items, many=True, context={"request": request}).data)
Comment on lines +792 to +793
Copy link

Copilot AI Feb 8, 2026

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.

Copilot uses AI. Check for mistakes.
Loading