This commit is contained in:
Ross
2022-06-07 21:32:49 +01:00
parent 349654a22b
commit 45100f27b0
11 changed files with 136 additions and 141 deletions
+6 -21
View File
@@ -50,7 +50,7 @@ from .models import (
# IncorrectAnswers, # IncorrectAnswers,
) )
from generic.models import CidUser, Examination from generic.models import CidUser, Examination
from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamViews, GenericViewBase from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamCreateBase, ExamDeleteBase, ExamUpdateBase, ExamViews, GenericViewBase
from reversion.views import RevisionMixin from reversion.views import RevisionMixin
from .decorators import user_is_author_or_anatomy_checker from .decorators import user_is_author_or_anatomy_checker
@@ -963,30 +963,19 @@ class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView):
success_url = reverse_lazy("anatomy:question_list") success_url = reverse_lazy("anatomy:question_list")
class ExamCreate(RevisionMixin, LoginRequiredMixin, CreateView): class ExamCreate(ExamCreateBase):
model = Exam model = Exam
form_class = ExamForm form_class = ExamForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id) class ExamUpdate(ExamUpdateBase, AuthorOrCheckerRequiredMixin):
return super().form_valid(form)
class ExamUpdate(
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
model = Exam model = Exam
form_class = ExamForm form_class = ExamForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id) class ExamDelete(AuthorOrCheckerRequiredMixin, ExamDeleteBase):
return super().form_valid(form) model = Exam
class ExamViewSet(RevisionMixin, viewsets.ModelViewSet): class ExamViewSet(RevisionMixin, viewsets.ModelViewSet):
@@ -1009,10 +998,6 @@ class ExamAuthorUpdate(RevisionMixin, LoginRequiredMixin, AuthorRequiredMixin, U
model = Exam model = Exam
form_class = ExamAuthorForm form_class = ExamAuthorForm
class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView):
model = Exam
template_name = "exam_confirm_delete.html"
success_url = reverse_lazy("anatomy:index")
GenericViews = GenericViewBase("anatomy", AnatomyQuestion, CidUserAnswer, Exam) GenericViews = GenericViewBase("anatomy", AnatomyQuestion, CidUserAnswer, Exam)
+30 -7
View File
@@ -32,7 +32,7 @@ import urllib
from django.views import View from django.views import View
from django.views.generic.edit import CreateView, UpdateView, DeleteView from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django_filters.views import FilterView from django_filters.views import FilterView
from django_filters import FilterSet from django_filters import FilterSet, OrderingFilter
from django_tables2.views import SingleTableMixin from django_tables2.views import SingleTableMixin
from reversion.views import RevisionMixin from reversion.views import RevisionMixin
from atlas.models import CaseCollection from atlas.models import CaseCollection
@@ -294,6 +294,8 @@ class ExamViews(View, LoginRequiredMixin):
# exam_group = group_map[self.app_name] # exam_group = group_map[self.app_name]
class ExamFilter(FilterSet): class ExamFilter(FilterSet):
sort_order = OrderingFilter(fields=(("name", "name"), ("exam_mode", "exam_mode")))
class Meta: class Meta:
model = exam model = exam
fields = { fields = {
@@ -329,6 +331,8 @@ class ExamViews(View, LoginRequiredMixin):
# filter param is either missing or empty, use initial as default # filter param is either missing or empty, use initial as default
if not data.get("archive"): if not data.get("archive"):
data["archive"] = False data["archive"] = False
if not data.get("sort_order"):
data["sort_order"] = "name"
super().__init__(data, *args, **kwargs) super().__init__(data, *args, **kwargs)
@@ -523,15 +527,18 @@ class ExamViews(View, LoginRequiredMixin):
@method_decorator(login_required) @method_decorator(login_required)
def exam_list(self, request, all=False): def exam_list(self, request, all=False):
if all:
exams = self.Exam.objects.all().order_by("name") if not self.check_user_access(request.user):
#raise PermissionDenied
exam_list = self.Exam.objects.filter(author__id=request.user.id).order_by("name")
else: else:
exams = self.Exam.objects.filter(exam_mode=True, archive=False).order_by( exam_list = self.Exam.objects.all().order_by("name")
if not all:
exams = exam_list.filter(exam_mode=True, archive=False).order_by(
"name" "name"
) )
if not self.check_user_access(request.user):
raise PermissionDenied
marking = False marking = False
@@ -1021,7 +1028,7 @@ class ExamViews(View, LoginRequiredMixin):
view_feedback = False view_feedback = False
if ( if (
#request.user.groups.filter(name=self.checker_group).exists() or # request.user.groups.filter(name=self.checker_group).exists() or
request.user.groups.filter(name="feedback_checker").exists() request.user.groups.filter(name="feedback_checker").exists()
or request.user in question.author.all() or request.user in question.author.all()
): ):
@@ -2095,6 +2102,14 @@ class ExamCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
form.instance.author.add(self.request.user.id) form.instance.author.add(self.request.user.id)
return super().form_valid(form) return super().form_valid(form)
def get_context_data(self, **kwargs):
# Call the base implementation first to get a context
context = super().get_context_data(**kwargs)
print(dir(context["view"]))
print(context["view"].template_name_suffix)
print(context["view"].model.app_name)
return context
class ExamUpdateBase(RevisionMixin, LoginRequiredMixin, UpdateView): class ExamUpdateBase(RevisionMixin, LoginRequiredMixin, UpdateView):
template_name = "exam_update_form.html" template_name = "exam_update_form.html"
@@ -2106,3 +2121,11 @@ class ExamUpdateBase(RevisionMixin, LoginRequiredMixin, UpdateView):
form.instance.author.add(self.request.user.id) form.instance.author.add(self.request.user.id)
return super().form_valid(form) return super().form_valid(form)
class ExamDeleteBase(RevisionMixin, DeleteView):
template_name = "exam_confirm_delete.html"
def get_success_url(self) -> str:
# return super().get_success_url()(self):
return reverse_lazy(f"{self.model.app_name}:index")
+8 -27
View File
@@ -75,7 +75,7 @@ from django.forms.models import model_to_dict
from longs.forms import LongCreationDefaultForm from longs.forms import LongCreationDefaultForm
from longs.models import LongCreationDefault from longs.models import LongCreationDefault
from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamViews from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamCreateBase, ExamDeleteBase, ExamUpdateBase, ExamViews
from reversion.views import RevisionMixin from reversion.views import RevisionMixin
import reversion import reversion
@@ -1112,47 +1112,28 @@ def long_series_order_upload_filename(request, pk):
GenericExamViews = ExamViews(Exam, Long, None, CidUserAnswer, "longs", "long") GenericExamViews = ExamViews(Exam, Long, None, CidUserAnswer, "longs", "long")
class ExamCreate(ExamCreateBase):
class ExamCreate(RevisionMixin, LoginRequiredMixin, CreateView):
model = Exam model = Exam
form_class = ExamForm form_class = ExamForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id) class ExamUpdate(ExamUpdateBase, AuthorOrCheckerRequiredMixin):
return super().form_valid(form) model = Exam
form_class = ExamForm
class ExamDelete(AuthorOrCheckerRequiredMixin, ExamDeleteBase):
model = Exam
class ExamClone(ExamCloneMixin, ExamCreate): class ExamClone(ExamCloneMixin, ExamCreate):
"""Clone exam view""" """Clone exam view"""
class ExamUpdate(
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
model = Exam
form_class = ExamForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id)
return super().form_valid(form)
class ExamAuthorUpdate(RevisionMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView): class ExamAuthorUpdate(RevisionMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView):
model = Exam model = Exam
form_class = ExamAuthorForm form_class = ExamAuthorForm
class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView):
model = Exam
template_name = "exam_confirm_delete.html"
success_url = reverse_lazy("longs:index")
class ExamViewSet(RevisionMixin, viewsets.ModelViewSet): class ExamViewSet(RevisionMixin, viewsets.ModelViewSet):
queryset = Exam.objects.all().order_by("name") queryset = Exam.objects.all().order_by("name")
serializer_class = ExamSerializer serializer_class = ExamSerializer
+12 -25
View File
@@ -45,6 +45,9 @@ import plotly.express as px
from generic.views import ( from generic.views import (
AuthorRequiredMixin, AuthorRequiredMixin,
ExamCloneMixin, ExamCloneMixin,
ExamCreateBase,
ExamDeleteBase,
ExamUpdateBase,
ExamViews, ExamViews,
GenericViewBase, GenericViewBase,
) )
@@ -377,17 +380,19 @@ GenericExamViews = ExamViews(Exam, Question, None, CidUserAnswer, "physics", "ph
GenericViews = GenericViewBase("physics", Question, CidUserAnswer, Exam) GenericViews = GenericViewBase("physics", Question, CidUserAnswer, Exam)
class ExamCreate(ExamCreateBase):
class ExamCreate(RevisionMixin, LoginRequiredMixin, CreateView):
model = Exam model = Exam
form_class = ExamForm form_class = ExamForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id) class ExamUpdate(ExamUpdateBase, AuthorOrCheckerRequiredMixin):
return super().form_valid(form) model = Exam
form_class = ExamForm
class ExamDelete(AuthorOrCheckerRequiredMixin, ExamDeleteBase):
model = Exam
class ExamClone(AuthorOrCheckerRequiredMixin, ExamCloneMixin, ExamCreate): class ExamClone(AuthorOrCheckerRequiredMixin, ExamCloneMixin, ExamCreate):
@@ -401,24 +406,6 @@ class ExamAuthorUpdate(
form_class = ExamAuthorForm form_class = ExamAuthorForm
class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView):
model = Exam
template_name = "exam_confirm_delete.html"
success_url = reverse_lazy("physics:index")
class ExamUpdate(
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
model = Exam
form_class = ExamForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id)
return super().form_valid(form)
class QuestionView( class QuestionView(
+5 -1
View File
@@ -1,12 +1,16 @@
from cProfile import label
import django_filters import django_filters
from generic.models import UserGrades
from django.contrib.auth.models import User from django.contrib.auth.models import User
class UserListFilter(django_filters.FilterSet): class UserListFilter(django_filters.FilterSet):
userprofile__grade = django_filters.CharFilter(label="Grade") userprofile__grade = django_filters.ModelChoiceFilter(label="Grade", queryset=UserGrades.objects.all())
#test2 = django_filters.CharFilter()
class Meta: class Meta:
model= User model= User
@@ -36,6 +36,7 @@
<button id="add-to-exam" data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}" <button id="add-to-exam" data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
data-exam_list_url="{% url 'rapid-exam-list' %}" data-type="rapid" data-csrf="{{ csrf_token}}" data-exam_list_url="{% url 'rapid-exam-list' %}" data-type="rapid" data-csrf="{{ csrf_token}}"
data-qid="{{question.pk}}">Add to exam</button> data-qid="{{question.pk}}">Add to exam</button>
<button id="cancel-add-to-exam" style="display:none">Cancel</button>
<span id="exam-options"></span> <span id="exam-options"></span>
</div> </div>
<p class="pre-whitespace"><b>Open Access:</b> {{ question.open_access }}</p> <p class="pre-whitespace"><b>Open Access:</b> {{ question.open_access }}</p>
@@ -156,8 +157,15 @@
}) })
}) })
$("#cancel-add-to-exam").click(function (evt) {
$("#add-to-exam").toggle();
$("#cancel-add-to-exam").toggle();
$("#exam-options").empty();
})
$("#add-to-exam").click(function (evt) { $("#add-to-exam").click(function (evt) {
$("#add-to-exam").toggle();
$("#cancel-add-to-exam").toggle();
var jqxhr = $.get(evt.target.dataset.exam_list_url, function (data) { var jqxhr = $.get(evt.target.dataset.exam_list_url, function (data) {
console.log(data); console.log(data);
@@ -380,11 +388,18 @@
<style> <style>
.toggle-button { .toggle-button {
font-size: x-small; font-size: x-small;
opacity: 10%; opacity: 30%;
} }
.toggle-button:hover { .toggle-button:hover {
font-size: x-small; font-size: x-small;
opacity: 100%; opacity: 100%;
} }
#cancel-add-to-exam {
border-style: dashed;
}
#cancel-add-to-exam:hover {
background-color: purple;
}
</style> </style>
+42 -1
View File
@@ -33,6 +33,18 @@ def create_superuser(db, django_user_model):
"admin", "ross@xkjq.uk", "adminpassword" "admin", "ross@xkjq.uk", "adminpassword"
) )
@pytest.fixture(params=EXAM_VIEWS)
def set_up_exams(request, django_user_model, create_superuser):
basic_user = django_user_model.objects.create_user(
"user1", "user@user.net", "pass2"
)
e1 = request.param.Exam.objects.create(name="test exam", exam_mode=True)
e = request.param.Exam.objects.create(name="test exam 2", exam_mode=True)
e.author.add(basic_user)
return request.param
# @pytest.fixture # @pytest.fixture
# def create_exam(db): # def create_exam(db):
@@ -40,6 +52,18 @@ def create_superuser(db, django_user_model):
# Very basic tests, make sure we can hit key urls without error # Very basic tests, make sure we can hit key urls without error
def test_index2(client, set_up_exams):
client.login(username="admin", password="adminpassword")
response = client.get(reverse(f"{set_up_exams.app_name}:index"))
assert response.status_code == 200
assert (
BeautifulSoup(response.content, "html.parser")
.find("h4", {"class": "exam-number-title"})
.getText()
== "2 exams found."
)
@pytest.mark.parametrize("exam_views", EXAM_VIEWS) @pytest.mark.parametrize("exam_views", EXAM_VIEWS)
def test_index(client, create_superuser, exam_views, django_user_model): def test_index(client, create_superuser, exam_views, django_user_model):
@@ -63,6 +87,15 @@ def test_index(client, create_superuser, exam_views, django_user_model):
== "2 exams found." == "2 exams found."
) )
response = client.get(reverse(f"{exam_views.app_name}:exam_list"))
assert response.status_code == 200
assert (
len(BeautifulSoup(response.content, "html.parser")
.select("a.exam-name"))
== 2
)
if exam_views.app_name in JSON_APPS: if exam_views.app_name in JSON_APPS:
response = client.get(reverse(f"{exam_views.app_name}:active_exams")) response = client.get(reverse(f"{exam_views.app_name}:active_exams"))
@@ -86,7 +119,6 @@ def test_index(client, create_superuser, exam_views, django_user_model):
HTTP_X_REQUESTED_WITH="XMLHttpRequest", HTTP_X_REQUESTED_WITH="XMLHttpRequest",
) )
print(f"{e1.valid_user_users.all()=}")
assert json.loads(add_user_response.content)["status"] == "success" assert json.loads(add_user_response.content)["status"] == "success"
@@ -103,6 +135,15 @@ def test_index(client, create_superuser, exam_views, django_user_model):
== "1 exams found." == "1 exams found."
) )
response = client.get(reverse(f"{exam_views.app_name}:exam_list"))
assert (
len(BeautifulSoup(response.content, "html.parser")
.select("a.exam-name"))
== 1
)
if exam_views.app_name in JSON_APPS: if exam_views.app_name in JSON_APPS:
response = client.get(reverse(f"{exam_views.app_name}:active_exams")) response = client.get(reverse(f"{exam_views.app_name}:active_exams"))
+6 -7
View File
@@ -46,6 +46,7 @@ from generic.views import (
AuthorRequiredMixin, AuthorRequiredMixin,
ExamCloneMixin, ExamCloneMixin,
ExamCreateBase, ExamCreateBase,
ExamDeleteBase,
ExamUpdateBase, ExamUpdateBase,
ExamViews, ExamViews,
GenericViewBase, GenericViewBase,
@@ -787,6 +788,9 @@ class ExamUpdate(ExamUpdateBase, AuthorOrCheckerRequiredMixin):
form_class = ExamForm form_class = ExamForm
class ExamDelete(AuthorOrCheckerRequiredMixin, ExamDeleteBase):
model = Exam
class ExamAuthorUpdate( class ExamAuthorUpdate(
RevisionMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView RevisionMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView
): ):
@@ -794,11 +798,6 @@ class ExamAuthorUpdate(
form_class = ExamAuthorForm form_class = ExamAuthorForm
class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView):
model = Exam
template_name = "exam_confirm_delete.html"
success_url = reverse_lazy("rapids:index")
class UserAnswerView(LoginRequiredMixin, DetailView): class UserAnswerView(LoginRequiredMixin, DetailView):
model = CidUserAnswer model = CidUserAnswer
@@ -840,9 +839,9 @@ class ExamViewSet(RevisionMixin, viewsets.ModelViewSet):
""" """
user = self.request.user user = self.request.user
if user.groups.filter(name="rapid_checker").exists(): if user.groups.filter(name="rapid_checker").exists():
return Exam.objects.all() return Exam.objects.filter(archive=False).order_by('name')
return Exam.objects.filter(author__id=user.id) return Exam.objects.filter(author__id=user.id, archive=False).order_by('name')
class QuestionAnswerViewSet(viewsets.ModelViewSet): class QuestionAnswerViewSet(viewsets.ModelViewSet):
+5 -43
View File
@@ -40,7 +40,7 @@ import json
import statistics import statistics
import plotly.express as px import plotly.express as px
from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamViews, GenericViewBase from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamCreateBase, ExamDeleteBase, ExamUpdateBase, ExamViews, GenericViewBase
from rest_framework import viewsets, permissions from rest_framework import viewsets, permissions
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
@@ -342,31 +342,17 @@ class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView):
success_url = reverse_lazy("sbas:question_list") success_url = reverse_lazy("sbas:question_list")
class ExamCreate(RevisionMixin, LoginRequiredMixin, CreateView): class ExamCreate(ExamCreateBase):
model = Exam model = Exam
form_class = ExamForm form_class = ExamForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id) class ExamUpdate(ExamUpdateBase, AuthorOrCheckerRequiredMixin):
return super().form_valid(form)
class ExamUpdate(
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
model = Exam model = Exam
form_class = ExamForm form_class = ExamForm
def form_valid(self, form): class ExamDelete(AuthorOrCheckerRequiredMixin, ExamDeleteBase):
self.object = form.save(commit=False) model = Exam
self.object.save()
form.instance.author.add(self.request.user.id)
return super().form_valid(form)
# class ExamViewSet(RevisionMixin, viewsets.ModelViewSet): # class ExamViewSet(RevisionMixin, viewsets.ModelViewSet):
# # queryset = Exam.objects.all().order_by('name') # # queryset = Exam.objects.all().order_by('name')
@@ -388,30 +374,6 @@ class ExamAuthorUpdate(RevisionMixin, LoginRequiredMixin, AuthorRequiredMixin, U
form_class = ExamAuthorForm form_class = ExamAuthorForm
class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView):
model = Exam
template_name = "exam_confirm_delete.html"
success_url = reverse_lazy("sbas:index")
class ExamClone(ExamCloneMixin, ExamCreate): class ExamClone(ExamCloneMixin, ExamCreate):
"""Clone exam view""" """Clone exam view"""
class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView):
model = Exam
template_name = "exam_confirm_delete.html"
success_url = reverse_lazy("sbas:index")
class ExamUpdate(
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
model = Exam
form_class = ExamForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id)
return super().form_valid(form)
+1 -3
View File
@@ -1,12 +1,10 @@
{% extends "base.html" %} {% extends view.model.app_name|add:"/base.html" %}
{% comment %} {% extends exam.app_name|add:"/base.html" %} {% endcomment %}
{% block js %} {% block js %}
{{ form.media }} {{ form.media }}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{{form.instance}}
<h2>Add Exam</h2> <h2>Add Exam</h2>
<form action="" method="post"> <form action="" method="post">
+4 -4
View File
@@ -4,11 +4,11 @@
<h1>Examinations</h1> <h1>Examinations</h1>
<div class="{{app_name}}"> <div class="{{app_name}}">
Active exams:<br/> Active exams:<br/>
<ul class="exam-list"> <ul class="exam-list exam-list-active">
{% for exam in exams %} {% for exam in exams %}
{% if exam.active %} {% if exam.active %}
<li class="exam-item"> <li class="exam-item">
<a href="{% url app_name|add:':exam_overview' pk=exam.pk %}" class="flex-col-2">{{exam.name}}</a> <a href="{% url app_name|add:':exam_overview' pk=exam.pk %}" class="flex-col-2 exam-name">{{exam.name}}</a>
{% if marking %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}" class="flex-col">Mark</a>{% endif %} {% if marking %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}" class="flex-col">Mark</a>{% endif %}
<a href="{% url app_name|add:':exam_cids' exam_id=exam.pk %}" class="flex-col">Candidates</a> <a href="{% url app_name|add:':exam_cids' exam_id=exam.pk %}" class="flex-col">Candidates</a>
<a href="{% url app_name|add:':exam_scores_cid' pk=exam.pk %}" class="flex-col">Scores</a> <a href="{% url app_name|add:':exam_scores_cid' pk=exam.pk %}" class="flex-col">Scores</a>
@@ -22,11 +22,11 @@
</ul> </ul>
Inactive exams:<br/> Inactive exams:<br/>
<ul class="exam-list"> <ul class="exam-list exam-list-inactive">
{% for exam in exams %} {% for exam in exams %}
{% if not exam.active %} {% if not exam.active %}
<li class="exam-item"> <li class="exam-item">
<a href="{% url app_name|add:':exam_overview' pk=exam.pk %}" class="flex-col-2">{{exam.name}}</a> <a href="{% url app_name|add:':exam_overview' pk=exam.pk %}" class="flex-col-2 exam-name">{{exam.name}}</a>
{% if marking %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}" class="flex-col">Mark</a>{% endif %} {% if marking %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}" class="flex-col">Mark</a>{% endif %}
<a href="{% url app_name|add:':exam_cids' exam_id=exam.pk %}" class="flex-col">Candidates</a> <a href="{% url app_name|add:':exam_cids' exam_id=exam.pk %}" class="flex-col">Candidates</a>
<a href="{% url app_name|add:':exam_scores_cid' pk=exam.pk %}" class="flex-col">Scores</a> <a href="{% url app_name|add:':exam_scores_cid' pk=exam.pk %}" class="flex-col">Scores</a>