Compare commits

..
27 Commits
Author SHA1 Message Date
Ross a491337a2e Add merge category functionality for admin users with confirmation prompt 2025-10-27 20:23:44 +00:00
Ross 65e95b698d Include active filters in navigation for review-next and skip actions 2025-10-27 17:01:46 +00:00
Ross e503576e7a Enhance trainees table with sortable columns and improved bulk edit options 2025-10-27 15:22:41 +00:00
Ross fba8d5ab05 Add supervisor search functionality with HTMX support for manual selection in bulk updates 2025-10-27 14:43:41 +00:00
Ross be94ed8d0f Refactor HTML structure and indentation in trainees bulk update template for improved readability 2025-10-27 14:35:59 +00:00
Ross 668007b899 Implement user search functionality with HTMX support for manual user selection in bulk updates 2025-10-27 14:32:03 +00:00
Ross 1c8f862303 Add user search functionality for manual trainee matching in bulk update 2025-10-27 14:26:55 +00:00
Ross 1f2a25c3ad Set 'UNREVIEWED' as the default selected option in review status dropdown 2025-10-27 14:21:09 +00:00
Ross 690cabe30c Update default status filter to return only unreviewed questions if not specified 2025-10-27 14:13:41 +00:00
Ross 4dc07b1860 Refactor trainees bulk update logic to improve user matching criteria and enhance clarity in code comments 2025-10-27 14:03:21 +00:00
Ross d9602eeae9 Add bulk update functionality for trainees from spreadsheet input 2025-10-27 13:07:50 +00:00
Ross 5d0eb56b37 Remove note about question review status from exam generation form 2025-10-27 13:00:09 +00:00
Ross 64238f4052 Add exam generation functionality with customizable name and filters 2025-10-27 12:58:13 +00:00
Ross ef26182b1a Refactor personal exam creation section for improved readability and structure 2025-10-27 12:42:34 +00:00
Ross 3f56af4881 Add functionality to create personal exams from filtered questions and update exam overview with edit option 2025-10-27 12:39:44 +00:00
Ross 473efbc8f4 Add source display to question review and improve wording in review start template 2025-10-27 12:02:56 +00:00
Ross 6e8c86ab10 Add question review list view and template with filtering options 2025-10-27 12:00:05 +00:00
Ross 511b3c3777 Add skip and next buttons to question review with corresponding JavaScript functionality 2025-10-27 11:54:02 +00:00
Ross a38c31ca87 Fix template path for question review block and remove unnecessary form wrapper to prevent nested forms 2025-10-27 11:27:59 +00:00
Ross 74c5f4d8fd Enhance question review template with active filters and improved metadata display 2025-10-27 11:19:07 +00:00
Ross 621e95cfd5 Add question title field to QuestionForm and improve question review template for better feedback display 2025-10-27 10:56:51 +00:00
Ross 3dc38ef57a Add filters to question review rendering for improved context 2025-10-27 10:39:53 +00:00
Ross 27e575f772 Add QuestionReviewForm to question review view and update template to include form 2025-10-27 10:38:49 +00:00
Ross a404a335c0 Refactor question review template for improved readability and structure; enhance question title display and answer rendering 2025-10-27 10:28:40 +00:00
Ross 8380ca1dd9 Implement question review flow with category and status filters; add templates for review start, question display, and completion 2025-10-27 10:21:07 +00:00
Ross ec45464e58 Refactor volume mounts in docker-compose.yml for clarity and consistency 2025-10-27 10:07:35 +00:00
Ross 702b7b35a7 Refactor docker-compose.yml build context and add question review URL and view; create question_review.html template 2025-10-27 09:47:35 +00:00
21 changed files with 1290 additions and 67 deletions
+10 -6
View File
@@ -8,15 +8,19 @@ services:
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
web:
build:
context: ..
dockerfile: rad/Dockerfile
# Use the rad/ directory as the build context and the Dockerfile inside it.
# This avoids relative path issues with different docker/compose versions and buildx.
context: ./rad
dockerfile: Dockerfile
command: python manage.py runserver 0.0.0.0:8000
#command: pip install -r requirements.txt
volumes:
- ../:/usr/src/app # mount the whole repo into the container
- ./backups:/usr/src/backups
- ./media:/usr/src/media
- ./static:/usr/src/static
# Mount the rad app directory into the container WORKDIR used by the Dockerfile
- ./rad:/usr/src/rad
# Mount host media/static/backups into sensible container paths
- ./backups:/usr/src/rad/backups
- ./media:/usr/src/rad/media
- ./static:/usr/src/rad/static
ports:
- 8000:8000
- 3459:3459
@@ -4,7 +4,6 @@
hx-swap="innerHTML"
class="w-100">
{% csrf_token %}
<div class="row g-2 align-items-start">
<div class="col-auto">
<label class="form-label mb-1">Status</label>
@@ -22,15 +21,15 @@
{% endif %}
</div>
<div class="col">
<div class="col-md-12 mt-2">
<label for="{{ form.comment.id_for_label }}" class="form-label mb-1">Comment</label>
{{ form.comment }}
{% if form.comment.errors %}
<div class="invalid-feedback d-block">{{ form.comment.errors|join:", " }}</div>
{% endif %}
</div>
<div class="col-auto d-flex align-items-end">
</div>
<div class="col-auto d-flex align-items-end mt-2">
<button class="btn btn-primary" type="submit">Save</button>
</div>
</div>
@@ -0,0 +1,12 @@
{% if results %}
<ul style="list-style:none;padding:0;margin:0">
{% for item in results %}
<li style="margin:6px 0;">
<strong>{{ item.text|escape }}</strong>
<button type="button" onclick="setManualSupervisor({{ row }}, {{ item.id }}, '{{ item.text|escapejs }}')" style="margin-left:8px;">Select</button>
</li>
{% endfor %}
</ul>
{% else %}
<div><em>No supervisors found</em></div>
{% endif %}
@@ -0,0 +1,12 @@
{% if results %}
<ul style="list-style:none;padding:0;margin:0">
{% for item in results %}
<li style="margin:6px 0;">
<strong>{{ item.text|escape }}</strong>
<button type="button" onclick="setManualUser({{ row }}, {{ item.id }}, '{{ item.text|escapejs }}')" style="margin-left:8px;">Select</button>
</li>
{% endfor %}
</ul>
{% else %}
<div><em>No users found</em></div>
{% endif %}
+64 -3
View File
@@ -11,6 +11,7 @@
{% endfor %}
(<a href="{% url 'create_trainee' %}" title="Click to add a trainee to the platform, creating an account for them.">Add trainee</a>)
(<a href="{% url 'accounts_bulk_create' %}" title="Click to create multiple trainees at once.">Bulk create</a>)
(<a href="{% url 'trainees_bulk_update_from_spreadsheet' %}" title="Click to update multiple trainees at once.">Bulk update</a>)
<h2>
{% if grade %}
@@ -18,10 +19,13 @@
{% endif %}Trainees
</h2>
<form>
<table>
<table id="trainees-table">
<tr class="header">
<th>Name</th>
<th>Grade</th><th>Email</th><th>Supervisor</th><th>Edit</th>
<th class="sortable" data-col="0">Name <span class="sort-indicator"></span></th>
<th class="sortable" data-col="1">Grade <span class="sort-indicator"></span></th>
<th class="sortable" data-col="2">Email <span class="sort-indicator"></span></th>
<th class="sortable" data-col="3">Supervisor <span class="sort-indicator"></span></th>
<th>Edit</th>
<th class="trainee-bulk-edit">Bulk Edit<br/>
<span _="on click log 'test' then log 'hello' then repeat for i in <input/>
-- set i.checked to not i.checked
@@ -143,6 +147,63 @@
background-color: darkgrey;
color: white;
}
th.sortable {
cursor: pointer;
user-select: none;
}
th .sort-indicator {
font-size: 0.8em;
margin-left: 6px;
opacity: 0.6;
}
</style>
<script>
(function(){
const table = document.getElementById('trainees-table');
if (!table) return;
const getCellValue = (tr, idx) => {
const cell = tr.children[idx];
return cell ? cell.innerText.trim().toLowerCase() : '';
};
const comparer = function(idx, asc){
return function(a,b){
const v1 = getCellValue(a, idx);
const v2 = getCellValue(b, idx);
// attempt numeric compare
const n1 = parseFloat(v1.replace(/[^0-9.\-]/g, ''));
const n2 = parseFloat(v2.replace(/[^0-9.\-]/g, ''));
if (!isNaN(n1) && !isNaN(n2)){
return (n1 - n2) * (asc ? 1 : -1);
}
return v1.localeCompare(v2) * (asc ? 1 : -1);
};
};
let lastSorted = {idx: null, asc: true};
table.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', function(){
const idx = parseInt(th.getAttribute('data-col'));
const tbodyRows = Array.from(table.querySelectorAll('tr.trainee'));
const asc = (lastSorted.idx === idx) ? !lastSorted.asc : true;
tbodyRows.sort(comparer(idx, asc));
// re-append rows in new order
const parent = table;
tbodyRows.forEach(r => parent.appendChild(r));
// update indicators
table.querySelectorAll('th.sortable .sort-indicator').forEach(el => el.textContent = '');
th.querySelector('.sort-indicator').textContent = asc ? '▲' : '▼';
lastSorted = {idx, asc};
});
});
})();
</script>
{% endblock %}
@@ -0,0 +1,122 @@
{% extends 'generic/base.html' %}
{% block content %}
<h2>Bulk update trainees (paste spreadsheet text)</h2>
<p>Paste the spreadsheet rows below. Headings like "Year 1 - Group 1" are used to set the grade (Year N -> STN). Rows should contain "Name <tab or 2+spaces> Supervisor".</p>
<form method="post">
{% csrf_token %}
<textarea name="data" rows="18" style="width:100%">{% if request.POST.data %}{{ request.POST.data }}{% endif %}</textarea>
<div style="margin-top:8px">
<button type="submit" name="action" value="preview" class="btn btn-primary">Preview</button>
<button type="submit" name="action" value="apply" class="btn btn-danger" onclick="return confirm('This will apply updates to matched trainee profiles. Are you sure?')">Apply updates</button>
</div>
</form>
{% if preview %}
<h3>Preview ({{ preview|length }} rows)</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Raw</th>
<th>Name</th>
<th>Grade</th>
<th>Matched user</th>
<th>Supervisor text</th>
<th>Matched supervisor</th>
</tr>
</thead>
<tbody>
{% for r in preview %}
<tr>
<td><pre style="margin:0">{{ r.raw }}</pre></td>
<td>{{ r.norm_name }}</td>
<td>{{ r.grade }}</td>
<td>
{% if r.matched_user %}
<a href="{% url 'account_profile' r.matched_user.username %}">{{ r.matched_user.first_name }} {{ r.matched_user.last_name }}</a>
{% else %}
<div>
<span style="color:red">No match</span>
<div>
<div class="d-inline-flex align-items-center" style="gap:6px">
<form hx-get="{% url 'generic:user_search' %}" hx-target="#user-results-{{ r.row_index }}" hx-swap="innerHTML" onsubmit="return false;" class="d-inline-flex align-items-center">
<input type="text" name="q" id="user-search-input-{{ r.row_index }}" placeholder="Search users..." style="width:220px" />
<input type="hidden" name="row" value="{{ r.row_index }}" />
<button type="submit" class="btn btn-sm btn-secondary">Search</button>
</form>
<div id="user-results-{{ r.row_index }}"></div>
</div>
<input type="hidden" name="manual_user_{{ r.row_index }}" id="manual_user_{{ r.row_index }}" />
</div>
{% endif %}
</td>
<td>{{ r.supervisor_text }}</td>
<td>
{% if r.matched_supervisor %}
<a href="{% url 'generic:supervisor_detail' r.matched_supervisor.pk %}">{{ r.matched_supervisor.name }}</a>
{% else %}
<div>
<span style="color:red">No match</span>
<div class="d-inline-flex align-items-center" style="gap:6px">
<form hx-get="{% url 'generic:supervisor_search' %}" hx-target="#supervisor-results-{{ r.row_index }}" hx-swap="innerHTML" onsubmit="return false;" class="d-inline-flex align-items-center">
<input type="text" name="q" id="supervisor-search-input-{{ r.row_index }}" placeholder="Search supervisors..." style="width:220px" />
<input type="hidden" name="row" value="{{ r.row_index }}" />
<button type="submit" class="btn btn-sm btn-secondary">Search</button>
</form>
<div id="supervisor-results-{{ r.row_index }}"></div>
</div>
<input type="hidden" name="manual_supervisor_{{ r.row_index }}" id="manual_supervisor_{{ r.row_index }}" />
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if report %}
<h3>Apply report</h3>
<p>Updated: {{ report.updated }}; Skipped (no user matched): {{ report.skipped }}</p>
{% endif %}
{% endblock %}
{% block js %}
<script>
// Called from the HTMX-rendered results fragment to set the manual selection
function setManualUser(rowIndex, id, text) {
const hiddenInput = document.getElementById('manual_user_' + rowIndex);
const resultsDiv = document.getElementById('user-results-' + rowIndex);
if (hiddenInput) {
hiddenInput.value = id;
}
if (resultsDiv) {
resultsDiv.innerHTML = '<div>Selected: ' + escapeHtml(text) + '</div>';
}
}
function setManualSupervisor(rowIndex, id, text) {
const hiddenInput = document.getElementById('manual_supervisor_' + rowIndex);
const resultsDiv = document.getElementById('supervisor-results-' + rowIndex);
if (hiddenInput) {
hiddenInput.value = id;
}
if (resultsDiv) {
resultsDiv.innerHTML = '<div>Selected: ' + escapeHtml(text) + '</div>';
}
}
function escapeHtml(unsafe) {
if (!unsafe) return '';
return String(unsafe)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
}
</script>
{% endblock %}
+17
View File
@@ -57,6 +57,8 @@ urlpatterns = [
views.SupervisorAutocomplete.as_view(model=Supervisor),
name="supervisor-autocomplete",
),
path("user-search/", views.user_search, name="user_search"),
path("supervisor-search/", views.supervisor_search, name="supervisor_search"),
path(
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
),
@@ -286,6 +288,21 @@ def generic_view_urls(generic_views: GenericViewBase):
generic_views.question_reviews_list,
name="question_reviews",
),
path(
"question/review/start",
generic_views.question_review_start,
name="question_review_start",
),
path(
"question/review/next",
generic_views.question_review_next,
name="question_review_next",
),
path(
"question/review/list",
generic_views.question_review_list,
name="question_review_list",
),
path("question/<int:pk>/thumbnail/fail", generic_views.question_thumbnail_fail, name="series_thumbnail_fail"),
path("question/<int:pk>/thumbnail", generic_views.question_thumbnail, name="series_thumbnail"),
path(
+361 -5
View File
@@ -68,6 +68,7 @@ from .forms import (
CidUserExamForm,
ExamCollectionCloneForm,
ExamCollectionForm,
QuestionReviewForm,
ExaminationForm,
CidUserGroupForm,
ExaminationMergeForm,
@@ -96,8 +97,10 @@ from .models import (
UserProfile,
UserUserGroup,
get_next_cid,
QuestionReview,
)
from rapids.models import (
Rapid as RapidQuestion,
ExamQuestionDetail as RapidsExamQuestionDetail,
@@ -2954,9 +2957,6 @@ class GenericViewBase:
GET: renders the current review block or the form (if ?edit=1)
POST: creates a new QuestionReview and returns the rendered block
"""
from django.contrib.contenttypes.models import ContentType
from generic.models import QuestionReview
from .forms import QuestionReviewForm
logger.debug("question_set_review called")
@@ -3018,8 +3018,6 @@ class GenericViewBase:
def question_reviews_list(self, request, pk):
"""Return a partial listing all QuestionReview instances for a question."""
from django.contrib.contenttypes.models import ContentType
from generic.models import QuestionReview
question = get_object_or_404(self.question_object, pk=pk)
@@ -3031,6 +3029,175 @@ class GenericViewBase:
return render(request, "generic/partials/question_reviews_list.html", {"question": question, "reviews": reviews, "app_name": self.app_name})
def question_review_start(self, request):
# Prepare category choices if the question model exposes a FK named 'category'
categories = None
try:
field = self.question_object._meta.get_field("category")
# Only handle FK-like fields
if hasattr(field, "related_model") and field.related_model is not None:
CategoryModel = field.related_model
# prefer 'category' or 'name' display field if present
order_by = "category" if hasattr(CategoryModel, "category") else "name" if hasattr(CategoryModel, "name") else "pk"
categories = CategoryModel.objects.all().order_by(order_by)
except Exception:
categories = None
# Status options map: label -> status code (or special 'UNREVIEWED')
status_options = [
("ANY", "Any"),
("UNREVIEWED", "Unreviewed"),
("AC", "Accepted"),
("OD", "Outdated"),
("ER", "Error"),
("RJ", "Rejected"),
("IP", "In Progress"),
]
return render(
request,
f"{self.app_name}/question_review_start.html",
{"app_name": self.app_name, "categories": categories, "status_options": status_options},
)
def question_review_next(self, request):
"""Find and redirect to the next question matching the supplied filters.
Accepts POST or GET parameters:
- category: integer primary key of category (optional)
- status: one of the status codes (AC, OD, ER, RJ, IP), or 'UNREVIEWED' or 'ANY'
"""
# Read filters
category = request.POST.get("category") or request.GET.get("category")
status = request.POST.get("status") or request.GET.get("status") or "ANY"
# How many matching questions to skip (useful for Skip button). Expected integer >= 0.
try:
skip = int(request.POST.get("skip") or request.GET.get("skip") or 0)
if skip < 0:
skip = 0
except Exception:
skip = 0
# Build base queryset of questions for this app
qs = self.question_object.objects.all().order_by("pk")
# Restrict by category if provided and the model has such a relation
if category:
try:
qs = qs.filter(category__id=int(category))
except Exception:
pass
# Apply permission filter similar to filters elsewhere: non-checkers see only open_access or their own
if not request.user.groups.filter(name=self.checker_group).exists():
try:
qs = qs.filter(open_access=True) | qs.filter(author__id=request.user.id)
except Exception:
# If model doesn't have those fields, ignore
pass
# Iterate questions and pick first matching status
for question in qs:
# Skip authors_only questions unless user is author or superuser
try:
if question.authors_only and not (
request.user.is_superuser or request.user in question.author.all()
):
continue
except Exception:
pass
ct = ContentType.objects.get_for_model(question)
latest = (
QuestionReview.objects.filter(content_type=ct, object_id=question.pk)
.order_by("-created_on").first()
)
match = False
if status == "ANY":
match = True
elif status == "UNREVIEWED":
match = latest is None
else:
# status is expected to be a QuestionReview.StatusChoices code
if latest is not None and latest.status == status:
match = True
if match:
# If caller asked us to skip N matches, decrement and continue until skip==0
if skip and skip > 0:
skip -= 1
continue
form = QuestionReviewForm()
return render(request, f"{self.app_name}/question_review_question.html", {"question": question, "form": form, "app_name": self.app_name, "filters": {"category": category, "status": status}})
# Nothing found - render complete page
return render(request, f"{self.app_name}/question_review_complete.html", {"app_name": self.app_name})
def question_review_list(self, request):
"""Return a page listing questions that match the supplied filters.
Accepts query parameters similar to question_review_next: category, status.
"""
# Read filters
category = request.GET.get("category") or request.POST.get("category")
status = request.GET.get("status") or request.POST.get("status") or "ANY"
# Build base queryset of questions for this app
qs = self.question_object.objects.all().order_by("pk")
# Restrict by category if provided
if category:
try:
qs = qs.filter(category__id=int(category))
except Exception:
pass
# Apply permission filter similar to question_review_next
if not request.user.groups.filter(name=self.checker_group).exists():
try:
qs = qs.filter(open_access=True) | qs.filter(author__id=request.user.id)
except Exception:
pass
# Optionally filter by review status
results = []
for question in qs:
try:
if question.authors_only and not (
request.user.is_superuser or request.user in question.author.all()
):
continue
except Exception:
pass
ct = ContentType.objects.get_for_model(question)
latest = (
QuestionReview.objects.filter(content_type=ct, object_id=question.pk)
.order_by("-created_on").first()
)
match = False
if status == "ANY":
match = True
elif status == "UNREVIEWED":
match = latest is None
else:
if latest is not None and latest.status == status:
match = True
if match:
results.append(question)
return render(request, f"{self.app_name}/question_review_list.html", {"questions": results, "app_name": self.app_name, "filters": {"category": category, "status": status}})
@method_decorator(user_passes_test(lambda u: u.is_superuser))
def user_answer_delete_multiple(self, request):
print(request.POST["answer_ids"])
@@ -4087,6 +4254,195 @@ def trainees(request, grade: None | str = None):
return render(request, "generic/trainees.html", context)
@user_is_cid_user_manager
def trainees_bulk_update_from_spreadsheet(request):
"""Bulk update trainees based on pasted spreadsheet-like data.
The view works in two stages:
- Preview: parse the pasted text and show rows with any matched user / supervisor
- Apply: apply grade and supervisor updates for matched users
"""
import re
preview = []
report = None
if request.method == "POST":
action = request.POST.get("action", "preview")
data = request.POST.get("data", "")
lines = [l.strip() for l in data.splitlines()]
current_grade = None
for idx, line in enumerate(lines):
if not line:
continue
# detect grade headings like 'Year 1 - Group 1' -> ST1
m = re.search(r"Year\s*(\d+)", line, re.IGNORECASE)
if m:
num = m.group(1)
current_grade = f"ST{num}"
continue
# split columns by tab or two+ spaces
parts = re.split(r"\t| {2,}", line)
if len(parts) < 2:
# maybe single-space separated; try splitting on tab or single tab
parts = [p.strip() for p in line.split("\t") if p.strip()]
if not parts:
continue
name_part = parts[0].strip()
supervisor_part = parts[1].strip() if len(parts) > 1 else ""
# normalize name (remove parenthetical annotations)
norm_name = re.sub(r"\s*\([^)]*\)", "", name_part).strip()
# split into tokens; assume first token is first name, last token is last name
tokens = [t for t in re.split(r"\s+", norm_name) if t]
first = tokens[0] if tokens else ""
last = tokens[-1] if tokens else ""
matched_user = None
if first and last:
# Prefer an exact first+last match
qs = User.objects.filter(first_name__iexact=first, last_name__iexact=last)
if qs.exists():
matched_user = qs.first()
else:
# Fallback: only match by last name when the last-name query returns
# a single candidate AND the candidate's first name reasonably
# matches the provided first token. This avoids ambiguous matches
# (e.g. Maria vs Stephanie both 'Bailey'). We accept a match when
# the candidate first name startswith the first 3 chars of the
# provided first token, or contains it as a substring (case-ins).
candidates = User.objects.filter(last_name__iexact=last)
if candidates.count() == 1:
candidate = candidates.first()
cand_first = (candidate.first_name or "").strip()
if cand_first:
# take at most 3 chars for prefix comparison
prefix = first[:3].lower()
cand_first_low = cand_first.lower()
if cand_first_low.startswith(prefix) or first.lower() in cand_first_low:
matched_user = candidate
matched_supervisor = None
if supervisor_part:
sup_qs = Supervisor.objects.filter(name__icontains=supervisor_part)
if sup_qs.exists():
matched_supervisor = sup_qs.first()
preview.append(
{
"raw": line,
"row_index": idx,
"name": name_part,
"norm_name": norm_name,
"first": first,
"last": last,
"supervisor_text": supervisor_part,
"matched_user": matched_user,
"matched_supervisor": matched_supervisor,
"grade": current_grade,
}
)
if action == "apply":
updated = 0
skipped = 0
for row in preview:
# Check for a manual override from the submitted form
manual_key = f"manual_user_{row.get('row_index')}"
manual_val = request.POST.get(manual_key)
user = None
if manual_val:
try:
user = User.objects.get(pk=int(manual_val))
except Exception:
user = None
# Fall back to automatic match
if not user:
user = row.get("matched_user")
if not user:
skipped += 1
continue
profile = user.userprofile
# apply grade if present
grade_name = row.get("grade")
if grade_name:
grade_obj, _ = UserGrades.objects.get_or_create(name=grade_name)
profile.grade = grade_obj
# apply supervisor if matched
sup = row.get("matched_supervisor")
if sup:
profile.supervisor = sup
profile.save()
updated += 1
report = {"updated": updated, "skipped": skipped}
return render(
request,
"generic/trainees_bulk_update.html",
{"preview": preview, "report": report},
)
@user_is_cid_user_manager
def user_search(request):
"""Simple JSON endpoint to search users by name/email for manual matching."""
q = request.GET.get("q", "").strip()
results = []
if q:
qs = User.objects.filter(
Q(first_name__icontains=q) | Q(last_name__icontains=q) | Q(email__icontains=q)
).order_by("last_name", "first_name")[:50]
for u in qs:
results.append({"id": u.pk, "text": f"{u.first_name} {u.last_name} ({u.email})"})
# If this is an HTMX request (or a row param was provided) render an HTML fragment
# that can be swapped into the preview row's results container. Otherwise return JSON.
row = request.GET.get("row")
if request.headers.get("HX-Request") == "true" or row is not None:
# Render a small partial that lists results with Select buttons that call
# the setManualUser(row, id, text) function in the parent page.
return render(request, "generic/partials/user_search_results.html", {"results": results, "row": int(row) if row is not None else None})
return JsonResponse({"results": results})
@user_is_cid_user_manager
def supervisor_search(request):
"""Simple JSON/HTMX endpoint to search supervisors by name/email for manual matching."""
q = request.GET.get("q", "").strip()
results = []
if q:
qs = Supervisor.objects.filter(
Q(name__icontains=q) | Q(email__icontains=q)
).order_by("name")[:50]
for s in qs:
results.append({"id": s.pk, "text": f"{s.name} ({s.email})"})
row = request.GET.get("row")
if request.headers.get("HX-Request") == "true" or row is not None:
return render(request, "generic/partials/supervisor_search_results.html", {"results": results, "row": int(row) if row is not None else None})
return JsonResponse({"results": results})
def create_trainee(request, context=None):
return create_user(request, context, trainee=True)
+31 -2
View File
@@ -108,6 +108,7 @@ if not DEBUG:
'rad.middleware.coep.COEPMiddleware'
)
ROOT_URLCONF = "rad.urls"
#default_loaders = [
@@ -117,6 +118,8 @@ ROOT_URLCONF = "rad.urls"
#
#cached_loaders = [("django.template.loaders.cached.Loader", default_loaders)]
if DEBUG:
# Development: do not use cached loader so templates reload on each request
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
@@ -130,9 +133,34 @@ TEMPLATES = [
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.static",
],
#"loaders": default_loaders if DEBUG else cached_loaders,
},
},
]
else:
# Production: enable cached loader for performance
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"APP_DIRS": False,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.static",
],
"loaders": [
(
"django.template.loaders.cached.Loader",
[
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
)
],
},
},
]
@@ -332,6 +360,7 @@ CIMAR_PASSWORD = ""
CELERY_BROKER_URL = "redis://redis:6379"
CELERY_RESULT_BACKEND = "redis://redis:6379"
try:
from .settings_local import *
except ImportError as e:
+3
View File
@@ -74,6 +74,9 @@ urlpatterns = [
path(
"accounts/trainees/<str:grade>/", generic_views.trainees, name="trainees_grade"
),
path(
"accounts/trainees/bulk_update/spreadsheet", generic_views.trainees_bulk_update_from_spreadsheet, name="trainees_bulk_update_from_spreadsheet"
),
path(
"accounts/update/<str:slug>/",
views.UpdateUserView.as_view(),
+1
View File
@@ -129,6 +129,7 @@ class QuestionForm(ModelForm):
model = Question
fields = [
"title",
"stem",
"feedback",
"category",
+2
View File
@@ -23,6 +23,8 @@
</a>
<ul class="dropdown-menu" aria-labelledby="sbasQuestionsDropdown">
<li><a class="dropdown-item" href="{% url 'sbas:question_view' %}"><i class="bi bi-list-ul"></i> All Questions</a></li>
<li><a class="dropdown-item" href="{% url 'sbas:question_review_start' %}"><i class="bi bi-bookmarks-fill"></i> Review Questions</a></li>
<li><a class="dropdown-item" href="{% url 'sbas:generate_exam' %}"><i class="bi bi-journal-plus"></i> Generate Exam</a></li>
<!-- Additional items can be added here -->
</ul>
</li>
@@ -12,6 +12,21 @@
{% include "exam_clock.html" %}
<h2>Exam: {{exam}}</h2>
{% if can_edit %}
<div class="card mb-3">
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center">
<div class="mb-2 mb-md-0">
{% include "generic/partials/exams/exam_status.html#publish-results" %}
</div>
<div>
<a href="{% url 'sbas:exam_update' pk=exam.id %}" class="btn btn-outline-primary btn-sm">
Edit exam
</a>
</div>
</div>
</div>
{% endif %}
{% if exam.publish_results %}
<div class="alert alert-primary review-mode-alert" role="alert">
Exam is in review mode. Score are available
+42
View File
@@ -0,0 +1,42 @@
{% extends 'sbas/base.html' %}
{% block title %}Generate Exam{% endblock %}
{% block content %}
<div class="container mt-3">
<h2>Generate Personal SBA Exam</h2>
<p>Choose filters and provide a name for your exam. The created exam will be active and available for you to take.</p>
<form method="post" action="{% url 'sbas:exam_create_from_filters_with_name' %}">
{% csrf_token %}
<div class="mb-3">
<label for="exam-name" class="form-label">Exam name</label>
<input type="text" class="form-control" id="exam-name" name="name" placeholder="My personal SBA exam">
</div>
<div class="mb-3">
<label for="id_category" class="form-label">Category</label>
<select id="id_category" name="category" class="form-select">
<option value="">Any</option>
{% for c in categories %}
<option value="{{ c.id }}">{{ c.category }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="create-count" class="form-label">Number of questions (0 = all)</label>
<input type="number" id="create-count" name="count" class="form-control" value="0" min="0">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" value="on" id="exclude-answered" name="exclude_answered">
<label class="form-check-label" for="exclude-answered">
Exclude questions I have already answered in other exams
</label>
</div>
<button type="submit" class="btn btn-primary">Generate Exam</button>
</form>
</div>
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends 'sbas/base.html' %}
{% block content %}
<h2>Merge Category</h2>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
<p>This admin tool will move all questions assigned to the <strong>Source</strong> category into the <strong>Target</strong> category. Optionally you may delete the Source category after the move.</p>
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label for="id_source" class="form-label">Source category (to be merged)</label>
<select name="source" id="id_source" class="form-select">
<option value="">-- select source --</option>
{% for c in categories %}
<option value="{{ c.pk }}">{{ c }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="id_target" class="form-label">Target category (destination)</label>
<select name="target" id="id_target" class="form-select">
<option value="">-- select target --</option>
{% for c in categories %}
<option value="{{ c.pk }}">{{ c }}</option>
{% endfor %}
</select>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" value="on" id="id_delete_source" name="delete_source">
<label class="form-check-label" for="id_delete_source">
Delete source category after merge
</label>
</div>
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to move all questions from the source to the target category? This action cannot be undone.');">Merge categories</button>
</form>
{% endblock %}
@@ -0,0 +1,9 @@
{% extends 'sbas/base.html' %}
{% block content %}
<h2>Review complete</h2>
<p>There are no more questions matching your filters.</p>
{% endblock %}
@@ -0,0 +1,44 @@
{% extends 'sbas/base.html' %}
{% block content %}
<h2>Filtered questions</h2>
<p>Showing questions matching the selected filters.</p>
<div class="mb-3">
Active filters:
{% if filters.category %}
<span class="badge bg-primary me-1">Category: {{ filters.category }}</span>
{% else %}
<span class="badge bg-secondary me-1">Category: Any</span>
{% endif %}
{% if filters.status %}
<span class="badge bg-primary me-1">Status: {{ filters.status }}</span>
{% else %}
<span class="badge bg-secondary me-1">Status: Any</span>
{% endif %}
</div>
{% if questions %}
<ul class="list-group">
{% for q in questions %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<a href="{% url app_name|add:':question_detail' q.pk %}">{{ q.title|default:q.stem|truncatechars:80 }}</a>
<div class="small text-muted">ID: {{ q.pk }}</div>
</div>
<div class="btn-group">
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' q.pk %}">View</a>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="alert alert-info">No questions match the selected filters.</div>
{% endif %}
<div class="mt-3">
<a href="{% url app_name|add:':question_review_start' %}" class="btn btn-secondary">Back to filters</a>
</div>
{% endblock %}
@@ -0,0 +1,184 @@
{% extends 'sbas/base.html' %}
{% block content %}
<div class="question-review-question mb-3">
Active filters:
{% if filters.category %}
<span class="badge bg-primary me-1">Category: {{ filters.category }}</span>
{% else %}
<span class="badge bg-secondary me-1">Category: Any</span>
{% endif %}
{% if filters.status %}
<span class="badge bg-primary me-1">Status: {{ filters.status }}</span>
{% else %}
<span class="badge bg-secondary me-1">Status: Any</span>
{% endif %}
<h5 class="mb-1">Reviewing question: {{ question.title |default:"No Title" }}</h5>
<a href="{% url 'sbas:question_detail' question.pk %}" target="_blank" class="btn btn-sm btn-outline-secondary mb-2">View full question details</a>
<a href="{% url 'sbas:question_update' question.pk %}" target="_blank" class="btn btn-sm btn-outline-secondary mb-2">Edit question</a>
<div class="question-metadata mt-2 mb-2">
<h6 class="small text-muted">Metadata</h6>
<ul class="list-inline small mb-0">
<li class="list-inline-item me-2">Category: {% if question.category %}{{ question.category }}{% else %}&mdash;{% endif %}</li>
<li class="list-inline-item me-2">Author(s):
{% if question.author.count %}
{% for a in question.author.all %}
{{ a.get_full_name|default:a.username }}{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}&mdash;{% endif %}
</li>
<li class="list-inline-item me-2">Created: {{ question.created_date|date:"Y-m-d H:i" }}</li>
<li class="list-inline-item me-2">Open: {% if question.open_access %}Yes{% else %}No{% endif %}</li>
{% if question.sources %}
<li class="list-inline-item me-2">Sources: {{ question.sources|length }} item{{ question.sources|length|pluralize }}</li>
{% endif %}
{% if question.finding.count %}
<li class="list-inline-item me-2">Findings: {{ question.finding.count }}</li>
{% endif %}
{% if question.structure.count %}
<li class="list-inline-item me-2">Structures: {{ question.structure.count }}</li>
{% endif %}
{% if question.condition.count %}
<li class="list-inline-item me-2">Conditions: {{ question.condition.count }}</li>
{% endif %}
{% if question.presentation.count %}
<li class="list-inline-item me-2">Presentations: {{ question.presentation.count }}</li>
{% endif %}
{% if question.subspecialty.count %}
<li class="list-inline-item me-2">Subspecialties: {{ question.subspecialty.count }}</li>
{% endif %}
</ul>
</div>
<div class="mb-2">
<div class="question-stem">Stem: {{ question.stem|safe }}</div>
</div>
{# Multiple-choice answers (SBA specific) #}
<div class="list-group mb-2">
{% if question.a_answer %}
<div class="list-group-item d-flex justify-content-between align-items-start">
<div><strong>A.</strong> <span class="ms-2">{{ question.a_answer|safe }}</span></div>
{% if question.best_answer == 'a' %}<span class="badge bg-success">Correct</span>{% endif %}
{% if question.a_feedback %}
<div class="mt-1"><strong>Feedback:</strong> {{ question.a_feedback|safe }}</div>
{% endif %}
</div>
{% endif %}
{% if question.b_answer %}
<div class="list-group-item d-flex justify-content-between align-items-start">
<div><strong>B.</strong> <span class="ms-2">{{ question.b_answer|safe }}</span></div>
{% if question.best_answer == 'b' %}<span class="badge bg-success">Correct</span>{% endif %}
{% if question.b_feedback %}
<div class="mt-1"><strong>Feedback:</strong> {{ question.b_feedback|safe }}</div>
{% endif %}
</div>
{% endif %}
{% if question.c_answer %}
<div class="list-group-item d-flex justify-content-between align-items-start">
<div><strong>C.</strong> <span class="ms-2">{{ question.c_answer|safe }}</span></div>
{% if question.best_answer == 'c' %}<span class="badge bg-success">Correct</span>{% endif %}
{% if question.c_feedback %}
<div class="mt-1"><strong>Feedback:</strong> {{ question.c_feedback|safe }}</div>
{% endif %}
</div>
{% endif %}
{% if question.d_answer %}
<div class="list-group-item d-flex justify-content-between align-items-start">
<div><strong>D.</strong> <span class="ms-2">{{ question.d_answer|safe }}</span></div>
{% if question.best_answer == 'd' %}<span class="badge bg-success">Correct</span>{% endif %}
{% if question.d_feedback %}
<div class="mt-1"><strong>Feedback:</strong> {{ question.d_feedback|safe }}</div>
{% endif %}
</div>
{% endif %}
{% if question.e_answer %}
<div class="list-group-item d-flex justify-content-between align-items-start">
<div><strong>E.</strong> <span class="ms-2">{{ question.e_answer|safe }}</span></div>
{% if question.best_answer == 'e' %}<span class="badge bg-success">Correct</span>{% endif %}
{% if question.e_feedback %}
<div class="mt-1"><strong>Feedback:</strong> {{ question.e_feedback|safe }}</div>
{% endif %}
</div>
{% endif %}
</div>
{# Generic feedback text if present #}
{% if question.feedback %}
<div class="mt-2"><strong>Feedback:</strong> {{ question.feedback|safe }}</div>
{% endif %}
{% if question.source %}
<div class="mt-2"><strong>Source:</strong> {{ question.source|safe }}</div>
{% endif %}
</div>
{% comment %} <span class="btn btn-sm btn-outline-success" data-bs-toggle="modal" data-bs-target="#reviewModal" hx-get="{% url 'sbas:question_set_review' question.pk %}?edit=1&new=1" hx-target="#review-form" hx-swap="innerHTML" hx-trigger="load">Add Review</button> {% endcomment %}
{% comment %} The included partial contains its own <form> with hx-post. Do not wrap it in another form (nested forms break submission). {% endcomment %}
{% include 'generic/partials/question_review_form.html' with app_name='sbas' question=question form=form %}
<div class="d-flex gap-2 mt-3">
{# Skip increments a 'skip' query param used by the view to skip matching questions #}
<button id="review-skip-btn" type="button" class="btn btn-outline-secondary">Skip</button>
{# Next should only be enabled after a successful Save (server will send HX-Trigger 'reviewSaved') #}
<button id="review-next-btn" type="button" class="btn btn-primary" disabled>Next</button>
</div>
<script>
(function(){
const nextBtn = document.getElementById('review-next-btn');
// Listen for the server-triggered event 'reviewSaved' (sent via HX-Trigger header).
// HTMX will dispatch a DOM event with the same name when it receives the header.
document.addEventListener('reviewSaved', function(){
nextBtn.disabled = false;
skipBtn.disabled = true;
});
// Build base URL for navigation
const base = "{% url app_name|add:':question_review_next' %}";
// Active filters passed from server-side context
const activeCategory = "{{ filters.category|default:'' }}";
const activeStatus = "{{ filters.status|default:'' }}";
// Navigate to the review-next endpoint including current filters
nextBtn.addEventListener('click', function(){
const params = new URLSearchParams();
if (activeCategory) params.set('category', activeCategory);
if (activeStatus) params.set('status', activeStatus);
// Next navigates to the next matching item (no skip)
const qs = params.toString();
window.location.href = qs ? (base + '?' + qs) : base;
});
// Skip: increment 'skip' query param (defaults to 1) and include current filters
const skipBtn = document.getElementById('review-skip-btn');
skipBtn.addEventListener('click', function(){
// read existing skip from current URL (if present) and increment
const curParams = new URLSearchParams(window.location.search);
let curSkip = parseInt(curParams.get('skip') || '0', 10) || 0;
const newSkip = curSkip + 1;
const params = new URLSearchParams();
params.set('skip', String(newSkip));
if (activeCategory) params.set('category', activeCategory);
if (activeStatus) params.set('status', activeStatus);
window.location.href = base + '?' + params.toString();
});
})();
</script>
<div id="question-review-block" class="mt-4">
{# Placeholder while HTMX fetches review/block #}
</div>
{% endblock %}
@@ -0,0 +1,64 @@
{% extends 'sbas/base.html' %}
{% load partials %}
{% block content %}
<h2>Review Questions</h2>
<p>Filter questions to review. Choose a category and/or a review status, then click Start to open the first matching question that needs reviewing.</p>
<form method="post" action="{% url 'sbas:question_review_next' %}">
{% csrf_token %}
<div class="mb-3">
<label for="id_category" class="form-label">Category</label>
{% if categories %}
<select name="category" id="id_category" class="form-select">
<option value="">Any</option>
{% for c in categories %}
<option value="{{ c.pk }}">{{ c }}</option>
{% endfor %}
</select>
{% else %}
<p><em>No category choices available for this question type.</em></p>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">Start review</button>
<div class="mb-3">
<label for="id_status" class="form-label">Review status</label>
<select name="status" id="id_status" class="form-select">
{% for code,label in status_options %}
<option value="{{ code }}" {% if code == 'UNREVIEWED' %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div>
<p>You can also view the list of questions matching the filters below.</p>
<button id="view-list-btn" type="button" class="btn btn-outline-primary">View list</button>
</div>
</form>
<script>
// When View list is clicked, read the values from the main filter form and navigate
(function(){
const btn = document.getElementById('view-list-btn');
btn.addEventListener('click', function(){
const base = "{% url 'sbas:question_review_list' %}";
const params = new URLSearchParams();
const categoryEl = document.getElementById('id_category');
const statusEl = document.getElementById('id_status');
if (categoryEl && categoryEl.value) params.set('category', categoryEl.value);
if (statusEl && statusEl.value) params.set('status', statusEl.value);
const url = base + (params.toString() ? ('?' + params.toString()) : '');
window.location.href = url;
});
})();
</script>
{% endblock %}
+12
View File
@@ -99,6 +99,18 @@ urlpatterns = [
views.import_llm_confirm,
name="import_llm_confirm",
),
path(
"exam/create_from_filters_with_name/",
views.create_personal_exam_with_name,
name="exam_create_from_filters_with_name",
),
path(
"question/generate/",
views.generate_exam,
name="generate_exam",
),
path("category/merge/", views.merge_category, name="merge_category"),
#path(
# "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
# views.exam_scores_cid_user,
+187
View File
@@ -29,6 +29,10 @@ from django.http import Http404, HttpResponseBadRequest, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from .models import Question, Category, Exam, UserAnswer
from django.contrib.contenttypes.models import ContentType
from generic.models import QuestionReview
from django.http import HttpResponseBadRequest
import random
from django.views.decorators.http import require_http_methods
from django.db import transaction
import re
@@ -149,6 +153,8 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
exam.check_user_can_take(cid, passcode, request.user)
can_edit = exam.check_user_can_edit(request.user)
questions = exam.get_questions()
if cid is not None:
@@ -182,6 +188,7 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
"exam_length": len(questions),
"passcode": passcode,
"cid_user_exam": cid_user_exam,
"can_edit": can_edit,
},
)
@@ -376,6 +383,133 @@ class QuestionClone(QuestionCreateBase):
class UserAnswerView(LoginRequiredMixin, DetailView):
model = UserAnswer
@login_required
def create_personal_exam_with_name(request):
"""Create a personal SBA exam from filters, using a provided name.
POST parameters expected:
- name (optional): exam name to use
- category (optional)
- status (optional)
- count (optional): number of questions to include (0 or missing = all)
"""
if request.method != "POST":
return HttpResponseBadRequest("POST required")
name = request.POST.get("name") or None
# reuse existing create_personal_exam filtering logic but allow custom name
category = request.POST.get("category") or None
# For generated personal exams we only include accepted questions
status = QuestionReview.StatusChoices.ACCEPTED
# Optionally exclude questions the current user has already answered
exclude_answered = request.POST.get("exclude_answered") in ("on", "true", "True", "1")
try:
count = int(request.POST.get("count") or 0)
if count < 0:
count = 0
except Exception:
count = 0
qs = Question.objects.all().order_by("pk")
if category:
try:
qs = qs.filter(category__id=int(category))
except Exception:
pass
# If requested, remove questions the current user has already answered
if exclude_answered:
try:
answered_qs = UserAnswer.objects.filter(user=request.user).values_list("question_id", flat=True)
qs = qs.exclude(pk__in=list(answered_qs))
except Exception:
pass
# Permission filter: non-checkers see open_access or their own questions
try:
if not request.user.groups.filter(name="sbas_checker").exists():
from django.db.models import Q
qs = qs.filter(Q(open_access=True) | Q(author__id=request.user.id))
except Exception:
pass
matches = []
for question in qs:
try:
if question.authors_only and not (
request.user.is_superuser or request.user in question.author.all()
):
continue
except Exception:
pass
ct = ContentType.objects.get_for_model(question)
latest = (
QuestionReview.objects.filter(content_type=ct, object_id=question.pk)
.order_by("-created_on").first()
)
match = False
if status == "ANY":
match = True
elif status == "UNREVIEWED":
match = latest is None
else:
if latest is not None and latest.status == status:
match = True
if match:
matches.append(question)
if not matches:
return HttpResponseBadRequest("No matching questions found")
# Optionally randomize and limit
if count and count > 0 and count < len(matches):
random.shuffle(matches)
matches = matches[:count]
# Create the Exam
if name is None or name.strip() == "":
name = f"{request.user.get_full_name() or request.user.username} - Personal SBA Exam"
exam = Exam(name=name, active=True, candidates_only=True, exam_mode=True)
exam.save()
# Add author and allow this user to take the exam
try:
exam.author.add(request.user)
except Exception:
pass
try:
exam.valid_user_users.add(request.user)
except Exception:
pass
# Add questions
for q in matches:
exam.exam_questions.add(q)
exam.order_questions()
return redirect("sbas:exam_start", pk=exam.pk)
@login_required
def generate_exam(request):
"""Render a small form allowing the user to name and generate a personal exam from filters."""
# Prepare categories and status options for the template
categories = Category.objects.all().order_by("category")
# Build status options: ANY, UNREVIEWED, plus choices from QuestionReview
status_options = [("ANY", "Any")]
status_options.append(("UNREVIEWED", "Unreviewed"))
for v, label in QuestionReview.StatusChoices.choices:
status_options.append((v, label))
return render(request, "sbas/generate_exam.html", {"categories": categories, "status_options": status_options})
class UserAnswerTableView(LoginRequiredMixin, SingleTableMixin, FilterView):
model = UserAnswer
@@ -878,6 +1012,59 @@ def import_llm_questions(request):
return JsonResponse({"ok": True, "report": report})
@login_required
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET", "POST"])
def merge_category(request):
"""Admin view: merge one Category into another.
GET: render form to choose source and target categories.
POST: expects 'source' and 'target' (category PKs) and optional 'delete_source' checkbox.
The view will reassign all Question.category==source -> target and optionally delete the source Category.
"""
categories = Category.objects.all().order_by('category')
if request.method == 'GET':
return render(request, 'sbas/merge_category.html', {'categories': categories})
# POST
source_id = request.POST.get('source')
target_id = request.POST.get('target')
delete_source = request.POST.get('delete_source') in ('on', 'true', '1', 'True')
if not source_id or not target_id:
return render(request, 'sbas/merge_category.html', {'categories': categories, 'error': 'Both source and target categories must be selected.'})
try:
source = Category.objects.get(pk=int(source_id))
target = Category.objects.get(pk=int(target_id))
except Category.DoesNotExist:
return render(request, 'sbas/merge_category.html', {'categories': categories, 'error': 'Invalid category selected.'})
if source.pk == target.pk:
return render(request, 'sbas/merge_category.html', {'categories': categories, 'error': 'Source and target must be different.'})
# perform update inside a transaction
from django.db import transaction
with transaction.atomic():
qs = Question.objects.filter(category=source)
count = qs.count()
qs.update(category=target)
deleted = False
if delete_source:
# safe to delete now (no FK references from Question)
source.delete()
deleted = True
msg = f"Moved {count} question{'s' if count != 1 else ''} from '{source}' to '{target}'."
if deleted:
msg += f" Deleted source category '{source}'."
return render(request, 'sbas/merge_category.html', {'categories': Category.objects.all().order_by('category'), 'success': msg})
@login_required
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["POST"])