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