From 991bea320a35c019cf372baa792d14c6752939be Mon Sep 17 00:00:00 2001
From: Paul Smith <3070332s@student.gla.ac.uk>
Date: Mon, 24 Mar 2025 18:20:52 +0000
Subject: [PATCH] Save and load searches

---
 flashcard/templates/flashcard/deck_list.html  | 40 ++++++++++--
 flashcard/templates/flashcard/quiz_take.html  | 10 +--
 flashcard/templatetags/search_tags.py         | 13 ++++
 flashcard/views.py                            | 65 ++++++++++---------
 ...2_search_search_query_search_subscribed.py | 23 +++++++
 search/models.py                              |  7 +-
 user/templates/user/logged_out.html           | 21 +++---
 7 files changed, 129 insertions(+), 50 deletions(-)
 create mode 100644 flashcard/templatetags/search_tags.py
 create mode 100644 search/migrations/0002_search_search_query_search_subscribed.py

diff --git a/flashcard/templates/flashcard/deck_list.html b/flashcard/templates/flashcard/deck_list.html
index 249dce9..6a64611 100644
--- a/flashcard/templates/flashcard/deck_list.html
+++ b/flashcard/templates/flashcard/deck_list.html
@@ -1,5 +1,6 @@
 {% extends 'base.html' %}
 {% load form_tags %}
+{% load search_tags %}
 
 {% block title %}Deck List{% endblock %}
 
@@ -12,9 +13,9 @@
                     <h5>Tools</h5><br>
                     {% if user.is_authenticated %}
                         <a href="{% url 'deck_create' %}" class="btn btn-primary w-100 mb-3">Create New Deck</a>
+                        <button type="button" class="btn btn-primary w-100 mb-3" data-bs-toggle="modal" data-bs-target="#saveSearchModal">Save</button>
+                        <button class="btn btn-secondary w-100 mb-3" disabled>Edit</button>
                     {% endif %}
-                    <button class="btn btn-secondary w-100 mb-3" disabled>Save</button>
-                    <button class="btn btn-secondary w-100 mb-3" disabled>Edit</button>
                 </div>
             </div>
 
@@ -26,7 +27,7 @@
                     <!-- Search Form -->
                     <form method="get" class="mb-4">
                         <div class="input-group">
-                            <input type="text" name="q" value="{{ search_query }}" class="form-control" placeholder="Search subscribed decks by name or description">
+                            <input type="text" name="q" value="{{ search_query|default:'' }}" class="form-control" placeholder="Search subscribed decks by name or description">
                             <input type="text" name="label" value="{{ label_filter }}" class="form-control" placeholder="Filter by label (comma-separated)">
                             <input type="text" name="author" value="{{ author_filter }}" class="form-control" placeholder="Filter by author">
                             <button type="submit" class="btn btn-primary">Search</button>
@@ -88,7 +89,7 @@
                         <ul class="list-group">
                             {% for search in saved_searches %}
                                 <li class="list-group-item">
-                                    <a href="{% url 'deck_list' %}?q={{ search.name }}&label={{ search.label.all|join:',' }}&author={{ search.author.all|join:',' }}">{{ search.name }}</a>
+                                    <a href="{% url 'deck_list' %}?q={{ search.search_query|default:''|urlencode }}&label={{ search.label.all|join:','|urlencode }}&author={{ search.author.all|iterate:'user.username'|join:','|urlencode }}&subscribed={{ search.subscribed|lower }}">{{ search.name }}</a>
                                 </li>
                             {% endfor %}
                         </ul>
@@ -105,4 +106,35 @@
             </div>
         </div>
     </div>
+
+    <!-- Modal for Saving Search -->
+    <div class="modal fade" id="saveSearchModal" tabindex="-1" aria-labelledby="saveSearchModalLabel" aria-hidden="true">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title" id="saveSearchModalLabel">Save Search</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                </div>
+                <form method="post" action="{% url 'deck_list' %}">
+                    {% csrf_token %}
+                    <div class="modal-body">
+                        <div class="mb-3">
+                            <label for="searchName" class="form-label">Search Name</label>
+                            <input type="text" class="form-control" id="searchName" name="search_name" placeholder="Enter a name for your search" required>
+                        </div>
+                        <!-- Hidden inputs to pass current search filters -->
+                        <input type="hidden" name="q" value="{{ search_query }}">
+                        <input type="hidden" name="label" value="{{ label_filter }}">
+                        <input type="hidden" name="author" value="{{ author_filter }}">
+                        <input type="hidden" name="subscribed" value="{{ subscribed_filter }}">
+                        <input type="hidden" name="save_search" value="true">
+                    </div>
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
+                        <button type="submit" class="btn btn-primary">Save Search</button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
 {% endblock %}
\ No newline at end of file
diff --git a/flashcard/templates/flashcard/quiz_take.html b/flashcard/templates/flashcard/quiz_take.html
index 8d2ef75..2c9a85e 100644
--- a/flashcard/templates/flashcard/quiz_take.html
+++ b/flashcard/templates/flashcard/quiz_take.html
@@ -15,20 +15,22 @@
         {% endfor %}
     {% endif %}
 
+    <p class="text-muted">Debug: progress_percentage = "{{ progress_percentage }}"</p>
+
     <div class="progress mb-3">
         <div class="progress-bar" role="progressbar" 
-             style="width: {{ progress_percentage }}%;" 
-             aria-valuenow="{{ question_index|add:1 }}" 
+             style="width: {{ progress_percentage|default:0 }}%;" 
+             aria-valuenow="{{ question_index|add:0 }}" 
              aria-valuemin="1" 
              aria-valuemax="{{ total_questions }}">
-            Question {{ question_index|add:1 }} of {{ total_questions }}
+            Question {{ question_index|add:0 }} of {{ total_questions }}
         </div>
     </div>
 
     <div class="question-list mb-4">
         {% for qa in question_attempts %}
             <a href="{% url 'take_quiz' attempt_id=quiz_attempt.id question_index=forloop.counter0 %}"
-               class="btn btn-sm {% if qa.answer or qa.choice %}btn-dark{% else %}btn-outline-secondary{% endif %} me-2">
+               class="btn btn-sm {% if forloop.counter0 == question_index %}btn-primary{% elif qa.answer or qa.choice %}btn-dark{% else %}btn-outline-secondary{% endif %} me-2">
                 {{ forloop.counter }}
             </a>
         {% endfor %}
diff --git a/flashcard/templatetags/search_tags.py b/flashcard/templatetags/search_tags.py
new file mode 100644
index 0000000..355628d
--- /dev/null
+++ b/flashcard/templatetags/search_tags.py
@@ -0,0 +1,13 @@
+from django import template
+from functools import reduce
+
+register = template.Library()
+
+@register.filter
+def iterate(queryset, attr):
+    """Extracts a specific attribute (including nested attributes) from a queryset and returns a list."""
+    def get_nested_attr(obj, attrs):
+        return reduce(getattr, attrs, obj)
+    
+    attr_parts = attr.split('.')
+    return [get_nested_attr(obj, attr_parts) for obj in queryset if obj]
\ No newline at end of file
diff --git a/flashcard/views.py b/flashcard/views.py
index c06aec1..8a0b146 100644
--- a/flashcard/views.py
+++ b/flashcard/views.py
@@ -19,7 +19,6 @@ logger = logging.getLogger(__name__)
 def help_view(request):
     return render(request, "flashcard/help.html")
 
-
 class DeckCreateView(LoginRequiredMixin, CreateView):
     model = Deck
     form_class = DeckForm
@@ -114,13 +113,11 @@ class DeckDetailView(LoginRequiredMixin, DetailView):
         if quiz:
             questions = Question.objects.filter(quiz=quiz).prefetch_related('choices')
             total_questions = questions.count()
-            # Sample questions (one per type)
             context['sample_multiple_choice'] = questions.filter(question_type=QuestionType.MULTIPLE_CHOICE).first()
             context['sample_true_false'] = questions.filter(question_type=QuestionType.TRUE_FALSE).first()
             context['sample_short_answer'] = questions.filter(question_type=QuestionType.SHORT_ANSWER).first()
-            # Quiz attempts list
             quiz_attempts = QuizAttempt.objects.filter(quiz=quiz, profile=profile).order_by('-id')
-            context['quiz_attempts'] = quiz_attempts  # Pass full queryset instead of count
+            context['quiz_attempts'] = quiz_attempts
             highest_grade = quiz_attempts.filter(is_completed=True).aggregate(Max('total_grade'))['total_grade__max'] or 0
             context['highest_grade'] = highest_grade
             context['total_questions'] = total_questions
@@ -169,23 +166,18 @@ class DeckListView(ListView):
     context_object_name = 'decks'
 
     def get_queryset(self):
-        """
-        Returns a queryset of decks, optionally filtered by search criteria or subscription status.
-        """
         queryset = Deck.objects.all().select_related('creator')
         search_query = self.request.GET.get('q', '').strip()
         label_filter = self.request.GET.get('label', '').strip()
         author_filter = self.request.GET.get('author', '').strip()
-        subscribed_filter = self.request.GET.get('subscribed', '').lower() == 'true'  # New filter
+        subscribed_filter = self.request.GET.get('subscribed', '').lower() == 'true'
         
-        # Apply subscription filter first if active and user is authenticated
         if subscribed_filter and self.request.user.is_authenticated:
             profile = self.request.user.profile
             queryset = queryset.filter(
                 Q(subscribers=profile) | Q(creator=profile)
             )
         
-        # Apply other filters only if provided
         if search_query:
             queryset = queryset.filter(
                 Q(name__icontains=search_query) |
@@ -215,11 +207,10 @@ class DeckListView(ListView):
         else:
             context['saved_searches'] = []
         
-        # Pass current search values to template
         context['search_query'] = self.request.GET.get('q', '')
         context['label_filter'] = self.request.GET.get('label', '')
         context['author_filter'] = self.request.GET.get('author', '')
-        context['subscribed_filter'] = self.request.GET.get('subscribed', '')  # Add to context
+        context['subscribed_filter'] = self.request.GET.get('subscribed', '')
         return context
 
     def post(self, request, *args, **kwargs):
@@ -230,6 +221,7 @@ class DeckListView(ListView):
         deck_id = request.POST.get('deck_id')
         
         if deck_id:
+            # Existing subscribe/unsubscribe logic
             deck = get_object_or_404(Deck, pk=deck_id)
             if profile == deck.creator:
                 messages.error(request, "Creators cannot subscribe to their own deck.")
@@ -249,15 +241,24 @@ class DeckListView(ListView):
                     messages.info(request, 'You are not subscribed.')
         
         elif 'save_search' in request.POST:
+            # Save search logic
+            search_name = request.POST.get('search_name', '').strip()
             search_query = request.POST.get('q', '').strip()
             label_filter = request.POST.get('label', '').strip()
             author_filter = request.POST.get('author', '').strip()
+            subscribed_filter = request.POST.get('subscribed', '').lower() == 'true'
             
-            if search_query or label_filter or author_filter:
+            if not search_name:
+                messages.error(request, 'Please provide a name for your search.')
+            elif not (search_query or label_filter or author_filter or subscribed_filter):
+                messages.error(request, 'No search criteria to save. Please enter at least one filter.')
+            else:
                 search = Search.objects.create(
                     profile=profile,
-                    name=search_query,
-                    type=''
+                    name=search_name,
+                    type='',
+                    search_query=search_query,  # Save the q parameter
+                    subscribed=subscribed_filter  # Save the subscribed checkbox state
                 )
                 if label_filter:
                     labels = [label.strip() for label in label_filter.split(',') if label.strip()]
@@ -265,11 +266,13 @@ class DeckListView(ListView):
                         label, _ = Label.objects.get_or_create(name=label_name)
                         search.label.add(label)
                 if author_filter:
-                    search.author.add(Profile.objects.get(user__username=author_filter))
+                    try:
+                        author_profile = Profile.objects.get(user__username=author_filter)
+                        search.author.add(author_profile)
+                    except Profile.DoesNotExist:
+                        messages.warning(request, f"Author '{author_filter}' not found.")
                 search.save()
-                messages.success(request, 'Search saved successfully!')
-            else:
-                messages.error(request, 'No search criteria to save. Please enter at least one filter.')
+                messages.success(request, f'Search "{search_name}" saved successfully!')
         
         return redirect('deck_list')
 
@@ -313,6 +316,7 @@ class StartQuizView(LoginRequiredMixin, View):
                 is_correct=None
             )
         
+        # Explicitly redirect to question_index=0 for new attempts
         return redirect(reverse('take_quiz', kwargs={'attempt_id': quiz_attempt.id, 'question_index': 0}))
 
 class TakeQuizView(LoginRequiredMixin, View):
@@ -333,13 +337,16 @@ class TakeQuizView(LoginRequiredMixin, View):
         if not question_attempts.exists():
             return render(request, self.template_name, {'quiz_attempt': quiz_attempt, 'error': "No questions available"})
         
-        question_index = max(0, min(int(question_index), question_attempts.count() - 1))
+        if not question_attempts.filter(Q(answer__isnull=False) | Q(choice__isnull=False)).exists():
+            question_index = 0
+        else:
+            question_index = max(0, min(int(question_index), question_attempts.count() - 1))
+        
         current_question_attempt = question_attempts[question_index]
         choices = Choice.objects.filter(question=current_question_attempt.question) if current_question_attempt.question.question_type == 1 else []
         
-        # Calculate progress percentage
         total_questions = question_attempts.count()
-        progress_percentage = ((question_index + 1) / total_questions * 100) if total_questions > 0 else 0
+        progress_percentage = (question_index / (total_questions - 1) * 100) if total_questions > 1 else 0
         logger.info(f"Attempt ID: {attempt_id}, Question Index: {question_index}, Total Questions: {total_questions}, Progress: {progress_percentage}%")
         
         return render(request, self.template_name, {
@@ -354,7 +361,7 @@ class TakeQuizView(LoginRequiredMixin, View):
             'question_attempts': question_attempts,
         })
         
-    def post(self, request, attempt_id, question_index):  # Added question_index
+    def post(self, request, attempt_id, question_index):
         quiz_attempt = get_object_or_404(QuizAttempt, id=attempt_id)
         profile = request.user.profile
         
@@ -366,7 +373,7 @@ class TakeQuizView(LoginRequiredMixin, View):
             return redirect(reverse('quiz_results', kwargs={'attempt_id': attempt_id}))
         
         question_attempts = QuestionAttempt.objects.filter(quiz_attempt=quiz_attempt)
-        question_index = int(request.POST.get('question_index', question_index))  # Use URL param if POST param missing
+        question_index = int(request.POST.get('question_index', question_index))
         current_question_attempt = question_attempts[question_index]
         
         if 'complete_quiz' in request.POST:
@@ -425,13 +432,13 @@ class QuizResultsView(LoginRequiredMixin, View):
         if not quiz_attempt.is_completed:
             return redirect(reverse('take_quiz', kwargs={'attempt_id': attempt_id}))
         
-        question_attempts = QuestionAttempt.objects.filter(quiz_attempt=quiz_attempt).select_related('question', 'choice')
-        total_questions = question_attempts.count()
-        correct_answers = question_attempts.filter(is_correct=True).count()
+        left_attempts = QuestionAttempt.objects.filter(quiz_attempt=quiz_attempt).select_related('question', 'choice')
+        total_questions = left_attempts.count()
+        correct_answers = left_attempts.filter(is_correct=True).count()
         score_percentage = (correct_answers / total_questions * 100) if total_questions > 0 else 0
         
         attempts_with_correct = []
-        for attempt in question_attempts:
+        for attempt in left_attempts:
             correct_answer = None
             if attempt.question.question_type == 1:
                 correct_choice = Choice.objects.filter(question=attempt.question, is_correct=True).first()
@@ -449,4 +456,4 @@ class QuizResultsView(LoginRequiredMixin, View):
             'total_questions': total_questions,
             'correct_answers': correct_answers,
             'score_percentage': score_percentage
-        })
+        })
\ No newline at end of file
diff --git a/search/migrations/0002_search_search_query_search_subscribed.py b/search/migrations/0002_search_search_query_search_subscribed.py
new file mode 100644
index 0000000..73281f0
--- /dev/null
+++ b/search/migrations/0002_search_search_query_search_subscribed.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.1.5 on 2025-03-24 18:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('search', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='search',
+            name='search_query',
+            field=models.CharField(blank=True, max_length=255, null=True),
+        ),
+        migrations.AddField(
+            model_name='search',
+            name='subscribed',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/search/models.py b/search/models.py
index a67c25a..58d3a5e 100644
--- a/search/models.py
+++ b/search/models.py
@@ -1,6 +1,5 @@
 from django.db import models
 from user.models import Profile
-# Create your models here.
 
 class Label(models.Model):
     name = models.CharField(max_length=128)
@@ -8,11 +7,15 @@ class Label(models.Model):
     def __str__(self):
         return self.name
 
-
 class Search(models.Model):
     profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="searches")
     label = models.ManyToManyField(Label, blank=True)
     author = models.ManyToManyField(Profile, blank=True, related_name="appears_in_searches")
     name = models.TextField(blank=True)
     type = models.TextField(blank=True)
+    search_query = models.CharField(max_length=255, blank=True, null=True)  # New field for q
+    subscribed = models.BooleanField(default=False)  # New field for subscribed checkbox
     created_at = models.DateTimeField(auto_now_add=True)
+
+    def __str__(self):
+        return self.name or "Unnamed Search"
\ No newline at end of file
diff --git a/user/templates/user/logged_out.html b/user/templates/user/logged_out.html
index 75da8bc..5eb2155 100644
--- a/user/templates/user/logged_out.html
+++ b/user/templates/user/logged_out.html
@@ -1,11 +1,10 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-    <meta charset="UTF-8">
-    <title>Logged Out</title>
-</head>
-<body>
-    <h2>You have been logged out.</h2>
-    <a href="{% url 'login' %}">Login again</a>
-</body>
-</html>
\ No newline at end of file
+{% extends 'base.html' %}
+
+{% block title %}Logged Out{% endblock %}
+
+{% block content %}
+    <div class="container mt-4">
+        <h2>You have been logged out.</h2>
+        <p><a href="{% url 'login' %}" class="btn btn-primary">Login again</a></p>
+    </div>
+{% endblock %}
\ No newline at end of file
-- 
GitLab