Compare commits
27
Commits
4f72de6be9
...
a491337a2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a491337a2e | ||
|
|
65e95b698d | ||
|
|
e503576e7a | ||
|
|
fba8d5ab05 | ||
|
|
be94ed8d0f | ||
|
|
668007b899 | ||
|
|
1c8f862303 | ||
|
|
1f2a25c3ad | ||
|
|
690cabe30c | ||
|
|
4dc07b1860 | ||
|
|
d9602eeae9 | ||
|
|
5d0eb56b37 | ||
|
|
64238f4052 | ||
|
|
ef26182b1a | ||
|
|
3f56af4881 | ||
|
|
473efbc8f4 | ||
|
|
6e8c86ab10 | ||
|
|
511b3c3777 | ||
|
|
a38c31ca87 | ||
|
|
74c5f4d8fd | ||
|
|
621e95cfd5 | ||
|
|
3dc38ef57a | ||
|
|
27e575f772 | ||
|
|
a404a335c0 | ||
|
|
8380ca1dd9 | ||
|
|
ec45464e58 | ||
|
|
702b7b35a7 |
@@ -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 %}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -129,6 +129,7 @@ class QuestionForm(ModelForm):
|
||||
model = Question
|
||||
|
||||
fields = [
|
||||
"title",
|
||||
"stem",
|
||||
"feedback",
|
||||
"category",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}—{% 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 %}—{% 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 %}
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user