diff --git a/anatomy/views.py b/anatomy/views.py index 268478b8..388dd9b9 100644 --- a/anatomy/views.py +++ b/anatomy/views.py @@ -786,6 +786,9 @@ def mark2_overview(request, pk): """Overview page for mark2 workflow: lists questions and unmarked counts and links into mark2.""" exam = get_object_or_404(Exam, pk=pk) + if not GenericExamViews.check_user_marker_access(request.user, pk): + raise PermissionDenied + if not exam.exam_mode: raise Http404("Packet not in exam mode") diff --git a/generic/migrations/0031_flag.py b/generic/migrations/0031_flag.py new file mode 100644 index 00000000..e2887c2d --- /dev/null +++ b/generic/migrations/0031_flag.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.7 on 2026-01-05 12:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('generic', '0030_ciduser_notes'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Flag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('note', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('cid_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='flags', to='generic.ciduser')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='flags', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/generic/models.py b/generic/models.py index 6b973fb2..2110b507 100644 --- a/generic/models.py +++ b/generic/models.py @@ -1704,6 +1704,48 @@ class CidUserExam(models.Model): self.save() +class Flag(models.Model): + """A reusable flag model that can be attached to any object via a + GenericForeignKey. Useful for marking questions, items or other objects + across apps. + """ + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + + # Optional actor who created the flag (either a user or a CID user) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="flags", + ) + cid_user = models.ForeignKey( + "CidUser", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="flags", + ) + + note = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ("-created_at",) + + def __str__(self) -> str: + actor = None + if self.user: + actor = f"user:{self.user.username}" + elif self.cid_user: + actor = f"cid:{self.cid_user.cid}" + else: + actor = "anon" + return f"Flag [{actor}] -> {self.content_type}({self.object_id})" + + CID_GROUP_EXAMS = ( ("SBAs", "sba_cid_user_groups"), ("Physics", "physics_cid_user_groups"), diff --git a/generic/templates/generic/exam_cids.html b/generic/templates/generic/exam_cids.html index c0dcac02..7d185bef 100644 --- a/generic/templates/generic/exam_cids.html +++ b/generic/templates/generic/exam_cids.html @@ -15,8 +15,10 @@
CID candidates ({{ cid_user_count }})
-
+
+ + Filtered
@@ -52,8 +54,10 @@
User candidates ({{ user_user_count }})
-
+
+ + Filtered
    @@ -122,16 +126,26 @@ function normalizeText(s){ return (s||'').toString().toLowerCase(); } - function filterList(inputElem, listSelector){ + function filterList(inputElem, listSelector, indicatorId, clearBtnId){ const q = normalizeText(inputElem.value.trim()); document.querySelectorAll(listSelector).forEach(function(li){ - const text = normalizeText(li.textContent); + const text = normalizeText(li.textContent || ''); if(!q || text.indexOf(q) !== -1){ - li.style.display = ''; + try{ li.style.removeProperty('display'); }catch(e){} + li.classList.remove('d-none'); } else { - li.style.display = 'none'; + try{ li.style.setProperty('display','none','important'); }catch(e){ li.style.display = 'none'; } + li.classList.add('d-none'); } }); + + // Toggle indicator & clear button + try{ + const ind = document.getElementById(indicatorId); + const clearBtn = document.getElementById(clearBtnId); + if(ind) ind.classList.toggle('d-none', !q); + if(clearBtn) clearBtn.classList.toggle('d-none', !q); + }catch(e){} } function initTooltips(){ @@ -141,32 +155,112 @@ } function highlightSubmitted(){ - document.querySelectorAll('#submitted-candidates li').forEach(function(el){ + // Find any submitted rows (table rows or list items) that include + // data attributes for cid/user. Then insert a small badge on matching list items. + // Collect submitted identifiers first to avoid duplicate processing + const submittedEls = document.querySelectorAll('#submitted-candidates [data-cid], #submitted-candidates [data-user]'); + // Build maps of submitted identifiers -> their timestamps so we + // can attach tooltip text to the badges we create below. + const submittedCids = new Map(); + const submittedUsers = new Map(); + submittedEls.forEach(function(el){ try{ - const cid = el.dataset.cid; - const user = el.dataset.user; - if(cid && cid !== 'None'){ - document.querySelectorAll(`.list-group-item`).forEach(function(li){ - try{ - const link = li.querySelector('.cid-link'); - if(link && link.textContent.trim() === cid.toString()) li.classList.add('submitted'); - }catch(e){} - }); - } - if(user && user !== 'None'){ - document.querySelectorAll('.list-group-item').forEach(function(li){ if(li.textContent.includes(user)) li.classList.add('submitted'); }); - } - }catch(e){ console.error(e); } + const cid = el.dataset && el.dataset.cid ? el.dataset.cid.toString() : null; + const user = el.dataset && el.dataset.user ? el.dataset.user.toString() : null; + const first = el.dataset && el.dataset.first ? el.dataset.first.toString() : ''; + const last = el.dataset && el.dataset.last ? el.dataset.last.toString() : ''; + if(cid && cid !== 'None') submittedCids.set(cid, {first:first, last:last}); + if(user && user !== 'None') submittedUsers.set(user, {first:first, last:last}); + }catch(e){ console.error('highlightSubmitted collect error', e); } }); + + // Helper to ensure a single badge exists inside an li and update its tooltip + function ensureBadge(li, selector, className, text, title){ + // Prefer the explicit badge container if present + const container = li.querySelector('.badge-container') || li; + let b = container.querySelector(selector); + if(!b){ + b = document.createElement('span'); + b.className = className; + b.textContent = text; + container.appendChild(b); + } + if(title){ + b.setAttribute('title', title); + b.setAttribute('data-bs-toggle', 'tooltip'); + } + return b; + } + + // Add/update badges for CID list items + if(submittedCids.size){ + document.querySelectorAll('#cid-list .list-group-item').forEach(function(li){ + try{ + const link = li.querySelector && li.querySelector('.cid-link'); + const text = (link && link.textContent) ? link.textContent.trim() : (li.textContent||'').trim(); + if(submittedCids.has(text)){ + const info = submittedCids.get(text) || {first:'', last:''}; + // add Started badge if start time exists + if(info.first){ + const t = `Started\nFirst: ${info.first}`; + ensureBadge(li, '.started-badge', 'started-badge badge bg-info ms-2', 'Started', t); + } + // add Submitted badge if end time exists (or always keep submitted) + const title = (info.first || info.last) ? `Submitted\nFirst: ${info.first}\nLast: ${info.last}` : 'Submitted'; + ensureBadge(li, '.submitted-badge', 'submitted-badge badge bg-success ms-2', 'Submitted', title); + } + }catch(e){ console.error('highlightSubmitted CID item error', e); } + }); + } + + // Add/update badges for User list items + if(submittedUsers.size){ + document.querySelectorAll('#user-list .list-group-item').forEach(function(li){ + try{ + const txt = (li.textContent||'').trim(); + for(const user of submittedUsers.keys()){ + if(txt.indexOf(user) !== -1){ + const info = submittedUsers.get(user) || {first:'', last:''}; + if(info.first){ + const t = `Started\nFirst: ${info.first}`; + ensureBadge(li, '.started-badge', 'started-badge badge bg-info ms-2', 'Started', t); + } + const title = (info.first || info.last) ? `Submitted\nFirst: ${info.first}\nLast: ${info.last}` : 'Submitted'; + ensureBadge(li, '.submitted-badge', 'submitted-badge badge bg-success ms-2', 'Submitted', title); + break; + } + } + }catch(e){ console.error('highlightSubmitted User item error', e); } + }); + } } // Wire filters document.addEventListener('DOMContentLoaded', function(){ const cidFilter = document.getElementById('cid-filter'); - if(cidFilter){ cidFilter.addEventListener('input', function(){ filterList(cidFilter, '.col-lg-6:first-of-type .list-group-item'); }); } + if(cidFilter && !cidFilter.dataset.bound){ + cidFilter.addEventListener('input', function(){ filterList(cidFilter, '#cid-list .list-group-item', 'cid-filter-indicator', 'cid-filter-clear'); }); + cidFilter.dataset.bound = '1'; + } const userFilter = document.getElementById('user-filter'); - if(userFilter){ userFilter.addEventListener('input', function(){ filterList(userFilter, '.col-lg-6:nth-of-type(2) .list-group-item, .col-lg-6:nth-of-type(2) .list-group-item.d-flex'); }); } + if(userFilter && !userFilter.dataset.bound){ + userFilter.addEventListener('input', function(){ filterList(userFilter, '#user-list .list-group-item', 'user-filter-indicator', 'user-filter-clear'); }); + userFilter.dataset.bound = '1'; + } + + // Clear buttons + const cidClear = document.getElementById('cid-filter-clear'); + if(cidClear && !cidClear.dataset.bound){ + cidClear.addEventListener('click', function(){ const f=document.getElementById('cid-filter'); if(f){ f.value=''; filterList(f, '#cid-list .list-group-item', 'cid-filter-indicator', 'cid-filter-clear'); }}); + cidClear.dataset.bound = '1'; + } + + const userClear = document.getElementById('user-filter-clear'); + if(userClear && !userClear.dataset.bound){ + userClear.addEventListener('click', function(){ const f=document.getElementById('user-filter'); if(f){ f.value=''; filterList(f, '#user-list .list-group-item', 'user-filter-indicator', 'user-filter-clear'); }}); + userClear.dataset.bound = '1'; + } // initial runs (may be no partials yet) initTooltips(); @@ -184,6 +278,14 @@ initTooltips(); // submitted-list may have loaded now; re-run highlighting highlightSubmitted(); + + // Re-run current filters for newly-loaded partials + try{ + const cf = document.getElementById('cid-filter'); + if(cf) filterList(cf, '#cid-list .list-group-item'); + const uf = document.getElementById('user-filter'); + if(uf) filterList(uf, '#user-list .list-group-item'); + }catch(e){ /* ignore */ } } }catch(e){ console.error(e); } }); diff --git a/generic/templates/generic/partials/exam_cids_cid_list.html b/generic/templates/generic/partials/exam_cids_cid_list.html index 0e23d7c4..1b175710 100644 --- a/generic/templates/generic/partials/exam_cids_cid_list.html +++ b/generic/templates/generic/partials/exam_cids_cid_list.html @@ -1,31 +1,32 @@ {% comment %} Partial: list of CID candidates for HTMX swap {% endcomment %} {% if cid_users %} -
      - {% for cid in cid_users %} -
    • -
      -
      -
      - - {{ cid.cid }} - [{{ cid.passcode }}] - +
        + {% for cid in cid_users %} +
      • +
        +
        +
        + + {{ cid.cid }} + [{{ cid.passcode }}] + +
        +
        {{ cid.name }}
        +
        Email: {{ cid.email }}
        + {% if cid.notes %} +
        Notes: {{ cid.notes|truncatechars:120 }}
        + {% endif %}
        -
        {{ cid.name }}
        -
        Email: {{ cid.email }}
        - {% if cid.notes %} -
        Notes: {{ cid.notes|truncatechars:120 }}
        - {% endif %} +
        -
      -
    • - {% endfor %} -
    + + {% endfor %} +
{% else %}

This exam has no CID candidates.

{% endif %} diff --git a/generic/templates/generic/partials/exam_cids_submitted_list.html b/generic/templates/generic/partials/exam_cids_submitted_list.html index 07d50b95..f6aa259a 100644 --- a/generic/templates/generic/partials/exam_cids_submitted_list.html +++ b/generic/templates/generic/partials/exam_cids_submitted_list.html @@ -23,11 +23,11 @@ {% for user_data in user_exam_data %} - - {{ user_data.cid_user }} + + {{ user_data.cid_user }}{% if not user_data.in_exam %} Not added{% endif %} {{ user_data.user_user }} - {{ user_data.start_time }} - {{ user_data.end_time }} + {{ user_data.start_time }} + {{ user_data.end_time }}
diff --git a/generic/templates/generic/partials/exam_cids_user_list.html b/generic/templates/generic/partials/exam_cids_user_list.html index c754a397..714b6332 100644 --- a/generic/templates/generic/partials/exam_cids_user_list.html +++ b/generic/templates/generic/partials/exam_cids_user_list.html @@ -1,27 +1,27 @@ {% comment %} Partial: list of User candidates for HTMX swap {% endcomment %} {% if user_users %} -
    - {% for user in user_users %} -
  • -
    -
    - - {% if request.user.is_superuser %} - - {% endif %} - {{ user.username }} +
      + {% for user in user_users %} +
    • +
      +
      + + {% if request.user.is_superuser %} + + {% endif %} + {{ user.username }} +
      +
      Email: {{ user.email }}
      -
      Email: {{ user.email }}
      -
    -
     
    -
  • - {% endfor %} -
+
+ + {% endfor %} + {% else %}

This exam has no User candidates.

{% endif %} diff --git a/generic/views.py b/generic/views.py index cdceb9ea..f4c92a23 100644 --- a/generic/views.py +++ b/generic/views.py @@ -56,7 +56,7 @@ from reversion.views import RevisionMixin from atlas.models import CaseCollection, CaseDetail from generic.decorators import user_is_cid_user_manager from generic.filters import CidUserFilter, ExaminationFilter, SupervisorFilter -from generic.models import UserUserGroup +from generic.models import UserUserGroup, CidUserExam from generic.tables import ( CidUserExamTable, @@ -2335,9 +2335,42 @@ class ExamViews(View, LoginRequiredMixin): if request.user not in exam.author.all() and not request.user.is_superuser: raise PermissionDenied - user_exam_data = exam.cid_users.all().prefetch_related("cid_user", "user_user") + # Explicitly query the CidUserExam entries for this exam to avoid any + # ambiguity from GenericRelation managers and to ensure we only return + # submissions that belong to this exam instance. + from django.contrib.contenttypes.models import ContentType - return render(request, "generic/partials/exam_cids_submitted_list.html", {"user_exam_data": user_exam_data, "exam": exam}) + ct = ContentType.objects.get_for_model(self.Exam) + user_exam_data = ( + CidUserExam.objects.filter(content_type=ct, object_id=exam.pk) + .select_related("cid_user", "user_user") + .order_by("start_time") + ) + + # Determine which submitted entries correspond to candidates/users + # that are actually added to the exam so we can highlight those that + # are not. Collect valid IDs once to avoid per-row queries. + valid_cid_pks = set(exam.valid_cid_users.values_list("pk", flat=True)) + valid_user_pks = set(exam.valid_user_users.values_list("pk", flat=True)) + + for ue in user_exam_data: + in_exam = False + try: + if ue.cid_user_id and ue.cid_user_id in valid_cid_pks: + in_exam = True + if ue.user_user_id and ue.user_user_id in valid_user_pks: + in_exam = True + except Exception: + in_exam = False + + # Attach a dynamic attribute the template can read + setattr(ue, "in_exam", in_exam) + + return render( + request, + "generic/partials/exam_cids_submitted_list.html", + {"user_exam_data": user_exam_data, "exam": exam}, + ) # def exam_groups_edit(self, request, exam_id): # exam = get_object_or_404(self.Exam, pk=exam_id) diff --git a/physics/templates/physics/exam_take.html b/physics/templates/physics/exam_take.html index f617a2d0..ec90c304 100755 --- a/physics/templates/physics/exam_take.html +++ b/physics/templates/physics/exam_take.html @@ -12,10 +12,15 @@ {% include "exam_clock.html" %}
-

- {{ exam }}: Question - [{{ pos|add:1 }}/{{ exam_length }}] -

+
+

+ {{ exam }}: Question + [{{ pos|add:1 }}/{{ exam_length }}] +

+
+ +
+
{% if exam.publish_results %}