diff --git a/id_to_uuid_script.py b/id_to_uuid_script.py deleted file mode 100644 index 437be08..0000000 --- a/id_to_uuid_script.py +++ /dev/null @@ -1,68 +0,0 @@ -import json -import uuid -from pathlib import Path - - -def migrate_user_ids(): - # Configuration - input_file = Path("db.json") - output_file = Path("migrated_db.json") - user_model = "users.user" - - # Load entire database - with open(input_file) as f: - data = json.load(f) - - # Phase 1: Create ID mapping and update users - id_mapping = {} - for entry in data: - if entry["model"] == user_model: - old_id = entry["pk"] - new_uuid = str(uuid.uuid4()) - id_mapping[old_id] = new_uuid - entry["pk"] = new_uuid - entry["fields"]["usos_id"] = old_id - - # Update ID field if explicitly defined in model - if "id" in entry["fields"]: - entry["fields"]["id"] = new_uuid - - # Phase 2: Update all foreign keys - for entry in data: - # Update foreign keys - if entry["model"] == "users.usersettings" and entry["pk"] in id_mapping: - # Handle OneToOneField as primary key - entry["pk"] = id_mapping[entry["pk"]] - - # Update regular foreign keys - for field in ["user", "maintainer"]: - if field in entry["fields"] and entry["fields"][field] in id_mapping: - entry["fields"][field] = id_mapping[entry["fields"][field]] - - # Update M2M relationships (StudyGroup members) - if entry["model"] == "users.studygroup" and "members" in entry["fields"]: - entry["fields"]["members"] = [ - id_mapping[member_id] for member_id in entry["fields"]["members"] if member_id in id_mapping - ] - - # Update admin log entries - if (entry["model"] == "admin.logentry") and ("object_id" in entry["fields"]): - object_id = entry["fields"]["object_id"] - try: - if int(object_id) in id_mapping: - entry["fields"]["object_id"] = id_mapping[int(object_id)] - except ValueError: - # Could not convert object_id to int; skipping mapping update for this entry. - pass - - # Save migrated data - with open(output_file, "w") as f: - json.dump(data, f, indent=2) - - # Save ID mapping for reference - with open("id_mapping.json", "w") as f: - json.dump(id_mapping, f, indent=2) - - -if __name__ == "__main__": - migrate_user_ids() diff --git a/quizzes/admin.py b/quizzes/admin.py index 6bd273c..2a4907f 100644 --- a/quizzes/admin.py +++ b/quizzes/admin.py @@ -107,7 +107,7 @@ def render_change_form(self, request, context, add=False, change=False, form_url list_display = [ "title", - "maintainer", + "creator", "visibility", "is_anonymous", "version", @@ -118,13 +118,13 @@ def render_change_form(self, request, context, add=False, change=False, form_url search_fields = [ "title", "description", - "maintainer__first_name", - "maintainer__last_name", - "maintainer__email", - "maintainer__student_number", + "creator__first_name", + "creator__last_name", + "creator__email", + "creator__student_number", ] readonly_fields = ["version", "created_at", "updated_at", "view_questions_link", "view_sessions_link"] - autocomplete_fields = ["maintainer", "folder"] + autocomplete_fields = ["creator", "folder"] date_hierarchy = "created_at" def view_questions_link(self, obj): diff --git a/quizzes/apps.py b/quizzes/apps.py index b86988f..bcba434 100644 --- a/quizzes/apps.py +++ b/quizzes/apps.py @@ -4,3 +4,8 @@ class QuizzesConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "quizzes" + + def ready(self): + from .signals import register_signals + + register_signals() diff --git a/quizzes/migrations/0004_folder_quiz_folder.py b/quizzes/migrations/0004_folder_quiz_folder.py index 85d8783..4d28263 100644 --- a/quizzes/migrations/0004_folder_quiz_folder.py +++ b/quizzes/migrations/0004_folder_quiz_folder.py @@ -1,8 +1,7 @@ -# Generated by Django 6.0 on 2025-12-26 14:18 - import uuid import django.db.models.deletion +import django.utils.timezone from django.conf import settings from django.db import migrations, models @@ -10,22 +9,59 @@ class Migration(migrations.Migration): dependencies = [ - ('quizzes', '0003_sharedquiz_allow_edit'), + ("quizzes", "0003_sharedquiz_allow_edit"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Folder', + name="Folder", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('name', models.CharField(max_length=128)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='folders', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=128)), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + ), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="folders", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="quizzes.folder", + ), + ), ], ), migrations.AddField( - model_name='quiz', - name='folder', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='quizzes', to='quizzes.folder'), + model_name="quiz", + name="folder", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="quizzes", + to="quizzes.folder", + ), ), ] diff --git a/quizzes/migrations/0005_rename_user_folder_owner_folder_created_at_and_more.py b/quizzes/migrations/0005_rename_user_folder_owner_folder_created_at_and_more.py index f3a1efb..138d582 100644 --- a/quizzes/migrations/0005_rename_user_folder_owner_folder_created_at_and_more.py +++ b/quizzes/migrations/0005_rename_user_folder_owner_folder_created_at_and_more.py @@ -1,7 +1,9 @@ -# Generated by Django 6.0 on 2025-12-29 20:16 +# Originally: rename user->owner, add created_at, updated_at +# These changes were squashed into 0004_folder_quiz_folder on this branch. +# This migration is kept as a no-op for backwards compatibility with +# environments that already applied it. -import django.utils.timezone -from django.db import migrations, models +from django.db import migrations class Migration(migrations.Migration): @@ -10,21 +12,4 @@ class Migration(migrations.Migration): ('quizzes', '0004_folder_quiz_folder'), ] - operations = [ - migrations.RenameField( - model_name='folder', - old_name='user', - new_name='owner', - ), - migrations.AddField( - model_name='folder', - name='created_at', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='folder', - name='updated_at', - field=models.DateTimeField(auto_now=True), - ), - ] + operations = [] diff --git a/quizzes/migrations/0006_folder_parent.py b/quizzes/migrations/0006_folder_parent.py index a5a8e25..c510135 100644 --- a/quizzes/migrations/0006_folder_parent.py +++ b/quizzes/migrations/0006_folder_parent.py @@ -1,7 +1,9 @@ -# Generated by Django 6.0 on 2025-12-31 13:00 +# Originally: add parent field to Folder +# This change was squashed into 0004_folder_quiz_folder on this branch. +# This migration is kept as a no-op for backwards compatibility with +# environments that already applied it. -import django.db.models.deletion -from django.db import migrations, models +from django.db import migrations class Migration(migrations.Migration): @@ -10,10 +12,4 @@ class Migration(migrations.Migration): ('quizzes', '0005_rename_user_folder_owner_folder_created_at_and_more'), ] - operations = [ - migrations.AddField( - model_name='folder', - name='parent', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='quizzes.folder'), - ), - ] + operations = [] diff --git a/quizzes/migrations/0023_folder_architecture.py b/quizzes/migrations/0023_folder_architecture.py new file mode 100644 index 0000000..6483c65 --- /dev/null +++ b/quizzes/migrations/0023_folder_architecture.py @@ -0,0 +1,75 @@ +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def create_root_folders(apps, schema_editor): + User = apps.get_model("users", "User") + Folder = apps.get_model("quizzes", "Folder") + Quiz = apps.get_model("quizzes", "Quiz") + + for user in User.objects.all().iterator(): + if user.root_folder_id: + folder = Folder.objects.get(pk=user.root_folder_id) + else: + folder = Folder.objects.create( + id=uuid.uuid4(), + name="Moje quizy", + owner=user, + parent=None, + ) + User.objects.filter(pk=user.pk).update(root_folder=folder) + Quiz.objects.filter(creator=user, folder__isnull=True).update(folder=folder) + + +class Migration(migrations.Migration): + + dependencies = [ + ("quizzes", "0022_question_is_markdown_enabled"), + ("users", "0009_user_root_folder"), + 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)), + ], + ), + migrations.RenameField( + model_name="quiz", + old_name="maintainer", + new_name="creator", + ), + migrations.AlterField( + model_name="quiz", + name="creator", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="created_quizzes", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.RunPython(create_root_folders, migrations.RunPython.noop), + migrations.AlterField( + model_name="quiz", + name="folder", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="quizzes", + to="quizzes.folder", + ), + ), + migrations.AlterField( + model_name="folder", + name="created_at", + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/quizzes/migrations/0024_sharedfolder_constraints.py b/quizzes/migrations/0024_sharedfolder_constraints.py new file mode 100644 index 0000000..5aa827e --- /dev/null +++ b/quizzes/migrations/0024_sharedfolder_constraints.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.1 on 2026-04-02 10:37 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('quizzes', '0023_folder_architecture'), + ('users', '0009_user_root_folder'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddConstraint( + model_name='sharedfolder', + constraint=models.CheckConstraint(condition=models.Q(models.Q(('study_group__isnull', True), ('user__isnull', False)), models.Q(('study_group__isnull', False), ('user__isnull', True)), _connector='OR'), name='sharedfolder_exactly_one_target'), + ), + migrations.AddConstraint( + model_name='sharedfolder', + constraint=models.UniqueConstraint(fields=('folder', 'user'), name='unique_sharedfolder_folder_user'), + ), + migrations.AddConstraint( + model_name='sharedfolder', + constraint=models.UniqueConstraint(fields=('folder', 'study_group'), name='unique_sharedfolder_folder_study_group'), + ), + ] diff --git a/quizzes/models.py b/quizzes/models.py index 664bd4a..1fcfdf9 100644 --- a/quizzes/models.py +++ b/quizzes/models.py @@ -2,6 +2,7 @@ from datetime import timedelta from django.db import models +from django.db.models import ProtectedError, Q from users.models import StudyGroup, User @@ -33,12 +34,67 @@ class Meta: def __str__(self): return f"{self.name} ({self.owner})" + @property + def is_root(self): + try: + return self.root_owner is not None + except self.__class__.root_owner.RelatedObjectDoesNotExist: + return False + + def delete(self, *args, **kwargs): + if self.is_root: + raise ProtectedError( + "Cannot delete root folder.", + set([self]), + ) + super().delete(*args, **kwargs) + + def has_edit_permission(self, user): + """Check if user can edit content in this folder.""" + if user == self.owner: + return True + return self.shares.filter( + Q(user=user) | Q(study_group__in=user.study_groups.all()), + allow_edit=True, + ).exists() + + +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) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=( + Q(user__isnull=False, study_group__isnull=True) | Q(user__isnull=True, study_group__isnull=False) + ), + name="sharedfolder_exactly_one_target", + ), + models.UniqueConstraint( + fields=["folder", "user"], + name="unique_sharedfolder_folder_user", + ), + models.UniqueConstraint( + fields=["folder", "study_group"], + name="unique_sharedfolder_folder_study_group", + ), + ] + + 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) description = models.TextField(null=True, blank=True) - maintainer = models.ForeignKey(User, on_delete=models.CASCADE) + creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name="created_quizzes") visibility = models.PositiveIntegerField(choices=QUIZ_VISIBILITY_CHOICES, default=2) allow_anonymous = models.BooleanField( default=False, @@ -51,7 +107,7 @@ class Quiz(models.Model): version = models.PositiveIntegerField(default=1) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - folder = models.ForeignKey(Folder, on_delete=models.SET_NULL, null=True, blank=True, related_name="quizzes") + folder = models.ForeignKey(Folder, on_delete=models.PROTECT, related_name="quizzes") class Meta: ordering = ["-created_at"] @@ -71,7 +127,7 @@ def get_last_used_at(self, user): def can_edit(self, user): return ( - user == self.maintainer + self.folder.has_edit_permission(user) or self.sharedquiz_set.filter(user=user, allow_edit=True).exists() or self.sharedquiz_set.filter(study_group__in=user.study_groups.all(), allow_edit=True).exists() ) diff --git a/quizzes/permissions.py b/quizzes/permissions.py index d821a65..89cb366 100644 --- a/quizzes/permissions.py +++ b/quizzes/permissions.py @@ -28,9 +28,9 @@ def has_permission(self, request, view): return api_key == settings.INTERNAL_API_KEY -class IsSharedQuizMaintainerOrReadOnly(permissions.BasePermission): +class IsSharedQuizCreatorOrReadOnly(permissions.BasePermission): """ - Custom permission to only allow maintainer of a shared quiz to edit it. + Custom permission to only allow creator of a shared quiz to edit it. Also enforces account-type restrictions: - Guests cannot view or share quizzes - Only Email, Student, and Lecturer accounts can view shared quizzes @@ -55,25 +55,24 @@ def has_object_permission(self, request, view, obj): if request.method in permissions.SAFE_METHODS: return True - # Write permissions are only allowed to the maintainer of the shared quiz. - return obj.quiz.maintainer == request.user + return obj.quiz.folder.owner == request.user -class IsQuizMaintainer(permissions.BasePermission): +class IsQuizCreator(permissions.BasePermission): """ Custom permission for critical actions like Move or Delete. - Blocks collaborators - only the quiz maintainer can perform these actions. + Only the folder owner can perform these actions. """ def has_object_permission(self, request, view, obj): - return obj.maintainer == request.user + return obj.folder.owner == request.user class IsQuizReadable(permissions.BasePermission): """ Custom permission for read access to a quiz. Allowed if: - - User is the maintainer + - User is the folder owner - Quiz is public or unlisted (visibility >= 2) and user is authenticated (or quiz allows anonymous) - Quiz is shared with the user explicitly (requires non-guest account) - Quiz is shared with a group the user belongs to (requires non-guest account) @@ -82,7 +81,7 @@ class IsQuizReadable(permissions.BasePermission): """ def has_object_permission(self, request, view, obj: Quiz): - if obj.maintainer == request.user: + if obj.folder.owner == request.user: return True if obj.visibility >= 2 and (request.user.is_authenticated or obj.allow_anonymous): @@ -106,16 +105,16 @@ def has_object_permission(self, request, view, obj: Question): return IsQuizReadable().has_object_permission(request, view, obj.quiz) -class IsQuizMaintainerOrCollaboratorOrReadOnly(permissions.BasePermission): +class IsQuizCreatorOrCollaboratorOrReadOnly(permissions.BasePermission): """ - Custom permission to allow quiz maintainers and accepted collaborators to edit the quiz. + Custom permission to allow quiz creator and accepted collaborators to edit the quiz. """ def has_object_permission(self, request, view, obj: Quiz | Question): if request.method in permissions.SAFE_METHODS: return True - # Write permissions are only allowed to the maintainer or accepted collaborators + # Write permissions are only allowed to the creator or accepted collaborators if isinstance(obj, Quiz): return obj.can_edit(request.user) diff --git a/quizzes/serializers.py b/quizzes/serializers.py index ae35281..bd6b416 100644 --- a/quizzes/serializers.py +++ b/quizzes/serializers.py @@ -218,7 +218,7 @@ class Meta: class QuizSerializer(serializers.ModelSerializer): - maintainer = PublicUserSerializer(read_only=True) + creator = PublicUserSerializer(read_only=True) can_edit = serializers.SerializerMethodField() questions = QuestionSerializer(many=True) @@ -233,7 +233,7 @@ class Meta: "id", "title", "description", - "maintainer", + "creator", "visibility", "is_anonymous", "allow_anonymous", @@ -245,7 +245,7 @@ class Meta: "current_session", "has_external_images", ] - read_only_fields = ["maintainer", "version", "can_edit", "folder"] + read_only_fields = ["creator", "version", "can_edit", "folder"] def get_can_edit(self, obj) -> bool: request = self.context.get("request") @@ -268,8 +268,8 @@ def to_representation(self, instance): request = self.context.get("request") user = getattr(request, "user", None) if request else self.context.get("user") - if not user or (instance.is_anonymous and user != instance.maintainer): - data["maintainer"] = None + if not user or (instance.is_anonymous and user != instance.creator): + data["creator"] = None if user and user.is_authenticated: includes = [item.strip() for item in request.query_params.get("include", "").split(",")] if request else [] @@ -285,9 +285,15 @@ def to_representation(self, instance): data["current_session"] = QuizSessionSerializer(session).data else: data.pop("current_session", None) + + if user.owns_quiz_via_folder(instance): + data["folder"] = FolderSerializer(instance.folder).data + else: + data.pop("folder", None) else: data.pop("user_settings", None) data.pop("current_session", None) + data.pop("folder", None) return data @@ -453,7 +459,7 @@ def _batch_sync_answers(self, answers_to_sync): class QuizMetaDataSerializer(serializers.ModelSerializer): - maintainer = PublicUserSerializer(read_only=True) + creator = PublicUserSerializer(read_only=True) can_edit = serializers.SerializerMethodField() last_used_at = serializers.SerializerMethodField() @@ -463,7 +469,7 @@ class Meta: "id", "title", "description", - "maintainer", + "creator", "visibility", "is_anonymous", "allow_anonymous", @@ -474,14 +480,19 @@ class Meta: "can_edit", "folder", ] - read_only_fields = ["maintainer", "created_at", "updated_at", "last_used_at", "version", "can_edit", "folder"] + read_only_fields = ["creator", "created_at", "updated_at", "last_used_at", "version", "can_edit", "folder"] def to_representation(self, instance): data = super().to_representation(instance) request = self.context.get("request") user = getattr(request, "user", None) if request else self.context.get("user") - if not user or (instance.is_anonymous and user != instance.maintainer): - data["maintainer"] = None + if not user or (instance.is_anonymous and user != instance.creator): + data["creator"] = None + + if user and user.is_authenticated and user.owns_quiz_via_folder(instance): + data["folder"] = FolderSerializer(instance.folder).data + else: + data.pop("folder", None) return data def get_last_used_at(self, obj: Quiz): @@ -568,6 +579,15 @@ class Meta: fields = ["id", "name", "created_at", "parent", "quizzes", "subfolders"] read_only_fields = ["id", "created_at", "quizzes", "subfolders"] + def validate_parent(self, value): + if value is None: + return value + + user = self.context["request"].user + if value.owner != user: + raise serializers.ValidationError("You can only create folders inside your own folders.") + return value + class MoveFolderSerializer(serializers.Serializer): parent_id = serializers.UUIDField(allow_null=True) @@ -576,6 +596,9 @@ def validate_parent_id(self, value): user = self.context["request"].user folder_to_move = self.context["view"].get_object() + if folder_to_move.is_root: + raise serializers.ValidationError("The root folder cannot be moved.") + if ( Folder.objects.filter(owner=user, parent_id=value, name=folder_to_move.name) .exclude(id=folder_to_move.id) @@ -608,7 +631,7 @@ def validate_parent_id(self, value): class QuizSearchResultSerializer(serializers.ModelSerializer): """Serializer for search results.""" - maintainer = serializers.CharField(source="maintainer.full_name", read_only=True) + creator = serializers.CharField(source="creator.full_name", read_only=True) class Meta: model = Quiz @@ -616,7 +639,7 @@ class Meta: "id", "title", "description", - "maintainer", + "creator", "is_anonymous", ] @@ -624,8 +647,8 @@ def to_representation(self, instance): data = super().to_representation(instance) request = self.context.get("request") user = getattr(request, "user", None) if request else self.context.get("user") - if instance.is_anonymous and user and user != instance.maintainer: - data["maintainer"] = None + if instance.is_anonymous and user and user != instance.creator: + data["creator"] = None return data @@ -633,15 +656,49 @@ class MoveQuizSerializer(serializers.Serializer): folder_id = serializers.UUIDField(allow_null=True) def validate_folder_id(self, value): - if value: - user = self.context["request"].user + user = self.context["request"].user + + if not value: + return user.root_folder_id - if not Folder.objects.filter(id=value, owner=user).exists(): - raise serializers.ValidationError("The folder does not exist or you do not have access to it.") + if not Folder.objects.filter(id=value, owner=user).exists(): + 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", + "owner": PublicUserSerializer(instance.owner).data, + "can_edit": instance.has_edit_permission(user), + "created_at": instance.created_at, + } + + if isinstance(instance, Quiz): + if instance.is_anonymous and user != instance.creator: + owner = None + else: + owner = PublicUserSerializer(instance.folder.owner).data + return { + "id": instance.id, + "name": instance.title, + "description": instance.description, + "type": "quiz", + "owner": owner, + "can_edit": instance.can_edit(user), + "created_at": instance.created_at, + } + + return super().to_representation(instance) + + class RecordAnswerSerializer(serializers.Serializer): question_id = serializers.UUIDField() selected_answers = serializers.ListField(allow_empty=False) diff --git a/quizzes/signals.py b/quizzes/signals.py new file mode 100644 index 0000000..57dd1bf --- /dev/null +++ b/quizzes/signals.py @@ -0,0 +1,17 @@ +from django.db.models.signals import post_save + + +def create_root_folder(sender, instance, created, **kwargs): + if created and not instance.root_folder_id: + from .models import Folder + + folder = Folder.objects.create(name="Moje quizy", owner=instance) + sender.objects.filter(pk=instance.pk).update(root_folder=folder) + instance.root_folder = folder + instance.root_folder_id = folder.id + + +def register_signals(): + from users.models import User + + post_save.connect(create_root_folder, sender=User, dispatch_uid="quizzes.create_root_folder") diff --git a/quizzes/tests/last_used_quizzes_test.py b/quizzes/tests/last_used_quizzes_test.py index 5fc5a56..1d62b06 100644 --- a/quizzes/tests/last_used_quizzes_test.py +++ b/quizzes/tests/last_used_quizzes_test.py @@ -19,7 +19,8 @@ def setUp(self): self.quiz = Quiz.objects.create( title="Test Quiz", description="Test Description", - maintainer=self.user, + creator=self.user, + folder=self.user.root_folder, visibility=2, ) @@ -53,11 +54,11 @@ def test_last_used_quizzes_does_not_include_questions(self): self.assertIn("id", result) self.assertIn("title", result) self.assertIn("description", result) - self.assertIn("maintainer", result) + self.assertIn("creator", result) self.assertIn("visibility", result) self.assertIn("can_edit", result) - # Verify that can_edit is True since user is the maintainer + # Verify that can_edit is True since user is the creator self.assertTrue(result["can_edit"]) def test_pagination(self): @@ -69,7 +70,7 @@ def test_pagination(self): now = timezone.now() for i in range(5): - q = Quiz.objects.create(title=f"Quiz {i}", maintainer=self.user, visibility=2) + q = Quiz.objects.create(title=f"Quiz {i}", creator=self.user, folder=self.user.root_folder, visibility=2) session = QuizSession.objects.create(quiz=q, user=self.user, is_active=True) session.started_at = now - timedelta(minutes=5 - i) session.save() diff --git a/quizzes/tests/test_copy_quiz.py b/quizzes/tests/test_copy_quiz.py index 632d507..fb89fdf 100644 --- a/quizzes/tests/test_copy_quiz.py +++ b/quizzes/tests/test_copy_quiz.py @@ -16,7 +16,9 @@ def setUp(self): ) self.client.force_authenticate(user=self.user) - self.quiz = Quiz.objects.create(title="Original Quiz", maintainer=self.owner, visibility=1) + self.quiz = Quiz.objects.create( + title="Original Quiz", creator=self.owner, folder=self.owner.root_folder, visibility=1 + ) self.question = Question.objects.create(quiz=self.quiz, order=1, text="Q1") self.answer = Answer.objects.create(question=self.question, order=1, text="A1", is_correct=True) @@ -31,7 +33,7 @@ def test_copy_shared_directly(self): # Original quiz ID is self.quiz.id. We expect a new one. new_quiz = Quiz.objects.exclude(id=self.quiz.id).first() self.assertIsNotNone(new_quiz) - self.assertEqual(new_quiz.maintainer, self.user) + self.assertEqual(new_quiz.creator, self.user) self.assertEqual(new_quiz.title, "Original Quiz - kopia") self.assertEqual(new_quiz.questions.count(), 1) new_question = new_quiz.questions.first() @@ -52,7 +54,7 @@ def test_copy_shared_via_group(self): new_quiz = Quiz.objects.exclude(id=self.quiz.id).first() self.assertIsNotNone(new_quiz) - self.assertEqual(new_quiz.maintainer, self.user) + self.assertEqual(new_quiz.creator, self.user) def test_forbidden_if_not_shared(self): # Create a quiz that is NOT shared with the current user at all @@ -66,7 +68,7 @@ def test_forbidden_if_not_shared(self): def test_copy_own_quiz(self): # User owns a quiz - my_quiz = Quiz.objects.create(title="My Quiz", maintainer=self.user) + my_quiz = Quiz.objects.create(title="My Quiz", creator=self.user, folder=self.user.root_folder) Question.objects.create(quiz=my_quiz, order=1, text="Q1") url = reverse("quiz-copy", kwargs={"pk": my_quiz.id}) @@ -75,7 +77,7 @@ def test_copy_own_quiz(self): new_quiz = Quiz.objects.exclude(id=my_quiz.id).exclude(id=self.quiz.id).first() self.assertIsNotNone(new_quiz) - self.assertEqual(new_quiz.maintainer, self.user) + self.assertEqual(new_quiz.creator, self.user) self.assertEqual(new_quiz.title, "My Quiz - kopia") def test_throttling(self): @@ -84,7 +86,7 @@ def test_throttling(self): ) self.client.force_authenticate(user=throttle_user) - my_quiz = Quiz.objects.create(title="Throttle Quiz", maintainer=throttle_user) + my_quiz = Quiz.objects.create(title="Throttle Quiz", creator=throttle_user, folder=throttle_user.root_folder) url = reverse("quiz-copy", kwargs={"pk": my_quiz.id}) for _ in range(5): @@ -96,7 +98,9 @@ def test_throttling(self): def test_copy_complex_structure(self): # Create a quiz with multiple questions and answers - complex_quiz = Quiz.objects.create(title="Complex Quiz", maintainer=self.owner, visibility=2) + complex_quiz = Quiz.objects.create( + title="Complex Quiz", creator=self.owner, folder=self.owner.root_folder, visibility=2 + ) # Q1: 2 answers q1 = Question.objects.create(quiz=complex_quiz, order=1, text="Q1") @@ -130,7 +134,9 @@ def test_copy_complex_structure(self): def test_copy_public_quiz(self): # Public quiz (visibility=3) not owned/shared # Using visibility=3 to ensure it's fully public - public_quiz = Quiz.objects.create(title="Public Quiz", maintainer=self.owner, visibility=3) + public_quiz = Quiz.objects.create( + title="Public Quiz", creator=self.owner, folder=self.owner.root_folder, visibility=3 + ) url = reverse("quiz-copy", kwargs={"pk": public_quiz.id}) response = self.client.post(url) @@ -138,12 +144,14 @@ def test_copy_public_quiz(self): new_quiz = Quiz.objects.exclude(id=public_quiz.id).filter(title="Public Quiz - kopia").first() self.assertIsNotNone(new_quiz) - self.assertEqual(new_quiz.maintainer, self.user) + self.assertEqual(new_quiz.creator, self.user) # Verify visibility is reset to default (2 - Unlisted) self.assertEqual(new_quiz.visibility, 2) def test_copy_empty_quiz(self): - empty_quiz = Quiz.objects.create(title="Empty Quiz", maintainer=self.owner, visibility=2) + empty_quiz = Quiz.objects.create( + title="Empty Quiz", creator=self.owner, folder=self.owner.root_folder, visibility=2 + ) url = reverse("quiz-copy", kwargs={"pk": empty_quiz.id}) response = self.client.post(url) @@ -152,3 +160,14 @@ def test_copy_empty_quiz(self): new_quiz = Quiz.objects.exclude(id=empty_quiz.id).filter(title="Empty Quiz - kopia").first() self.assertIsNotNone(new_quiz) self.assertEqual(new_quiz.questions.count(), 0) + + def test_copy_lands_in_copier_root_folder(self): + """Copied quiz is placed in the copier's root folder, not original owner's.""" + SharedQuiz.objects.create(quiz=self.quiz, user=self.user) + url = reverse("quiz-copy", kwargs={"pk": self.quiz.id}) + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + new_quiz = Quiz.objects.exclude(id=self.quiz.id).first() + self.assertEqual(new_quiz.folder_id, self.user.root_folder_id) + self.assertNotEqual(new_quiz.folder_id, self.owner.root_folder_id) diff --git a/quizzes/tests/test_folder_crud.py b/quizzes/tests/test_folder_crud.py new file mode 100644 index 0000000..1c00003 --- /dev/null +++ b/quizzes/tests/test_folder_crud.py @@ -0,0 +1,162 @@ +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 + +User = get_user_model() + + +class FolderPermissionTests(APITestCase): + """Tests for folder-based quiz permissions.""" + + def setUp(self): + self.owner = User.objects.create_user( + email="owner@example.com", password="password123", first_name="Owner", last_name="User" + ) + self.owner.refresh_from_db() + self.other = User.objects.create_user( + email="other@example.com", password="password123", first_name="Other", last_name="User" + ) + self.other.refresh_from_db() + + def test_folder_owner_can_edit_quiz_they_didnt_create(self): + """Folder owner can edit a quiz even if they didn't create it.""" + quiz = Quiz.objects.create(title="By Other", creator=self.other, folder=self.owner.root_folder) + + self.client.force_authenticate(user=self.owner) + url = reverse("quiz-detail", kwargs={"pk": quiz.id}) + response = self.client.put(url, {"title": "Edited by Owner", "questions": []}, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + quiz.refresh_from_db() + self.assertEqual(quiz.title, "Edited by Owner") + + def test_creator_cannot_edit_quiz_in_other_users_folder(self): + """Quiz creator cannot edit quiz if it's in someone else's folder.""" + quiz = Quiz.objects.create(title="My Quiz", creator=self.other, folder=self.owner.root_folder) + + self.client.force_authenticate(user=self.other) + url = reverse("quiz-detail", kwargs={"pk": quiz.id}) + response = self.client.put(url, {"title": "Hacked", "questions": []}, format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + quiz.refresh_from_db() + self.assertEqual(quiz.title, "My Quiz") + + def test_folder_owner_can_delete_quiz(self): + """Folder owner can delete quiz even if created by someone else.""" + quiz = Quiz.objects.create(title="Delete Me", creator=self.other, folder=self.owner.root_folder) + + self.client.force_authenticate(user=self.owner) + url = reverse("quiz-detail", kwargs={"pk": quiz.id}) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Quiz.objects.filter(id=quiz.id).exists()) + + def test_creator_cannot_delete_quiz_in_other_users_folder(self): + """Quiz creator cannot delete quiz if it's in someone else's folder.""" + quiz = Quiz.objects.create(title="Not Yours", creator=self.other, folder=self.owner.root_folder) + + self.client.force_authenticate(user=self.other) + url = reverse("quiz-detail", kwargs={"pk": quiz.id}) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(Quiz.objects.filter(id=quiz.id).exists()) + + +class FolderCRUDTests(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email="crud@example.com", password="password123", first_name="Crud", last_name="User" + ) + self.user.refresh_from_db() + self.client.force_authenticate(user=self.user) + + def test_create_folder_via_api(self): + """POST /folders/ creates a new folder owned by the user.""" + url = reverse("folder-list") + response = self.client.post( + url, {"name": "Nowy Folder", "parent": str(self.user.root_folder_id)}, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + folder = Folder.objects.get(id=response.data["id"]) + self.assertEqual(folder.name, "Nowy Folder") + self.assertEqual(folder.owner, self.user) + self.assertEqual(folder.parent_id, self.user.root_folder_id) + + def test_rename_folder_via_api(self): + """PATCH /folders/{id}/ renames the folder.""" + folder = Folder.objects.create(name="Old Name", owner=self.user, parent=self.user.root_folder) + url = reverse("folder-detail", kwargs={"pk": folder.id}) + response = self.client.patch(url, {"name": "New Name"}, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + folder.refresh_from_db() + self.assertEqual(folder.name, "New Name") + + def test_move_quiz_between_folders(self): + """POST /quizzes/{id}/move/ moves quiz to another folder.""" + folder_a = Folder.objects.create(name="A", owner=self.user, parent=self.user.root_folder) + folder_b = Folder.objects.create(name="B", owner=self.user, parent=self.user.root_folder) + quiz = Quiz.objects.create(title="Movable", creator=self.user, folder=folder_a) + + url = reverse("quiz-move", kwargs={"pk": quiz.id}) + response = self.client.post(url, {"folder_id": str(folder_b.id)}, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + quiz.refresh_from_db() + self.assertEqual(quiz.folder_id, folder_b.id) + + def test_move_folder_to_root(self): + """Moving a folder to root folder works via API.""" + parent = Folder.objects.create(name="Parent", owner=self.user, parent=self.user.root_folder) + child = Folder.objects.create(name="Child", owner=self.user, parent=parent) + + url = reverse("folder-move", kwargs={"pk": child.id}) + response = self.client.post(url, {"parent_id": str(self.user.root_folder_id)}, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + child.refresh_from_db() + self.assertEqual(child.parent_id, self.user.root_folder_id) + + def test_cannot_rename_other_users_folder(self): + """PATCH /folders/{id}/ on another user's folder returns 404.""" + other = User.objects.create_user( + email="other@example.com", password="password123", first_name="Other", last_name="User" + ) + other.refresh_from_db() + folder = Folder.objects.create(name="Private", owner=other, parent=other.root_folder) + + url = reverse("folder-detail", kwargs={"pk": folder.id}) + response = self.client.patch(url, {"name": "Hacked"}, format="json") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + folder.refresh_from_db() + self.assertEqual(folder.name, "Private") + + def test_cannot_move_quiz_to_other_users_folder(self): + """Moving quiz to another user's folder fails validation.""" + other = User.objects.create_user( + email="other@example.com", password="password123", first_name="Other", last_name="User" + ) + other.refresh_from_db() + other_folder = Folder.objects.create(name="Other's", owner=other, parent=other.root_folder) + quiz = Quiz.objects.create(title="My Quiz", creator=self.user, folder=self.user.root_folder) + + url = reverse("quiz-move", kwargs={"pk": quiz.id}) + response = self.client.post(url, {"folder_id": str(other_folder.id)}, format="json") + self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_403_FORBIDDEN]) + quiz.refresh_from_db() + self.assertEqual(quiz.folder_id, self.user.root_folder_id) + + def test_cannot_create_folder_in_other_users_folder(self): + """Creating a folder with another user's folder as parent is rejected.""" + other = User.objects.create_user( + email="other2@example.com", password="password123", first_name="Other", last_name="User" + ) + other.refresh_from_db() + + url = reverse("folder-list") + response = self.client.post(url, {"name": "Sneaky Folder", "parent": str(other.root_folder_id)}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Folder.objects.filter(name="Sneaky Folder").exists()) diff --git a/quizzes/tests/test_folder_root.py b/quizzes/tests/test_folder_root.py new file mode 100644 index 0000000..72cab0e --- /dev/null +++ b/quizzes/tests/test_folder_root.py @@ -0,0 +1,95 @@ +from django.contrib.auth import get_user_model +from django.db.models import ProtectedError +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from quizzes.models import Folder, Quiz + +User = get_user_model() + + +class RootFolderTests(APITestCase): + def test_root_folder_created_on_user_creation(self): + """post_save signal creates root folder for new users.""" + user = User.objects.create_user( + email="test@example.com", password="password123", first_name="Test", last_name="User" + ) + user.refresh_from_db() + self.assertIsNotNone(user.root_folder) + self.assertEqual(user.root_folder.owner, user) + self.assertEqual(user.root_folder.name, "Moje quizy") + + def test_root_folder_cannot_be_deleted(self): + """Deleting a root folder raises ProtectedError.""" + user = User.objects.create_user( + email="test@example.com", password="password123", first_name="Test", last_name="User" + ) + user.refresh_from_db() + with self.assertRaises(ProtectedError): + user.root_folder.delete() + + def test_folder_deletion_blocked_when_has_quizzes(self): + """Deleting a folder that contains quizzes raises ProtectedError.""" + user = User.objects.create_user( + email="test@example.com", password="password123", first_name="Test", last_name="User" + ) + user.refresh_from_db() + folder = Folder.objects.create(name="Temp", owner=user, parent=user.root_folder) + Quiz.objects.create(title="Temp Quiz", creator=user, folder=folder) + + with self.assertRaises(ProtectedError): + folder.delete() + + def test_empty_folder_can_be_deleted(self): + """Deleting a folder without quizzes succeeds.""" + user = User.objects.create_user( + email="test@example.com", password="password123", first_name="Test", last_name="User" + ) + user.refresh_from_db() + folder = Folder.objects.create(name="Empty", owner=user, parent=user.root_folder) + folder_id = folder.id + + folder.delete() + self.assertFalse(Folder.objects.filter(id=folder_id).exists()) + + def test_unique_root_folder_per_user(self): + """Each user gets exactly one root folder, not duplicated on save.""" + user = User.objects.create_user( + email="unique@example.com", password="password123", first_name="Unique", last_name="User" + ) + user.refresh_from_db() + root_id = user.root_folder_id + + user.save() + user.refresh_from_db() + self.assertEqual(user.root_folder_id, root_id) + self.assertEqual(Folder.objects.filter(root_owner=user).count(), 1) + + def test_cannot_move_root_folder(self): + """Root folders cannot be moved into other folders.""" + user = User.objects.create_user( + email="move@example.com", password="password123", first_name="Move", last_name="User" + ) + user.refresh_from_db() + self.client.force_authenticate(user=user) + other_folder = Folder.objects.create(name="Other", owner=user, parent=user.root_folder) + url = reverse("folder-move", kwargs={"pk": user.root_folder.id}) + response = self.client.post(url, {"parent_id": str(other_folder.id)}, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("The root folder cannot be moved.", str(response.data)) + user.root_folder.refresh_from_db() + self.assertIsNone(user.root_folder.parent_id) + + def test_cannot_delete_root_folder(self): + """Root folders cannot be deleted via API.""" + user = User.objects.create_user( + email="del@example.com", password="password123", first_name="Del", last_name="User" + ) + user.refresh_from_db() + self.client.force_authenticate(user=user) + url = reverse("folder-detail", kwargs={"pk": user.root_folder.id}) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(Folder.objects.filter(id=user.root_folder.id).exists()) diff --git a/quizzes/tests/test_has_external_images.py b/quizzes/tests/test_has_external_images.py index 907bfe7..d9684ca 100644 --- a/quizzes/tests/test_has_external_images.py +++ b/quizzes/tests/test_has_external_images.py @@ -21,7 +21,7 @@ def setUp(self): self.user = User.objects.create_user( email="test@example.com", password="password", first_name="Test", last_name="User" ) - self.quiz = Quiz.objects.create(title="Test Quiz", maintainer=self.user) + self.quiz = Quiz.objects.create(title="Test Quiz", creator=self.user, folder=self.user.root_folder) # Create a dummy uploaded image self.uploaded_image = UploadedImage.objects.create( diff --git a/quizzes/tests/test_library_endpoint.py b/quizzes/tests/test_library_endpoint.py new file mode 100644 index 0000000..19638ba --- /dev/null +++ b/quizzes/tests/test_library_endpoint.py @@ -0,0 +1,180 @@ +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) + + # user_a's root folder is auto-created by signal + self.folder_main = Folder.objects.create(name="Main", owner=self.user_a, parent=self.user_a.root_folder) + 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", creator=self.user_a, folder=self.folder_sub) + self.quiz_root = Quiz.objects.create(title="Root Quiz", creator=self.user_a, folder=self.user_a.root_folder) + + def test_root_shows_only_own_toplevel_items(self): + """GET /library returns items from user's root folder.""" + self.client.force_authenticate(user=self.user_a) + response = self.client.get(reverse("library-root")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + returned_ids = {str(item["id"]) for item in response.data["items"]} + # Root folder should contain: Main folder + Root Quiz + expected_ids = {str(self.folder_main.id), str(self.quiz_root.id)} + self.assertEqual(returned_ids, expected_ids) + + # Sub folder and hidden quiz should NOT appear at root level + self.assertNotIn(str(self.folder_sub.id), returned_ids) + self.assertNotIn(str(self.quiz_hidden.id), returned_ids) + + def test_shared_folder_visible_in_root(self): + """Shared folders are accessible and show their content.""" + 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_sub.id}) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + returned_names = {item.get("name") for item in response.data["items"] if item.get("type") == "quiz"} + self.assertIn("Hidden Quiz", returned_names) + + def test_study_group_sharing(self): + """Folder shared via study group is accessible.""" + SharedFolder.objects.create(folder=self.folder_main, study_group=self.group) + SharedFolder.objects.create(folder=self.folder_sub, study_group=self.group) + + 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) + returned_names = {item.get("name") for item in response.data["items"] if item.get("type") == "folder"} + self.assertIn("Sub", returned_names) + + def test_authorized_folder_access(self): + """Browsing a shared folder returns its contents.""" + 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) + returned_ids = {str(item["id"]) for item in response.data["items"]} + self.assertIn(str(self.folder_sub.id), returned_ids) + + def test_unauthorized_folder_access(self): + """User without access gets 403.""" + 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) + + def test_cascading_access_to_subfolder(self): + """Sharing a parent folder gives access to its subfolders without separate shares.""" + SharedFolder.objects.create(folder=self.folder_main, user=self.user_b) + + self.client.force_authenticate(user=self.user_b) + url = reverse("library-folder", kwargs={"folder_id": self.folder_sub.id}) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + returned_names = {item.get("name") for item in response.data["items"] if item.get("type") == "quiz"} + self.assertIn("Hidden Quiz", returned_names) + + +class LibraryBreadcrumbTests(APITestCase): + def setUp(self): + self.owner = User.objects.create_user( + email="owner@example.com", password="password123", first_name="Jan", last_name="Kowalski" + ) + self.viewer = User.objects.create_user( + email="viewer@example.com", password="password123", first_name="Anna", last_name="Nowak" + ) + + # owner's root folder auto-created ("Moje quizy") + self.folder_a = Folder.objects.create(name="A", owner=self.owner, parent=self.owner.root_folder) + self.folder_b = Folder.objects.create(name="B", owner=self.owner, parent=self.folder_a) + self.folder_c = Folder.objects.create(name="C", owner=self.owner, parent=self.folder_b) + + def test_owner_root_breadcrumbs(self): + """Owner viewing root folder gets path with just the root folder.""" + self.client.force_authenticate(user=self.owner) + response = self.client.get(reverse("library-root")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + path = response.data["path"] + self.assertEqual(len(path), 1) + self.assertEqual(path[0]["id"], str(self.owner.root_folder.id)) + self.assertEqual(path[0]["name"], "Moje quizy") + + def test_owner_nested_breadcrumbs(self): + """Owner viewing nested folder gets full path from root.""" + self.client.force_authenticate(user=self.owner) + url = reverse("library-folder", kwargs={"folder_id": self.folder_c.id}) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + path = response.data["path"] + self.assertEqual(len(path), 4) + self.assertEqual(path[0]["name"], "Moje quizy") + self.assertEqual(path[1]["name"], "A") + self.assertEqual(path[2]["name"], "B") + self.assertEqual(path[3]["name"], "C") + + def test_shared_user_breadcrumbs_start_at_shared_folder(self): + """Viewer with shared access sees breadcrumbs starting from shared folder.""" + SharedFolder.objects.create(folder=self.folder_a, user=self.viewer) + + self.client.force_authenticate(user=self.viewer) + url = reverse("library-folder", kwargs={"folder_id": self.folder_c.id}) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + path = response.data["path"] + # Should start from A (first directly shared ancestor), not root + self.assertEqual(len(path), 3) + self.assertEqual(path[0]["name"], "A") + self.assertEqual(path[1]["name"], "B") + self.assertEqual(path[2]["name"], "C") + + def test_shared_user_breadcrumbs_direct_access(self): + """Viewer viewing the directly shared folder gets just that folder in path.""" + SharedFolder.objects.create(folder=self.folder_b, user=self.viewer) + + self.client.force_authenticate(user=self.viewer) + url = reverse("library-folder", kwargs={"folder_id": self.folder_b.id}) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + path = response.data["path"] + self.assertEqual(len(path), 1) + self.assertEqual(path[0]["name"], "B") + + def test_response_has_path_and_items_keys(self): + """Response contains both 'path' and 'items' keys.""" + self.client.force_authenticate(user=self.owner) + response = self.client.get(reverse("library-root")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("path", response.data) + self.assertIn("items", response.data) diff --git a/quizzes/tests/test_metadata_action.py b/quizzes/tests/test_metadata_action.py index 0fbc010..f48fe20 100644 --- a/quizzes/tests/test_metadata_action.py +++ b/quizzes/tests/test_metadata_action.py @@ -17,8 +17,8 @@ class MetadataActionTestCase(APITestCase): def setUp(self): self.user = User.objects.create( - email="maintainer@example.com", - first_name="Maintainer", + email="creator@example.com", + first_name="Creator", last_name="User", student_number="123456", ) @@ -31,7 +31,8 @@ def setUp(self): # Create a public quiz with questions (needs 3 answers for preview) self.public_quiz = Quiz.objects.create( title="Public Quiz", - maintainer=self.user, + creator=self.user, + folder=self.user.root_folder, visibility=3, # Public ) q1 = Question.objects.create( @@ -80,23 +81,25 @@ def test_metadata_private_quiz_no_user(self): """Test that private quiz returns 403 without user_id.""" private_quiz = Quiz.objects.create( title="Private Quiz", - maintainer=self.user, + creator=self.user, + folder=self.user.root_folder, visibility=0, # Private ) url = self._get_metadata_url(private_quiz.id) response = self.client.get(url, HTTP_API_KEY="test-api-key") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_metadata_private_quiz_maintainer(self): - """Test that private quiz returns 200 for maintainer.""" + def test_metadata_private_quiz_creator(self): + """Test that private quiz returns 200 for creator.""" private_quiz = Quiz.objects.create( title="Private Quiz", - maintainer=self.user, + creator=self.user, + folder=self.user.root_folder, visibility=0, ) url = self._get_metadata_url(private_quiz.id) - # Authenticate as maintainer + # Authenticate as creator self.client.force_authenticate(user=self.user) response = self.client.get( url, @@ -109,7 +112,8 @@ def test_metadata_shared_quiz_access(self): """Test that shared quiz allows access to shared user.""" shared_quiz = Quiz.objects.create( title="Shared Quiz", - maintainer=self.user, + creator=self.user, + folder=self.user.root_folder, visibility=1, # Shared ) SharedQuiz.objects.create(quiz=shared_quiz, user=self.other_user) @@ -137,7 +141,8 @@ def test_metadata_shared_quiz_no_preview(self): """Test that shared quiz returns NO preview question even if requested.""" shared_quiz = Quiz.objects.create( title="Shared Quiz", - maintainer=self.user, + creator=self.user, + folder=self.user.root_folder, visibility=1, ) # Create valid question @@ -163,7 +168,8 @@ def test_preview_question_criteria_min_answers(self): """Test that preview question must have at least 3 answers.""" quiz = Quiz.objects.create( title="Criteria Quiz", - maintainer=self.user, + creator=self.user, + folder=self.user.root_folder, visibility=3, ) @@ -189,7 +195,8 @@ def test_preview_question_criteria_no_images(self): """Test that preview question must not have images.""" quiz = Quiz.objects.create( title="Image Quiz", - maintainer=self.user, + creator=self.user, + folder=self.user.root_folder, visibility=3, ) diff --git a/quizzes/tests/test_question_crud.py b/quizzes/tests/test_question_crud.py index 29a0b0e..47e8f24 100644 --- a/quizzes/tests/test_question_crud.py +++ b/quizzes/tests/test_question_crud.py @@ -24,8 +24,9 @@ def setUp(self): self.quiz = Quiz.objects.create( title="Test quiz", - maintainer=self.user, + creator=self.user, visibility=1, + folder=self.user.root_folder, ) self.question = Question.objects.create(quiz=self.quiz, order=1, text="Original Question", multiple=False) @@ -106,7 +107,9 @@ def test_smart_update_answers(self): self.assertTrue(self.question.answers.filter(text="Brand New Answer").exists()) def test_update_security_prevent_moving_quiz(self): - other_user_quiz = Quiz.objects.create(title="Other", maintainer=self.other_user) + other_user_quiz = Quiz.objects.create( + title="Other", creator=self.other_user, folder=self.other_user.root_folder + ) data = {"quiz": other_user_quiz.id, "text": "Hacked", "answers": []} response = self.client.patch(self.detail_url, data, format="json") diff --git a/quizzes/tests/test_question_types.py b/quizzes/tests/test_question_types.py index 48133da..4a75127 100644 --- a/quizzes/tests/test_question_types.py +++ b/quizzes/tests/test_question_types.py @@ -13,7 +13,9 @@ def setUp(self): ) self.client.force_authenticate(user=self.user) - self.quiz = Quiz.objects.create(title="Test Quiz", maintainer=self.user, visibility=2) + self.quiz = Quiz.objects.create( + title="Test Quiz", creator=self.user, folder=self.user.root_folder, visibility=2 + ) # Pytanie zamknięte self.closed_question = Question.objects.create( diff --git a/quizzes/tests/test_quiz_crud.py b/quizzes/tests/test_quiz_crud.py index a5cac64..500ffe9 100644 --- a/quizzes/tests/test_quiz_crud.py +++ b/quizzes/tests/test_quiz_crud.py @@ -64,7 +64,7 @@ def test_create_quiz_with_questions(self): # Verify quiz created quiz = Quiz.objects.get(id=response.data["id"]) self.assertEqual(quiz.title, "Test Quiz") - self.assertEqual(quiz.maintainer, self.user) + self.assertEqual(quiz.creator, self.user) # Verify questions self.assertEqual(quiz.questions.count(), 2) @@ -124,8 +124,8 @@ def test_create_quiz_with_explicit_ids(self): # --- READ --- def test_list_own_quizzes(self): """Test listing only returns user's own quizzes.""" - Quiz.objects.create(title="My Quiz", maintainer=self.user) - Quiz.objects.create(title="Other Quiz", maintainer=self.other_user) + Quiz.objects.create(title="My Quiz", creator=self.user, folder=self.user.root_folder) + Quiz.objects.create(title="Other Quiz", creator=self.other_user, folder=self.other_user.root_folder) url = reverse("quiz-list") response = self.client.get(url) @@ -136,7 +136,7 @@ def test_list_own_quizzes(self): def test_retrieve_quiz_with_questions(self): """Test retrieving a single quiz includes nested questions.""" - quiz = Quiz.objects.create(title="My Quiz", maintainer=self.user) + quiz = Quiz.objects.create(title="My Quiz", creator=self.user, folder=self.user.root_folder) q = Question.objects.create(quiz=quiz, order=1, text="Q1") Answer.objects.create(question=q, order=1, text="A1", is_correct=True) @@ -150,7 +150,7 @@ def test_retrieve_quiz_with_questions(self): # --- UPDATE --- def test_update_quiz_title(self): """Test updating quiz title.""" - quiz = Quiz.objects.create(title="Old Title", maintainer=self.user) + quiz = Quiz.objects.create(title="Old Title", creator=self.user, folder=self.user.root_folder) url = reverse("quiz-detail", kwargs={"pk": quiz.id}) response = self.client.patch(url, {"title": "New Title"}, format="json") @@ -161,7 +161,7 @@ def test_update_quiz_title(self): def test_update_quiz_add_question(self): """Test adding a question to an existing quiz.""" - quiz = Quiz.objects.create(title="Quiz", maintainer=self.user) + quiz = Quiz.objects.create(title="Quiz", creator=self.user, folder=self.user.root_folder) url = reverse("quiz-detail", kwargs={"pk": quiz.id}) data = { @@ -179,7 +179,7 @@ def test_update_quiz_add_question(self): def test_update_quiz_modify_existing_question(self): """Test modifying an existing question.""" - quiz = Quiz.objects.create(title="Quiz", maintainer=self.user) + quiz = Quiz.objects.create(title="Quiz", creator=self.user, folder=self.user.root_folder) question = Question.objects.create(quiz=quiz, order=1, text="Old Text") url = reverse("quiz-detail", kwargs={"pk": quiz.id}) @@ -193,7 +193,7 @@ def test_update_quiz_modify_existing_question(self): # --- DELETE --- def test_delete_quiz(self): """Test deleting a quiz.""" - quiz = Quiz.objects.create(title="To Delete", maintainer=self.user) + quiz = Quiz.objects.create(title="To Delete", creator=self.user, folder=self.user.root_folder) url = reverse("quiz-detail", kwargs={"pk": quiz.id}) response = self.client.delete(url) @@ -202,7 +202,7 @@ def test_delete_quiz(self): def test_cannot_delete_other_users_quiz(self): """Test that user cannot delete another user's quiz.""" - quiz = Quiz.objects.create(title="Other's Quiz", maintainer=self.other_user) + quiz = Quiz.objects.create(title="Other's Quiz", creator=self.other_user, folder=self.other_user.root_folder) url = reverse("quiz-detail", kwargs={"pk": quiz.id}) response = self.client.delete(url) @@ -216,6 +216,26 @@ def test_unauthenticated_cannot_create(self): response = self.client.post(url, {"title": "Test", "questions": []}, format="json") self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + def test_create_quiz_lands_in_root_folder(self): + """Quiz created via API is assigned to creator's root folder.""" + url = reverse("quiz-list") + data = {"title": "Auto Folder Quiz", "questions": []} + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + quiz = Quiz.objects.get(id=response.data["id"]) + self.assertEqual(quiz.folder_id, self.user.root_folder_id) + + def test_quiz_response_includes_folder(self): + """GET /quizzes/{id}/ response contains folder field.""" + quiz = Quiz.objects.create(title="Folder Quiz", creator=self.user, folder=self.user.root_folder) + url = reverse("quiz-detail", kwargs={"pk": quiz.id}) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("folder", response.data) + self.assertEqual(str(response.data["folder"]["id"]), str(self.user.root_folder_id)) + class QuizQuestionAnswerTestCase(APITestCase): """Comprehensive tests for question and answer management.""" @@ -228,7 +248,7 @@ def setUp(self): student_number="123456", ) self.client.force_authenticate(user=self.user) - self.quiz = Quiz.objects.create(title="Test Quiz", maintainer=self.user) + self.quiz = Quiz.objects.create(title="Test Quiz", creator=self.user, folder=self.user.root_folder) # --- ANSWER CORRECTNESS --- def test_change_answer_correctness(self): diff --git a/quizzes/tests/test_quiz_progress.py b/quizzes/tests/test_quiz_progress.py index 34abb5e..007e8d2 100644 --- a/quizzes/tests/test_quiz_progress.py +++ b/quizzes/tests/test_quiz_progress.py @@ -25,7 +25,7 @@ def setUp(self): self.client.force_authenticate(user=self.user) # Create quiz with questions - self.quiz = Quiz.objects.create(title="Test Quiz", maintainer=self.user) + self.quiz = Quiz.objects.create(title="Test Quiz", creator=self.user, folder=self.user.root_folder) self.q1 = Question.objects.create(quiz=self.quiz, order=1, text="Q1") self.a1_correct = Answer.objects.create(question=self.q1, order=1, text="Correct", is_correct=True) self.a1_wrong = Answer.objects.create(question=self.q1, order=2, text="Wrong", is_correct=False) @@ -147,7 +147,7 @@ def setUp(self): ) self.client.force_authenticate(user=self.user) - self.quiz = Quiz.objects.create(title="Test Quiz", maintainer=self.user) + self.quiz = Quiz.objects.create(title="Test Quiz", creator=self.user, folder=self.user.root_folder) self.q1 = Question.objects.create(quiz=self.quiz, order=1, text="Q1") self.a1_correct = Answer.objects.create(question=self.q1, order=1, text="Correct", is_correct=True) self.a1_wrong = Answer.objects.create(question=self.q1, order=2, text="Wrong", is_correct=False) @@ -235,7 +235,7 @@ def test_record_answer_invalid_question_uuid(self): def test_record_answer_question_not_in_quiz(self): """Test that question from another quiz returns 404.""" - other_quiz = Quiz.objects.create(title="Other Quiz", maintainer=self.user) + other_quiz = Quiz.objects.create(title="Other Quiz", creator=self.user, folder=self.user.root_folder) other_question = Question.objects.create(quiz=other_quiz, order=1, text="Other Q") url = reverse("quiz-record-answer", kwargs={"pk": self.quiz.id}) @@ -287,7 +287,7 @@ def test_record_answer_invalid_next_question_uuid(self): def test_record_answer_next_question_not_in_quiz(self): """Test that next_question from another quiz returns 400.""" - other_quiz = Quiz.objects.create(title="Other Quiz", maintainer=self.user) + other_quiz = Quiz.objects.create(title="Other Quiz", creator=self.user, folder=self.user.root_folder) other_question = Question.objects.create(quiz=other_quiz, order=1, text="Other Q") url = reverse("quiz-record-answer", kwargs={"pk": self.quiz.id}) diff --git a/quizzes/tests/test_quiz_serializer_extras.py b/quizzes/tests/test_quiz_serializer_extras.py index 453e7c5..2e75dee 100644 --- a/quizzes/tests/test_quiz_serializer_extras.py +++ b/quizzes/tests/test_quiz_serializer_extras.py @@ -12,7 +12,7 @@ def setUp(self): email="test@example.com", password="password", first_name="Test", last_name="User" ) self.settings = UserSettings.objects.create(user=self.user, initial_reoccurrences=3) - self.quiz = Quiz.objects.create(title="Test Quiz", maintainer=self.user) + self.quiz = Quiz.objects.create(title="Test Quiz", creator=self.user, folder=self.user.root_folder) self.question = Question.objects.create(quiz=self.quiz, order=1, text="Test Question") self.url = reverse("quiz-detail", kwargs={"pk": self.quiz.id}) diff --git a/quizzes/tests/test_record_answer_updates_session.py b/quizzes/tests/test_record_answer_updates_session.py index ed68824..3bd0dc7 100644 --- a/quizzes/tests/test_record_answer_updates_session.py +++ b/quizzes/tests/test_record_answer_updates_session.py @@ -16,7 +16,7 @@ def setUp(self): ) self.client.force_authenticate(user=self.user) - self.quiz = Quiz.objects.create(title="Test Quiz", maintainer=self.user) + self.quiz = Quiz.objects.create(title="Test Quiz", creator=self.user, folder=self.user.root_folder) self.question = Question.objects.create(quiz=self.quiz, order=1, text="Question 1") self.answer = Answer.objects.create(question=self.question, order=1, text="Answer 1", is_correct=True) diff --git a/quizzes/urls.py b/quizzes/urls.py index 021599f..693cd0e 100644 --- a/quizzes/urls.py +++ b/quizzes/urls.py @@ -4,6 +4,7 @@ from quizzes.views import ( FolderViewSet, LastUsedQuizzesView, + LibraryView, QuestionViewSet, QuizViewSet, RandomQuestionView, @@ -22,6 +23,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/", LibraryView.as_view(), name="library-root"), + path("library//", LibraryView.as_view(), name="library-folder"), path( "report-question-issue/", ReportQuestionIssueView.as_view(), diff --git a/quizzes/views.py b/quizzes/views.py index dd5af4f..c3227cd 100644 --- a/quizzes/views.py +++ b/quizzes/views.py @@ -39,15 +39,16 @@ IsFolderOwner, IsInternalApiRequest, IsQuestionReadable, - IsQuizMaintainer, - IsQuizMaintainerOrCollaboratorOrReadOnly, + IsQuizCreator, + IsQuizCreatorOrCollaboratorOrReadOnly, IsQuizReadable, - IsSharedQuizMaintainerOrReadOnly, + IsSharedQuizCreatorOrReadOnly, ) from quizzes.serializers import ( AnswerRecordSerializer, AnswerSerializer, FolderSerializer, + LibraryItemSerializer, MoveFolderSerializer, MoveQuizSerializer, QuestionSerializer, @@ -159,7 +160,7 @@ def get(self, request, *args, **kwargs): def get_queryset(self): return ( Quiz.objects.filter(sessions__user=self.request.user, sessions__is_active=True) - .select_related("maintainer") + .select_related("creator", "folder", "folder__owner") .order_by("-sessions__updated_at") .distinct() ) @@ -198,15 +199,15 @@ def get(self, request): if not query: return Response({"error": "Query parameter is required"}, status=400) - user_quizzes = Quiz.objects.filter(maintainer=request.user, title__icontains=query).select_related("maintainer") + user_quizzes = Quiz.objects.filter(creator=request.user, title__icontains=query).select_related("creator") shared_quizzes = SharedQuiz.objects.filter( user=request.user, quiz__title__icontains=query, quiz__visibility__gte=1 - ).select_related("quiz__maintainer") + ).select_related("quiz__creator") group_quizzes = SharedQuiz.objects.filter( study_group__in=request.user.study_groups.all(), quiz__title__icontains=query, quiz__visibility__gte=1, - ).select_related("quiz__maintainer") + ).select_related("quiz__creator") result = { "user_quizzes": QuizSearchResultSerializer(user_quizzes, many=True, context={"request": request}).data, @@ -219,7 +220,7 @@ def get(self, request): } if request.user.account_type == AccountType.STUDENT: - public_quizzes = Quiz.objects.filter(title__icontains=query, visibility__gte=3).select_related("maintainer") + public_quizzes = Quiz.objects.filter(title__icontains=query, visibility__gte=3).select_related("creator") result["public_quizzes"] = QuizSearchResultSerializer( public_quizzes, many=True, context={"request": request} ).data @@ -233,7 +234,7 @@ def get(self, request): # but will allow to view all quizzes when retrieving a single quiz. # This is by design, if the user wants to view shared quizzes, # they should use the SharedQuizViewSet and for public quizzes they should use the api_search_quizzes view. -# It will also allow to create, update and delete quizzes only if the user is the maintainer of the quiz. +# It will also allow to create, update and delete quizzes only if the user is the creator of the quiz. @extend_schema_view( retrieve=extend_schema( parameters=[ @@ -255,7 +256,7 @@ class QuizViewSet(viewsets.ModelViewSet): serializer_class = QuizSerializer permission_classes = [ permissions.IsAuthenticatedOrReadOnly, - IsQuizMaintainerOrCollaboratorOrReadOnly, + IsQuizCreatorOrCollaboratorOrReadOnly, IsQuizReadable, ] @@ -266,7 +267,7 @@ def get_queryset(self): if not user.is_authenticated: return Quiz.objects.none() - return Quiz.objects.filter(maintainer=user) + return Quiz.objects.filter(creator=user).select_related("creator", "folder", "folder__owner") queryset = Quiz.objects.all() @@ -317,7 +318,7 @@ def metadata(self, request, pk=None): Get quiz metadata for Next.js server-side rendering. Access Rules: - - Private (0): Only maintainer + - Private (0): Only creator - Shared (1): Everyone but without preview question and always anonymous - Unlisted/Public (≥2): Everyone @@ -333,7 +334,7 @@ def metadata(self, request, pk=None): user = request.user - if not (quiz.visibility >= 1 or user == quiz.maintainer): + if not (quiz.visibility >= 1 or (user.is_authenticated and user.owns_quiz_via_folder(quiz))): raise PermissionDenied("You do not have permission to access this quiz metadata.") data = QuizMetaDataSerializer(quiz, context={"request": request}).data @@ -358,21 +359,21 @@ def metadata(self, request, pk=None): if quiz.visibility == 1: data["is_anonymous"] = True - data["maintainer"] = None + data["creator"] = None data["question_count"] = question_count return Response(data) def perform_create(self, serializer): - serializer.save(maintainer=self.request.user) + serializer.save(creator=self.request.user, folder=self.request.user.root_folder) def perform_update(self, serializer): serializer.save(version=serializer.instance.version + 1) def perform_destroy(self, instance): - if instance.maintainer != self.request.user: - raise PermissionDenied("Only the maintainer can delete this quiz") + if instance.folder.owner != self.request.user: + raise PermissionDenied("Only the folder owner can delete this quiz") instance.delete() def get_serializer_class(self): @@ -398,7 +399,7 @@ def update(self, request, *args, **kwargs): detail=True, methods=["post"], serializer_class=MoveQuizSerializer, - permission_classes=[permissions.IsAuthenticated, IsQuizMaintainer], + permission_classes=[permissions.IsAuthenticated, IsQuizCreator], ) def move(self, request, pk=None): quiz = self.get_object() @@ -575,7 +576,8 @@ def copy(self, request, pk=None): new_quiz = Quiz.objects.create( title=new_title, description=original_quiz.description, - maintainer=request.user, + creator=request.user, + folder=request.user.root_folder, ) original_questions = list(original_quiz.questions.all()) @@ -623,7 +625,7 @@ def copy(self, request, pk=None): class SharedQuizViewSet(viewsets.ModelViewSet): queryset = SharedQuiz.objects.all() serializer_class = SharedQuizSerializer - permission_classes = [permissions.IsAuthenticated, IsSharedQuizMaintainerOrReadOnly] + permission_classes = [permissions.IsAuthenticated, IsSharedQuizCreatorOrReadOnly] def get_queryset(self): _filter = ( @@ -632,7 +634,8 @@ def get_queryset(self): study_group__in=self.request.user.study_groups.all(), quiz__visibility__gte=1, ) - | Q(quiz__maintainer=self.request.user) + | Q(quiz__creator=self.request.user) + | Q(quiz__folder__owner=self.request.user) ) if self.request.query_params.get("quiz"): _filter &= Q(quiz_id=self.request.query_params.get("quiz")) @@ -691,7 +694,7 @@ def post(self, request): if not quiz: return Response({"error": "Quiz not found"}, status=404) - if request.user == quiz.maintainer: + if request.user == quiz.creator: return Response( {"error": "You cannot report issues with your own questions"}, status=400, @@ -712,7 +715,7 @@ def post(self, request): f"{escape(data.get('issue'))}" ) - recipient_list = [quiz.maintainer.email] + recipient_list = [quiz.creator.email] reply_to = [request.user.email] try: @@ -742,7 +745,7 @@ class QuestionViewSet( ): serializer_class = QuestionSerializer queryset = Question.objects.all() - permission_classes = [permissions.IsAuthenticated, IsQuizMaintainerOrCollaboratorOrReadOnly, IsQuestionReadable] + permission_classes = [permissions.IsAuthenticated, IsQuizCreatorOrCollaboratorOrReadOnly, IsQuestionReadable] @extend_schema( responses={ @@ -787,7 +790,15 @@ def get_queryset(self): return Folder.objects.filter(owner=self.request.user).order_by("-created_at") def perform_create(self, serializer): - serializer.save(owner=self.request.user) + if not serializer.validated_data.get("parent"): + serializer.save(owner=self.request.user, parent=self.request.user.root_folder) + else: + serializer.save(owner=self.request.user) + + def perform_destroy(self, instance): + if hasattr(instance, "root_owner"): + raise PermissionDenied("Cannot delete root folder.") + instance.delete() @action(detail=True, methods=["post"], serializer_class=MoveFolderSerializer) def move(self, request, pk=None): @@ -799,4 +810,125 @@ def move(self, request, pk=None): folder.save() return Response({"status": "Folder moved successfully"}) - return Response(serializer.errors) + return Response(serializer.errors, status=400) + + +class LibraryView(APIView): + permission_classes = [IsAuthenticated] + + def _access_predicate(self, user): + return Q(owner=user) | Q(shares__user=user) | Q(shares__study_group__in=user.study_groups.all()) + + def _has_access(self, user, folder_id): + # Precompute IDs of all folders the user can directly access. + accessible_folder_ids = set(Folder.objects.filter(self._access_predicate(user)).values_list("id", flat=True)) + + # Direct access to this folder. + if folder_id in accessible_folder_ids: + return True + + # Walk up the ancestor chain using lightweight queries and check access in-memory. + folder = Folder.objects.filter(id=folder_id).only("id", "parent_id").first() + if not folder: + return False + + current_parent_id = folder.parent_id + while current_parent_id: + if current_parent_id in accessible_folder_ids: + return True + parent = Folder.objects.filter(id=current_parent_id).only("id", "parent_id").first() + if not parent: + break + current_parent_id = parent.parent_id + return False + + def _get_subfolders(self, user, folder_id): + return Folder.objects.filter(parent_id=folder_id).distinct().order_by("-created_at") + + def _get_quizzes(self, user, folder_id): + return Quiz.objects.filter(folder_id=folder_id).distinct().order_by("-created_at") + + def _build_breadcrumbs(self, user, folder_id): + try: + folder = Folder.objects.get(id=folder_id) + except Folder.DoesNotExist: + return [] + + chain = [] + current = folder + while current: + chain.append(current) + current = current.parent + + chain.reverse() + + accessible_ids = set( + Folder.objects.filter( + self._access_predicate(user), + id__in=[f.id for f in chain], + ).values_list("id", flat=True) + ) + + for i, f in enumerate(chain): + if f.id in accessible_ids: + return [{"id": str(entry.id), "name": entry.name} for entry in chain[i:]] + + return [] + + @extend_schema( + summary="List library contents", + parameters=[ + OpenApiParameter( + name="folder_id", + type=str, + location=OpenApiParameter.PATH, + required=False, + description="UUID of the folder to browse. Defaults to the user's root folder.", + ), + ], + responses={ + 200: OpenApiResponse( + description="Folder contents with breadcrumb path", + response={ + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "format": "uuid"}, + "name": {"type": "string"}, + }, + }, + "description": "Breadcrumb path from the topmost accessible folder to the current folder.", + }, + "items": { + "type": "array", + "items": {"type": "object"}, + "description": "List of folders and quizzes in the current folder.", + }, + }, + }, + ), + 403: OpenApiResponse(description="No permission to access this folder"), + }, + ) + def get(self, request, folder_id=None): + user = request.user + + if folder_id is None: + folder_id = user.root_folder_id + + if not self._has_access(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( + { + "path": self._build_breadcrumbs(user, folder_id), + "items": LibraryItemSerializer(items, many=True, context={"request": request}).data, + } + ) diff --git a/uploads/tests.py b/uploads/tests.py index 7217a0c..9e90d98 100644 --- a/uploads/tests.py +++ b/uploads/tests.py @@ -199,7 +199,7 @@ def test_link_image_to_question(self): image_id = response.data["id"] # 2. Create Quiz with Question using image_upload - quiz = Quiz.objects.create(title="Test Quiz", maintainer=self.user) + quiz = Quiz.objects.create(title="Test Quiz", creator=self.user, folder=self.user.root_folder) question_data = {"order": 1, "text": "Question with image", "image_upload": image_id, "answers": []} @@ -217,7 +217,7 @@ def test_link_image_to_question(self): def test_link_external_url_to_question(self): """Test using external URL for question image.""" - quiz = Quiz.objects.create(title="Test Quiz", maintainer=self.user) + quiz = Quiz.objects.create(title="Test Quiz", creator=self.user, folder=self.user.root_folder) external_url = "https://example.com/image.jpg" quiz_url = reverse("quiz-detail", args=[quiz.id]) @@ -245,7 +245,7 @@ def test_copy_quiz_shares_image(self): image_id = response.data["id"] upload_obj = UploadedImage.objects.get(id=image_id) - quiz = Quiz.objects.create(title="Original", maintainer=self.user) + quiz = Quiz.objects.create(title="Original", creator=self.user, folder=self.user.root_folder) q1 = Question.objects.create(quiz=quiz, order=1, text="Q1", image_upload=upload_obj) # 2. Copy the quiz @@ -291,7 +291,7 @@ def test_cleanup_keeps_used_images(self): uploaded_img.save() # Link it to a question - quiz = Quiz.objects.create(title="Q", maintainer=self.user) + quiz = Quiz.objects.create(title="Q", creator=self.user, folder=self.user.root_folder) Question.objects.create(quiz=quiz, order=1, text="Q", image_upload=uploaded_img) # 2. Run cleanup @@ -324,7 +324,7 @@ def test_image_upload_priority_over_url(self): response = self.client.post(self.upload_url, {"image": img}, format="multipart") upload_obj = UploadedImage.objects.get(id=response.data["id"]) - quiz = Quiz.objects.create(title="Test", maintainer=self.user) + quiz = Quiz.objects.create(title="Test", creator=self.user, folder=self.user.root_folder) question = Question.objects.create( quiz=quiz, order=1, diff --git a/users/migrations/0009_user_root_folder.py b/users/migrations/0009_user_root_folder.py new file mode 100644 index 0000000..1f9d4d6 --- /dev/null +++ b/users/migrations/0009_user_root_folder.py @@ -0,0 +1,24 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0008_user_account_level"), + ("quizzes", "0016_quizsession_updated_at"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="root_folder", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="root_owner", + to="quizzes.folder", + ), + ), + ] diff --git a/users/models.py b/users/models.py index 8126756..0165f42 100644 --- a/users/models.py +++ b/users/models.py @@ -97,6 +97,14 @@ class User(AbstractBaseUser, PermissionsMixin): is_superuser = BooleanField(default=False) is_staff = BooleanField(default=False) + root_folder = models.OneToOneField( + "quizzes.Folder", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="root_owner", + ) + hide_profile = BooleanField( default=False, help_text="Hide profile from other users in search and leaderboards," @@ -143,6 +151,20 @@ def is_student_and_not_staff(self) -> bool: and self.staff_status is StaffStatus.NOT_STAFF.value ) + def owns_quiz_via_folder(self, quiz) -> bool: + """ + Return True if the user owns the given quiz via its folder. + + This checks `quiz.folder.owner == self` and does not look at `quiz.creator`. + """ + folder = getattr(quiz, "folder", None) + owner = getattr(folder, "owner", None) + return owner == self + + def is_creator(self, quiz) -> bool: + """Deprecated: use owns_quiz_via_folder() instead.""" + return self.owns_quiz_via_folder(quiz) + @property def photo(self) -> str | None: return self.overriden_photo_url or self.photo_url diff --git a/users/services.py b/users/services.py index e54d30c..089fa18 100644 --- a/users/services.py +++ b/users/services.py @@ -14,7 +14,7 @@ def migrate_guest_to_user(guest_id: str, target_user: User) -> bool: Migrate all data from a guest account to the target user account, then delete the guest. Transfers: - - Quizzes (maintainer) + - Quizzes (creator) - Quiz sessions (where target doesn't already have one for that quiz) - Folders (owner) @@ -46,7 +46,7 @@ def migrate_guest_to_user(guest_id: str, target_user: User) -> bool: with transaction.atomic(): from quizzes.models import Folder, Quiz, QuizSession - Quiz.objects.filter(maintainer=guest).update(maintainer=target_user) + Quiz.objects.filter(creator=guest).update(creator=target_user) guest_active_sessions = {s.quiz_id: s for s in QuizSession.objects.filter(user=guest, is_active=True)} target_active_sessions = { @@ -63,7 +63,18 @@ def migrate_guest_to_user(guest_id: str, target_user: User) -> bool: QuizSession.objects.filter(user=guest).update(user=target_user) - Folder.objects.filter(owner=guest).update(owner=target_user) + guest_root = guest.root_folder + target_root = target_user.root_folder + + if guest_root and target_root: + Quiz.objects.filter(folder=guest_root).update(folder=target_root) + Folder.objects.filter(parent=guest_root).update(parent=target_root, owner=target_user) + Folder.objects.filter(owner=guest).exclude(pk=guest_root.pk).update(owner=target_user) + guest.root_folder = None + guest.save(update_fields=["root_folder"]) + guest_root.delete() + else: + Folder.objects.filter(owner=guest).update(owner=target_user) guest.delete() diff --git a/users/tests/test_guest.py b/users/tests/test_guest.py index a5df8d5..fe4c947 100644 --- a/users/tests/test_guest.py +++ b/users/tests/test_guest.py @@ -35,17 +35,17 @@ def setUp(self): def test_migrate_quizzes(self): """Quizzes owned by the guest are transferred to the target user.""" - quiz = Quiz.objects.create(title="Guest Quiz", maintainer=self.guest) + quiz = Quiz.objects.create(title="Guest Quiz", creator=self.guest, folder=self.guest.root_folder) result = migrate_guest_to_user(str(self.guest.id), self.target_user) self.assertTrue(result) quiz.refresh_from_db() - self.assertEqual(quiz.maintainer, self.target_user) + self.assertEqual(quiz.creator, self.target_user) def test_migrate_sessions_no_conflict(self): """Sessions are transferred when the target has no session for that quiz.""" - quiz = Quiz.objects.create(title="Quiz", maintainer=self.target_user) + quiz = Quiz.objects.create(title="Quiz", creator=self.target_user, folder=self.target_user.root_folder) QuizSession.objects.create(quiz=quiz, user=self.guest, is_active=True) result = migrate_guest_to_user(str(self.guest.id), self.target_user) @@ -56,7 +56,7 @@ def test_migrate_sessions_no_conflict(self): def test_migrate_sessions_with_conflict_archives_older(self): """When both guest and target have an active session for the same quiz, the older one is archived.""" - quiz = Quiz.objects.create(title="Quiz", maintainer=self.target_user) + quiz = Quiz.objects.create(title="Quiz", creator=self.target_user, folder=self.target_user.root_folder) guest_session = QuizSession.objects.create(quiz=quiz, user=self.guest, is_active=True) target_session = QuizSession.objects.create(quiz=quiz, user=self.target_user, is_active=True) @@ -193,7 +193,7 @@ def setUp(self): student_number="333333", ) self.guest = User.objects.create_guest_user() - self.quiz = Quiz.objects.create(title="Guest Quiz", maintainer=self.guest) + self.quiz = Quiz.objects.create(title="Guest Quiz", creator=self.guest, folder=self.guest.root_folder) def test_otp_login_migrates_guest_data(self): """OTP login with valid guest_id migrates quizzes to the logged-in user when authenticated as guest.""" @@ -209,7 +209,7 @@ def test_otp_login_migrates_guest_data(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.quiz.refresh_from_db() - self.assertEqual(self.quiz.maintainer, self.user) + self.assertEqual(self.quiz.creator, self.user) self.assertFalse(User.objects.filter(id=self.guest.id).exists()) def test_otp_login_without_guest_id_skips_migration(self): @@ -224,7 +224,7 @@ def test_otp_login_without_guest_id_skips_migration(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.quiz.refresh_from_db() - self.assertEqual(self.quiz.maintainer, self.guest) # unchanged + self.assertEqual(self.quiz.creator, self.guest) # unchanged class GuestMigrationOnLinkLoginTestCase(APITestCase): @@ -238,7 +238,7 @@ def setUp(self): student_number="333333", ) self.guest = User.objects.create_guest_user() - self.quiz = Quiz.objects.create(title="Guest Quiz", maintainer=self.guest) + self.quiz = Quiz.objects.create(title="Guest Quiz", creator=self.guest, folder=self.guest.root_folder) def test_link_login_migrates_guest_data(self): """Link login with valid guest_id migrates quizzes to the logged-in user when authenticated as guest.""" @@ -254,7 +254,7 @@ def test_link_login_migrates_guest_data(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.quiz.refresh_from_db() - self.assertEqual(self.quiz.maintainer, self.user) + self.assertEqual(self.quiz.creator, self.user) self.assertFalse(User.objects.filter(id=self.guest.id).exists()) def test_link_login_without_guest_id_skips_migration(self): @@ -269,4 +269,4 @@ def test_link_login_without_guest_id_skips_migration(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.quiz.refresh_from_db() - self.assertEqual(self.quiz.maintainer, self.guest) # unchanged + self.assertEqual(self.quiz.creator, self.guest) # unchanged diff --git a/users/views.py b/users/views.py index d0b4c1f..9ae24c1 100644 --- a/users/views.py +++ b/users/views.py @@ -998,9 +998,9 @@ def post(self, request): except User.DoesNotExist: return Response({"error": "User to transfer quizzes to not found"}, status=404) - quizzes = Quiz.objects.filter(maintainer=request.user) + quizzes = Quiz.objects.filter(creator=request.user) for quiz in quizzes: - quiz.maintainer = transfer_to_user + quiz.creator = transfer_to_user quiz.save() QuizSession.objects.filter(user=request.user).delete()